Skip to content

Chapter 42: Building the Metadata Boundary

The architecture has a runtime (Chapter 39), a shell (Chapter 40), and a boundary system (Chapter 41). The last piece needed to close the loop is the metadata boundary — the listener that observes DOM events inside the shell, reads metadata attributes from the elements that fired them, collects boundary context from the surrounding tree, and dispatches runtime events or commands.

This chapter builds the metadata boundary. It’s the bridge from rendered UI to application architecture. The component declares meta-event="profile.saved" in its markup. The metadata boundary turns that declaration into a runtime event with full context. After this chapter, the architecture is end-to-end: a button click in the DOM produces a runtime event that capability modules can observe.

The implementation is around 200 lines of TypeScript. It’s the most-important piece of the runtime in terms of architectural leverage and one of the smallest in terms of code.

Concretely, the metadata boundary:

  1. Attaches a single delegated listener to the shell’s root element (and to certain custom events).
  2. When a DOM event fires, walks up from the target to find the nearest element with meta-event or meta-command attributes (or its data-meta-* equivalents).
  3. Parses the metadata from that element.
  4. Collects boundary context by walking further up the tree (using the algorithm from Chapter 41).
  5. Constructs a runtime event or command with the metadata + context attached.
  6. Dispatches the event or command into the runtime.

Steps 1, 2, 4, and 6 are mostly plumbing. Step 3 (parsing metadata) and step 5 (constructing the dispatch) are where the real work lives.

Chapter 22 introduced the metadata protocol. The full attribute vocabulary, encoded in a parser:

interface ParsedMetadata {
event?: string
command?: string
intent?: string
entityType?: string
entityId?: string
props: Record<string, string>
}
function parseMetadata(element: Element): ParsedMetadata | null {
// Custom elements (kit-*) use bare attribute names.
// Plain DOM elements use data-meta-* prefixed names.
const isKitElement = element.tagName.toLowerCase().startsWith('kit-')
function read(name: string): string | null {
if (isKitElement) {
const direct = element.getAttribute(`meta-${name}`)
if (direct !== null) return direct
}
return element.getAttribute(`data-meta-${name}`)
}
const event = read('event') || undefined
const command = read('command') || undefined
const intent = read('intent') || undefined
const entityType = read('entity-type') || undefined
const entityId = read('entity-id') || undefined
// If neither event nor command, this isn't a metadata-bearing element.
if (!event && !command) return null
// Collect all *-prop-* attributes into the props object
const props: Record<string, string> = {}
for (const attr of Array.from(element.attributes)) {
const propMatch = isKitElement
? /^meta-prop-(.+)$/.exec(attr.name)
: /^data-meta-prop-(.+)$/.exec(attr.name)
if (propMatch) {
props[camelize(propMatch[1])] = attr.value
}
}
return { event, command, intent, entityType, entityId, props }
}
function camelize(s: string): string {
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
}

The parser handles both attribute conventions (bare for <kit-*> custom elements, prefixed for plain DOM elements) so the metadata layer works regardless of how the markup was produced. The element doesn’t need to know which form was used; the parser tries one and falls back to the other.

The *-prop-* collection is what lets the metadata protocol carry arbitrary structured data. A button with meta-prop-target="help-dialog" produces a props.target = "help-dialog" in the parsed metadata. Modules subscribed to the event receive the props as part of the dispatched event.

The listener attaches once, to the shell’s root element, and observes the events that matter for metadata interpretation:

