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.
The createShell() API
Section titled “The createShell() API”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): ShellThe 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 <kit-shell> Custom Element
Section titled “The <kit-shell> Custom Element”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.
The Runtime’s Public API
Section titled “The Runtime’s Public API”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.
Lifecycle Hooks in Detail
Section titled “Lifecycle Hooks in Detail”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.
Testing Patterns
Section titled “Testing Patterns”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.
Multiple Shells
Section titled “Multiple Shells”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.
Production Deployment Patterns
Section titled “Production Deployment Patterns”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.
The Educational Version Comparison
Section titled “The Educational Version Comparison”A short summary of how the maintained version differs from Part IV’s:
| Part IV Educational | Kitsune Maintained |
|---|---|
any typing in several places | Full TypeScript types |
| User responsible for module order | Declared dependsOn with topological sort |
| Single lifecycle hook | onInstall, onStart, onStop, onUninstall |
| No history buffer | Bounded getDiagnosticHistory() |
| User-written test setup | @kitsune/testing helpers |
| User manages reload behavior | Versioning hooks |
| Static module list | Lazy loading via dynamic import |
| Implicit diagnostics handling | Configurable sampling and filtering |
Each row is a polish-layer addition. The architectural shape stays the same.
Bridge to Modules and Providers
Section titled “Bridge to Modules and Providers”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.
Exercise: Build a Multi-Shell Page
Section titled “Exercise: Build a Multi-Shell Page”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
BroadcastChannelfor 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:
- The two shells are independent — events from one don’t fire modules in the other.
- The cross-shell communication works — saving in the main shell updates the preview.
- Each shell has its own diagnostic stream.
- The diagnostic streams are separately inspectable.
Then experiment with lazy loading:
- Make the preview module load lazily — only when the user clicks Show preview.
- Verify the initial bundle is smaller without the preview shell’s modules.
- Verify the lazy load works mid-session — the module registers and starts processing messages.
Reflect on:
- How does multi-shell composition compare to multi-application setups in React (e.g., multiple
ReactDOM.createRootcalls)? - What’s the failure mode if the preview shell crashes? (Just the preview — the main shell continues working.)
- 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.