scrml v0.3.0 — Approach A complete: per-route, per-role, content-addressed
scrml v0.3.0 — Approach A complete: per-route, per-role, content-addressed
scrml v0.3.0 is shipped. The big new thing — call it Approach A — is whole-stack closure analysis: at compile time, the compiler computes exactly which component code, server functions, and stdlib units are reachable from each entry point and from each role. Two consequences fall out, and they're the reasons v0.3.0 took the shape it did:
1. Auth gates aren't runtime checks. A <auth role="Admin"> block surrounding markup is a compile-time visibility constraint. Anonymous visitors download a strictly smaller initial bundle than admins because the gated subtree's atoms aren't in their per-role chunk. They can't even see the admin code, let alone fetch it.
2. Per-route per-role chunks ship in tiers. The compiler emits one chunk per (entry-point, role, tier) tuple. Tier 1 (idle-prefetch) fires on requestIdleCallback. Tier 2 (hover-prefetch) fires on hover via data-scrml-prefetch markup that the compiler weaves in based on internal <a href> links it can resolve. Tier N (on-demand) pulls when navigation actually fires. Every chunk filename embeds an FNV-1a content hash — adopter caches stay valid across builds when source bytes don't change.
This is the v0.3.0 critical-path investment, and it absorbed S88 → S92 of development. The scrmlTS repo, MIT-licensed, is at github.com/bryanmaclee/scrmlTS — bun install, bun link, scrml init your first app.
<auth role> as a first-class element
<program auth="required">
<page>
<h1>Dashboard</h1>
<auth role="Admin">
<section>
<h2>Admin controls</h2>
<button onclick=clearCache()>Clear cache</button>
</>
</auth>
</page>
</program>
The <auth role> attribute supports the universal value-bearing-attr shape: string literal (role="Admin"), variable ref (role=@currentRole), or ${expr}. Closed-form predicates — variant literals, literal comma-OR (role=(Admin | Editor)), const-refs, boolean composition of statically-known operands — ship per-role bundles. Runtime-fallback predicates (reactive reads, async server-fn checks, arbitrary expressions) emit W-AUTH-RUNTIME-FALLBACK and ship the gated component eagerly with a runtime gate. The grammar is open; the classifier discriminates.
This was a load-bearing design decision — the natural framing was "scope tractable: only allow string-literal roles." That framing was rejected. Compiler-supported state deserves the same expressive surface as user-defined state. Anything less makes the language feel like a toy.
Per-route per-role chunks, in detail
For each entry point (each <program>-rooted file or each <page> under routes/) and each role variant, the compiler emits:
- The initial chunk — what loads on first render. Components mounted directly in the route, plus any reactive cells, server-fn stubs, and stdlib units the component needs.
- The tier-1 prefetch chunk — what
requestIdleCallbackwill pull next. Today this is a delta over initial; future work refines what's eligible. - The tier-2 prefetch chunk — what hovering an internal
<a href>will pull. Cross-route navigation is wired automatically: when the compiler sees<a href="/loads">in your markup androutes/loads.scrmlexists, the link getsdata-scrml-prefetch="/loads"and the right chunk is fetched on hover. - Tier-N on-demand — anything not reachable via the closure but registered in the manifest. Pulled by
_scrml_fetch_chunk(epId, role, tier)when navigation actually fires.
The manifest is at chunks.json. Adopter tooling can read it; the compiler field carries "scrml-0.3.0" (sourced from package.json automatically — drift between hard-coded constant and pkg.json was the kind of thing v0.3.0 also fixed).
{
"version": 1,
"compiler": "scrml-0.3.0",
"entryPoints": {
"src/app.scrml#program": {
"_anonymous": {
"initial": "/_anonymous.initial.4f2a8b9c.js",
"prefetch": "/_anonymous.prefetch.7d3e1a2b.js"
},
"Admin": {
"initial": "/Admin.initial.9c1b4d3e.js",
"prefetch": "/Admin.prefetch.2e5f8a1d.js"
}
}
}
}
Content-addressing (FNV-1a, lowercase base36, 8 chars)
Every chunk filename includes the lower-32-bit FNV-1a hash of the chunk's normalized canonical string, lowercase base36, zero-padded to 8 characters. Per §47.1.3:
- FNV prime:
16777619(32-bit) - Offset basis:
2166136261(32-bit) - Hash inputs: chunk's component-node-ids ∪ reactive-cell-node-ids ∪ server-fn-node-ids ∪ vendor-unit-names ∪ payloadJs (admission ids sorted via stratified comparator joined with
","; fields joined with\x1FASCII US separator)
The compiler field is not in the hash inputs. Two chunks produced by different scrmlTS versions but with identical post-canonicalization payload bytes produce identical FNV-1a hashes. This is intentional — adopter-side cache invalidation should track source-byte changes, not compiler-version churn.
The diagnostic family
Eight warnings + three errors + one info-lint flag shapes that defeat the closure analysis. The compiler tells you at compile time, not from production:
W-CG-CHUNK-EMPTY— a route's initial chunk has no atoms (placeholder body)W-CG-CHUNK-LARGE— initial chunk exceeds size budget (configurable via--chunk-size-budget)W-CG-CHUNK-NO-PREFETCH(Info) — page has no internal<a href>links (genuine no-prefetch case)W-CG-CHUNK-PREFETCH-UNRESOLVED— page has internal links but none resolve to known routes (typo / missing page)W-CG-CHUNK-MISSING-ROLE— source-cited role not in chunk's emitted-roles set (typo / unresolved enum)W-CG-UNDEFINED-INTERPOLATION— codegen interpolation site would emit literal"undefined"to wire formatW-AUTH-RUNTIME-FALLBACK(Info) — gate predicate isn't closed-form; gated component shipped eagerly with runtime gateW-AUTH-PAGE-INFERRED(Info) —<page>lacks explicitauth=under enclosing<program auth=>; inference appliedW-AUTH-LOGIN-MISSING—<program auth="required">withoutloginRedirectAND no/loginpage existsE-CLOSURE-001— RS outer fixpoint iteration-cap overflowE-CLOSURE-002— RS Component 4 implicit-anonymous + auth-role-blockE-AUTH-GRAPH-002— AuthGraph: role enum required but ambiguous discoveryE-AUTH-GRAPH-003— AuthGraph: role variant not found in resolved enumI-AUTH-REDIRECT-UNRESOLVED— redirect cross-ref resolves to no known route
Each one points at the source position that triggered it and tells you what shape would compile cleanly.
What else landed during v0.2.6 → v0.3.0
The Approach A close was the headline, but the v0.3.0 development cycle absorbed a substantial set of v0.3 surface ratifications too:
- v0.3 Wave 1 spec anchor (S85) — one-program-per-app +
<page>helper element registered + filesystem-inferred routing +W-PROGRAM-SPA-INFERREDinfo lint when an entry file has<program>but no<page>siblings + nopages/directory. - Insight 30 channel-architecture closure (S87) — channels are now CHILDREN of the entry-file
<program>(sibling of<page>), not file-level siblings.E-CHANNEL-OUTSIDE-PROGRAMif at file top in a file that declares<program>. Pure-channel module files (no<program>) may declare<channel>at file top — the "pure channel file" sharing pattern. - §36 input devices closure (S89) —
<keyboard>,<mouse>,<gamepad>live-input retention closed end-to-end with theconf-INPUT-*test family and a canvas demo. - §13.2 auto-await
Promise<T>closure (S89) — the typer extension now classifies 37 stdlibPromise<T>functions for one-line auto-await;const result = fetchItems()Just Works whetherfetchItemsis sync or async. - safeCall + safeCallAsync stdlib host primitives (S87/S88) — Approach α: the stdlib
.scrmldeclaressafeCall(thunk)! -> HostError, with a hand-authored JS shim atcompiler/runtime/stdlib/host.jscarrying the try/catch. Zerotry/catchin scrml source. - LIFT-template codegen bug family CLOSED (S88) — the per-item interactive-markup-inside-for/lift pattern (TodoMVC edit-mode shape) now works across all five LIFT bug families.
- M-7C-D-12 wire envelope
notsemantics (S90) —nullin the wire format encodes scrml'snotper§12.5.1+ new§57Wire Format normative section. The wire is JSON-canonical; scrml's absence semantics are preserved across the boundary. nullandundefinederadication from scrml source (S89) — absolute rule. In every.scrmlfile, every SPEC normative example, every stdlib.scrml, every primer / kickstarter / sample / example:notfor absence. The empty string""is a defined value (a string of length zero);0/false/[]/{}are defined values. Empty ≠ absent.
What this means for adopters
If you have a v0.2.6 scrml app, v0.3.0 doesn't break it. The new surface — <auth role>, per-route chunks, content-addressing — is opt-in via the --emit-per-route CLI flag. Without that flag, codegen behaves as v0.2.6 did. The Approach A pipeline runs in both modes (the analysis itself is part of pipeline Stage 7.6 + 7.55 + 8) but the per-route artifact emission only fires when you ask for it.
When you DO ask for it, the workflow is: declare <auth role="X"> blocks where you want compile-time visibility constraints, run scrml build --emit-per-route, and inspect dist/chunks.json for the manifest. The role-detection bootstrap script the compiler injects into per-route HTML handles the rest — localStorage > cookie > <meta name="scrml-role"> > "_anonymous" is the dispatch order.
If you're new to scrml, just scrml init my-app and start writing. The default scaffold doesn't use <auth role> yet (the kickstarter is meant to keep the early surface small) but everything in the Tutorial works the same as it did at v0.2.6 — with one new section (§9 Auth gates and per-route bundles) covering the new surface.
What's next
The v0.3.0 cut clears the v0.3 critical path, but a few items wait for v0.3.1 / v0.4:
- A-2.9 perf + memory characterization — corpus-wide ceiling-baseline measurement post-Approach A. Standalone work; not blocking, but worth knowing the actual bundle-size deltas across realistic adopter shapes.
- Tier-2 hover-prefetch at finer granularity — today the compiler wires hover-prefetch on internal
<a href>markup it can statically resolve. Reactivehrefattributes (computed at runtime) ship eagerly. v0.3.1 or v0.4 may address. - Mount-marker emitter granularity for CE-expanded children — currently the chunk-mount-emitter folds CE-expanded children into the imported component's root mount marker rather than emitting per-element markers. NOT a closure-analysis correctness issue (the per-role variance is correct at the
componentNodeIdslevel), but a codegen-emission granularity concern that may matter for adopter test-bind / DevTools precision. - Wave 4.B (deferred from this cut) — articles currency re-verification + tutorial-snippet recompile against the v0.3.0 binary + cross-doc final cleanup beyond what landed in S92.
- The self-host — scrml's own compiler will eventually be a from-scratch rewrite in scrml itself. v1.0 territory; not v0.3 work. The TS implementation in
compiler/src/is a temporary scaffold through that horizon.
TL;DR
- scrml v0.3.0 is shipped. Tag
v0.3.0. Tests at cut: 12,694 pass / 0 fail / 638 files (fullbun test). - Approach A complete — whole-stack closure analysis (
§40), per-route per-role content-addressed chunk splitting (§47),<auth role>first-class element with universal value-bearing-attr shape, tiered prefetching (idle / hover / on-demand), W-CG-CHUNK- + W-AUTH- diagnostic family. - Opt-in via
--emit-per-route— your v0.2.6 apps still build the same way without it. - Adopter-facing surface refreshed — scrml.dev landing, README, tutorial (new
§9), primer (new§9.7), changelog all carry the v0.3 mental model. - Next up: A-2.9 perf characterization, tier-2 hover-prefetch refinement, Wave 4.B docs polish, self-host horizon.
The full repo is at github.com/bryanmaclee/scrmlTS — MIT, no node_modules, runs on Bun. The tutorial is the place to start; the new §9 covers the v0.3 auth-gates surface.
Semver history: v0.2.0 (S83) → v0.2.1 → v0.2.2 (S83) → v0.2.3 → v0.2.4 (S84) → v0.2.5 → v0.2.6 (S85) → v0.3.0 (S92, 2026-05-14).