Wanderland

Web Signals

Third walkthrough in the Engine in a Box series, the companion piece to a series of shorts on Sprout that are themselves teaching platform engineering concepts. Tutorial 1 stood the engine up. Tutorial 2 walked a grid and showed events[] rolling out the back of the response carrying :signals:* crossings. Today we wire those crossings into actual page mutation.

The page is a bus

A request hits the engine. A boundary chain runs. The response carries a list of signals — each one a typed crossing with a payload. So far the trail ends at JSON.parse: the page knows the events arrived, but no part of the page knows what to do with any of them.

Wanderland Web Signals is the contract that closes the loop. Boundaries emit crossings in the :signals:web:* namespace; the initiating component walks events[] and fans each one onto window.lanternBus as the action name from the type address. Receivers register at page load — refresh, execute / run, toast, dialog-input — and decide what each action means. The bus never knows a slug from a message; it delivers payloads to whoever's listening.

That makes the server-to-page surface trivially extensible. A new pipeline that wants to refresh a sibling component declares the action in the boundary and emits the signal; the receiver already exists. A new component that wants to listen for an existing action implements applySignal(name, payload) and registers nothing else. The engine doesn't know there's a page; the page doesn't know there's a chain. They meet at the events array.

Today we put that loop on the floor with the smallest possible level: a boundary that takes a list of slug:section paths and emits a :signals:web:refresh crossing aimed at a specific page-side mount. A second boundary in the chain — collect_signals — projects the signal crossing into events[] so the response carries the shape the page expects. A <wanderland-button> next to an `` calls the route on click; the response's signal travels back through the bus and the node swaps content. Click again, different section. Then a second mount + a second emitter cell, and one click swaps both. When you're done you'll be able to: - write a boundary that emits a classified `:signals:web:*` signal carrying a target id and an arbitrary payload - write a `collect_signals` mapping boundary that projects signal-classified crossings out of context into the response's `events[]` slot - declare a route whose chain composes the two boundaries - drop the three component files (`lantern-bus.js`, `wanderland-signals.js`, `wanderland-button.js`) into a sprout page and wire one button against one mount - route the button's traffic through Lantern's external-services proxy via the `service` attribute - toggle the `[ ] trace` checkbox to capture the request's full signed merkle and view it as a stack of cards in a centered modal - extend the chain with a second emitting cell and a second mount and watch one click drive two targets This tutorial picks up where [Walking the Grid](wanderland-dev-tutorial-engine-grid-route) left off — `sprout-engine` running on port 9295, the `oculus_fence` and `emit_signal` boundaries already registered, the `/run` route walking the grocery grid end to end. The components and the new boundaries slot in alongside. ## What the wire carries Every `` click POSTs to a named route and reads back a `{ value, events, items }` envelope, with one extra slot when the trace toggle is on: ```json[id=wanderland-dev-tutorial-engine-web-signals-what-the-wire-carries-fence-0] { "value": { "...": "domain state projection — last-writer-wins" }, "events": [ { "type_addr": ":signals:web:refresh", "result": { "target": "story-board", "slug": "dag-tutorial", "section": "pet-store" } } ], "items": [], "_trace": [ /* every crossing the run produced, signed, in order — only when X-Wanderland-Trace is set */ ] } ``` That envelope doesn't appear on its own. A chain whose only boundary is the signal emitter renders the boundary's flat result and nothing else — `format` projects the prior crossing's result into the response body. To get `events[]` on the wire, the chain composes a mapping boundary that walks the context, filters the signal-classified crossings, and writes them into the response shape. `collect_signals` is the tutorial-side primitive that does that for any chain route; `collect_grid_state` (from [Walking the Grid](wanderland-dev-tutorial-engine-grid-route)) is the equivalent for grid sub-streams. Two slots in the chain — emitter + collector — and the response carries the shape the page expects. Each entry in `events[]` is a slice of a signal-classified crossing. `type_addr` carries the `:signals:web:` classifier; `result` carries whatever payload the boundary stuffed in. The page-side fanout is twelve lines of code: ```javascript[id=wanderland-dev-tutorial-engine-web-signals-what-the-wire-carries-fence-1] for (const ev of response.events || []) { if (typeof ev.type_addr !== 'string') continue; if (!ev.type_addr.startsWith(':signals:web:')) continue; const action = ev.type_addr.slice(':signals:web:'.length); window.lanternBus.emit(action, ev.result); } ``` `` does that walk for you. The receivers in `wanderland-signals.js` resolve `payload.target` to an element by id, then call `applySignal(name, payload)` on it (or fall back to `.reload()` / `.refresh()` for components that don't expose the richer hook). Component-specific behavior — like `` knowing how to swap `slug` and `section` from a refresh payload — lives on the target, not in the receiver. ## What you drop on a page Three files in `wanderland-loss/sprout/public/js/`, written once: - `lantern-bus.js` — `window.lanternBus`. Pub/sub on top of `document.dispatchEvent` with listener counting. Eighty lines. - `wanderland-signals.js` — registers the default receivers (`refresh`, `execute` / `run`). Each resolves a `target` id and dispatches via `applySignal` with `.reload()` / `.refresh()` / `.run()` fallbacks. - `wanderland-button.js` — the `` custom element. Reads the route's `/inspect/route/:name`, renders Run (or Submit / Clear when the route declares `params_shape`), POSTs on click, walks `events[]`, fans each `:signals:web:` to `lanternBus.emit('', payload)`. Optional `[ ] trace` checkbox sets `X-Wanderland-Trace`; the response's `_trace` opens in a centered modal with one card per crossing and a recursive k/v explosion on click. Then on the engine side: - a boundary that emits the signal you want - a `collect_signals` boundary that projects the signal crossings into the response's `events[]` - a route that composes both - a markdown page with an `` mount and a `` next to it The same files serve every page on the site — drop them into the layout once. ## 1. Write the `pick_section` boundary The boundary takes a list of `"slug:section"` paths and a `target` id, picks one path at random, and emits a `:signals:web:refresh` crossing carrying the target plus the chosen `slug` and `section`. The page-side `refresh` receiver resolves the target and hands the payload to `.applySignal('refresh', ...)`, which swaps its attributes and reloads. Create `lib/sprout_engine/boundaries/pick_section.rb`: ```ruby[id=wanderland-dev-tutorial-engine-web-signals-1-write-the-pick-section-boundary-fence-0] # frozen_string_literal: true module SproutEngine module Boundaries class PickSection include Wanderland::Boundary IDENTITY = Wanderland::Identity.new( id: "boundary:pick_section", name: "PickSection", roles: [:boundary], type: :service, scopes: [:web_signal] ).freeze boundary :pick_section, identity: IDENTITY, capabilities: [:section_picker, :signal_emission], description: "Pick a random slug:section path; emit :signals:web:refresh", input_shape: { args: { target: true, paths: true } }, output_shape: { target: true, slug: true, section: true, chosen: true } def call(input) target = input.find_or_fail("args.target") paths = Array(input.find_or_fail("args.paths")) chosen = paths.sample slug, section = chosen.to_s.split(":", 2) Wanderland::Signal.emit( ":signals:web:refresh", target: target, slug: slug, section: section, chosen: chosen ) end end end end ``` Anatomy: - `Wanderland::Signal.emit(":signals:web:refresh", …)` — the boundary returns a typed signal directly rather than an OK with bookkeeping for `format` to render. The crossing's `type_addr` carries the action name `refresh`; the kwargs become the payload. `` walks `events[]` on response and fans each `:signals:web:` to `lanternBus.emit('', payload)`, so `refresh` lands at the receiver registered in `wanderland-signals.js`. - `args.target` — opaque to the engine, meaningful to the page. The string is the `id` attribute of the `` (or any other element exposing `applySignal`/`reload`/`refresh`) the receiver should resolve. - `args.paths` — the candidate set, each shaped `"slug:section"`. The boundary splits on `:`. The `slug` and `section` fields ride on the payload for ``'s `applySignal('refresh', { slug, section })` to consume. - `chosen` — the full original string. Useful for the trace modal and for any logging side that wants to know which path won without re-joining. - `Wanderland::Signal` — fully qualified. Bare `Signal` resolves to Ruby's built-in Unix-signal module; see the style guide's [Boundary code](wanderland-dev-style-guide) section. ## 2. Write the `collect_signals` boundary `collect_signals` is the chain-route counterpart to grid-route's `collect_grid_state`. It walks the parent context, filters signal-classified crossings, and writes them into the response's `events[]` slot. Default `value` is empty; routes that need a richer value side compose another mapping boundary or pass the value through on a separate cell. Create `lib/sprout_engine/boundaries/collect_signals.rb`: ```ruby[id=wanderland-dev-tutorial-engine-web-signals-2-write-the-collect-signals-boundary-fence-0] # frozen_string_literal: true module SproutEngine module Boundaries class CollectSignals include Wanderland::Boundary IDENTITY = Wanderland::Identity.new( id: "boundary:collect_signals", name: "CollectSignals", roles: [:boundary], type: :service, scopes: [:context_remap] ).freeze boundary :collect_signals, identity: IDENTITY, capabilities: [:context_remap, :signal_collect], description: "Project :signals:web:* crossings from context into events[]", input_shape: {}, output_shape: { value: true, events: true, items: true } def call(input) ctx = input["context"] events = ctx.events .select { |c| c["type_addr"].to_s.start_with?(":signals:web:") } .map { |c| { "type_addr" => c["type_addr"], "result" => c["result"] } } Wanderland::Signal.ok( value: {}, events: events, items: [] ) end end end end ``` Anatomy: - `ctx = input["context"]` — bracket access on the framework key returns the request's `Wanderland::Context`. From here we walk the events array directly rather than via the LWW projection, because we want the full classified set, not the latest writer per key. - `select { |c| c["type_addr"].to_s.start_with?(":signals:web:") }` — the projection contract is "anything in the `:signals:web:*` namespace becomes a page-side action." Stop and pass signals (`:signals:stop:*`, `:signals:pass:*`) ride elsewhere; web is the page's slice. - `map { ... type_addr, result ... }` — each event entry is the bare two-field shape `` reads. The whole crossing record (with `to_addr`, `signature`, `trace`, etc.) is preserved in `_trace` when the trace toggle is on; `events[]` is the lean projection. - `Signal.ok(value: {}, events:, items: [])` — value defaults to empty. Cell crossings on the chain that contributed real state can ride `value` if you wire a cell ahead of `collect_signals` that returns the state under that key. For the tutorial demo, the signal payloads carry everything the page needs. `collect_signals` is generic across any chain that uses `:signals:web:*` — it doesn't know about pick_section or any specific signal action. Reuse it for every chain route that emits page signals. ## 3. Wire the `/refresh` route Append to `~/working/sprout/sprout-engine/config.yml`: ```yaml[id=wanderland-dev-tutorial-engine-web-signals-3-wire-the-refresh-route-fence-0] routes: /refresh: method: post name: refresh chain: - boundary: pick_section args: target: story-board paths: - "dag-tutorial:pet-store" - "dag-tutorial:grocer" - "dag-tutorial:home" - boundary: collect_signals ``` Anatomy: - `name: refresh` — the route's name. `` looks up `/inspect/route/refresh` to discover the path and method. - `chain:` — two slots, in order. `pick_section` emits the signal; `collect_signals` projects the signal-classified crossings from context into the response's `events[]`. The framework's standard injections wrap the whole thing: `enforce_denials` → `pick_section` → `collect_signals` → `verify_route` → `trace_emit` → `format` → `seal`. Every chain that wants `events[]` on the wire ends with `collect_signals` (or another mapping boundary that produces the same shape). - `target: story-board` — the page-side mount the receiver will refresh. Edit the YAML, no boundary code change required. - `paths` — the candidate set the boundary samples from. Adding, removing, or reordering paths happens in this list. `POST /refresh` returns: ```json[id=wanderland-dev-tutorial-engine-web-signals-3-wire-the-refresh-route-fence-1] { "value": {}, "events": [ { "type_addr": ":signals:web:refresh", "result": { "target": "story-board", "slug": "dag-tutorial", "section": "grocer", "chosen": "dag-tutorial:grocer" } } ], "items": [] } ``` A different `section` lands every click. `events[]` is the projection `` reads to fan signals onto the bus. ## 4. Drop the page Sprout pages are markdown. The components self-bootstrap from `/js/`. The button takes a `route` attribute; the mount takes an `id` matching the boundary's `target`: ````markdown[id=wanderland-dev-tutorial-engine-web-signals-4-drop-the-page-fence-0] # Roll a Section


The `service` attribute routes the button's calls through Lantern's external-services proxy. With `service="sprout-engine"`, all of the button's traffic — the inspect-route lookup, the run POST, anything carrying the request — goes through `/api/external/sprout-engine/...` (configured in [lantern-external-services](lantern-external-services)) instead of an absolute upstream URL. Keeps the credentials, the rewriting, and the routing in one place; the markdown only names the upstream by short name. Drop `service` and the button calls `/api/engine/...` directly (the default).

On load:

- `<wanderland-button>` fetches `/api/external/sprout-engine/inspect/route/refresh`. The route declares no `params_shape`, so the button renders a single Run face with the override label "Roll the dice".
- `<!--wld-shield-1-->
<!--wld-shield-2-->

POST /refresh now produces two signal crossings on the way to collect_signals. The collector projects both into events[]. The button fans both. Each lands at a different target id; both nodes swap on every click.

The engine has no concept of "the page has two mounts." It produces two signals because the chain has two emitter cells. The page resolves them by id. The contract is the events array.

6. The trace toggle

Add trace to the button:

<wanderland-button route="refresh" service="sprout-engine" label="Roll both" trace></wanderland-button>

A [ ] trace checkbox renders next to the primary button. When checked, the button sets X-Wanderland-Trace: true on the request. The engine's trace_emit slot stamps _trace onto the response — every crossing the run produced, in chain order, signed, with to_addr and trace (the previous crossing's signature) on each.

After a run with trace on, the primary button face swaps to View Trace. Clicking opens a centered modal with one card per crossing. Each card shows the boundary name, the type_addr, and a one-line preview of the result. Click a card and it expands to a recursive k/v tree of the full crossing record (boundary, from_addr, requirements, capabilities, result, type_addr, to_addr, signature, trace).

The card order is the merkle order. Reading top to bottom is reading the request the way the engine ran it. Run again without trace and the button reverts to its Run face. The toggle is per-run.

What's next

Site Audit

wanderland.dev

oculus-view: fence: fence execute HTTP 404