Interceptors
An interceptor can add logic to clients, similar to the decorators or middleware you may have seen in other libraries. Interceptors may mutate the request and response, catch errors and retry/recover, emit logs, or do nearly anything else.
For a simple example, this interceptor logs all requests:
import { Interceptor } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
const logger: Interceptor = (next) => async (req) => {
console.log(`sending message to ${req.url}`);
return await next(req);
};
createConnectTransport({
baseUrl: "https://demo.connectrpc.com",
interceptors: [logger],
});
You can think of interceptors like a layered onion. A request initiated
by a client goes through the outermost layer first. Each call to next()
traverses to the next layer. In the center, the actual HTTP request is
run by the transport. The response then comes back through all layers and
is returned to the client. In the array of interceptors passed to the
transport, the interceptor at the end of the array is applied first.
To intercept responses, we simply look at the return value of next()
:
const logger: Interceptor = (next) => async (req) => {
console.log(`sending message to ${req.url}`);
const res = await next(req);
if (!res.stream) {
console.log("message:", res.message);
}
return res;
};
The stream
property of the response tells us whether this is a streaming
response. A streaming response has not fully arrived yet when we intercept it
— we have to wrap it to see individual messages:
const logger: Interceptor = (next) => async (req) => {
const res = await next(req);
if (res.stream) {
// to intercept streaming response messages, we wrap
// the AsynchronousIterable with a generator function
return {
...res,
message: logEach(res.message),
};
}
return res;
};
async function* logEach(stream: AsyncIterable<any>) {
for await (const m of stream) {
console.log("message received", m);
yield m;
}
}
Context values
Context values are a type safe way to pass arbitary values from the call site or from one interceptor to the next. You can use createContextValues
function to create a new ContextValues
. The contextValues
call option can be used to provide a ContextValues
instance for each request.
ContextValues
has methods to set, get, and delete values. The keys are ContextKey
objects:
Context Keys
ContextKey
is a type safe and collision free way to use context values. It is defined using createContextKey
function which takes a default value and returns a ContextKey
object. The default value is used when the context value is not set.
import { createContextKey } from "@connectrpc/connect";
type User = { name: string };
const kUser = createContextKey<User>(
{ name: "Anonymous" }, // Default value
{
description: "Current user", // Description useful for debugging
},
);
export { kUser };
For values where a default doesn't make sense you can just modify the type:
import { createContextKey } from "@connectrpc/connect";
type User = { name: string };
const kUser = createContextKey<User | undefined>(undefined, {
description: "Authenticated user",
});
export { kUser };
It is best to define context keys in a separate file and export them. This is better for code splitting and also avoids circular imports. This also helps in the case where the provider changes based on the environment.
Example
Let's say you want to log the response body. But you don't want to do it for every request. You only want to do it from a specific component. You can use context values to achieve this.
First create a context key:
import { createContextKey } from "@connectrpc/connect";
const kLogBody = createContextKey<boolean>(false, {
description: "Log request/response body",
});
export { kLogBody };
Then in your interceptor, check the context value:
import type { Interceptor } from "@connectrpc/connect";
import { kLogBody } from "./log-body-context.js";
const logger: Interceptor = (next) => async (req) => {
console.log(`sending message to ${req.url}`);
const res = await next(req);
if (!res.stream && req.contextValues.get(kLogBody)) {
console.log("message:", res.message);
}
return res;
};
Then in your component, set the context value:
import { kLogBody } from "./log-body-context.js";
import { elizaClient } from "./eliza-client.js";
const res = elizaClient.say({ sentence: "Hey!" }, { contextValues: createContextValues().set(kLogBody, true) });
Setting fetch()
options
Another valuable use case for interceptors is customizing the Fetch API for individual requests by leveraging
the request.init
object.
For example, by default, Connect sets the Fetch option redirect
to error
, which means that a network error will be returned when a request is
met with a redirect. However, if you wish to change this value to follow
for
example, you can do so using an interceptor.
const followRedirects: Interceptor = (next) => async (request) => {
return await next({
...request,
init: {
...request.init,
// Follow all redirects
redirect: "follow",
},
});
};
const client = createPromiseClient(ElizaService, createConnectTransport({
baseUrl: "https://demo.connectrpc.com",
interceptors: [followRedirects],
}));