Skip to content

Chapter 53: The Shell and the Runtime

The shell is Kitsune’s application root. The runtime is the small coordination kernel the shell hosts. This chapter develops both in the maintained form — the types, the production patterns, the testing helpers, the operational concerns Part IV’s educational version left implicit.

The architecture is unchanged from Chapter 40 and Chapter 39. The polish is the chapter’s content. A working team adopting Kitsune doesn’t have to re-invent the patterns; they’re already shaped, tested, and documented.

Kitsune’s createShell function with its full type surface:

import type { Runtime, KitModule } from '@kitsune/core'
interface ShellOptions {
name: string
modules?: KitModule[]
rootContext?: ShellContext
onStart?: (shell: Shell) => Promise<void> | void
onStop?: (shell: Shell) => Promise<void> | void
onError?: (error: Error, context: ShellErrorContext) => void
diagnosticHandler?: (entry: DiagnosticEntry) => void
}
interface ShellContext {
appName?: string
appVersion?: string
[key: string]: string | number | boolean | undefined
}
interface ShellErrorContext {
phase: 'mount' | 'start' | 'stop' | 'module-install' | 'module-uninstall' | 'on-start' | 'on-stop' | 'unknown'
moduleName?: string
}
interface Shell {
readonly runtime: Runtime
readonly name: string
readonly mounted: boolean
readonly started: boolean
readonly stopped: boolean
mount(root: Element): void
start(): Promise<void>
stop(): Promise<void>
install(module: KitModule): Promise<void>
uninstall(name: string): Promise<void>
}
function createShell(options: ShellOptions): Shell

The types are explicit. The shell tracks its state (mounted, started, stopped). The error context is structured so the onError handler can react appropriately (a module-install error is different from a mount error). The diagnostic handler is exposed at the shell level for convenience; it forwards to the runtime’s diagnostic stream.

The shape from Part IV is preserved. Kitsune adds the types and the production-grade error handling.

The declarative form mirrors the factory:

<kit-shell
name="design-system-app"
context-app-name="design-system"
context-app-version="1.0.0"
>
<!-- application content -->
</kit-shell>

The element’s connectedCallback constructs the shell, attaches the runtime, registers the modules, calls mount() on itself, and calls start(). The element’s disconnectedCallback calls stop().

A subtle production detail: the shell’s modules can be registered through several paths.

Direct registration in createShell() — the factory accepts modules as an option. This is the most explicit form.

Module discovery from script imports — modules can declare themselves through a global registry. The shell’s connectedCallback collects from the registry. This pattern lets the application’s modules be in separate files without explicit imports in the shell setup.

Module discovery from attributes — for fully declarative use, the <kit-shell> element can accept module names via attributes (modules="analytics,audit,notifications"). The element looks up the named modules from the registry.

Most production applications use the direct registration path because it makes the application’s wiring visible and testable. The attribute-based form is useful for server-rendered scenarios where the modules are selected per page.

Runtime is the full interface the architecture exposes:

interface Runtime {
// Events
emit(event: AppEvent): void
on<T extends AppEvent = AppEvent>(
type: string | '*',
handler: (event: T) => void
): Unsubscribe
// Commands
command<R = unknown>(command: AppCommand): Promise<CommandResult<R>>
handleCommand<R = unknown>(
type: string,
handler: (command: AppCommand) => R | Promise<R>
): Unsubscribe
// Modules
install(module: KitModule): Promise<void>
uninstall(name: string): Promise<void>
isInstalled(name: string): boolean
installed(): readonly string[]
// Providers
provide<T>(token: ProviderToken<T>, value: T): void
inject<T>(token: ProviderToken<T>): T | undefined
hasProvider<T>(token: ProviderToken<T>): boolean
// Boundaries
attachBoundary(element: Element, options: BoundaryOptions): BoundaryHandle
// Diagnostics
onDiagnostic(handler: (entry: DiagnosticEntry) => void): Unsubscribe
getDiagnosticHistory(): readonly DiagnosticEntry[]
}

A few additions over the Part IV version:

isInstalled(name) and installed() — let modules check which other modules are present. Useful for modules that adapt their behavior based on the application’s installed capability set.

hasProvider(token) — let modules check whether a provider is available without injecting and checking for undefined. Cleaner conditional logic.

getDiagnosticHistory() — returns a bounded recent history (default last 500 entries). The history is useful for testing (assertions over the trace) and for the debug overlay (which can show a buffer rather than only the live stream).

