This document specifies a minimalist strongly typed immutable plugin system for Node.js.
Via negativa, we're:
Library: this package — the immutable plugin system as specified here
(exported types and the ImmutableHost
machinery).
Integration: application‑specific code that uses the library (your concrete host, plugin set, and surrounding app logic). Prefer this term over "implementation" when referring to consumer code.
Library implementation: the code of this package that realizes the spec.
Plugin URN: unique non‑empty string identifier of a plugin (PluginURN
).
Entity type: a top‑level key under plugin.entities
; identifies a family
of entities (e.g., assets
, commands
, a symbol, or another string key).
Entities record: the container mapping entity types to their inner maps
(ImmutableEntitiesRecord
).
Inner entity map: the record for a specific entity type mapping entity
keys to values (ImmutableEntities<K, V>
).
Entity: an opaque value stored in an inner entity map, provided by a plugin under a given entity type and key. The library treats entities as immutable and does not mutate or manage their lifecycle; their shape and semantics are defined by the integration.
Entity key: a key within an inner entity map; must be a non‑empty string
or a symbol (ImmutableEntityKey
).
Plugin: an integration-provided object implementing ImmutablePlugin<C>
that supplies entities via entities
.
Plugins record: a mapping from PluginURN
to plugin
(ImmutablePlugins<P>
). Each plugin’s name
MUST equal its URN key.
Host: the orchestrator instance (ImmutableHost<P>
) that aggregates
plugin entities and exposes discovery via entity collections.
Concrete Entities type: the integration’s schema for plugin.entities
,
defining which entity types exist, their inner map shapes, and which are
required vs. optional.
Entity collection: the aggregated view per entity type
(ImmutableEntityCollection<K, E>
), iterable and providing helpers like
get
, entries
, flat
, map
, flatMap
.
Entity discovery: the host’s construction‑time grouping of all plugin‑provided inner maps into entity collections (by entity type).
Provenance (attribution): for every entity in a collection, the host keeps
the providing plugin’s PluginURN
.
Conflict (duplication): multiple entities under the same entity key within an entity type; semantics are integration‑defined, while the host preserves multiplicity.
Plain object: objects with prototype Object.prototype
or null
;
excludes arrays, Maps, Sets, Dates, class instances. Used by runtime guards to
validate entities
and inner entity maps.
Entity matching logic: integration‑defined rules for interpreting entity keys and uniqueness within an entity type. The host remains agnostic.
Plugins expose the full set of their available entities.
Host maintains the full set of the entities available by plugin.
Entity matching logic is provided by integrations for each entity type.
Entity conflicts are handled by integrations. Host is agnostic to entity duplication between plugins.
The entities
property on every plugin is mandatory.
Within entities
, every entity type declared by the integration’s concrete
schema MUST be defined as an own property on the plugin. Plugins that have no
contributions for a given entity type MUST supply an empty ImmutableEntities
map instead of omitting the property.
Omitted entity types are specification violations. The host performs runtime validation and fails fast when a plugin is missing any declared entity type.
Empty maps are the canonical representation of “no entities”. Integrations MAY apply additional semantics (e.g., logging, warnings) around empty maps, but the host always exposes the collection regardless of population.
Primary contract: TypeScript. Plugin schemas reject optional properties so the absence of an entity type becomes a compile-time error.
Runtime enforcement: the host validates that each plugin supplies all declared entity types and that every inner map is a valid entity record. This ensures deterministic discovery even in dynamic loading scenarios.
Library stance: Runtime validation focuses on the single invariant of entity-type presence plus existing structural checks. Integrations are free to layer additional validation but are not required to configure anything beyond providing complete entities objects.
Plugin and entity lifetime management. Immutable by design.
Plugin discovery. See e.g.
installed-node-modules
.
Plugin ordering and dependency management. Handled by integrations prior to host initialization.
API version management. Concrete version is selected on the integration side. We provide a high-level generic API. If we ever find a need to introduce a breaking change post v1.0.0, it will be released as a separate module (e.g. simple-plugin-host-2).
Plugin and host configuration. Implementations provide their own.
export
are a normative part of the API.ImmutableEntities
''
is excluded at the type
level via NonEmptyString
.string
key, ergonomics are preserved (all strings allowed at
the type level); runtime guards still reject empty keys."0"
, "-1"
, "1.5"
, "1e3"
) are rejected
by runtime guards to avoid ambiguity with JavaScript's coercion of numeric
object keys to strings. Integrations MUST use textual identifiers that are not
numeric‑like. (This is stricter than the type‑level exclusion of numeric keys
and prevents collisions caused by JavaScript coercing numeric keys to
strings.)undefined
is forbidden. Integrations MUST
omit the key rather than store undefined
. This is enforced at runtime by
guards, and public types exclude undefined
from entity value types.type NonEmptyString<S extends string = string> = '' extends S ? never : S;
export type ImmutableEntityKey = symbol | NonEmptyString;
// Keys are textual (non-empty strings) or symbols; numeric keys are forbidden
export type ImmutableEntities<K extends string | symbol, V> = Readonly<
Record<K, NonNullable<V>>
>;
ImmutablePlugin
export type PluginURN = string;
export type ImmutableEntitiesRecord<
K extends string | symbol = string | symbol,
V extends unknown = unknown,
> = Readonly<Record<PropertyKey, ImmutableEntities<K, V>>>;
export interface ImmutablePlugin<
C extends ImmutableEntitiesRecord = ImmutableEntitiesRecord,
> {
readonly name: PluginURN;
readonly entities: Readonly<C>;
}
ImmutableEntityCollection
Map<K, E[]>
.flat
, map
and flatMap
methods for convenience.export interface ImmutableEntityCollection<K extends string | symbol, E> {
get(key: K): E[];
readonly size: number;
keys(): IterableIterator<K>;
values(): IterableIterator<E[]>;
entries(): Iterator<[K, E[]]>;
flat(): [E, K, PluginURN][];
map<U>(fn: (entities: E[], key: K) => U): U[];
flatMap<U>(fn: (entity: E, key: K, plugin: PluginURN) => U): U[];
[Symbol.iterator](): Iterator<[E, K, PluginURN]>;
}
ImmutableHost
type ImmutablePlugins<P extends ImmutablePlugin = ImmutablePlugin> = Readonly<
Record<PluginURN, P>
>;
type ImmutableEntityCollections<
K extends PropertyKey,
// Each entity type maps to an inner entity map (record of values)
T extends { [k in K]: Readonly<Record<string | symbol, unknown>> },
> = {
readonly [k in K]: ImmutableEntityCollection<
Extract<keyof T[k], string | symbol>,
T[k][keyof T[k]]
>;
};
type ImmutableEntityCollectionsFromPlugin<P extends ImmutablePlugin> =
ImmutableEntityCollections<keyof P['entities'], P['entities']>;
// Intentionally no default parameter to prevent accidental use.
export class ImmutableHost<P extends ImmutablePlugin> {
constructor(plugins: ImmutablePlugins<P>);
readonly plugins: ImmutablePlugins<P>;
readonly entities: ImmutableEntityCollectionsFromPlugin<P>;
}
A simple didactic usage example is included:
The example is tested by the main test suite as a black-box program, verifying against expected output, which is stored alongside the example.
Code below is normative, and, in addition to being the example, further illustrating intended semantics of the library implementation.
Normative pseudocode.
type EventURN = string;
interface Event<T extends EventURN> {
readonly name: T;
}
type EventListener<E extends Event> = (event: E) => void;
type Events<
EventUnion extends Event,
K extends EventUnion['name'] = EventUnion['name']
> = Record<K, EventUnion>;
type EventEntities<EventUnion extends Event> = ImmutableEntities<
EventUnion['name'],
{ [E in EventUnion as E['name']]: EventListener<E> }
>;
interface Emitter<EventUnion extends Event> {
emit(event: EventUnion) => boolean;
on<E extends EventUnion>(listener: EventListener<E>) => this;
}
function emitterFromEntities<
EventUnion extends Event
>(entities: EventEntities<EventUnion>): Emitter<EventUnion>;
import type {
ImmutableHost,
ImmutablePlugin,
ImmutablePlugins,
ImmutableEntities
} from 'simple-plugin-host';
import type { Emitter, EventEntities } from 'events.ts';
import { emitterFromEntities } from 'events.ts';
class Context {
emitter: Emitter<Events>;
constructor(emitter: Emitter<Events>) {
this.emitter = emitter;
}
print(text: string): void {
globalThis.process.stdout.write(`${text}\n`);
}
}
type Command = (ctx: Context, ...args: string[]) => string;
type Entities = {
assets: ImmutableEntities<string, string>;
commands: ImmutableEntities<string, Command>;
on: EventEntities<Events>;
};
type BeforeCommandExecution = {
name: 'beforeCommandExecution',
ctx: Context,
command: string
};
type AfterCommandExecution = {
name: 'afterCommandExecution',
ctx: Context,
command: string,
result: string,
};
type Events = BeforeCommandExecution | AfterCommandExecution;
interface Plugin extends ImmutablePlugin<Entities> {
description: string;
}
const pluginA: Plugin = {
name: 'pluginA',
description: 'this is plugin A',
entities: {
on: {
beforeCommandExecution: (e: BeforeCommandExecution) => {
e.ctx.print(
`[pluginA] beforeCommandExecution command: "${e.command}"`
);
},
afterCommandExecution: (e: AfterCommandExecution) => {
e.ctx.print(
`[pluginA] afterCommandExecution command: "${e.command}" result: "${e.result}"`
);
},
},
assets: {
foo: 'this is `foo`',
duplicate: 'this is duplicate asset from PluginA',
},
commands: {
bar: (ctx: Context, ...args: string[]): string => {
ctx.print(`this is bar(${args.join(', ')})`);
return '`bar` return value';
},
},
},
};
class PluginB implements Plugin {
readonly name: string;
readonly description: string;
readonly entities: {
on: {
beforeCommandExecution: (e: BeforeCommandExecution) => {
e.ctx.print(
`[pluginB] beforeCommandExecution command: "${e.command}"`
);
},
afterCommandExecution: (e: AfterCommandExecution) => {
e.ctx.print(
`[pluginB] afterCommandExecution command: "${e.command}" result: "${e.result}"`
);
},
},
assets: {
baz: 'this is `baz`',
duplicate: 'this is duplicate asset from PluginB',
},
commands: {
quo: (ctx: Context, ...args: string[]): string => {
ctx.print(`this is quo(${args.join(', ')})`);
return '`quo` return value';
},
},
},
constructor(name: string, description: string) {
this.name = name;
this.description = description;
}
};
const pluginB = new PluginB('pluginB', 'this is plugin B');
class Host extends ImmutableHost<Plugin> {
context: Context;
constructor(plugins: ImmutablePlugins<Plugin>) {
super(plugins);
this.context = new Context(emitterFromEntities<Events>(this.entities.on));
// Verify commands are unique on load
for ([ name, items ] of this.entities.commands.entries()) {
if (items.length !== 1) {
throw new Error(`duplicate commands "${name}" found: ${JSON.stringify(items)}`);
}
}
}
assets(name: string): string[] {
return this.entities.assets.get(name);
}
run(name: string, ...args: string[]): string {
// We know commands are unique
const [ command ] = this.entities.commands.get(name);
if (!command) {
throw new Error(`unknown command "${name}"`);
}
this.context.emitter.emit({
name: 'beforeCommandExecution',
ctx: this.context,
command: name
});
const result = command(this.context, ...args);
this.context.emitter.emit({
name: 'afterCommandExecution',
ctx: this.context,
command: name,
result
});
return result;
}
}
const host = new Host({ pluginA, pluginB });
const ctx = host.context;
ctx.emitter.on((e: BeforeCommandExecution) => {
e.ctx.print(
`[main] beforeCommandExecution: "${e.command}"`
);
});
ctx.emitter.on((e: AfterCommandExecution) => {
e.ctx.print(
`[main] afterCommandExecution: "${e.command}" result: "${e.result}"`
);
});
ctx.print('Available plugins:');
for (const [ name, plugin ] of Object.entries(host.plugins)) {
ctx.print(`- ${name}: ${plugin.description}`);
}
ctx.print(`PluginA name: ${host.plugins['pluginA'].name}`);
ctx.print('Available assets:');
for (const [ value, uri, plugin_name ] of host.entities.assets) {
// Prints both duplicates
ctx.print(`- ${uri} [${plugin_name}]: "${value}"`);
}
ctx.print(`Assets "duplicate": "${host.assets('duplicate').join('", "')}"`);
ctx.print('Available commands:');
for (const [ command, name, plugin_name ] of host.entities.commands) {
ctx.print(`- ${name} [${plugin_name}]: ${host.run(name, 'hello')}`);
}
// Triggers events
ctx.print(`Running "bar": ${ host.run('bar', 'world') }`);
No external dependencies.
Git
pnpm: >= 10
ESLint
Prettier
markdownlint
lefthook
Single package repository
Cutting-edge package.json
structure.
ImmutableEntityCollection
:
readonly size: number
property returning the count of unique keyskeys(): IterableIterator<K>
method returning an iterator over unique keysvalues(): IterableIterator<E[]>
method returning an iterator over entity
arrays per keyNonNullable<V>
in the ImmutableEntities
snippet; runtime guards enforce
this.ImmutableEntityCollections
snippet refined: constrained the entities record
to an inner record shape and used Extract<keyof T[k], string | symbol>
for
key type clarity; added an explanatory comment.}
in the assets print example line.ImmutableEntityKey = symbol | NonEmptyString
(removes
broad string
; numeric keys remain excluded by construction).ImmutableEntities<K extends string | symbol, V>
; literal string unions
exclude empty string ''
via NonEmptyString
, broad string
remains allowed
for ergonomics. Runtime guards still reject empty and numeric‑like keys.ImmutableEntitiesRecord<K extends string | symbol, V>
.ImmutableEntityCollection<K extends string | symbol, E>
.ImmutableEntityCollections
mapping marked readonly
in the snippet.readonly (keyof P['entities'])[]
, while standalone guard functions accept a
runtime readonly PropertyKey[]
list; semantics are the same.requiredEntityTypes
runtime option; the host now enforces
completeness unconditionally.Clarified entity type optionality:
plugin.entities
is mandatory; entity types (top‑level fields) are
required/optional exactly as declared by the concrete Entities
type.Terminology clarified:
API: Added optional runtime validation hook
ImmutableHost
constructor accepts
options?: { requiredEntityTypes?: readonly (keyof P['entities'])[] }
to
optionally enforce presence of required entity types at runtime.isImmutablePlugin(s)
and assertImmutablePlugin(s)
accept
the same option to enable the same check outside the host.ImmutableEntityKey = Exclude<PropertyKey, number | ''>
.ImmutableEntities<K, V>
uses K extends ImmutableEntityKey
(was
PropertyKey
). This excludes numeric and empty-string keys from inner entity
maps to avoid JS numeric key coercion ambiguity and degenerate identifiers.ImmutableEntitiesRecord<K, V>
to model the entities container
mapping entity types to inner entity maps.ImmutableEntities<K, V>
and ImmutableEntitiesRecord<K, V>
are
Readonly<…>
.ImmutablePlugins<P>
is Readonly<Record<…>>
.ImmutablePlugin.entities
is Readonly<C>
.ImmutablePlugin
now defaults its generic to ImmutableEntitiesRecord
, and
ImmutablePlugins
defaults to ImmutablePlugin
.ImmutableHost
intentionally has no default parameter.Fixed ImmutableEntityCollections
type to remove accidental type coupling of
entities record keys and keys of its nested object
- [k in K]: ImmutableEntityCollection<k, T[k]>;
+ [k in K]: ImmutableEntityCollection<keyof T[k], T[k][keyof T[k]]>