DEV Community

Cover image for Introducing scrml: a single-file, full-stack reactive web language
Bryan MacLee
Bryan MacLee

Posted on • Originally published at scrml.dev

Introducing scrml: a single-file, full-stack reactive web language

Introducing scrml

scrml is a compiled language that puts your markup, reactive state, scoped CSS, SQL, server functions, WebSocket channels, and tests in the same file — and lets the compiler handle everything in between. The compiler splits server from client, wires reactivity, routes HTTP, types your database schema, and emits plain HTML/CSS/JS. No build config, no separate route files, no state-management library, no node_modules mountain.

This post is an introduction. scrml is pre-1.0 and not production-ready — the language surface will still shift, some diagnostics are rough, and I'm sharing it now mainly to get design feedback before things calcify.


Why another language?

A typical modern web app spreads across five or more tools and files. You have React on the client, Node or Next on the server, a state library (Redux, Zustand, Jotai...), a separate router config, an API layer keeping client and server types in sync, a CSS system, a build toolchain, and your ORM. Each of those tools makes reasonable local choices, and collectively they produce the sprawl everyone complains about but nobody quite knows how to fix.

What if the compiler owned the whole stack?

That's the bet. Same file, same language, one compile pass. The compiler knows which functions are reachable from the client and which stay on the server, because it parses both. It knows which @var is read in which DOM node, because it builds a dependency graph. It knows your SQL schema, because it ran the schema extraction. So it can enforce things across those boundaries instead of leaving them to runtime coordination.


A counter in one file

Here's the entire program:

<program>

${
  @count = 0
  @step = 1

  function increment() { @count = @count + @step }
  function decrement() { @count = @count - @step }
  function reset()     { @count = 0 }
}

<div class="flex flex-col items-center gap-6 p-8 min-h-screen bg-gray-50">
  <h1 class="text-3xl font-bold text-gray-800">Counter</h1>
  <p class="text-6xl font-bold text-blue-600">${@count}</p>

  <div class="flex gap-2">
    <button class="px-5 py-2 text-lg bg-red-500 text-white rounded-lg cursor-pointer hover:bg-red-600" onclick=decrement()>−</button>
    <button class="px-5 py-2 text-lg bg-gray-200 rounded-lg cursor-pointer hover:bg-gray-300" onclick=reset()>Reset</button>
    <button class="px-5 py-2 text-lg bg-green-500 text-white rounded-lg cursor-pointer hover:bg-green-600" onclick=increment()>+</button>
  </div>

  <label class="flex items-center gap-2 text-sm text-gray-600">
    Step:
    <input type="number" class="w-16 p-1 text-center border border-gray-300 rounded" bind:value=@step min="1" max="100">
  </label>
</div>

</program>
Enter fullscreen mode Exit fullscreen mode

@count and @step are reactive variables — language primitives, not library wrappers. bind:value is two-way binding. onclick=increment() wires the handler. The compiler emits direct DOM updates (no vdom, no diffing) for every site that reads @count. The Tailwind utility classes above compile via a built-in Tailwind engine — no tailwind.config.js, no postcss, no content scan — so utility CSS works out of the box and you can still drop into a scoped #{} block when utilities aren't enough.


Three things that are different

1. State is a first-class type

< Card> declares a state type; <Card> instantiates one. HTML elements like <input> and <form> are state types — the language treats them uniformly with your own types. Every state value flows through match, through fn signatures, and across the server/client boundary with static checks. The compiler knows what shape a Contact is, which fields are protected (server-only), and which routes need to serialize what. You write Types once; they hold across the stack.

2. Any variable can carry a compile-time contract

Contracts come in three flavours.

Value predicate — reject out-of-range writes:

@price: number(>0 && <10000) = 1
Enter fullscreen mode Exit fullscreen mode

Presence lifecycle (lin) — must be consumed exactly once; the compiler refuses a double-use or a silent drop:

lin token = fetchCsrfToken()
submitForm(token)    // consumed — compile error if you used it twice or not at all
Enter fullscreen mode Exit fullscreen mode

State transitions — only legal moves allowed:

type DoorState:enum = { Locked, Unlocked }

< machine name=DoorMachine for=DoorState>
    .Locked   => .Unlocked
    .Unlocked => .Locked
</>

@door: DoorMachine = DoorState.Locked
@door = .Unlocked    // ok
@door = .Locked      // ok — Unlocked => Locked is declared
Enter fullscreen mode Exit fullscreen mode

The < machine> rejects illegal transitions at both compile time and runtime. If the compiler can prove the destination is unreachable from the current state, it errors then and there. If something dynamic sneaks through (a network response, user input routed into a transition), the runtime enforces the same table. One source of truth, two layers of enforcement.

3. N+1 gets rewritten automatically

A pattern like this:

for (let user of users) {
  let orders = ?{`SELECT * FROM orders WHERE user_id = ${user.id}`}.all()
  ...
}
Enter fullscreen mode Exit fullscreen mode

becomes a single WHERE user_id IN (...) pre-fetch plus a keyed Map lookup. Because the compiler owns both the query context and the loop context, it can see that the per-iteration query is a safe batching candidate. When it isn't safe, you get a D-BATCH-001 diagnostic with the exact disqualifier and a ?{...}.nobatch() escape hatch.

Measured on on-disk WAL bun:sqlite (median of 50 iterations after 5 warmups, table size 1000, full results in benchmarks/sql-batching/RESULTS.md):

N Baseline (ms) Optimized (ms) Speedup
10 0.0111 0.0057 1.95×
100 0.1068 0.0410 2.60×
500 0.5124 0.1654 3.10×
1000 1.0490 0.2625 4.00×

Upper bound is SQLITE_MAX_VARIABLE_NUMBER (32,766). Network-attached storage would widen the gap further.


There's more that didn't fit here

To keep this post focused, I left out:

  • WebSocket channels (<channel>) — auto-generated upgrade routes, auto-reconnect, and @shared vars that sync across connected clients
  • Web Workers as nested <program>s — heavy work compiles to a worker with typed RPC and supervised restarts
  • Scoped CSS (#{}) that applies only inside the component it's declared in
  • Typed SQL where the schema extraction feeds back into type checking
  • Inline tests as language constructs

Each is worth its own writeup. I'll do follow-up posts if there's interest.


Where it is today

  • Pre-1.0 — breaking changes likely, rough edges, not production-ready
  • MIT — compiler is fully public
  • 6,800+ tests passing — every language feature is test-covered
  • ~45 ms — full compile for a TodoMVC-sized app on a 2021 laptop
  • Bun today — Node/Deno ports are possible but not priorities

Try it / follow along

Feedback on the design is the thing I'm actually optimising for right now. If something looks wrong, looks over-engineered, looks like it'll trip on a real app — I want to hear about it here, on X (@BryanMaclee), or as an issue on the repo. Happy to chase threads before the language surface calcifies.

Top comments (0)