interface MetadataBoundaryOptions {
runtime: Runtime
root: Element
}
function attachMetadataBoundary({ runtime, root }: MetadataBoundaryOptions) {
const handlers: Array<{ event: string; handler: EventListener }> = []
function on(event: string, handler: EventListener) {
root.addEventListener(event, handler)
handlers.push({ event, handler })
}
// Click — the most common interaction
on('click', (event) => handleInteraction(event as MouseEvent))
// Form submission — the transaction-boundary event
on('submit', (event) => handleFormSubmit(event as SubmitEvent))
// Custom meta:event — for programmatic emissions from components
on('meta:event', (event) => handleCustomMetaEvent(event as CustomEvent))
// Custom meta:command — for programmatic dispatches from components
on('meta:command', (event) => handleCustomMetaCommand(event as CustomEvent))
function handleInteraction(event: MouseEvent) {
const target = (event.target as Element).closest<HTMLElement>(
'[data-meta-event], [data-meta-command], ' +
'kit-button[meta-event], kit-button[meta-command]'
)
if (!target) return
const meta = parseMetadata(target)
if (!meta) return
const context = collectContext(target, root)
dispatchMetadata(meta, context, { source: 'click', target })
}
function handleFormSubmit(event: SubmitEvent) {
const form = event.target as HTMLFormElement
if (!form.matches('form')) return
// Forms can declare their own metadata
const meta = parseMetadata(form)
if (!meta) return
const context = collectContext(form, root)
const payload = {
data: Object.fromEntries(new FormData(form))
}
dispatchMetadata(meta, context, { source: 'submit', target: form, payload })
}
function handleCustomMetaEvent(event: CustomEvent) {
const detail = event.detail ?? {}
if (!detail.type) return
const context = collectContext(event.target as Element, root)
runtime.emit({
type: detail.type,
context,
payload: detail.payload
})
}
function handleCustomMetaCommand(event: CustomEvent) {
const detail = event.detail ?? {}
if (!detail.type) return
const context = collectContext(event.target as Element, root)
runtime.command({
type: detail.type,
context,
payload: detail.payload
})
}
function dispatchMetadata(
meta: ParsedMetadata,
context: CollectedContext,
extras: {
source: string
target: Element
payload?: unknown
}
) {
// If the metadata names an event, emit it
if (meta.event) {
runtime.emit({
type: meta.event,
context: enrichContext(context, meta),
payload: { ...meta.props, ...(extras.payload ?? {}) }
})
}
// If the metadata names a command, dispatch it
if (meta.command) {
runtime.command({
type: meta.command,
context: enrichContext(context, meta),
payload: { ...meta.props, ...(extras.payload ?? {}) }
})
}
}
function enrichContext(
collected: CollectedContext,
meta: ParsedMetadata
): CollectedContext {
return {
...collected,
// The element itself can supply entity context, which overrides
// any inherited entity context for this specific event.
entity: meta.entityType && meta.entityId
? { type: meta.entityType, id: meta.entityId }
: collected.entity,
intent: meta.intent ?? collected.intent
}
}
// Return a cleanup function
return () => {
for (const { event, handler } of handlers) {
root.removeEventListener(event, handler)
}
}
}

A few design choices are worth pointing out.

One listener per event type, on the root. The listener uses closest() to find the nearest metadata-bearing element. This works for arbitrary nesting and adapts to new elements added later — components added after page load still have their events observed, because the listener is attached to the root, not to individual elements.

Form submissions are handled separately. A <form> with data-meta-event declares its own meta-event for the submission. The form’s data is collected through FormData and added as the event’s payload. This is the form-as-transaction semantics from Chapter 24, wired into the metadata boundary.

Custom meta:event and meta:command events. Components can dispatch these programmatically (with bubbles: true, composed: true) when they want to emit events outside the click/submit cycle. The metadata boundary listens for them and dispatches them into the runtime. This is the escape hatch for components that need to fire events not tied to a DOM input.

The dispatch can produce both an event and a command. If an element declares both meta-event and meta-command, both fire. The order is event-first, then command — events are facts about what happened, commands are requests for what should happen, and the order matches the conceptual sequence.

The metadata listener doesn’t call preventDefault. Native form submissions still happen. Native button clicks still trigger their natural behavior. The metadata layer is observational by default; it adds runtime events alongside the native handling, not in place of it. This preserves progressive enhancement.

Putting Chapters 39–42 together, a complete small application:

// runtime.ts (from Chapter 39)
const runtime = createRuntime()
// shell.ts (from Chapter 40)
const shell = createShell({
name: 'settings-app',
modules: [
debugModule(),
notificationsModule(),
analyticsModule(),
auditModule()
],
rootContext: {
appName: 'settings'
}
})
// boundaries.ts (from Chapter 41) — already available on runtime
// metadata-boundary.ts (from this chapter)
shell.mount(document.getElementById('app')!)
const cleanup = attachMetadataBoundary({
runtime: shell.runtime,
root: document.getElementById('app')!
})
await shell.start()

The page’s markup:

<body>
<div id="app">
<kit-boundary surface="settings-page" feature="preferences">
<kit-boundary surface="profile-form"
entity-type="profile" entity-id="me">
<form data-meta-event="profile.save_requested">
<label>Display name <input name="displayName" required></label>
<button type="submit">Save</button>
</form>
</kit-boundary>
</kit-boundary>
</div>
</body>

The user types a new display name and clicks Save. The end-to-end trace:

  1. The browser fires submit on the form.
  2. The metadata boundary’s submit listener handles it.
  3. The listener parses the form’s metadata: { event: 'profile.save_requested', props: {} }.
  4. The listener collects context: { surfaces: ['settings-page', 'profile-form'], surface: 'profile-form', feature: 'preferences', entity: { type: 'profile', id: 'me' } }.
  5. The listener reads FormData from the form: { displayName: 'Jeremy' }.
  6. The listener constructs and emits the event:
    {
    type: 'profile.save_requested',
    context: { /* the collected context */ },
    payload: { data: { displayName: 'Jeremy' } }
    }
  7. The runtime distributes to subscribers. The save-orchestrator module receives it, dispatches form.validate and profile.save commands, and then emits profile.saved.
  8. The analytics, audit, notification, and observability modules each respond to profile.saved.
  9. The diagnostic stream records every step.

Everything from Chapter 38’s closed loop is now real code. The button declared its meaning. The form declared its event. The boundaries supplied the context. The metadata boundary observed the interaction. The runtime routed. The modules responded.

