Browser runtime and client graphs
The browser does not get a second implementation of LZR. It gets the same render core, compiled ahead of time into a client-graph package and executed by a wasm request engine that a ServiceWorker owns. This page documents that boundary: what the packager emits, what the format guarantees, which operations the engine accepts, what the ServiceWorker adds, and — the part most runtime documentation skips — exactly what the browser cannot do. Every artifact quoted below is this site's own build, and two tables are read live from the graph.
Three artifacts, one authority
Nothing in the browser discovers anything. The graph decides, at build time, what a browser is allowed to render, and hands it over as bytes:
The browser-runtime chain
Each row is a real artifact of this project; the recipes are in src/rendered/public/_hypermake/runtime/client-graphs/ and src/rendered/public/assets/.
| Stage | Artifact | Produced by |
|---|---|---|
| Package | public://_hypermake/runtime/client-graphs/!owner.hmcpack2 | hypermake client-graph package |
| Engine | public://assets/hypermake-request-engine.wasm + wasm_exec.js | Go js/wasm build of cmd/hypermake-request-engine-wasm |
| Host | public://assets/lzr-runtime-sw.js (ServiceWorker) and lzr-runtime.js (window) | runtime/browser/, published by graph-owned copy recipes |
The engine executes; it never fetches. The ServiceWorker fetches, stores, and mutates; it never renders LZR itself. Two flags stamped into every package make that non-negotiable — this site's carries fakeBackendForbidden: true and pageLocalDOMPlannerForbidden: true: neither a stub backend nor a page-local DOM planner may stand in for the real graph.
Packaging a client graph
A client graph is bang-owned: a directory src/!name/ declares a route family that the packager compiles into one package. This site owns three — an admin vertical, a local-settings vertical, and the playground:
public://_hypermake/runtime/client-graphs/!tape-playground.hmcpack2: src/!tape-playground/*.html src/_components/*.html config.json data/tape-mini/a.json data/tape-mini/labels.json ../../bin/hypermake | rendered/public/_hypermake/runtime/client-graphs/ ../../bin/hypermake client-graph package --root . --owner !tape-playground --bootstrap index.html --out "$@"
One command writes a whole artifact family, not one file. The route index (!owner.route-index.json) enumerates it, each entry carrying both identities described below. Read live from this build:
Artifacts emitted for one owner
File names under /_hypermake/runtime/client-graphs/, rendered from that owner's route-index.json at build time — this table cannot drift from the packager.
| Kind | File | Scope | Schema |
|---|---|---|---|
canonical-owner | !tape-playground.hmcpack2 | full | hypermake.client-graph-package.v2 |
debug-owner-json | !tape-playground.debug.json | derived | hypermake.client-graph-package.v1 |
compatibility-owner-hmcpack2 | !tape-playground.compat.hmcpack2 | derived | hypermake.client-graph-package.v1 |
canonical-route | !tape-playground.route.tape-playground-card.hmcpack2 | route | hypermake.client-graph-package.v2 |
compatibility-route-hmcpack2 | !tape-playground.route.tape-playground-card.compat.hmcpack2 | derived | hypermake.client-graph-package.v1 |
debug-route-json | !tape-playground.route.tape-playground-card.debug.json | derived | hypermake.client-graph-package.v1 |
compatibility-route-json | !tape-playground.route.tape-playground-card.json | derived | hypermake.client-graph-package.v1 |
canonical-route | !tape-playground.route.tape-playground-compact.hmcpack2 | route | hypermake.client-graph-package.v2 |
compatibility-route-hmcpack2 | !tape-playground.route.tape-playground-compact.compat.hmcpack2 | derived | hypermake.client-graph-package.v1 |
debug-route-json | !tape-playground.route.tape-playground-compact.debug.json | derived | hypermake.client-graph-package.v1 |
compatibility-route-json | !tape-playground.route.tape-playground-compact.json | derived | hypermake.client-graph-package.v1 |
compatibility-json | !tape-playground.json | derived | hypermake.client-graph-package.v1 |
Three tiers. canonical-owner is the production artifact — typed HMCPACK2, schema hypermake.client-graph-package.v2. The compatibility-* and debug-* rows are derived projections at schema …v1: the .json sibling is a readable compatibility form, the .debug.json an explicitly non-executable projection, carrying "executable": false and its own schema hypermake.client-graph-debug-projection.v1. The *.route.* rows are per-route shards, so one route installs without its siblings.
The route manifest
Owners do not collide by convention; a generated manifest maps public routes to owners and pins each package by digest. This is read live from this build:
public://_hypermake/runtime/client-graphs/manifest.json
Schema hypermake.client-graph-route-manifest.v2. graphSetId covers the whole set, so a partial update is detectable.
| Owner | Canonical artifact | Artifact digest |
|---|---|---|
!admin | !admin.hmcpack2 | 667f9f7ae2572cf2… |
!local | !local.hmcpack2 | 10cd41f87b2310bd… |
!tape-playground | !tape-playground.hmcpack2 | 4f226412d35d6b99… |
And the route ownership it publishes: /admin/dashboard.html → !admin · /admin/summary.html → !admin · /local/settings.html → !local · /tape-playground/card.html → !tape-playground · /tape-playground/compact.html → !tape-playground ·
Two identities: meaning and bytes
A package carries a semantic identity inside itself — owner, scope (full or route), route id, public route, and the graphDigest of the graph it was compiled from. It cannot carry its own artifact identity, because no byte artifact can contain the SHA-256 of itself. That digest lives outside, in the manifest and route index, and the loader verifies it before installation. Verify it by hand:
sha256sum rendered/public/_hypermake/runtime/client-graphs/!tape-playground.hmcpack2 # d4de1350a3dbdfb844b9063018afaa6b1ab9faf61fc79482d8731c96eae78b02 # — the same value this page prints in the manifest table above
The result is a load ladder with no soft failures: schema, package format, ABI, semantic-identity well-formedness, expectation match, then byte digest — each mismatch a stable refusal code, listed under the engine surface.
HMCPACK2, the package format
The canonical artifact is not JSON. internal/clientgraph/hmcpack2 encodes a JSON-shaped value into a typed binary container: the magic HMCPACK2T\n, then interned tables of deterministic strings and object schemas, then typed values (null, false, true, i32, u32, f64, string, array, object). Verified header of this site's package:
head -c 40 rendered/public/_hypermake/runtime/client-graphs/!tape-playground.hmcpack2 | od -c # 0000000 H M C P A C K 2 T \n 353 001 \0 \0 … # 0000020 \0 \0 \v \0 \0 \0 a c t i o n P a c k
Decoding is bounded on every axis — total bytes (64 MiB), table entries, collection sizes, nesting depth (512), reference validity, and trailing data — and every failure has a stable code: magic_invalid, truncated, limit_exceeded, reference_invalid, tag_unknown, trailing_bytes. The envelope's own top-level fields are fixed (identity, engine contract, capabilities, bootstrap, data stores, sections, compiled artifacts, the two forbidden-fallback flags); domain payloads inside stay JSON-shaped because the graph compiler owns their types.
The compatibility .json form exposes the same content as digested sections — eight in this site's playground package: header, routes, data-stores, render-streams, bindings-and-mutations, protocol-resources, client-extension-packages, debug-source-projection. Each carries its own byteLength and digest, which is what makes partial verification and route sharding possible.
The compiled render stream
A packaged route does not carry a template. It carries a lowered stream (lzr.stream.v1) of typed ops plus side tables — static runs, dynamic tokens, shape gates, scopes, expression programs, dependency programs, collection manifests, frame calls. The op vocabulary is eight kinds, and that is the whole executor contract:
Stream op kinds
internal/lzr/ir/ir.go. There is no component op, no include op, and no parse op — see the note below.
| Op | What it does |
|---|---|
emit_static_run | Emit a precompiled literal run of markup by id. |
emit_dynamic_token | Evaluate an expression program and emit its value, with the escape context (html_text / html_attr) fixed at compile time. |
shape_gate | Branch or iterate: the gate's shape program decides an if outcome or a collection manifest. |
set_var | Bind a name from an expression program (the lowered <set>). |
enter_scope / exit_scope | Push and pop a scope frame by id. |
frame_call | Invoke a <frame> by its compiled call record. |
checkpoint | A named resumption point for fragment-level patching. |
Verified op census of this site's /tape-playground/card.html route — a template with a <set> pair, an <if>/<else>, a <foreach>, and two component calls:
jq '.projection.compiledArtifacts.renderRoutes[0].renderStream.ops
| group_by(.kind) | map({(.[0].kind): length}) | add' \
rendered/public/_hypermake/runtime/client-graphs/!tape-playground.route.tape-playground-card.debug.json
# {"checkpoint":3,"emit_dynamic_token":7,"emit_static_run":15,"set_var":2,"shape_gate":2}
Each route also carries bindings: rows of {key, dataRoot, pointer, exprProgramId, dynamicTokenIds, targets} connecting a mutable data address to the exact tokens it feeds. That table is how a JSON-Patch write becomes a targeted DOM update rather than a re-render.
The wasm request engine
The engine publishes five globals and nothing else:
JS entry points
cmd/hypermake-request-engine-wasm/main.go. Startup is registration-driven: the module calls __hypermakeRequestEngineReady; no loader may poll for it.
| Global | Argument | Returns |
|---|---|---|
hypermakeRequestEngineLoadPackage | package bytes or text, plus an optional ArtifactIdentity JSON string | Load result or a typed refusal. |
hypermakeRequestEngineExecute | request JSON | Response envelope with ok, status, engine, abi, and on success html/bindings/routeTiming. |
hypermakeStatechartInitial / …Step / …Tick | statechart request JSON | See statecharts. |
Exactly four operations are accepted; anything else is refused with operation_unsupported:
Accepted operations
executeRequest in main.go. renderMode is reported back so a consumer can tell a streamed render from a direct-HTML shortcut.
| Operation | Meaning |
|---|---|
render-bindings | Resolve the route's binding table against the current data stores and protocol resources — values only, no HTML. Unresolvable pointers are skipped, not errors. |
render-route-html | Execute a route stream supplied in the request. Reports renderMode frozen-compiled-graph. |
render-route-html-resident | Same, but the route is resolved from the package already installed in the engine, and resident data stores are merged under the request's own. |
finalize-route-html | Post-process finished HTML: expand <fa-icon> from packaged protocol resources, then minify. Reports postprocessed-static-html. |
Every render answers with a routeTiming map the engine measured itself: engineTotalMs plus per-phase plan costs (planDigestMs, planDynamicEvalMs, planSetEvalMs, planShapeEvalMs, planCollection*Ms, planForeachBodyMs, planFrameEvalMs) and the matching evaluation counts. The playground renders that map directly; it is not an animation.
Refusal reasons
Verbatim reason codes. A refusal is always {ok:false, status:'refused', reason} — the engine never degrades silently.
| Stage | Reasons |
|---|---|
| Request | request_missing, request_json_invalid, operation_unsupported |
| Package intake | package_missing, package_read_failed, package_json_invalid, package_schema_unsupported, package_format_unsupported, package_abi_unsupported, package_routes_missing |
| HMCPACK2 decode | package_hmcpack2_magic_invalid, package_hmcpack2_truncated, package_hmcpack2_decode_failed, package_hmcpack2_project_failed, package_hmcpack2_unmarshal_failed |
| Identity | package_artifact_identity_missing, package_artifact_identity_invalid, package_graph_identity_invalid, package_graph_identity_mismatch, package_artifact_digest_mismatch |
| Render | resident_route_missing, request_engine_render_stream_missing, request_engine_frame_result_missing, request_engine_frame_cycle:<routeKey>, request_engine_finalize_html_missing, request_engine_finalize_icon_missing_name |
Note the asymmetry in the identity rows: a v2 typed package requires the caller to pass the expected ArtifactIdentity. Loading a canonical package without one is package_artifact_identity_missing — there is no "trust the bytes you were handed" path.
Routes, params, and frames in the browser
The engine resolves a requested path against the resident routes by id, public route, route path, path pattern, or source reference. A pathPattern containing {name} segments matches by segment count with URL-decoding, and ties are broken by the most literal segments; otherwise trailing segments bind positionally through the route's paramSchema.
Matched params enter the render context in the same shape the server render uses:
route.path requested path route.pathList its segments, decoded route.resolvedPath the route's own declared path route.resolvedPathList its segments route.resolvedID the route id route.params the bound parameters ui.route.* the same object, mirrored
Each param is additionally lifted to a bare top-level name, unless it collides with an existing name or with a reserved root (agents, branches, data, detail, graph, mutations, plugins, runtime, runtimePlugin, scope, source, testing, timeline, ui, route).
<frame> — whose authoring surface is documented on the LZR language reference — resolves recursively inside the engine. Each non-container frame call resolves its src to another resident route, renders it, and splices the result into the frame host. The recursion carries a route stack and refuses re-entry with request_engine_frame_cycle:<routeKey>. Two deliberate non-errors: a frame declared as a container keeps its server-authored placeholder body untouched, and a frame pointing at a route that is not in this shard is left empty on purpose, so the browser frame runtime can fetch that route's own shard.
Expression parity with the CLI
The browser evaluates expressions with the same code the CLI does. internal/lzr/render/compat_expr.go carries no js/wasm build exclusion and has no wasm-specific twin, and the package is in the engine's real dependency closure:
GOOS=js GOARCH=wasm go list -deps ./cmd/hypermake-request-engine-wasm | grep internal/lzr/ # internal/lzr/ir, collection, address, htmls, fragment, intern, tapeledger, # datafrag, datatransform, expr, rendercompat, richtext, templateexpr, render
So bracket indexing, negative list indices, the pipe registry, protocol-read semantics, and truthiness behave in the browser exactly as documented on expressions and pipes. There is no browser subset of the dialect and no second evaluator to keep in step. What differs is not the language but the resolvable inputs, which is the next section.
What the browser genuinely cannot do
The limits are structural — enforced by the build closure and by package-time refusals, not by discipline:
Honest limits
Absences verified against the js/wasm dependency closure above; refusals quoted from internal/clientgraph/package.go.
| Not available | How it is enforced |
|---|---|
| Parsing or compiling a template | internal/lzr/parse, compile, component, componentpack, client, and legacy are absent from the js/wasm closure. The browser executes streams; it cannot author them. |
| Full-document render and scope debugging | render/foreach.go and render/debug.go are build-tagged !js ||!wasm. |
| Tape-ledger emission | render/tapeledger.go carries the same build tag; the ledger is a server-side artifact. |
rendered:// reads | Packager refusal: "server-filesystem reads … must be represented as packaged protocol resources, data roots, or explicit route context before browser execution". |
file:// reads | Packager refusal: "host-filesystem reads … not available to the browser request engine". |
Unpackaged public:// target reads | Refused unless the address resolves to a packaged protocol resource — otherwise it "depend[s] on the server graph". |
Everything the browser can read was decided at package time and shipped: data stores (keyed by their data:// address), protocol resources, and explicit route context. The playground package carries exactly two data stores, data://tape-mini/a.json and data://tape-mini/labels.json; nothing else is reachable from those routes.
The ServiceWorker runtime
A bang-owner directory becomes a live surface only when the ServiceWorker installs its package. The worker owns a fixed set of same-origin runtime routes — no discovery, no wildcards:
ServiceWorker-owned routes under /_hypermake/runtime/
runtime/browser/lzr-runtime-sw.js. Every response is wrapped in a hypermake.authority-envelope.v1 so the Inspector can tell browser state from graph state.
| Route | Method | Role |
|---|---|---|
/route-state, /data-store | GET | Current route projection and data-store values, with generations. |
/diagnostics, /probe | GET | Typed runtime diagnostics (worker, wasm engine, client pipeline) and liveness. |
/patch, /patches | POST / GET | Apply a typed JSON-Patch mutation; list stored mutations. |
/writable-resource | POST | Mutate through a named resource declared in clientGraph.writableResources. |
/action-pack, /host-command | POST | Invoke a declared action-pack action; serve its host calls. |
/sync, /remote-data-change, /reset-data | POST | Outbound sync, inbound remote change reconciliation, and store reset. |
/client-pipeline, /extensions, /projection-cache | GET / POST | Client pipeline runs, installed extension packages, projection-cache control. |
Mutations are typed packets (hypermake.runtime.patch.v1) carrying the package's runtimeRootDigest, the target route, the data:// target, an expectedDataStoreGeneration, and a JSON-Patch array. Generations are monotonic and checked: a write against a stale generation is refused rather than merged; an invalidation at or below the current generation answers status: "stale". A successful mutation returns the affected binding keys and broadcasts hypermake.runtime.data.changed — which is how a patch reaches the DOM without a re-render. Named writable resources add a layer: the packet names a resource, and the worker resolves dataTarget, patchPointer, and patchOp from the package; a resolved client:// target routes to a window provider instead of worker storage.
Action packs are the sanctioned way for browser code to have effects. Each declared action carries an effect list drawn from a fixed vocabulary and capabilities that scope those effects to concrete addresses; browser.host-call is the effect that lets an action reach back through /_hypermake/runtime/host-command. This site's own package declares one, lzr-testing/admin/update-campaign-title, whose capabilities name data://campaign.json and the host calls runtime.data.read, runtime.data.patch, runtime.sync.enqueue — the declaration is in hypermake.json and is compiled into the package header.
The live proof of this whole layer is the bang-owned ServiceWorker runtime canary: it installs the worker, reaches diagnostics, persists a typed patch in worker-owned storage, and reads the wasm-backed admin route state — without the fake backend or a page-local DOM planner.
Statecharts in the browser
The same wasm module hosts the statechart runtime as three operations over an IR the graph compiled (hypermake statechart compile --input CHART.scxml, schema hypermake.statechart.ir.v1): initial returns the initial snapshot, step applies one event, tick runs to quiescence under a maxSteps bound. Responses carry hypermake.browser-statechart-runtime.v1 plus diagnostics; refusals are statechart_ir_invalid, statechart_request_json_invalid, statechart_operation_unsupported. Browser and server must agree exactly, checked against internal/hyperstatechart/testdata/browser-runtime-agreement.v1.json.
Weight, disclosed
The engine is not small, and this site does not round the number down. Measured on this build:
Browser-runtime asset weight
Byte counts from this project's rendered/public/assets/. Budgets from tools/site-weight-budget.json, enforced by the graph-owned site-page-weight canary.
| Asset | Bytes | Budget | State |
|---|---|---|---|
hypermake-request-engine.wasm | 21,594,827 raw · 4,443,327 gz · 3,114,362 br | 7,500,000 | over |
lzr-runtime-sw.js | 277,603 | 80,000 | over |
lzr-runtime.js | 180,090 | 70,000 | over |
Verify this page
hypermake client-graph package --root . --owner !tape-playground --bootstrap index.html --out /tmp/pkg.hmcpack2 hypermake client-graph manifest --out /tmp/manifest.json hypermake explain public://_hypermake/runtime/client-graphs/!tape-playground.hmcpack2 hypermake affected-by data/tape-mini/a.json hypermake explain rendered/public/docs/browser-runtime.html
Source authorities, in reading order: internal/clientgraph/package.go (packaging, browser-protocol refusals), internal/clientgraph/hmcpack2/ (format and identity contract), internal/hypercli/client_graph.go (the artifact family and its validation), internal/lzr/ir/ir.go (stream op vocabulary), cmd/hypermake-request-engine-wasm/main.go (engine surface, route matching, frame recursion), internal/hyperstatechart/browser_runtime.go (statechart operations), runtime/browser/lzr-runtime-sw.js and runtime/browser/README.md (ServiceWorker contract).