Skip to content
Created by

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 every RPC:

import type { 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 are a type safe way to attach arbitrary values to a call, and share them with interceptors through the full request/response cycle. They can be used to modify interceptor behavior from the call site.

The ContextValues type is a map-like object with methods to set, get, and delete values:

import { createContextValues, type ContextValues } from "@connectrpc/connect";
import { createContextKey } from "@connectrpc/connect";
const key = createContextKey("default value");
const values: ContextValues = createContextValues();
values.get(key); // "default value"
values.set(key, "custom value");

The keys are ContextKey objects, and enable type safe and collision-free use of context values. They carry a default value that is used when the context value is not set, and an optional description that’s helpful for debugging.

As a practical example of context values, let’s say that you have implemented the logging interceptor from the top of the page, but you don’t want to log the response messages 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 in log-body-context.ts:

import { createContextKey } from "@connectrpc/connect";
export const kLogBody = createContextKey<boolean>(false, {
description: "Log request/response body",
});

Then in your interceptor, check the context value:

import type { Interceptor } from "@connectrpc/connect";
import { kLogBody } from "./log-body-context";
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)) {
// log response messages here
}
return res;
};

Then in your component, set the context value:

import { createContextValues } from "@connectrpc/connect";
import { kLogBody } from "./log-body-context";
import { elizaClient } from "./eliza-client";
const res = elizaClient.say(
{ sentence: "Hey!" },
{ contextValues: createContextValues().set(kLogBody, true) },
);