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.
Scopes and the REQUEST Token
Most applications have two types of state: Global state (like a database connection pool) and Request state (like the current user or a transaction ID).
While singletons handle global state perfectly, using them for request-specific data is impossible in a concurrent environment. Scopes solve this by creating a temporary scope for a single unit of work.
What is a Scope?
A scope is a short-lived container created from a root container. It inherits all the singletons from the root but maintains its own independent instances of any provider marked as lifetime: 'scoped'. Create one with ship.createScope({ request }). A forRoot() module works inside a scope as it does at the root.
The examples on this page assume the request.d.ts file from Typing your Request below.
import { Nexus, REQUEST, Token, defineModule, provide } from '@nexusdi/core';
interface Mission {
readonly id: string;
readonly target: string;
}
interface IReactorCore {
readonly output: number;
}
interface IShipComputer {
readonly reactor: IReactorCore;
}
interface IFlightLog {
readonly mission: Mission;
readonly computer: IShipComputer;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
const COMPUTER = new Token<IShipComputer>('ShipComputer');
const MISSION = new Token<Mission>('Mission');
const FLIGHT_LOG = new Token<IFlightLog>('FlightLog');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
class QuantumComputer implements IShipComputer {
static deps = [REACTOR] as const;
constructor(readonly reactor: IReactorCore) {}
}
class ShuttleFlightLog implements IFlightLog {
static deps = [MISSION, COMPUTER] as const;
constructor(
readonly mission: Mission,
readonly computer: IShipComputer,
) {}
}
const Meridian = defineModule({
name: 'Meridian',
providers: [
provide(REACTOR, { useClass: FusionReactor }),
provide(COMPUTER, { useClass: QuantumComputer }),
provide(MISSION, {
useFactory: (request) => request.mission,
deps: [REQUEST],
lifetime: 'scoped',
}),
provide(FLIGHT_LOG, { useClass: ShuttleFlightLog, lifetime: 'scoped' }),
],
});
await using ship = await Nexus.create(Meridian);
await using first = await ship.createScope({
request: { mission: { id: 'survey-7', target: 'Kepler-442b' } },
});
await using second = await ship.createScope({
request: { mission: { id: 'survey-8', target: 'Gliese 667 Cc' } },
});
const ids = [first.id, second.id]; // -> ['s0', 's1']
console.log(ids);
const firstLog = first.get(FLIGHT_LOG);
const secondLog = second.get(FLIGHT_LOG);
const missions = [firstLog.mission.id, secondLog.mission.id]; // -> ['survey-7', 'survey-8']
console.log(missions);
const sameLog = firstLog === first.get(FLIGHT_LOG); // -> true
console.log(sameLog);
const sharedComputer = firstLog.computer === secondLog.computer; // -> true
console.log(sharedComputer);In the example above, each shuttle has its own FLIGHT_LOG (scoped), but both share the same COMPUTER (singleton). Each scope has an id, s0 and s1 here, and inside one scope get(FLIGHT_LOG) returns the same instance every time. Once the unit of work is finished, the scope is disposed, and its scoped instances are cleaned up. await using disposes each scope at the end of its block.
Try it: Save request.d.ts and the region as shuttles.ts in one folder, and run npx tsx shuttles.ts. It prints the two scope ids and the two missions. Add a third createScope() call with the mission survey-9, bind it as third, and print third.id and third.get(FLIGHT_LOG).mission.id. It prints s2 and survey-9. Compare that flight log with the first scope’s and it prints false: a third scope gets a third flight log. Delete mission from the second scope’s request and tsc reports Property 'mission' is missing in type '{}' but required in type 'NexusRequest' on the request line of that call.
The REQUEST Token
To make a scope useful, you need to pass external data (like an HTTP request) into it. NexusDI provides a built-in REQUEST token for this purpose.
When you call createScope({ request }), the object you pass as the request is bound to the REQUEST token for the duration of that scope.
Typing your Request
To get full TypeScript support, augment the NexusRequest interface in a .d.ts file:
Not run
// request.d.ts
import '@nexusdi/core';
declare module '@nexusdi/core' {
interface NexusRequest {
mission: { id: string; target: string };
}
}With it in place, request.mission in a factory over REQUEST is typed, and createScope() rejects a request without a mission at compile time.
Resolving Scoped Tokens from the Root
The root container has no request, so NexusDI refuses to build a scoped provider there. ship.get(MISSION) on the root throws NEXUS_SCOPE_REQUIRED, and the fix is to resolve the token from a scope.
import { Nexus, REQUEST, Token, isNexusError, provide } from '@nexusdi/core';
interface Mission {
readonly id: string;
readonly target: string;
}
const MISSION = new Token<Mission>('Mission');
await using ship = await Nexus.create([
provide(MISSION, {
useFactory: (request) => request.mission,
deps: [REQUEST],
lifetime: 'scoped',
}),
]);
let error: unknown;
try {
ship.get(MISSION);
} catch (caught) {
error = caught;
}
const code = isNexusError(error) ? error.code : null; // -> 'NEXUS_SCOPE_REQUIRED'
console.log(code);Try it: Save the region as root-get.ts and run it. It prints NEXUS_SCOPE_REQUIRED. Add this after the catch block:
Not run
await using shuttle = await ship.createScope({
request: { mission: { id: 'survey-7', target: 'Kepler-442b' } },
});
console.log(shuttle.get(MISSION));The program now prints NEXUS_SCOPE_REQUIRED and then { id: 'survey-7', target: 'Kepler-442b' }, because the scope has a request to build the mission from.
The Captive Dependency Rule
One of the most important rules in NexusDI is that a singleton cannot depend on a scoped provider. The same holds for REQUEST.
If a singleton (which lasts forever) depended on a scoped provider (which lasts for one request), the singleton would “capture” the first request’s instance and hold onto it forever. This is a Captive Dependency.
NexusDI prevents this at startup. It does so when it compiles the graph, before any constructor runs. If it detects a singleton reaching a scoped provider, Nexus.create rejects with NEXUS_BLUEPRINT_INVALID, whose errors list holds NEXUS_LIFETIME_VIOLATION. See NEXUS_LIFETIME_VIOLATION.
import { Nexus, REQUEST, Token, isNexusError, provide } from '@nexusdi/core';
interface Mission {
readonly id: string;
readonly target: string;
}
interface IShipComputer {
readonly mission: Mission;
}
const MISSION = new Token<Mission>('Mission');
const COMPUTER = new Token<IShipComputer>('ShipComputer');
class QuantumComputer implements IShipComputer {
static deps = [MISSION] as const;
constructor(readonly mission: Mission) {}
}
const blueprint = [
provide(MISSION, {
useFactory: (request) => request.mission,
deps: [REQUEST],
lifetime: 'scoped',
}),
// COMPUTER is a singleton, and MISSION is scoped.
provide(COMPUTER, { useClass: QuantumComputer }),
];
const error = await Nexus.create(blueprint).catch((caught: unknown) => caught);
const inner = isNexusError(error, 'NEXUS_BLUEPRINT_INVALID')
? error.errors
: [];
const codes = inner.map((each) => each.code); // -> ['NEXUS_LIFETIME_VIOLATION']
console.log(codes);
const pathOf = (each: unknown) =>
isNexusError(each, 'NEXUS_LIFETIME_VIOLATION') ? each.path : [];
const path = pathOf(inner[0]); // -> ['ShipComputer', 'Mission']
console.log(path);The error’s path names the providers from the singleton to the scoped provider, here ShipComputer and Mission. The fix is to give the consumer lifetime: 'scoped', or to move the scoped value into a method argument.
Try it: Save the region as captive.ts and run it. It prints [ 'NEXUS_LIFETIME_VIOLATION' ] and [ 'ShipComputer', 'Mission' ]. Add lifetime: 'scoped' to the COMPUTER provider and run it again. Nexus.create resolves, so the program prints [] twice. A scoped COMPUTER is built once per scope.
Scoped Execution Details
Eager vs Lazy Scoped Providers
- Scoped Factories: By default, these run immediately when
createScope()is called. Each runs once per scope, even when the request never asks for its token. - Scoped Classes: These are created lazily on the first
get()within the scope. A scoped factory declared witheager: falsebehaves the same way.
If you have heavy work (like starting a DB transaction), put it in a scoped class, not a factory, to ensure it only runs if actually requested. Keep scoped factories cheap.
import { Nexus, REQUEST, Token, provide } from '@nexusdi/core';
interface Mission {
readonly id: string;
readonly target: string;
}
interface IFlightLog {
record(entry: string): void;
}
const MISSION = new Token<Mission>('Mission');
const FLIGHT_LOG = new Token<IFlightLog>('FlightLog');
const built: string[] = [];
class ShuttleFlightLog implements IFlightLog {
static deps = [MISSION] as const;
constructor(readonly mission: Mission) {
built.push(`flight log for ${mission.id}`);
}
record(_entry: string) {}
}
await using ship = await Nexus.create([
provide(MISSION, {
useFactory: (request) => {
built.push(`mission ${request.mission.id}`);
return request.mission;
},
deps: [REQUEST],
lifetime: 'scoped',
}),
provide(FLIGHT_LOG, { useClass: ShuttleFlightLog, lifetime: 'scoped' }),
]);
await using shuttle = await ship.createScope({
request: { mission: { id: 'survey-7', target: 'Kepler-442b' } },
});
const atLaunch = [...built]; // -> ['mission survey-7']
console.log(atLaunch);
shuttle.get(FLIGHT_LOG).record('orbit reached');
const afterGet = [...built]; // -> ['mission survey-7', 'flight log for survey-7']
console.log(afterGet);Right after createScope() the log holds mission survey-7: the MISSION factory ran. The flight log appears only after shuttle.get(FLIGHT_LOG).
Try it: Save the region as factories.ts and run it. It prints [ 'mission survey-7' ] and then [ 'mission survey-7', 'flight log for survey-7' ]. Remove the shuttle.get(FLIGHT_LOG) line and run it again. Both lines print [ 'mission survey-7' ]: the factory runs at createScope() whether the shuttle uses it or not, and the flight log is never built. Put the line back and the second line holds both entries again.
Transients Inside a Scope
A transient or scoped provider may depend on scoped providers, and NexusDI builds it inside the scope that resolves it. From this page on, ScoutDrone declares static deps = [COMPUTER, MISSION] as const: each drone reads the mission of the shuttle that launched it and the ship’s shared computer.
import { Nexus, REQUEST, Token, provide } from '@nexusdi/core';
interface Mission {
readonly id: string;
readonly target: string;
}
interface IShipComputer {
plot(target: string): string;
}
interface ISurveyDrone {
survey(): string;
}
const COMPUTER = new Token<IShipComputer>('ShipComputer');
const MISSION = new Token<Mission>('Mission');
const DRONE = new Token<ISurveyDrone>('SurveyDrone');
class QuantumComputer implements IShipComputer {
plot(target: string) {
return `course to ${target}`;
}
}
class ScoutDrone implements ISurveyDrone {
static deps = [COMPUTER, MISSION] as const;
constructor(
private readonly computer: IShipComputer,
private readonly mission: Mission,
) {}
survey() {
return `${this.mission.id}: ${this.computer.plot(this.mission.target)}`;
}
}
await using ship = await Nexus.create([
provide(COMPUTER, { useClass: QuantumComputer }),
provide(MISSION, {
useFactory: (request) => request.mission,
deps: [REQUEST],
lifetime: 'scoped',
}),
provide(DRONE, { useClass: ScoutDrone, lifetime: 'transient' }),
]);
await using shuttle = await ship.createScope({
request: { mission: { id: 'survey-7', target: 'Kepler-442b' } },
});
const report = shuttle.get(DRONE).survey(); // -> 'survey-7: course to Kepler-442b'
console.log(report);Try it: Save the region as drone.ts and run it. It prints survey-7: course to Kepler-442b. Delete lifetime: 'transient' from the DRONE provider, which makes it a singleton, and run it again. Nexus.create rejects with NEXUS_BLUEPRINT_INVALID with one inner error, NEXUS_LIFETIME_VIOLATION with path=SurveyDrone,Mission lifetimes=singleton,scoped: a singleton drone would capture one shuttle’s mission. Put lifetime: 'transient' back and it prints survey-7: course to Kepler-442b again.