The additions are small. The architecture’s shape is preserved.

Module Installation Order and Dependencies

Section titled “Module Installation Order and Dependencies”

Production modules sometimes depend on each other. The notification module might need the user module’s provider to know which user to address. The audit module might depend on the user module too. The dialog module is standalone but expects the runtime to be available.

Kitsune’s install order is the order modules are passed to createShell(). Modules can declare dependencies:

const auditModule = defineKitModule({
name: 'audit',
dependsOn: ['user'], // installs after the user module
events: {
// ...
}
})

The shell’s installer walks the dependency graph. If dependsOn references a module that isn’t being installed, the installer raises an error at startup. If there’s a circular dependency, the installer raises an error. If the graph is valid, modules install in topological order.

This is the production-grade replacement for the Part IV version’s user is responsible for ordering. The team declares dependencies; the installer figures out the order.

Modules have richer lifecycle in the maintained version:

interface KitModule {
name: string
dependsOn?: string[]
events?: Record<string, EventHandler>
commands?: Record<string, CommandHandler>
providers?: Array<{ token: ProviderToken<any>; value: any }>
onInstall?: (ctx: ModuleContext) => Promise<void> | void
onStart?: (ctx: ModuleContext) => Promise<void> | void
onStop?: (ctx: ModuleContext) => Promise<void> | void
onUninstall?: (ctx: ModuleContext) => Promise<void> | void
}

The split between onInstall / onStart (and onUninstall / onStop) matters for production.

onInstall runs when the module is added to the runtime. The runtime calls it after registering the module’s event and command handlers and providers. The hook is for configuration — setting up the module’s internal state, validating its options, registering any additional handlers programmatically.

onStart runs when the shell starts. The runtime calls it after all modules have been installed. The hook is for startup work — opening connections, hydrating state from storage, dispatching initial events, fetching initial data. The hook can depend on other modules’ providers being available, because all modules are installed before any onStart runs.

The asymmetry produces a clean composition. Modules can be installed (with their handlers and providers registered) without doing any startup work. The shell then starts all modules at once. The startup work happens with the full architecture available.

onStop and onUninstall are the mirror — onStop shuts down active behavior (close WebSockets, cancel timers, flush pending writes), onUninstall cleans up registrations.

The @kitsune/testing package provides helpers for the common patterns:

import { createTestShell, fixture, expectEvent } from '@kitsune/testing'
test('save flow emits saved event', async () => {
const { shell, runtime, root } = await createTestShell({
modules: [tokenSaveOrchestrator()],
template: html`
<kit-boundary surface="token-editor" entity-type="token" entity-id="color.primary">
<form data-meta-event="token.save_requested">
<kit-text-field name="value" value="#4a90e2"></kit-text-field>
<kit-button type="submit">Save</kit-button>
</form>
</kit-boundary>
`
})
// Trigger the form submission
const form = root.querySelector('form')!
form.requestSubmit()
// Assert the event eventually fires
await expectEvent(runtime, 'token.saved', { timeout: 1000 })
// Cleanup
await shell.stop()
})

The helpers reduce ceremony. createTestShell constructs the shell, mounts the fixture, starts the shell, and returns the relevant references. expectEvent waits for a specific event to fire (or times out). The pattern is similar to React Testing Library or Vue Test Utils — production-grade ergonomics for the architecture’s tests.

For module-only tests (no DOM), the package provides:

import { createTestRuntime, fakeProvider } from '@kitsune/testing'
test('analytics tracks token.saved with context', () => {
const tracked: any[] = []
const runtime = createTestRuntime()
runtime.install(analyticsModule({
provider: { track: (name, props) => tracked.push({ name, props }) }
}))
runtime.emit({
type: 'token.saved',
context: { surface: 'token-editor', feature: 'design-system' },
payload: { id: 'color.primary' }
})
expect(tracked).toHaveLength(1)
expect(tracked[0].name).toBe('Token Saved')
expect(tracked[0].props.surface).toBe('token-editor')
})

The runtime is created in isolation, the module is installed, the event is emitted, the assertion runs against the fake provider’s captured calls. No DOM. No shell. Just the module’s logic.

A single page can host multiple shells with independent runtimes. The pattern is useful for:

Embedded applications — a chat widget, a calendar embed, a comments panel — each as its own shell with its own modules and its own diagnostic stream.

Micro-frontend composition — multiple independent applications on the same page, each architected as a Kitsune shell, communicating through standard DOM events when they need to.

