Runtime
Two classes, one inheritance.
@nwire/app.Runtimeowns the substrate;@nwire/forge.Runtimeadds CQRS.
Why a base class
Runtime is the object every dispatch flows through. Before 0.9 the dispatch substrate (hooks, middleware, telemetry, framework events) and the CQRS pipeline (actors, workflows, projections, queries, event store, timers) lived in a single class inside @nwire/forge.
That coupled two different concerns:
- Substrate — what every Nwire app needs to dispatch, observe, and extend. Generic over the workload.
- CQRS — what a domain runtime adds to that substrate. Specific to the actor/event/projection model.
0.9 splits them. @nwire/app.Runtime is the substrate. @nwire/forge.Runtime extends it and layers CQRS on top. Domains that don't want CQRS (a minimal HTTP wire, a queue worker that only dispatches handlers) can run on the base class alone.
What the base owns
@nwire/app.Runtime ships:
| Surface | Notes |
|---|---|
container | A Container<TCradle> from @nwire/container (default: createContainer()). |
dispatchHook | A Hook from @nwire/hooks named runtime.dispatch. Subclasses pin their handler step at -Infinity priority. |
use(middleware) | Registers user middleware on dispatchHook. Outermost first. |
hooks | A FrameworkHooks registry of lifecycle slots (AppBooted, PluginBooted, WireMounted, …). Plugins tap them with on(hookName, fn). |
defineHook(name) | Declares a new framework hook slot by name. |
adoptHook(hook) | Wires any hook's per-step tap into the canonical telemetry stream. |
onTelemetry(listener) | Subscribe to the unified observation stream (every consumer — dev logger, Studio Live SSE, OTel exporter — listens here). |
emit(record) | Protected. Subclasses push domain-widened records onto the same stream. |
The dispatch hook is the universal extension point. Every middleware, every per-action before:<name> / after:<name> hook, every per-plugin lifecycle hook in the framework runs on the same @nwire/hooks primitive. One observation channel (onTelemetry) sees them all.
What forge adds
@nwire/forge.Runtime extends RuntimeBase and registers domain-shaped surface on top:
| Surface | Notes |
|---|---|
registerHandler(handler) | Pins the handler step at -Infinity on dispatchHook. |
dispatch(action, input, envelope?) | Runs the action through middleware → retry loop → handler → event apply. |
publish(events, envelope) | Applies events to actors, workflows, projections in one commit. |
applyExternalEvent(name, payload, env) | Like publish but does not re-publish to the bus. |
query(name, input, tenant?) | Runs a registered query. |
request(action, input) | Handler-to-handler dispatch with a derived child envelope. |
onEvent(listener) | Domain event telemetry tap. |
| Actors, workflows, projections, queries, event store, timers | Full CQRS pipeline. |
Telemetry-wise, forge widens the Telemetry union with kinds like action.dispatched, actor.transitioned, event.deduped. Listeners narrow with switch (rec.kind). No casts; the substrate's emit(record: unknown) accepts the widening by design.
The relationship
import { Runtime as RuntimeBase } from "@nwire/app";
export class Runtime extends RuntimeBase implements RuntimeContract {
private readonly handlers = new Map<string, ActionDefinition>();
private readonly actors = new Map<string, ActorDefinition>();
// ...registries...
constructor(options: RuntimeOptions = {}) {
super(options);
// Pin the handler step at -Infinity on the inherited dispatchHook.
this.dispatchHook.use(handlerStep, { name: "handler", priority: -Infinity });
}
}The base owns the hook. The subclass owns what runs at the innermost step. User middleware (runtime.use(...)) sits between them, wrapping the handler step in registration order.
The runtime.dispatch hook
The dispatch hook is named runtime.dispatch. It was renamed in 0.9 (the old name was forge.action.dispatch) to reflect ownership: the substrate owns the hook, the CQRS layer registers steps on it.
Listeners observe every chain step through the canonical telemetry stream:
runtime.onTelemetry((rec) => {
if (rec.kind === "hook.step" && rec.hookName === "runtime.dispatch") {
console.log(rec.stepName, rec.phase, rec.durationMs);
}
});The same stream carries lifecycle records (framework-event firings), action.dispatched, event.deduped, every other domain kind. One channel, one ring buffer per wire, one SSE endpoint, one OTel exporter bridge.
When you'd use the base alone
A minimal HTTP wire that dispatches @nwire/handler operations directly — no actors, no events, no projections — can run on @nwire/app.Runtime without pulling forge in:
import { createApp } from "@nwire/app";
const app = createApp({ plugins: [...] });
await app.start();
app.runtime.use(tracingMiddleware);
app.runtime.onTelemetry((rec) => { /* ... */ });This is what the interop examples (examples/interop/*) prove: the substrate is shippable on its own. The full CQRS pipeline is opt-in via forge.
See also
- Framework hooks — the lifecycle slots a plugin taps with
on(hookName, fn). - Telemetry — the canonical observation stream every consumer listens on.
- Runtime API — the full method surface of the forge
Runtime. - Telemetry kinds — the tagged union every listener narrows.