scrml

/ˈskrɪmɛl/

A complete compiler for the web. Stop wiring. Start building.

MIT open source pre-1.0 active development GitHub repo
View on GitHub Read the tutorial Language spec

What is scrml?

scrml is a compiled language that replaces your frontend framework, backend glue, and most of your build toolchain with one file type. Write markup, reactive state, scoped CSS, SQL, server functions, realtime channels, and inline tests together in a .scrml file. The compiler splits server from client, wires reactivity, routes HTTP, types the database schema, and emits plain HTML, CSS, and JavaScript.

No virtual DOM. No JSX. No separate route files. No state management library. No node_modules.

A reactive counter, the whole file

<program>
  <count> = 0
  <step>  = 1

  <div class="counter">
    <span class="value">${@count}</>
    <button onclick=${@count = @count + @step}>+</>
    <button onclick=${@count = @count - @step}>-</>
    <select onchange=${@step = event.target.value}>
      <option value="1">step 1</>
      <option value="5">step 5</>
    </>
  </>

</program>

Why scrml

State is the declaration primitive

State cells are the atomic unit of a scrml program. Every <name> = value declares a reactive cell; every @name reads or writes one. Validators ride on the decl (<email req length(>=3)> = <input/>); types ride on the decl (<phase>: Phase = .Idle); render-spec markup rides on the decl (<userName> = <input type="text"/>). Reads in markup and logic look the same — ${@count} — and the compiler builds the reactive dependency graph from those reads.

The UI is the state machine

The structural shape of the UI tree IS the structural shape of the app's state. A <match for=Phase on=@phase> block requires every variant of Phase to have UI. An <engine for=Phase initial=.Idle> takes it further — every state-child declares the variants it may legally transition to via rule=, and the compiler rejects writes that skip a step. Boolean lifecycle flags (@isLoading, @hasError) become an enum; the enum becomes exhaustive UI; the exhaustive UI becomes provably-complete behavior. The compiler nudges via lints (W-LIFECYCLE-CANDIDATE, W-MATCH-TRANSITIONS-ACCRUING) but lets you prototype with booleans first.

Mutability contracts

Any mutable variable can carry a compile-time contract about what it's allowed to be. Value predicates (<price>: number(>0 && <10000)) constrain every write. Presence lifecycle (not, is some, is not, given, lin) gates reads until a value exists and ensures exact-once consumption. State transitions (<engine>) declare an enum's legal moves — every state-child declares the variants it may legally transition to via rule= — and reject writes that skip a step. Layer these as you need them; leave them off where you don't.

Full-stack in one file

Markup, logic, styles, SQL, server functions, error handling, tests — everything lives in .scrml. The compiler analyzes your code and splits it across server and client automatically. No API layer to maintain, no route files to keep in sync.

Realtime and workers are first-class

A <channel> element declares a WebSocket endpoint — the compiler generates the upgrade route, the client connection manager, auto-reconnect, and pub/sub topic routing. Cells declared inside the channel body (<messages> = []) sync across every connected client automatically — no modifier needed; lexical placement is the contract. Heavy work goes in a nested <program> that compiles to a Web Worker or WASM module — with typed RPC, supervised restarts, and when message from <#name> event hooks.

The compiler eliminates N+1 automatically

Because scrml owns both the query context and the loop context, a for (let x of xs) { ?{... WHERE id = ${x.id}}.get() } pattern is rewritten to one pre-loop WHERE id IN (...) fetch plus a keyed Map lookup. Independent reads in a ! handler share one BEGIN DEFERRED..COMMIT envelope. On-mount server @var loads coalesce into a single __mountHydrate round-trip. Measured wins (v0.3.0 refresh): ~1.7× at N=10, ~2.3× at N=100, ~3.3× at N=1000 on on-disk WAL SQLite.

The compiler knows what code is reachable to whom

