Node SDK · Middleware and interceptors
An adapter installs a per-request Event (reachable with current(), or req.event under Express), auto-populates actor (via your resolver) and origin (from headers), and records the event after the handler completes. Handlers just set action / target on it and the adapter does the rest.
All adapters take the same options shape (recorder, resolveActor, logger); only the mount and how you reach the event differ.
| Framework | Import | Mount |
|---|---|---|
| Express | @everscribe/sdk-node/express |
expressMiddleware(opts) |
| Fastify | @everscribe/sdk-node/fastify |
fastifyMiddleware(opts) |
| Hono | @everscribe/sdk-node/hono |
honoMiddleware(opts) |
| grpc-js | @everscribe/sdk-node/grpc |
grpcServerInterceptor(opts) |
Actor Resolver
The middleware needs a resolveActor: ActorResolver. A function that derives the Actor for a request. Sessions live in cookies, JWTs, headers, OAuth tokens. Whatever your auth stack uses. Pair the resolver with a session middleware that plants identity on the request before the audit middleware runs.
import type { RequestHandler } from "express";
import { type ActorResolver } from "@everscribe/sdk-node/express";
declare module "express-serve-static-core" {
interface Request { user?: User }
}
// withSession plants the user on req. Real apps read a session cookie
// or JWT here.
const withSession: RequestHandler = (req, _res, next) => {
const id = req.headers["x-user-id"];
if (typeof id === "string") req.user = users[id];
next();
};
// resolveActor reads req.user (set by withSession) and turns it into
// an Actor for the audit middleware.
const resolveActor: ActorResolver = (req) => {
if (!req.user) return { type: "anonymous" };
return {
type: "user",
id: req.user.id,
displayName: req.user.name,
email: req.user.email,
};
};
ActorResolver type:
type ActorResolver = (req: Request) => Actor;
Wiring it up
Session middleware first, audit middleware second, then your routes. The resolver runs inside the audit middleware and reads what withSession planted:
import { expressMiddleware } from "@everscribe/sdk-node/express";
app.use(express.json());
app.use(withSession); // session first
app.use(expressMiddleware({ recorder: rec, resolveActor }));
Ordering matters. If the audit middleware runs first, the resolver sees an empty request and every event records as anonymous.
Other frameworks
Fastify and Hono take the same options object and differ only in mount signature:
import { fastifyMiddleware } from "@everscribe/sdk-node/fastify";
import { honoMiddleware } from "@everscribe/sdk-node/hono";
app.register(fastifyMiddleware({ recorder: rec, resolveActor })); // Fastify
app.use(honoMiddleware({ recorder: rec, resolveActor })); // Hono
Fastify's resolveActor receives a FastifyRequest, the same way Express's receives a Request, so a resolver written for one ports over with only the type changed. Hono's receives the Hono Context instead, since Hono has no request object separate from it.
Reaching the event
Reach the event inside a handler with current() (from @everscribe/sdk-node/event). Express and Fastify also stash it on the request:
app.post("/login", (req, res) => {
req.event.action = "user.login"; // or current().action
res.send("ok");
});
app.post("/login", (request, reply) => {
request.event.action = "user.login"; // or current().action
reply.send("ok");
});
app.post("/login", (c) => {
current().action = "user.login"; // no request accessor, see below
return c.text("ok");
});
function login(call, callback) {
current().action = "user.login";
callback(null, { ok: true });
}
Hono has no adapter-specific accessor: its
Contextis generic over an app-suppliedEnv, and stashing an untyped value would force an EverscribeVariablestype through every consumer'sHono<Env>or bypass its typing. Usecurrent().
gRPC
grpcServerInterceptor mounts on a grpc-js server the same way the HTTP adapters mount on their frameworks:
import { grpcServerInterceptor } from "@everscribe/sdk-node/grpc";
const server = new grpc.Server({
interceptors: [grpcServerInterceptor({ recorder: rec, resolveActor })],
});
Unary RPCs only. Streaming calls (client-streaming, server-streaming, bidi) are detected by their method descriptor and passed through untouched: no lifecycle, no auto-record, current() behaves as if the interceptor were not installed. This keeps a mixed unary/streaming service from silently mis-recording the streaming half rather than shipping something half-verified.
Two differences from the HTTP adapters are worth knowing.
Every RPC is recorded by default. The interceptor sets action to the full method name (for example /billing.v1.Billing/RefundInvoice), so an RPC records unless the handler clears it. The HTTP adapters record nothing until a handler names the event.
Result.code carries the HTTP equivalent of the gRPC status, not the native gRPC code. OK records as 200, PERMISSION_DENIED as 403, NOT_FOUND as 404. This keeps result.code filters and dashboards working the same way across both protocols, since a native OK is code 0 and would be indistinguishable from an unset code.
One behavioral difference in Fastify
Express and Hono can both tell "the handler returned without writing" apart from "the handler wrote a 200," and record the former as an error. Fastify cannot: its own dispatcher auto-sends an async handler's resolved value before onResponse ever runs, so a handler that resolves without calling reply.send() looks identical, by the time this adapter observes it, to one that sent an empty 200. That case records as ok / 200 under Fastify, same as sdk-go's fiber adapter documents for the equivalent fasthttp behavior. A sync handler that never calls reply.send() does not get this treatment; it simply hangs, and onResponse never fires at all.