The application’s behavior is composed at module-install time and the application code itself is tiny — markup plus a small set of modules. The runtime is generic. The shell is generic. The boundary system is generic. The metadata layer is generic. The application-specific code is the modules and the markup.

A subtle design choice the metadata boundary makes is worth highlighting.

The form’s native submit fires. The metadata boundary observes it. The runtime emits the event. And then the form continues with its native submission — the browser sends the request to /profile and navigates to the response.

For applications where this is the wrong behavior (the form should submit asynchronously, the page shouldn’t reload, the response should be handled in JavaScript), the form’s submit handler — or a module subscribed to the form’s meta-event — has to call preventDefault(). The metadata boundary doesn’t do it automatically because some applications want the native submit to happen.

A common pattern: the form’s meta-event includes prevent-default="true" as a prop, and a module checks for the flag:

<form data-meta-event="profile.save_requested"
data-meta-prop-prevent-default="true">
const formInterceptModule = defineKitModule({
name: 'form-intercept',
events: {
'profile.save_requested': (event) => {
// The metadata layer has already cancelled if prevent-default was set
// (handled by the metadata boundary itself when it sees the flag)
}
}
})

Or, the metadata boundary itself checks for the flag:

function handleFormSubmit(event: SubmitEvent) {
const form = event.target as HTMLFormElement
const meta = parseMetadata(form)
if (!meta) return
if (meta.props.preventDefault === 'true') {
event.preventDefault()
}
// ... rest of dispatch logic
}

Both patterns work. The architectural point is that progressive enhancement is preserved by default, and applications that want different behavior opt in.

The architecture is now wired end-to-end. The runtime routes; the shell hosts; the boundaries provide context; the metadata boundary observes DOM events; modules handle consequences. What’s missing is the visibility layer — the ability to see what’s flowing through the runtime in real time, for debugging, performance work, and verification.

Chapter 43 introduces data patterns — how modules talk to a server through repository objects, adapters, and validation at boundaries. Chapter 44 builds the diagnostic surface — a small debug overlay that subscribes to the runtime’s diagnostic stream and renders the live trace. After Chapter 44, Part IV is complete and Part V starts building web-native components on top of the architecture.

Use the runtime, shell, boundary system, and metadata boundary from Chapters 39–42 to build a complete small application.

The markup:

<kit-shell name="exercise">
<kit-boundary surface="exercise-page" feature="learning">
<kit-boundary surface="profile-section"
entity-type="profile" entity-id="me">
<form data-meta-event="profile.save_requested"
data-meta-prop-prevent-default="true">
<label>Name <input name="name" required></label>
<button type="submit">Save</button>
</form>
</kit-boundary>
<kit-boundary surface="actions-section">
<button data-meta-command="notification.show"
data-meta-prop-message="Hello">
Notify
</button>
<button data-meta-event="page.action_taken"
data-meta-prop-action-name="custom">
Custom Action
</button>
</kit-boundary>
</kit-boundary>
</kit-shell>

Implement three modules:

  1. debug — wildcard event subscriber, command observer, prints everything with timestamps.
  2. notifications — handles notification.show and renders a real toast element.
  3. save-orchestrator — observes profile.save_requested, simulates an async save (setTimeout returning a fake response), then emits profile.saved.

Bootstrap the application: create the shell, install the modules, mount on the body, attach the metadata boundary, start.

Then exercise the application:

  1. Submit the form. Watch the trace: profile.save_requested → (orchestrator works) → profile.saved → modules respond.
  2. Click the Notify button. Watch the trace: notification.show command → handler runs → toast appears.
  3. Click the Custom Action button. Watch the trace: page.action_taken event with prop actionName: "custom".

Verify the context propagation:

  • Every event in your trace should include the surface stack (['exercise-page', 'profile-section'] or ['exercise-page', 'actions-section']).
  • Every event from profile-section should have the entity context { type: 'profile', id: 'me' }.
  • The feature should be 'learning' on every event.

Now try the architecture’s flex points:

  1. Add a fourth module — audit — that subscribes to specific event types and logs an audit record. Verify it works without changing the markup.
  2. Programmatically attach a new boundary to a dynamically-created element. Verify events from that element inherit the new context.
  3. Remove the form’s data-meta-prop-prevent-default attribute. The form now submits natively. Verify the metadata event still fires and the browser still navigates.
  4. Add a runtime.onDiagnostic subscriber that counts events and commands. After the test session, log the totals.

Reflect on:

  1. How many lines of code did the whole application take (excluding the runtime/shell/boundary/metadata-boundary plumbing from earlier chapters)?
  2. What did the markup declare?
  3. What did the runtime decide?
  4. What did each module handle?
  5. If you wanted to add analytics tomorrow, where would the code go? (One new module.)
  6. If you wanted to swap the notification provider, where would the code change? (One module’s implementation.)

The exercise is the architecture working. Chapters 39–42 are the entire coordination layer. From here, the rest of the book builds richer pieces — diagnostics (Chapter 44), web-native components (Part V), and the maintained Kitsune framework (Part VI) — all on top of what you’ve now implemented by hand.