Auth gates aren't runtime checks; they're compile-time visibility constraints. A <auth role="Admin"> block surrounding a subtree tells the compiler — via the whole-stack closure analysis at §40 — exactly which component code, server functions, and stdlib units are reachable to which role at which entry point. Two consequences fall out. Anonymous visitors download a strictly smaller initial bundle than admins because the gated subtree's atoms aren't shipped; and prefetching is tiered by route topology — visible chunks ship at idle, hover-targets prefetch on hover, deeper navigation pulls on-demand. Every chunk filename embeds a stable FNV-1a content hash (§47), so adopter caches stay valid across builds when source bytes don't change. The compiler flags shapes that defeat the analysis — a route that links nowhere, a gate that needs a runtime check — so you fix them at the source.

Validators auto-synthesize a validity surface

Bare-word validators on a state decl (<email req length(>=3) pattern(emailRegex)> = <input/>) generate the per-field and compound rollup automatically: @signup.email.isValid / @signup.email.errors / @signup.email.touched at the field level and @signup.isValid / @signup.errors at the form level — all reactive, all read-only. Errors are enum tags (.Required, .LengthFailed(predicate)), not strings, so consumers match on the variant. The same predicate vocabulary fires in three places — state validators (reactive), refinement types (compile + boundary), schema columns (DBMS-enforced) — and the rules compose cleanly because they're the same words.

Errors-as-states is the canonical lifting

scrml has no try/catch. Failable functions declare what they fail with (fn load() !LoadError); call-sites handle each variant exhaustively (let rows = load() !{ \| ::Network msg -> @phase = .Error(msg) \| ::Empty -> @phase = .Empty }). The natural shape is to lift errors into a Phase enum — every failure mode becomes a state with its own UI. <isError> and <errorMsg> cells are anti-patterns; the failure modes live in the type.

No npm escape hatch

scrml has no import from 'npm-package' syntax. The stdlib is what you import: 16 modules covering auth, OAuth, http, schema validation, router, store, crypto, time, format, regex, redis, cron, fs, path, process, and the test runner. Bundled with the compiler. Single-version. Version-locked. No registry. No transitive-dependency tree. The language itself covers more than the stdlib needs to — scrml's validators eliminate zod/yup; V5-strict cells eliminate redux/zustand; auto-synthesized validity surfaces eliminate react-hook-form/formik; the tier-ladder (if/match/<engine>) eliminates xstate for the most-reachable cases. ~88-90% of typical-app npm needs disappear by construction. The escape hatch isn't missing by oversight; it's missing by design. When a real gap surfaces, the answer is to extend the stdlib — not to bolt on the package ecosystem we're trying to fix.

This page is the highlights

The full language surface — refinement types (<price>: number(>0 && <10000)), the not / is some / given presence model, linear types (lin for exact-once consumption), inline-test blocks, the !{} error handler, lifecycle effects via <onTransition> + temporal effects via <onTimeout> / <onIdle>, the meta layer (^{} blocks that run at compile time), nested <program> for workers, hierarchy + history + internal:rule= on composite engine state-children, <auth role> first-class auth gates with full interpolation, per-route per-role content-addressed chunk splitting with tier-1/tier-2/tier-N prefetching, the import system with pinned for forward-references, scoped CSS via #{}, and the bun scrml promote --match CLI that mechanically lifts if-chains into exhaustive match blocks — is documented in the tutorial and the spec. What's on this page is the headline. What's in the spec is the language.

Quick start

# Install (Bun required)
bun install

# Link the scrml binary onto your PATH (one-time, from the repo root)
bun link

# Scaffold a new project, then run it
scrml init my-app
cd my-app
scrml dev src/app.scrml   # watch + serve

# Or use the CLI directly on any .scrml file or directory
scrml compile <file|dir>
scrml dev <file|dir>      # watch + serve
scrml build <dir>         # production build

Articles

Status

scrml is in active pre-1.0 development. The language spec evolves as we find friction and the compiler catches up. The changelog tracks what just landed and what's in flight. The compiler runs on Bun; compiled output is plain JavaScript and runs in any browser or JavaScript runtime.

Learn more