Skip to Content
Scope an HTTP request in Node

This page documents the 0.4 release candidates of @nexusdi/core. The newest on npm is 0.4.0-rc.1. The documentation for 0.3.2, the current release, is at nexus.js.org. Try the release candidate.

Scoping Node.js HTTP Requests

In a web server, you often need a “request scope”: a set of dependencies that are created when a request arrives and disposed when its handler settles.

The @nexusdi/node package provides nodeScopes(), which leverages Node’s AsyncLocalStorage to bind a NexusDI scope to the current asynchronous execution context. This allows you to access the request’s scope deep within your call chain without having to pass the scope object manually through every function. Scopes and the REQUEST Token covers the scopes themselves, and the Node documentation for node:http covers the server.

Install the package from the next tag:

npm install @nexusdi/node@next

Running Handlers in a Scope

nodeScopes() returns an object with two methods over one AsyncLocalStorage: run(scope, fn) and current(). To use request scopes, you create a scope for each request with ship.createScope() and run your handler with scopes.run(scope, fn). run() makes that scope the current scope of everything that happens inside the function.

import { Nexus, REQUEST, Token, defineModule, provide } from '@nexusdi/core'; import { nodeScopes } from '@nexusdi/node'; interface Mission { readonly id: string; readonly target: string; } interface IFlightLog { record(entry: string): string; } interface IncomingRequest { readonly mission: Mission; } const MISSION = new Token<Mission>('Mission'); const FLIGHT_LOG = new Token<IFlightLog>('FlightLog'); class ShuttleFlightLog implements IFlightLog { static deps = [MISSION] as const; constructor(private readonly mission: Mission) {} record(entry: string) { return `${this.mission.id}: ${entry}`; } } const Tactical = defineModule({ name: 'Tactical', providers: [ provide(MISSION, { useFactory: (request) => request.mission, deps: [REQUEST], lifetime: 'scoped', }), ], exports: [MISSION], }); const BridgeApi = defineModule({ name: 'BridgeApi', imports: [Tactical], providers: [ provide(FLIGHT_LOG, { useClass: ShuttleFlightLog, lifetime: 'scoped' }), ], exports: [FLIGHT_LOG], }); const Meridian = defineModule({ name: 'Meridian', imports: [BridgeApi] }); await using ship = await Nexus.create(Meridian); const scopes = nodeScopes(); // Deep in the call chain: no scope is passed in, scopes.current() finds it. async function logArrival(): Promise<string> { await Promise.resolve(); const shuttle = scopes.current(); if (shuttle === undefined) throw new Error('called outside a request'); return shuttle.get(FLIGHT_LOG).record('arrived'); } async function dispatch(req: IncomingRequest): Promise<string> { await using shuttle = await ship.createScope({ request: { mission: req.mission }, }); return await scopes.run(shuttle, () => logArrival()); } const req: IncomingRequest = { mission: { id: 'survey-7', target: 'Kepler-442b' }, }; const reply = await dispatch(req); // -> 'survey-7: arrived' console.log(reply);

dispatch(req) creates one scope per request with the request’s mission and runs the handler inside it. logArrival() takes no scope argument: it reads the scope from scopes.current() and resolves the scoped FLIGHT_LOG.

dispatch returns await scopes.run(...). The await keeps the scope open until the handler finishes. Without it, await using disposes the scope first, and the handler’s next get() throws NEXUS_DISPOSED. See NEXUS_DISPOSED.

Try it:

  1. Save the region as dispatch.ts and run npx tsx dispatch.ts. It prints survey-7: arrived.
  2. Change return await scopes.run( to return scopes.run( and run it again. The handler throws NEXUS_DISPOSED, because the scope closed before logArrival() called get().
  3. Put the await back and run it again. It prints survey-7: arrived.

The REQUEST Token

REQUEST resolves to the object you pass to createScope({ request }). Any scoped provider can inject it to read request-specific data (like user IDs or headers) directly.

To get full type safety, augment the NexusRequest interface:

Not run

declare module '@nexusdi/core' { interface NexusRequest { mission: { id: string; target: string }; } }

The dispatch region relies on this declaration.

Accessing the Current Scope

If you are in a function that was called inside a run() block, you can retrieve the current scope using scopes.current(). If current() is called outside of a run() block, it returns undefined. A function that needs a scope checks for it and fails with its own message, as logArrival() does above.

import { nodeScopes } from '@nexusdi/node'; const scopes = nodeScopes(); const outside = scopes.current(); // -> undefined console.log(outside);

Try it: Save the region as outside.ts and run it. It prints undefined. Then, in dispatch.ts, replace return await scopes.run(shuttle, () => logArrival()); with return logArrival(); and run it. The scope exists, but logArrival() runs outside run(), so current() returns undefined and the program throws Error: called outside a request. Restore the line to get survey-7: arrived back.

Concurrency and Isolation

Because nodeScopes() uses AsyncLocalStorage, it is completely safe for concurrent requests. Even if a thousand requests are being handled simultaneously, scopes.current() will always return the scope associated with the specific async chain of the current request. Each run() starts its own async context.

The nodeScopes() object registers with no container. Create it once per process and share it between every container and every handler.

import { Nexus, REQUEST, Token, defineModule, provide } from '@nexusdi/core'; import { nodeScopes } from '@nexusdi/node'; interface Mission { readonly id: string; readonly target: string; } const MISSION = new Token<Mission>('Mission'); const Tactical = defineModule({ name: 'Tactical', providers: [ provide(MISSION, { useFactory: (request) => request.mission, deps: [REQUEST], lifetime: 'scoped', }), ], }); await using ship = await Nexus.create(Tactical); const scopes = nodeScopes(); async function dispatch(mission: Mission): Promise<string | undefined> { await using shuttle = await ship.createScope({ request: { mission } }); return await scopes.run(shuttle, async () => { await new Promise((resolve) => setTimeout(resolve, 1)); return scopes.current()?.get(MISSION).id; }); } const first = dispatch({ id: 'survey-7', target: 'Kepler-442b' }); const second = dispatch({ id: 'survey-8', target: 'Proxima b' }); const ids = await Promise.all([first, second]); // -> ['survey-7', 'survey-8'] console.log(ids);

Try it: Run the region. It prints [ 'survey-7', 'survey-8' ], although both handlers wait on a timer at the same time.

Integrating with node:http

Integrating NexusDI scopes into a standard Node.js server is straightforward. The server calls dispatch from the region above, which creates a scope for every incoming request and runs the handler in it. The server reads the mission from the request, and the reply goes back as the response body.

A server keeps its container for the life of the process, so declare it with const ship. With await using ship at the top level of a module, the container is disposed as soon as the module finishes loading, and the first request throws NEXUS_DISPOSED.

Not run

import { createServer } from 'node:http'; createServer(async (req, res) => { const url = new URL(req.url ?? '/', 'http://localhost'); const id = url.searchParams.get('mission') ?? 'survey-7'; const reply = await dispatch({ mission: { id, target: 'Kepler-442b' } }); res.end(reply); }).listen(3000);

By doing this, every request gets its own isolated set of scoped providers, which are automatically disposed of when the request settles.

Try it: In dispatch.ts, change await using ship to const ship, add the server lines at the end, and run it. The terminal prints survey-7: arrived once, from the region’s own call, and the process keeps running. Open http://localhost:3000/?mission=survey-9. The page shows survey-9: arrived. Stop the server with Ctrl-C. To see the failure, change const ship back to await using ship and restart the server: the first request throws NEXUS_DISPOSED in the terminal and the process exits without a reply. Change it to const ship again to fix it.