Skip to content

Create Node.js request scopes

The Node adapter provides explicit async request scopes with AsyncLocalStorage.

Use it when you want request-local DI in Node code outside the React Server Components render tree:

  • Route Handlers;
  • Server Actions with nested async code that should read request context without passing the container through every function;
  • middleware-like server code;
  • jobs and custom server entry points;
  • code where passing the container through every function would be noisy.

For React Server Components in Next.js, keep using di-craft/next/server with React’s cache primitive when you only need RSC render scope. Use this adapter for Node runtime entry points when nested async code should read the current request container without receiving it as an argument.

This adapter is Node.js-only because it imports node:async_hooks. It is not intended for Edge runtimes.

For the ALS path, prefer the Node runtime for Next.js entry points. Some Edge runtimes polyfill node:async_hooks, but the semantics can differ from Node and context may be lost across worker awaits. For Edge or other runtimes without reliable AsyncLocalStorage, pass the container explicitly, use a callback helper that owns the request lifecycle, or use an equivalent Edge-native context primitive.

import { createNodeDi } from "di-craft/node";
examples/node/async-context.ts
import { createToken, provideFactory, provideValue, Scopes } from "di-craft";
import { createNodeDi } from "di-craft/node";
type RequestContext = {
readonly requestId: string;
};
class UsersService {
private readonly requestContext: RequestContext;
constructor(requestContext: RequestContext) {
this.requestContext = requestContext;
}
list(): readonly string[] {
return [`users:${this.requestContext.requestId}`];
}
}
const REQUEST_CONTEXT = createToken<RequestContext>("REQUEST_CONTEXT");
const USERS_SERVICE = createToken<UsersService>("USERS_SERVICE");
const { getRequestContainer, runWithRequestContainer } = createNodeDi({
providers: [
provideFactory(USERS_SERVICE, {
scope: Scopes.Scoped,
deps: { requestContext: REQUEST_CONTEXT },
useFactory: ({ requestContext }) => new UsersService(requestContext),
}),
],
});
const listUsers = async (): Promise<readonly string[]> => {
await Promise.resolve();
return getRequestContainer().get(USERS_SERVICE).list();
};
await runWithRequestContainer({
providers: [
provideValue(REQUEST_CONTEXT, {
requestId: crypto.randomUUID(),
}),
],
run: async () => listUsers(),
});

getRequestContainer() works only inside runWithRequestContainer(). Calling it outside an active async scope throws a clear error.

Await every async operation that reads the request container before the callback returns. Async work started inside the scope can keep the AsyncLocalStorage store, but runWithRequestContainer() disposes the request container as soon as the callback settles.

di-craft/node and di-craft/next/server solve different scope boundaries:

Boundary Adapter Primitive
Server Components / nested RSC di-craft/next/server React cache
Explicit Node async scopes di-craft/node AsyncLocalStorage
Simple Route Handlers / Actions Either explicit helper Callback scope
Browser / Client Components Neither server container Serializable state

AsyncLocalStorage does not magically make a Page/RSC render container equal to a later Server Action container. It gives you a request container inside an explicit async scope that you create with runWithRequestContainer().

For simple Next.js Route Handlers and Server Actions, runWithRequestContainer from di-craft/next/server is usually enough. Reach for di-craft/node when nested Node async calls need to read the current request container with getRequestContainer().

For the full Next.js entry-point pattern, see Share request scope with AsyncLocalStorage.

runWithRequestContainer() disposes the request container after the callback settles, even when the callback throws:

await runWithRequestContainer({
run: async (container) => {
container.get(DB_CONNECTION);
},
});

Use onDispose on cached providers to release resources.

Do not start detached async work that later calls getRequestContainer() from inside the callback. Pass the plain data it needs, or await that work before the callback returns, so it does not read from a disposed request container.

Deferred work may inherit the runtime async context, but di-craft marks that request scope inactive after disposal. Calling getRequestContainer() from the deferred callback throws NodeRequestScopeError. Re-enter a new scope or pass plain values to that work.

The Node adapter composes with per-test request containers. In tests, wrap the code under test with runWithRequestContainer() and register test-only providers there instead of mocking modules:

await runWithRequestContainer({
providers: [provideValue(DB, fakeDb)],
run: async () => {
await serviceUnderTest();
},
});

Nested calls to getRequestContainer() resolve from that test scope, and the container is disposed when the callback settles.