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.
Writing NexusDI Plugins
NexusDI is designed to be extended. Whether you need to add custom logging, integrate with an external monitoring tool, or implement a specialized provider lookup, you can do so by writing a plugin.
The first-party packages @nexusdi/errors, @nexusdi/devtools, @nexusdi/testing, @nexusdi/federation and @nexusdi/interceptors use only the API on this page, so a third-party plugin can do what they do.
The Extension Principle
To ensure that the ecosystem remains stable and maintainable, all NexusDI plugins follow the Extension Principle. This principle ensures that packages integrate through public contribution points and never reach into internal state. A package that does not own a new error, event, graph note or package needs no change when one is added.
Key Rules for Plugin Authors:
- Own your vocabulary: Never rely on internal strings or hidden fields of other packages. A package reads another package’s codes, event types and ids only through what that package exports. It never lists them in a table, a switch, a regex or a string compare.
- Open Types: Use augmentable interfaces (like
NexusErrorByCode) so other plugins can add their own types. A set that other packages extend is open in its type.NexusErrorByCodeandTraceEventByTypeare interfaces a package augments, text packs and graph annotators are arguments the application passes, and code that branches on such a set handles a member it does not know. - Public Hooks: Only integrate via official plugin hooks or the
PluginContext. The public, versioned contribution points are a plugin hook, aPluginContextmember, a view field and an options argument. - No Logic Duplication: If two plugins need the same logic, the owner of that logic should export a helper.
- One-Way Optional Dependencies: An optional dependency points one way, and the application writes the wiring line. A package names an optional package only in a type import or from a subpath the application opts into, such as
./text, does not look for a missing package at run time, and never looks a plugin up by name.
The Plugin Object
A plugin is a plain JavaScript object that declares its name and the apiVersion it was written against. The version is NEXUS_PLUGIN_API from @nexusdi/core. It then implements one or more optional hooks. Core reads the hooks once, when Nexus.create registers the plugin.
Not run
const myPlugin = {
name: 'my-plugin',
apiVersion: NEXUS_PLUGIN_API,
// Hooks go here...
};A package exports a function that returns the plugin, so the application passes options and registers one object per container. This plugin observes the trace and records each provider the container builds:
import { NEXUS_PLUGIN_API, Nexus, Token, defineModule } from '@nexusdi/core';
import { provide } from '@nexusdi/core';
import type { NexusPlugin } from '@nexusdi/core';
interface IReactorCore {
readonly output: number;
}
interface IShipComputer {
status(): string;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
const COMPUTER = new Token<IShipComputer>('ShipComputer');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
class QuantumComputer implements IShipComputer {
static deps = [REACTOR] as const;
constructor(private readonly reactor: IReactorCore) {}
status() {
return `online at ${this.reactor.output} GW`;
}
}
// @acme/flight-recorder
function flightRecorder(log: string[]): NexusPlugin {
return {
name: 'acme:flight-recorder',
apiVersion: NEXUS_PLUGIN_API,
observe(event) {
if (event.type === 'construct') log.push(`built ${event.token}`);
},
};
}
const log: string[] = [];
await using ship = await Nexus.create(
defineModule({
name: 'Engineering',
providers: [
provide(REACTOR, { useClass: FusionReactor }),
provide(COMPUTER, { useClass: QuantumComputer }),
],
exports: [COMPUTER],
}),
{ plugins: [flightRecorder(log)] },
);
const recorded = log; // -> ['built ReactorCore', 'built ShipComputer']
console.log(recorded);Try it:
- Save the region as
recorder.tsand runnpx tsx recorder.ts. It prints[ 'built ReactorCore', 'built ShipComputer' ]. - Change
'construct'to'init'and run again. It prints[]: aninitevent comes from anonInitmethod, and neither class has one.npx tsc --noEmitaccepts the line, because'init'is a trace event type. - Change
'init'back to'construct'and the two lines return.
Common Hooks
Every hook is optional:
| Hook | Purpose |
|---|---|
modules | Adds modules to the root’s imports, compiled like any import. |
onInit | false skips onInit at create and load. |
tokenKey | Maps a token to the key every lookup uses; one key finds one provider. |
compile.module | Replaces a module definition before the compiler validates it. |
compile.provider | Replaces or removes a provider before the compiler validates it. |
compile.check | Validate the graph and report custom errors before the container starts. |
construct | Wrap or observe instances as they are created. It may return a wrapper to store. |
observe | Listen to every trace event emitted by the container. |
formatError | Provide custom human-readable text for specific error codes. |
setup | Perform one-time initialization after singletons are built. It runs once, in plugin order, and receives a PluginContext. |
dispose | Runs at container disposal, after every root-owned instance, in reverse plugin order, for plugins whose setup finished. |
Reporting Custom Errors
If your plugin finds a problem with the module graph during compile.check, you can report it as a formal NexusDI error. The call is compile.check(view, report). Nexus.create adds the error to the NEXUS_BLUEPRINT_INVALID it throws.
- Define the Error: Use
errorBase()to create a stable error class with a code and a link to your documentation. The call iserrorBase(code, name, docsBase). The third argument is the base URL of your docs, and the one-line message links to it followed by the code. Add the code toNexusErrorByCode, soisNexusErrornarrows it and a text pack can name it. - Provide the Text: Create a text pack that maps the error code to a human-readable message. Type it with
satisfies ErrorTextPackand publish it at the package’s./textsubpath. The application passes the pack toerrors({ text }). - Report the Error: Call
report(), the second parameter of yourcompile.checkhook.
import { NEXUS_PLUGIN_API, Nexus, Token, defineModule } from '@nexusdi/core';
import { errorBase, isNexusError, provide } from '@nexusdi/core';
import type { ErrorTextPack, NexusPlugin } from '@nexusdi/core';
import { errors } from '@nexusdi/errors';
// @acme/flight-recorder
interface RecorderTargetFields {
readonly module: string;
}
class RecorderTargetError extends errorBase<
'ACME_RECORDER_TARGET',
RecorderTargetFields
>('ACME_RECORDER_TARGET', 'RecorderTargetError', 'https://acme.dev/errors/') {}
declare module '@nexusdi/core' {
interface NexusErrorByCode {
ACME_RECORDER_TARGET: RecorderTargetError;
}
}
function flightRecorder(options: { module: string }): NexusPlugin {
return {
name: 'acme:flight-recorder',
apiVersion: NEXUS_PLUGIN_API,
compile: {
check(view, report) {
if (!view.modules.some((module) => module.name === options.module))
report(new RecorderTargetError({ module: options.module }));
},
},
};
}
// @acme/flight-recorder/text
const recorderText = {
ACME_RECORDER_TARGET: (error) => ({
message: `the flight recorder watches ${error.module}, which the module graph does not hold.`,
fix: `import ${error.module}, or pass the name of a module the graph holds.`,
}),
} satisfies ErrorTextPack;
// The application.
interface IReactorCore {
readonly output: number;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
const Engineering = defineModule({
name: 'Engineering',
providers: [provide(REACTOR, { useClass: FusionReactor })],
});
async function messageWith(withText: boolean) {
const error = await Nexus.create(Engineering, {
plugins: [
errors(withText ? { text: [recorderText] } : undefined),
flightRecorder({ module: 'Tactical' }),
],
}).catch((caught: unknown) => caught);
return isNexusError(error, 'NEXUS_BLUEPRINT_INVALID')
? error.errors[0]?.message
: undefined;
}
const thin = await messageWith(false); // -> '[ACME_RECORDER_TARGET] module=Tactical. https://acme.dev/errors/ACME_RECORDER_TARGET'
console.log(thin);
const full = await messageWith(true);
const lines = full?.split('\n'); // -> ['[ACME_RECORDER_TARGET] the flight recorder watches Tactical, which the module graph does not hold.', ' Fix: import Tactical, or pass the name of a module the graph holds.']
console.log(full);The pack and the error class are published in one package at one version, so the pack reads the fields of the class it was written with. Keep the codes of your package under a prefix of its own, such as ACME_.
Try it:
- Run
npm install @nexusdi/errors@next, save the region asrecorder-error.tsand runnpx tsx recorder-error.ts. The first line is the one-line message with the link, and the next two are the full text with itsFix:line. - In the
recorderTextentry, changeerror.moduletoerror.targetin themessageline.npx tsc --noEmitflags it withProperty 'target' does not exist on type 'RecorderTargetError'., andnpx tsx recorder-error.tsstill runs and printswatches undefinedin the full text. Change it back toerror.module. - Change the plugin’s option to
{ module: 'Engineering' }. The graph holds that module, socompile.checkreports nothing andNexus.createresolves. The region then printsundefinedtwice, because it reads the message of an error that never happened. Change the option back to'Tactical'and the messages return.
Emitting Custom Trace Events
You can add your own events to the container’s trace, which allows other plugins (and the @nexusdi/devtools plugin) to observe your plugin’s behavior.
By augmenting the TraceEventByType interface, you can add type-safe events that you can emit from the PluginContext. Use keys of the form <package>/<event>. Emit with context.emit(make) from the PluginContext your setup hook receives. Every observe hook receives the event, and make runs only when an observer exists.
import { NEXUS_PLUGIN_API, Nexus, Token, defineModule } from '@nexusdi/core';
import { provide } from '@nexusdi/core';
import type { NexusPlugin, TraceEvent } from '@nexusdi/core';
import { trace } from '@nexusdi/devtools';
// @acme/flight-recorder
declare module '@nexusdi/core' {
interface TraceEventByType {
'acme-recorder/armed': { providers: number };
}
}
const flightRecorder: NexusPlugin = {
name: 'acme:flight-recorder',
apiVersion: NEXUS_PLUGIN_API,
setup(context) {
const providers = context.blueprint().providers.length;
context.emit(() => ({ type: 'acme-recorder/armed', providers }));
},
};
// The application.
interface IReactorCore {
readonly output: number;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
const events: TraceEvent[] = [];
await using ship = await Nexus.create(
defineModule({
name: 'Engineering',
providers: [provide(REACTOR, { useClass: FusionReactor })],
}),
{ plugins: [trace((event) => events.push(event)), flightRecorder] },
);
const types = events.map((event) => event.type); // -> ['compile', 'construct', 'acme-recorder/armed']
console.log(types);Try it:
- Run
npm install @nexusdi/devtools@next, save the region asarmed.tsand runnpx tsx armed.ts. It prints[ 'compile', 'construct', 'acme-recorder/armed' ]. - Delete the
declare module '@nexusdi/core'block.npx tsc --noEmitflags thecontext.emitline withType '{ type: string; providers: number; }' is not assignable to type 'never'., because noTraceEventByTypekey matches.npx tsx armed.tsstill prints the same three types. - Put the block back and the error goes away.
Graph Annotation
If your package analyzes the graph, it can add “notes” to specific providers. Annotators are not a plugin hook: the application passes GraphAnnotator functions to devtools({ annotate }), and graph() lists the notes on each provider. An annotator reads the container’s BlueprintView and returns notes, each a provider id and a label. It matches the shape with no import from @nexusdi/devtools, so a package publishes one from a subpath and keeps devtools out of its dependencies.
import { Nexus, Token, defineModule, provide } from '@nexusdi/core';
import type { BlueprintView } from '@nexusdi/core';
import { devtools, graph } from '@nexusdi/devtools';
// @acme/flight-recorder/devtools: the shape of a GraphAnnotator, with no import from @nexusdi/devtools.
const recorderNotes = (view: BlueprintView) =>
view.providers
.filter((provider) => provider.lifetime === 'singleton')
.map((provider) => ({ provider: provider.id, label: 'recorded' }));
// The application.
interface IReactorCore {
readonly output: number;
}
const REACTOR = new Token<IReactorCore>('ReactorCore');
class FusionReactor implements IReactorCore {
readonly output = 1.21;
}
await using ship = await Nexus.create(
defineModule({
name: 'Engineering',
providers: [provide(REACTOR, { useClass: FusionReactor })],
}),
{ plugins: [devtools({ annotate: [recorderNotes] })] },
);
const reactor = graph(ship).providers.find(
(provider) => provider.token === 'ReactorCore',
);
const notes = reactor?.notes; // -> ['recorded']
console.log(notes);Try it:
- Run
npm install @nexusdi/devtools@next, save the region asnotes.tsand runnpx tsx notes.ts. It prints[ 'recorded' ]. - Change
'singleton'to'transient'.npx tsc --noEmitaccepts it, and the run prints[], becauseReactorCoreis a singleton and no note matches it. - Change it back to
'singleton', then changelabel: 'recorded'tolabel: 42.npx tsc --noEmitflags theannotateoption with an error that ends withType 'number' is not assignable to type 'string'., and the run prints[ 42 ]. Restore'recorded'and the first output returns.
Comparing Tokens with view.canonical()
With a tokenKey plugin registered, several tokens find one provider, and ProviderView.token holds the first of them the container met. A plugin compares a token it holds through canonical(), which the compile context and every BlueprintView carry:
import { NEXUS_PLUGIN_API, Nexus, Token, defineModule } from '@nexusdi/core';
import { provide } from '@nexusdi/core';
import type { NexusPlugin } from '@nexusdi/core';
interface ISubspaceLink {
send(message: string): string;
}
// Two copies of one contracts package each make their own token.
const SHELL_LINK = new Token<ISubspaceLink>('comms/SubspaceLink');
const REMOTE_LINK = new Token<ISubspaceLink>('comms/SubspaceLink');
class SubspaceRelay implements ISubspaceLink {
send(message: string) {
return `relayed ${message}`;
}
}
const byDescription: NexusPlugin = {
name: 'acme:by-description',
apiVersion: NEXUS_PLUGIN_API,
tokenKey: (token) => (token instanceof Token ? token.description : undefined),
};
let provided = false;
const linkProbe: NexusPlugin = {
name: 'acme:link-probe',
apiVersion: NEXUS_PLUGIN_API,
compile: {
check(view) {
const link = view.canonical(REMOTE_LINK);
provided = view.providers.some((provider) => provider.token === link);
},
},
};
await using ship = await Nexus.create(
defineModule({
name: 'Comms',
providers: [provide(SHELL_LINK, { useClass: SubspaceRelay })],
exports: [SHELL_LINK],
}),
{ plugins: [byDescription, linkProbe] },
);
const seen = provided; // -> true
console.log(seen);
const sent = ship.get(REMOTE_LINK).send('hail'); // -> 'relayed hail'
console.log(sent);Try it:
- Save the region as
canonical.tsand runnpx tsx canonical.ts. It printstrueandrelayed hail. - Change
plugins: [byDescription, linkProbe]toplugins: [linkProbe].npx tsc --noEmitaccepts it. The run printsfalse, then throws NEXUS_MISSING_PROVIDER forcomms/SubspaceLinkin moduleComms, because the two tokens are now two keys. - Put
byDescriptionback and both lines return.
Versioning and Compatibility
To prevent crashes, NexusDI checks the apiVersion of every plugin. NEXUS_PLUGIN_API is 1 in @nexusdi/core 0.4. A new optional member of a hook, a PluginContext member, an ErrorTextKit member, a GraphNote field or a TraceEventByType key keeps the version. If the core API changes in a way that breaks a plugin, the version is incremented. Core accepts every plugin API version it still supports. If a plugin’s apiVersion is not one of them, Nexus.create rejects the startup process with a NEXUS_PLUGIN_VERSION error. See NEXUS_PLUGIN_VERSION.
Declare @nexusdi/core as a peer dependency, and add the npm keyword nexusdi-plugin to your package.json so a reader can find the package. NexusDI Plugins shows the application side.