lit-ssr-wasm

LitElement WASM Javy Go esbuild Compiled Mode Runtime Mode How it Works

Server-side render Lit web components from any language via WebAssembly

Two modes

Builtin mode (lit-ssr-builtin.wasm)

Component definitions are baked into the WASM module at build time. The module reads HTML from stdin and writes DSD-enhanced HTML to stdout. Fast and predictable -- ideal for static site generators, build pipelines, or any scenario where components are known ahead of time.

echo '<my-alert type="success">Done</my-alert>' | wasmtime lit-ssr-builtin.wasm

Runtime mode (lit-ssr-runtime.wasm)

No component definitions are baked in. The module reads JSON from stdin containing the component JavaScript source, HTML to render, and element tag names. Lit APIs (LitElement, html, css, classMap, etc.) are exposed as globals inside the QuickJS context, so user-provided source does not need import statements. The source is evaluated internally by QuickJS, registering custom elements, then the HTML is rendered with DSD.

echo '{"source":"...","html":"...","elements":["my-alert"]}' | wasmtime lit-ssr-runtime.wasm

How it works

Both modes share the same core: @lit-labs/ssr and QuickJS bundled into a WASM module via esbuild and Javy. Node.js built-in modules are stubbed with lightweight shims (e.g. Buffer wraps TextEncoder/Uint8Array), since QuickJS does not provide them. The resulting .wasm file (~2 MB) communicates through WASI stdin/stdout, making it callable from any language with a WASI-compatible runtime -- Go, Rust, Python, or even the browser.

Why this works

Lit SSR deliberately avoids full DOM emulation. It intercepts Lit's template system at the string level, using a minimal DOM shim (@lit-labs/ssr-dom-shim) that provides just enough of the HTMLElement and customElements API for Lit's rendering logic. This means it runs comfortably in QuickJS -- no full browser engine needed.

Declarative Shadow DOM

The WASM module's output includes <template shadowrootmode="open"> elements containing the component's styles and rendered shadow DOM content. The browser attaches these as shadow roots during HTML parsing, before any JavaScript runs. Users see styled, laid-out content on first paint with zero layout shift.

Critically, the component's JavaScript definition is not needed on the page for DSD styles to apply. The compiled mode demo proves this: it loads lit-ssr-builtin.wasm and uses its output via setHTMLUnsafe(), but never loads the <my-alert> element definition as JavaScript. The colored, styled alerts you see come entirely from Declarative Shadow DOM.

Running WASM in the browser

Both demos on this page run the actual WASM modules in the browser. A minimal inline WASI shim (~50 lines) maps the nine WASI preview 1 functions the module imports (fd_read, fd_write, proc_exit, etc.) to JavaScript buffers. No bundled JS is needed beyond the shim and a click handler -- the SSR engine runs entirely inside the WASM module.

Server-side usage

On the server, the WASM module is called from Go using wazero (a pure-Go WASM runtime with no CGo dependency). HTML is piped to stdin, DSD-enhanced HTML comes back on stdout. Cold start is ~350ms; subsequent renders ~320ms. The same module works with any WASI-compatible runtime (wasmtime, wasmer, etc.).