Chapter 39: Building a Tiny Runtime
Part IV is the building part of the book.
Part III named the architectural principles — components describe meaning, boundaries supply context, capabilities are modules, events are facts and commands are requests, the closed loop is the architecture. Part IV writes those principles into code, in plain TypeScript, without a framework. The point isn’t to ship the result. The point is to make the moving parts visible. The maintained version in Part VI will look like a more polished version of what you’ll build here, and the polish won’t matter until you’ve internalized the shape.
This chapter is the first piece: the runtime kernel. The smallest possible coordination layer that makes the rest of the architecture compose. The runtime doesn’t render UI. It doesn’t own the DOM. It doesn’t know about analytics vendors. It doesn’t decide how applications work. Its job is just to route events, dispatch commands, manage module lifecycles, expose providers, and record diagnostics.
The implementation is under three hundred lines. The architecture’s leverage comes not from the runtime’s size but from its contract.
What the Runtime Owns
Section titled “What the Runtime Owns”The runtime owns five things:
Events — a publish/subscribe bus with wildcard support and ordered fan-out to all subscribers.
Commands — a single-handler-per-type dispatch with results, errors, and async handler support.
Modules — packaged sets of event subscribers, command handlers, providers, and lifecycle hooks that install into the runtime as a unit.
Providers — stable services modules can inject by token. The current user. The active locale. The router state. The runtime’s own diagnostic surface.
Diagnostics — a record of every event, command, module installation, and error that passes through the runtime, exposed to subscribers (a debug overlay, a logger, a network instrumentation tool).
That’s it. The runtime doesn’t own the DOM. It doesn’t own components. It doesn’t own data fetching. It doesn’t own routing. Those things sit on top of or alongside the runtime; the runtime coordinates them.
The Public Shape
Section titled “The Public Shape”The runtime’s external API:
interface Runtime { // Events: many observers, no return value emit(event: AppEvent): void on<T extends AppEvent>(type: T['type'] | '*', handler: (event: T) => void): Unsubscribe
// Commands: single handler, awaited result command<R = unknown>(command: AppCommand): Promise<CommandResult<R>> handleCommand<R = unknown>(type: string, handler: CommandHandler<R>): Unsubscribe
// Modules: lifecycle-aware capability units install(module: KitModule): Promise<void> uninstall(name: string): Promise<void>
// Providers: stable service injection provide<T>(token: ProviderToken<T>, value: T): void inject<T>(token: ProviderToken<T>): T | undefined
// Diagnostics: observable runtime trace onDiagnostic(handler: (entry: DiagnosticEntry) => void): Unsubscribe}Each method has one job. None of them implies the others. A module that only handles commands doesn’t need to know about events. A provider isn’t tangled into the event bus. Diagnostics is observable without being mandatory.
The shape is deliberately small. The smaller the public surface, the easier the runtime is to learn, audit, and replace. Part VI’s maintained Kitsune runtime adds polish (better types, better error messages, better test ergonomics, more diagnostic detail), but the public surface stays close to what’s above.
Implementing the Event Bus
Section titled “Implementing the Event Bus”The event bus is the simplest piece. Subscribers register by type; emissions fan out to all subscribers for the type plus all wildcard subscribers.
type EventHandler = (event: AppEvent) => void
interface AppEvent { type: string context?: Record<string, unknown> payload?: unknown}
function createEventBus(diagnostic: DiagnosticEmitter) { const handlers = new Map<string, Set<EventHandler>>()
function on(type: string, handler: EventHandler) { let set = handlers.get(type) if (!set) { set = new Set() handlers.set(type, set) } set.add(handler) return () => set!.delete(handler) }
function emit(event: AppEvent) { diagnostic({ kind: 'event', type: event.type, event })
// Type-specific subscribers const exact = handlers.get(event.type) if (exact) { for (const handler of exact) { runHandler(handler, event) } }
// Wildcard subscribers const wildcard = handlers.get('*') if (wildcard) { for (const handler of wildcard) { runHandler(handler, event) } } }
function runHandler(handler: EventHandler, event: AppEvent) { try { handler(event) } catch (error) { diagnostic({ kind: 'event-handler-error', type: event.type, error: serializeError(error) }) } }
return { on, emit }}A few design choices are worth noting.
Handler errors are isolated. A subscriber that throws doesn’t break the emission. The error is captured in the diagnostic stream and other subscribers keep running. This matches Chapter 37’s claim that events are fire-and-forget — the emitter shouldn’t have to care about handler failures.
Wildcard subscribers receive every event. The runtime.on('*', handler) pattern is the foundation of debug overlays, analytics-bypass loggers, and any cross-cutting observer that wants to see everything. The cost is a single extra Map lookup per emission.
Unsubscribe returns a cleanup function. The pattern is consistent with the DOM’s AbortController and modern JavaScript conventions. Modules and components that subscribe always have a way to clean up.
Subscribers run synchronously. The emission is synchronous; subscribers complete before emit returns. This makes the trace order deterministic and matches the DOM event model’s behavior. Asynchronous work inside a subscriber is fine, but the runtime doesn’t wait for it.
Implementing the Command Bus
Section titled “Implementing the Command Bus”Commands are different. One handler per type. The dispatch returns a promise with the result. Errors propagate to the caller.
type CommandHandler<R = unknown> = (command: AppCommand) => R | Promise<R>
interface AppCommand { type: string context?: Record<string, unknown> payload?: unknown}
interface CommandResult<R = unknown> { ok: boolean value?: R error?: SerializedError}
function createCommandBus(diagnostic: DiagnosticEmitter) { const handlers = new Map<string, CommandHandler>()
function handleCommand<R>(type: string, handler: CommandHandler<R>) { if (handlers.has(type)) { throw new Error( `Command handler for "${type}" already registered. ` + `Commands have a single handler per type.` ) } handlers.set(type, handler as CommandHandler) return () => handlers.delete(type) }
async function command<R>(command: AppCommand): Promise<CommandResult<R>> { diagnostic({ kind: 'command', type: command.type, command })
const handler = handlers.get(command.type) if (!handler) { const error = new Error(`No handler registered for command "${command.type}"`) diagnostic({ kind: 'command-no-handler', type: command.type, error: serializeError(error) }) return { ok: false, error: serializeError(error) } }
try { const value = await handler(command) as R diagnostic({ kind: 'command-result', type: command.type, ok: true }) return { ok: true, value } } catch (error) { diagnostic({ kind: 'command-result', type: command.type, ok: false, error: serializeError(error) }) return { ok: false, error: serializeError(error) } } }
return { command, handleCommand }}The design choices here:
Single handler is enforced. Registering a second handler for the same command type throws. This is the architectural invariant from Chapter 37 — commands are requests, and the application has to know which module is responsible for executing a given request type.
No handler returns a structured error, not an exception. When no handler is registered for a dispatched command type, the runtime doesn’t throw; it returns { ok: false, error }. The caller can decide whether to surface the error or swallow it. This matches the architectural pattern of the runtime doesn’t crash because the application’s wiring is incomplete.
Results are wrapped. Every command returns CommandResult<R>. The caller doesn’t need to remember whether to use try/catch or check a status flag — the result shape is uniform. Successful results have ok: true and value; failed results have ok: false and error.
Async by default. The handler can return a value synchronously or asynchronously; either is awaited. The caller always receives a promise. This handles the common case (commands that call APIs or do other async work) without forcing every handler to be async.
Implementing Modules
Section titled “Implementing Modules”A module is a package of subscribers, handlers, providers, and lifecycle hooks that installs into the runtime as a unit. The factory function:
interface KitModule { name: string events?: Record<string, EventHandler> commands?: Record<string, CommandHandler> providers?: Array<{ token: ProviderToken<any>; value: any }> onInstall?: (ctx: ModuleContext) => Promise<void> | void onUninstall?: (ctx: ModuleContext) => Promise<void> | void}
interface ModuleContext { runtime: Runtime inject<T>(token: ProviderToken<T>): T | undefined}
function defineKitModule(module: KitModule): KitModule { return module}Installation registers every subscriber, handler, and provider in one transaction:
function createModuleInstaller(runtime: Runtime, diagnostic: DiagnosticEmitter) { const installed = new Map<string, { module: KitModule cleanups: Array<() => void> }>()
async function install(module: KitModule) { if (installed.has(module.name)) { throw new Error(`Module "${module.name}" already installed`) }
diagnostic({ kind: 'module-install', name: module.name })
const cleanups: Array<() => void> = []
// Register event subscribers for (const [type, handler] of Object.entries(module.events ?? {})) { cleanups.push(runtime.on(type, handler)) }
// Register command handlers for (const [type, handler] of Object.entries(module.commands ?? {})) { cleanups.push(runtime.handleCommand(type, handler)) }
// Register providers for (const { token, value } of module.providers ?? []) { runtime.provide(token, value) }
// Run install hook if (module.onInstall) { try { await module.onInstall({ runtime, inject: (token) => runtime.inject(token) }) } catch (error) { diagnostic({ kind: 'module-install-error', name: module.name, error: serializeError(error) }) // Roll back partial registration for (const cleanup of cleanups) cleanup() throw error } }
installed.set(module.name, { module, cleanups }) }
async function uninstall(name: string) { const record = installed.get(name) if (!record) return
diagnostic({ kind: 'module-uninstall', name })
// Run uninstall hook if (record.module.onUninstall) { try { await record.module.onUninstall({ runtime, inject: (token) => runtime.inject(token) }) } catch (error) { diagnostic({ kind: 'module-uninstall-error', name, error: serializeError(error) }) } }
// Unregister all subscriptions and handlers for (const cleanup of record.cleanups) cleanup() installed.delete(name) }
return { install, uninstall }}A few things worth noting.
Modules install transactionally. If a module’s onInstall hook fails partway through, the runtime rolls back any subscriptions and handlers it had already registered. The runtime doesn’t end up in a half-installed state.
Modules can be uninstalled cleanly. The cleanup functions collected at install time are called at uninstall time. The runtime returns to a state where the module’s subscriptions are no longer active. This matters for testing (a test can install a module, run, uninstall, and have a clean slate) and for hot-reloading during development.
Module install order matters. The runtime installs in the order install is called. A module that depends on another module’s providers should be installed after the dependency. The runtime doesn’t try to compute install order automatically; the application is responsible for ordering.
Implementing Providers
Section titled “Implementing Providers”Providers are typed services modules expose to each other. The token-based pattern is standard:
interface ProviderToken<T> { description: string __brand: T // phantom type for inference}
function createProviderToken<T>(description: string): ProviderToken<T> { return { description, __brand: undefined as unknown as T }}
function createProviderRegistry() { const values = new Map<ProviderToken<any>, unknown>()
function provide<T>(token: ProviderToken<T>, value: T) { values.set(token, value) }
function inject<T>(token: ProviderToken<T>): T | undefined { return values.get(token) as T | undefined }
return { provide, inject }}A canonical use:
// In a shared types file:export const USER = createProviderToken<{ current: () => { id: string; email: string } | null}>('user')
// In the user module:const userModule = defineKitModule({ name: 'user', providers: [ { token: USER, value: { current: () => /* ... */ } } ]})
// In any other module:const auditModule = defineKitModule({ name: 'audit', events: { 'profile.saved': (event) => { const user = runtime.inject(USER)?.current() record('profile.saved', { actor: user?.id, ... }) } }})The token-based design has two virtues. The TypeScript type system can infer the value’s shape from the token. And the registration is structural — modules don’t import each other to share services; they share through the token registry.
Implementing Diagnostics
Section titled “Implementing Diagnostics”The diagnostic stream is the trace surface every other piece writes to:
type DiagnosticEntry = | { kind: 'event'; type: string; event: AppEvent } | { kind: 'event-handler-error'; type: string; error: SerializedError } | { kind: 'command'; type: string; command: AppCommand } | { kind: 'command-result'; type: string; ok: boolean; error?: SerializedError } | { kind: 'command-no-handler'; type: string; error: SerializedError } | { kind: 'module-install'; name: string } | { kind: 'module-install-error'; name: string; error: SerializedError } | { kind: 'module-uninstall'; name: string } | { kind: 'module-uninstall-error'; name: string; error: SerializedError }
type DiagnosticEmitter = (entry: DiagnosticEntry) => void
function createDiagnosticStream() { const handlers = new Set<(entry: DiagnosticEntry) => void>()
const emit: DiagnosticEmitter = (entry) => { // Add timestamp const timestamped = { ...entry, at: Date.now() } for (const handler of handlers) { try { handler(timestamped) } catch { // diagnostic handlers must never break the runtime } } }
function onDiagnostic(handler: (entry: DiagnosticEntry) => void) { handlers.add(handler) return () => handlers.delete(handler) }
return { emit, onDiagnostic }}The diagnostic surface is observable but not mandatory. Production applications might not subscribe at all (the runtime still records nothing, since there are no subscribers). A debug overlay subscribes during development. An automated test subscribes to assert that specific events fired in the right order. The mechanism is consistent.
The handlers run in a try/catch that swallows their errors. Diagnostic subscribers must never break the runtime. If a logger throws, the runtime keeps working.
The Lifecycle and the Closed Loop
Section titled “The Lifecycle and the Closed Loop”Putting the pieces together, the runtime’s factory function:
function createRuntime(): Runtime { const diagnostics = createDiagnosticStream() const events = createEventBus(diagnostics.emit) const commands = createCommandBus(diagnostics.emit) const providers = createProviderRegistry()
// Build a partial runtime that the module installer can use const runtime = { emit: events.emit, on: events.on, command: commands.command, handleCommand: commands.handleCommand, provide: providers.provide, inject: providers.inject, onDiagnostic: diagnostics.onDiagnostic } as Runtime
// Add module lifecycle (depends on the partial runtime existing) const modules = createModuleInstaller(runtime, diagnostics.emit) runtime.install = modules.install runtime.uninstall = modules.uninstall
return runtime}That’s the runtime. Probably 250 lines of TypeScript when the helpers and types are written cleanly. The architecture’s leverage isn’t in the code; it’s in the contract the code implements.
A small application using the runtime:
const runtime = createRuntime()
// Install a debug module that logs every eventawait runtime.install(defineKitModule({ name: 'debug', events: { '*': (event) => console.log('[event]', event.type, event) }}))
// Install a notification module that handles a commandawait runtime.install(defineKitModule({ name: 'notifications', commands: { 'notification.show': (command) => { const message = command.payload?.message ?? 'Notification' console.log('[toast]', message) return { id: `n${Date.now()}` } } }}))
// Trigger events and commandsruntime.emit({ type: 'profile.saved', context: { entity: { type: 'profile', id: 'me' } }})
const result = await runtime.command({ type: 'notification.show', payload: { message: 'Profile saved' }})
console.log('notification id:', result.value?.id)The application is composed at install time. After installation, the runtime routes events and commands without further configuration. Modules can be added or removed dynamically. The diagnostic surface makes the routing inspectable.
The closed loop from Chapter 38 is now real code. User actions become events or commands. The runtime routes them to subscribed modules. Modules respond. The chain is traceable through the diagnostic stream.
What We Built and What’s Next
Section titled “What We Built and What’s Next”The runtime is the smallest piece of the architecture. The next chapter builds the shell — the application root element that hosts the runtime and bootstraps the application’s lifecycle. After that, boundaries (Chapter 41), the metadata boundary that bridges DOM events to the runtime (Chapter 42), and diagnostics (Chapter 44).
The Part IV chapters build the architecture piece by piece. Each chapter is short on prose and dense on code. The point is to make the whole thing visible. By the end of Part IV, you’ll have a working coordination runtime in plain TypeScript, with no framework dependencies. Part V wraps it in custom elements via Lit. Part VI ships it as Kitsune with polish, testing, and production-ready ergonomics.
Exercise: Build createRuntime()
Section titled “Exercise: Build createRuntime()”Implement createRuntime() from this chapter, in plain TypeScript. Use the type definitions and function shapes shown.
Then build three modules:
- A debug module that logs every event and every command via wildcard subscribers.
- A notifications module that handles a
notification.showcommand and renders a real DOM toast (an element appended to the document body that auto-removes after 3 seconds). - A fake-analytics module that subscribes to specific events (
profile.saved,checkout.started) and logs them as if it were sending to an analytics provider.
Wire up a small page with two buttons:
<button id="save">Save</button><button id="notify">Notify</button>The first button should emit a profile.saved event when clicked. The second should dispatch a notification.show command with a message.
Add a diagnostic subscriber that prints every diagnostic entry to the console with a timestamp.
Then experiment:
- Click each button. Watch the diagnostic stream produce a complete trace.
- Try to install two modules with the same name. The second installation should fail cleanly.
- Try to register two command handlers for the same command type. The second registration should fail cleanly.
- Dispatch a command with no registered handler. The result should be
{ ok: false, error }— the runtime shouldn’t crash. - Uninstall the debug module. Click the buttons again — no more debug logging, but the buttons still work.
- Reinstall it. The debug logging returns.
Reflect on:
- How many lines of code is your runtime? (Probably 200–300.)
- Which architectural properties from Chapter 38 are visible in the trace? (The event-then-command flow, the multi-observer fan-out, the diagnostic record.)
- What would change if you wanted to make the runtime async-aware across the whole loop? (Probably not much — the command bus is already async; the event bus could be made
Promise.all-aware in a follow-up.) - Where would a component library plug in? (Components fire events into
runtime.emitand dispatch commands throughruntime.command. The runtime doesn’t know about components.)
The exercise is the core architectural lift of Part IV. Everything else (shell, boundaries, metadata, diagnostics) builds on this runtime. The runtime itself is small enough that you can read every line and understand exactly what’s happening. That transparency is the architecture’s most important property.