Skip to content
result-rpc
Esc
navigateopen⌘Jpreview
On this page

Services and request context

Two kinds of context: a process-lifetime service graph and request middleware composed by requirement.

A procedure sees one context, but two different things feed it, with different lifetimes and failure rules:

Services Request middleware
Examples database pool, worker bindings, API clients session, viewer, organization
Lifetime process request
Shape dependency graph ordered chain
Can fail with a wire error no — a broken service is a broken process yes — failures join the operation union
Owned by defineService / resolveServices middleware

Services

If you have read about Effect: this is its service/dependency-injection idea at its useful core — declare what each resource needs, resolve the graph once, memoize by identity — without fibers, without Effect.gen, without a runtime. It is a small feature, not a religion.

import { defineService, resolveServices } from "result-rpc";

const Db = defineService("db", {
  create: () => createPool(env.DATABASE_URL),
});

const Mailer = defineService("mailer", {
  needs: { db: Db },
  create: ({ db }) => createMailer(db),
});

const services = await resolveServices({ db: Db, mailer: Mailer });

The graph is resolved once at process start and memoized by definition reference — a service two others depend on is constructed exactly once. The sharp edge, stated plainly: identity is by reference, so store definitions in module constants; two defineService calls are two services.

The resolved record becomes the root context that every request closes over:

export const handleRpc = createFetchHandler({
  router: appRouter,
  createContext: ({ request }) => ({ ...services, request }),
});

Nothing pulls services per call — the auth middleware reads context.db because the root context guarantees it, and swapping the whole record for a test double is one argument to createContext.

Middleware composes by requirement, not by ordering

The footgun: middleware order as tribal knowledge — session must run before requireViewer, enforced by a comment. Here a middleware declares what it runs after; the dependency’s output becomes its input, the dependency’s errors join the union, and any .use() site pulls the whole chain in dependency order:

next takes only this middleware’s declared contribution. result-rpc merges it into the established context, so a middleware cannot accidentally discard upstream fields and refinements need no whole-context spread.

import { serverRpc } from "result-rpc/server";

const server = serverRpc.context<AppContext>();
const session = server
  .middleware<{ viewer: User | null }>()
  .use(async ({ context, next }) => next({ context: { viewer: await userFromCookie(context) } }));

const requireViewer = server
  .middleware<{ viewer: User }>()
  .after(session) // handler sees viewer: User | null
  .errors({ Unauthorized })
  .use(({ context, errors, next }) =>
    context.viewer === null
      ? err(errors.Unauthorized())
      : next({ context: { viewer: context.viewer } }),
  );

A mutation then demands exactly one thing:

export const renameDoc = server
  .procedure()
  .input(RenameInput)
  .output(DocCodec)
  .use(requireViewer) // session comes along, in order
  .mutation(({ context, input }) =>
    // context.viewer: User
    context.db.docs.rename(input, context.viewer),
  );

.use(session) followed by .use(requireViewer) still runs session once — composition is deduplicated by reference identity, the same rule as services (module constants, not inline builds). A middleware whose input demands context the procedure cannot supply is a type error, so requirements are checked, not hoped for.

Setting response headers, and logging someone in

Writing a response header is a declared capability. A procedure calls .headers() and receives context.headers, a Headers to append to — a session cookie on login, a cache-control, a rate-limit hint. A procedure that does not declare it has no context.headers at all.

const login = server
  .procedure()
  .headers()
  .input(wire.object({ email: wire.string, password: wire.string }))
  .output(wire.object({ userId: wire.string }))
  .errors({ BadCredentials })
  .mutation(async ({ input, context, errors }) => {
    const user = await context.db.verify(input.email, input.password);
    if (!user) return err(errors.BadCredentials({}));

    context.headers.append(
      "set-cookie",
      `session=${await mintToken(user)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=604800`,
    );
    return ok({ userId: user.id });
  });

Note the mutation returns a Result like anything else — bad credentials are a declared failure, not an exception.

Reading cookies needs nothing new; createContext has the request:

createContext: ({ request }) => ({
  db,
  session: parseCookie(request.headers.get("cookie"))?.session,
});

A middleware that rotates a session cookie declares the same way, and then every procedure using it must declare .headers() too — the same rule that makes a middleware’s errors part of its procedures’ declared unions:

const rotateSession = server
  .middleware()
  .headers()
  .use(({ context, next }) => {
    context.headers.append("set-cookie", `session=${refresh(context)}; HttpOnly; Path=/`);
    return next({ context: {} });
  });

Why declare it instead of just writing it

The declaration is recorded in the contract, which means a transport knows before dispatch that this call’s response headers cannot be sent early.

That matters because batching and streaming pull in opposite directions. A streamed batch sends its headers first and its results as they arrive — which means a set-cookie written by a handler that has not finished yet arrives after the headers are already on the wire, and is silently dropped. tRPC has exactly this hazard: ctx.resHeaders works under httpBatchLink and silently stops working under httpBatchStreamLink, with no error and no warning. The usual workaround is to move the cookie into responseMeta, a hook that runs before the procedure has produced a result.

Declaring the capability makes the conflict statically visible instead of silent, and it is why the flag is part of the contract digest — a client and server that disagreed about it would reintroduce the same dropped cookie.

Two consequences follow:

A batch shares one response. Several procedures answered in one HTTP request share its headers, so their set-cookies combine rather than overwrite. That is usually what you want; it does mean two logins in one batch set two cookies.

A subscription cannot write response headers at all — a compile error, whether you declare .headers() directly or acquire it by .use()-ing a middleware that declares it. Its response is on the wire before the stream, and therefore before any of its middleware or handler code runs, so there is no moment at which a write could land. Set the header in the request that opens the stream instead.

The capability survives .use(). A middleware replaces the context, but headers is re-applied from the capability rather than inherited from whatever the middleware passed on — so .headers().use(mw) and .use(mw).headers() behave identically. Builder order is not something you have to remember here, which is the same promise middleware composition makes above.

The response is otherwise the protocol’s. Status is derived from the failing error’s declared httpStatus rather than chosen by a handler — that is what lets a client tell a real result-rpc failure from an intermediary’s 502 — and the body is always the Result envelope.

Was this page helpful?