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.
Adding Modules After Startup
In many applications, you don’t want to load every single provider at boot time. For example, a specific set of tools might only be needed when a user visits a certain route.
Dynamic Loading allows you to add new modules to a running NexusDI container on demand. load() is named for sections of an application that load lazily, such as a route whose code is split into its own bundle:
Not run
await ship.load((await import('./survey.js')).SurveyModule);Using ship.load()
The load() method allows you to inject a new module into the existing container. This is particularly useful for code-splitting in frontend or Node.js applications. ship.load(Survey) compiles Survey as a new import of the root module.
import { Nexus, Token, defineModule, provide } from '@nexusdi/core';
interface INavCharts {
plot(to: string): string;
}
interface ISurveyDrone {
survey(): string;
}
const NAV_CHARTS = new Token<INavCharts>('NavCharts');
const DRONE = new Token<ISurveyDrone>('SurveyDrone');
const log: string[] = [];
class ScoutDrone implements ISurveyDrone {
static deps = [NAV_CHARTS] as const;
constructor(private readonly charts: INavCharts) {}
onInit() {
log.push('drone online');
}
survey() {
return this.charts.plot('Kepler-442b');
}
}
const Survey = defineModule({
name: 'Survey',
providers: [
provide(NAV_CHARTS, {
useFactory: async (): Promise<INavCharts> => {
log.push('charts downloaded');
return { plot: (to) => `course to ${to}` };
},
}),
provide(DRONE, { useClass: ScoutDrone }),
],
exports: [DRONE],
});
await using ship = await Nexus.create(defineModule({ name: 'Meridian' }));
const before = ship.has(DRONE); // -> false
console.log(before);
await ship.load(Survey);
const steps = log; // -> ['charts downloaded', 'drone online']
console.log(steps);
const course = ship.get(DRONE).survey(); // -> 'course to Kepler-442b'
console.log(course);When you call load(), NexusDI performs three critical steps:
- Validation: It validates the new module against the current live graph to ensure no wiring mistakes are introduced.
- Initialization: It builds the new module’s singletons and runs their
onInitmethods. It awaits their async factories first. - Publication: It updates the container’s internal registry so the new providers are now available via
get().
Before the call the container does not see DRONE. After it, NAV_CHARTS has finished its async download and ScoutDrone.onInit() has run, so get(DRONE) is synchronous like every other get().
Try it: Save the region as load.ts and run npx tsx load.ts. Its last line is course to Kepler-442b. Move the const course = ship.get(DRONE).survey(); line above await ship.load(Survey); and run it again: get() throws NEXUS_MISSING_PROVIDER, because the container does not see DRONE before the load. Move the line back and the last line is course to Kepler-442b again.
Validation and Safety
Dynamic loading does not bypass NexusDI’s safety guarantees. load() runs the same compiler Nexus.create runs, against the graph the container already holds. A loaded module sees its own providers, the exports of the modules it imports, and the exports of global modules. A provider in the root module is not visible to it unless one of those exports it. If the module depends on a token it cannot see, load() rejects with a NEXUS_BLUEPRINT_INVALID error. The container stays as it was. See NEXUS_BLUEPRINT_INVALID.
This ensures that your application never enters a partially-broken state where some dependencies are missing. Here ScoutDrone needs NAV_CHARTS, which nothing provides, so the errors list holds one NEXUS_MISSING_PROVIDER, and ship.has(DRONE) is still false:
import { Nexus, Token, defineModule, isNexusError } from '@nexusdi/core';
import { provide } from '@nexusdi/core';
interface INavCharts {
plot(to: string): string;
}
interface ISurveyDrone {
survey(): string;
}
const NAV_CHARTS = new Token<INavCharts>('NavCharts');
const DRONE = new Token<ISurveyDrone>('SurveyDrone');
class ScoutDrone implements ISurveyDrone {
static deps = [NAV_CHARTS] as const;
constructor(private readonly charts: INavCharts) {}
survey() {
return this.charts.plot('Kepler-442b');
}
}
const Survey = defineModule({
name: 'Survey',
providers: [provide(DRONE, { useClass: ScoutDrone })],
exports: [DRONE],
});
await using ship = await Nexus.create(defineModule({ name: 'Meridian' }));
const error = await ship.load(Survey).catch((caught: unknown) => caught);
const errors = isNexusError(error, 'NEXUS_BLUEPRINT_INVALID')
? error.errors
: [];
const codes = errors.map((inner) => inner.code); // -> ['NEXUS_MISSING_PROVIDER']
console.log(codes);
const unchanged = ship.has(DRONE); // -> false
console.log(unchanged);Try it: Add provide(NAV_CHARTS, { useFactory: () => ({ plot: (to: string) => to }) }) to the providers of Survey and run the region again. The load resolves, the errors list is empty, and the region prints [] and true. Delete the line and the region prints ['NEXUS_MISSING_PROVIDER'] and false again.
Loading Once, in Call Order
load() does nothing for a module the root already imports, and a second load() of the same module does nothing either. Below, three calls load Survey and ScoutDrone is built once.
import { Nexus, Token, defineModule, provide } from '@nexusdi/core';
interface ISurveyDrone {
readonly serial: number;
}
const DRONE = new Token<ISurveyDrone>('SurveyDrone');
let launched = 0;
class ScoutDrone implements ISurveyDrone {
readonly serial = ++launched;
}
const Survey = defineModule({
name: 'Survey',
providers: [provide(DRONE, { useClass: ScoutDrone })],
exports: [DRONE],
});
await using ship = await Nexus.create(defineModule({ name: 'Meridian' }));
await Promise.all([ship.load(Survey), ship.load(Survey)]);
await ship.load(Survey);
const built = launched; // -> 1
console.log(built);Try it: Add imports: [Survey] to the root module, defineModule({ name: 'Meridian', imports: [Survey] }), and run the region again. It still prints 1: the root already imports Survey, so every load(Survey) returns at once.
Concurrent load() calls run one at a time, in call order. Here Science waits for Survey, although Survey’s factory is the slower one:
import { Nexus, Token, defineModule, provide } from '@nexusdi/core';
interface ISensor {
readonly name: string;
}
const SURVEY_SENSOR = new Token<ISensor>('SurveySensor');
const SCIENCE_SENSOR = new Token<ISensor>('ScienceSensor');
const log: string[] = [];
const Survey = defineModule({
name: 'Survey',
providers: [
provide(SURVEY_SENSOR, {
useFactory: async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
log.push('survey');
return { name: 'survey' };
},
}),
],
});
const Science = defineModule({
name: 'Science',
providers: [
provide(SCIENCE_SENSOR, {
useFactory: () => {
log.push('science');
return { name: 'science' };
},
}),
],
});
await using ship = await Nexus.create(defineModule({ name: 'Meridian' }));
await Promise.all([ship.load(Survey), ship.load(Science)]);
const order = log; // -> ['survey', 'science']
console.log(order);Updating Scopes with scope.extend()
Scopes are snapshots of the graph at the moment they were created. If you load a new module into the root container, existing scopes won’t see those new providers by default. A token that a later load() added throws NEXUS_LOADED_AFTER_SCOPE in that scope.
To bring a scope up to date with the latest graph, use scope.extend(). Call it with await. It builds the scoped factories the loaded modules add, and the instances the scope already built stay as they are.
import { Nexus, Token, defineModule, isNexusError } from '@nexusdi/core';
import { provide } from '@nexusdi/core';
interface ISensorSweep {
readonly target: string;
}
const SWEEP = new Token<ISensorSweep>('SensorSweep');
const Science = defineModule({
name: 'Science',
providers: [
provide(SWEEP, {
useFactory: () => ({ target: 'Kepler-442b' }),
lifetime: 'scoped',
}),
],
exports: [SWEEP],
});
await using ship = await Nexus.create(defineModule({ name: 'Meridian' }));
await using shuttle = await ship.createScope();
await ship.load(Science);
let code: string | null = null;
try {
shuttle.get(SWEEP);
} catch (error) {
code = isNexusError(error) ? error.code : null;
}
const beforeExtend = code; // -> 'NEXUS_LOADED_AFTER_SCOPE'
console.log(beforeExtend);
await shuttle.extend();
const target = shuttle.get(SWEEP).target; // -> 'Kepler-442b'
console.log(target);This allows a long-lived scope (like a user session) to gain access to features that were loaded dynamically after the session started.
Try it: Delete the await shuttle.extend(); line and run the region. The region prints NEXUS_LOADED_AFTER_SCOPE for the first get(), and the final get() throws the same code and stops the program. Put the line back and the region prints NEXUS_LOADED_AFTER_SCOPE and then Kepler-442b.
Design Constraints
To maintain the integrity of the graph, NexusDI imposes a few restrictions on dynamic loading:
- No Global Modules: You cannot
load()a module marked asglobal: true. Global modules must be declared in the root module at startup because they affect the visibility of every other module. A global module makes its exports visible to every module, so a global module new to the graph would change what every existing module sees. Import a global module from the root module beforeNexus.create, or load it withoutglobal: true. - Sealed Container: Once a provider is registered, it cannot be changed.
load()adds new providers but does not allow you to replace existing ones.
load() rejects a global module with NEXUS_LOAD_GLOBAL_MODULE, which it throws directly, outside a NEXUS_BLUEPRINT_INVALID error. Its module property names the module, Science here:
import { Nexus, Token, defineModule, isNexusError } from '@nexusdi/core';
import { provide } from '@nexusdi/core';
interface ISensorSweep {
readonly target: string;
}
const SWEEP = new Token<ISensorSweep>('SensorSweep');
const Science = defineModule({
name: 'Science',
global: true,
providers: [provide(SWEEP, { useValue: { target: 'Kepler-442b' } })],
exports: [SWEEP],
});
await using ship = await Nexus.create(defineModule({ name: 'Meridian' }));
const error = await ship.load(Science).catch((caught: unknown) => caught);
const message = error instanceof Error ? error.message : ''; // -> '[NEXUS_LOAD_GLOBAL_MODULE] module=Science. https://nexus.js.org/errors/NEXUS_LOAD_GLOBAL_MODULE'
console.log(message);
const fields = isNexusError(error, 'NEXUS_LOAD_GLOBAL_MODULE') ? error : null;
const code = fields?.code; // -> 'NEXUS_LOAD_GLOBAL_MODULE'
console.log(code);
const named = fields?.module; // -> 'Science'
console.log(named);Try it: Delete the global: true, line and run the region again. The load resolves and the region prints an empty line and then undefined twice, because there is no error to show.
Why There Is No set()
In 0.3, set() registered one provider into a mutable container at any time, before or after the first get(). Nexus.create in 0.4 validates the whole graph before it builds anything, so every provider is declared in a module, and the container is sealed once create resolves. load() keeps that guarantee: it takes a whole module and validates it against the live graph. A per-token set() after create would add a provider the compiler never validated. The upgrade guide shows what each 0.3 set() call becomes.
Rules
- On a compile error, the container stays as it was. When a build or an
onInitfails during the load, NexusDI disposes what the load built. load()of a module the root already imports, or a secondload()of the same module, does nothing.- Concurrent
load()calls run one at a time, in call order. get()of a token from a module that is not loaded yet throwsNEXUS_MISSING_PROVIDER.