Skip to content

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.

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 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.

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.

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.

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.

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.

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.

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 event
await runtime.install(defineKitModule({
name: 'debug',
events: {
'*': (event) => console.log('[event]', event.type, event)
}
}))
// Install a notification module that handles a command
await 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 commands
runtime.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.

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.

Implement createRuntime() from this chapter, in plain TypeScript. Use the type definitions and function shapes shown.

Then build three modules:

  1. A debug module that logs every event and every command via wildcard subscribers.
  2. A notifications module that handles a notification.show command and renders a real DOM toast (an element appended to the document body that auto-removes after 3 seconds).
  3. 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:

  1. Click each button. Watch the diagnostic stream produce a complete trace.
  2. Try to install two modules with the same name. The second installation should fail cleanly.
  3. Try to register two command handlers for the same command type. The second registration should fail cleanly.
  4. Dispatch a command with no registered handler. The result should be { ok: false, error } — the runtime shouldn’t crash.
  5. Uninstall the debug module. Click the buttons again — no more debug logging, but the buttons still work.
  6. Reinstall it. The debug logging returns.

Reflect on:

  1. How many lines of code is your runtime? (Probably 200–300.)
  2. Which architectural properties from Chapter 38 are visible in the trace? (The event-then-command flow, the multi-observer fan-out, the diagnostic record.)
  3. 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.)
  4. Where would a component library plug in? (Components fire events into runtime.emit and dispatch commands through runtime.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.