Testing in isolation — a single test page can host both a production-like shell and a test-harness shell, with the harness observing the production shell’s diagnostic stream.

The shells are independent. Events emitted in one don’t reach the other. Providers in one are invisible to the other. Cross-shell communication happens through the platform — BroadcastChannel, localStorage events, custom DOM events, or postMessage if they’re in separate iframes.

<!-- Main application -->
<kit-shell name="main-app">
<!-- ... main content ... -->
</kit-shell>
<!-- Embedded chat widget -->
<kit-shell name="chat-widget">
<!-- ... chat content ... -->
</kit-shell>

Each shell installs its own modules. The architecture composes at the shell boundary; cross-shell communication is explicit.

A few patterns the Kitsune team has converged on for production deployments.

Lazy module loading. For applications with many modules, not all of them need to load on the initial page. Modules can be loaded lazily — registered only when a specific route or feature is reached. The shell’s install method works at runtime, so a module imported via dynamic import() can be installed mid-session.

async function enableAdvancedFeatures() {
const { aiSuggestionsModule } = await import('./modules/ai-suggestions.js')
await shell.install(aiSuggestionsModule())
}

The pattern keeps the initial bundle small. Modules that aren’t always needed don’t ship until they’re needed.

Module versioning. Modules carry their own version (in the name or as a separate field). For applications with long-running sessions or hot-reloaded code, the runtime can detect version drift and either refuse to install a stale module or coordinate an upgrade.

Feature-flagged modules. The shell’s installation can be conditional on feature flags:

const shell = createShell({
modules: [
coreAnalyticsModule(),
coreAuditModule(),
...(featureFlags.aiAssistant ? [aiAssistantModule()] : []),
...(featureFlags.experimentalDialogs ? [newDialogModule()] : [oldDialogModule()])
]
})

The same application can ship different module sets to different user cohorts without forking the codebase.

Production diagnostics sampling. The diagnostic stream can be configured to sample (record one in every N entries) or to record only specific entry types (errors, slow handlers, audit-relevant events). The configuration is per-shell.

A short summary of how the maintained version differs from Part IV’s:

Part IV EducationalKitsune Maintained
any typing in several placesFull TypeScript types
User responsible for module orderDeclared dependsOn with topological sort
Single lifecycle hookonInstall, onStart, onStop, onUninstall
No history bufferBounded getDiagnosticHistory()
User-written test setup@kitsune/testing helpers
User manages reload behaviorVersioning hooks
Static module listLazy loading via dynamic import
Implicit diagnostics handlingConfigurable sampling and filtering

Each row is a polish-layer addition. The architectural shape stays the same.

The next chapter (Chapter 54) develops the modules-and-providers system in detail. The patterns for capability modules, the provider-token discipline, the testing of modules in isolation, the production patterns for module composition. The shell hosts modules; the modules are what give the application its specific behavior.

Take the design-token-editor application from Chapter 52. Add a second shell on the same page — a theme-preview shell that renders a preview of how design tokens look across a sample UI.

The two shells:

  • Main shell — the token editor. Modules: save-orchestrator, analytics, audit, notifications, dialog.
  • Preview shell — the theme preview. Modules: theme-sync (which listens to a BroadcastChannel for token-saved messages from the main shell and re-renders the preview).

The main shell, on save, dispatches a BroadcastChannel message indicating the new token value. The preview shell observes the message and updates its preview.

Verify:

  1. The two shells are independent — events from one don’t fire modules in the other.
  2. The cross-shell communication works — saving in the main shell updates the preview.
  3. Each shell has its own diagnostic stream.
  4. The diagnostic streams are separately inspectable.

Then experiment with lazy loading:

  1. Make the preview module load lazily — only when the user clicks Show preview.
  2. Verify the initial bundle is smaller without the preview shell’s modules.
  3. Verify the lazy load works mid-session — the module registers and starts processing messages.

Reflect on:

  1. How does multi-shell composition compare to multi-application setups in React (e.g., multiple ReactDOM.createRoot calls)?
  2. What’s the failure mode if the preview shell crashes? (Just the preview — the main shell continues working.)
  3. How would this pattern extend to a real micro-frontend deployment, with shells loaded from different teams?

The exercise demonstrates Kitsune’s composition properties. The architecture supports independent shells, lazy module loading, cross-shell communication through platform mechanisms, and runtime version coordination. Each capability is small individually; together they support the kinds of compositions production applications need.