Browser Substrate & WASM Bootstrap
On this page 7 sections
QSCS includes a small WebAssembly module, the browser substrate, that acts as the browser's client for the QSCS substrate. The browser does not talk to the origin directly; it talks to QSCS through this WASM module. The module fetches state, applies deltas, detects HEADLESS mode, and notifies the page whenever the upstream origin is unreachable or when fresh content is available. This gives the browser the same behaviour as a thin client: cache-hit latency, automatic recovery when the origin returns, and a clear signal when the page is being served from cached content.
QSCS can still be operated as a plain reverse-proxy for legacy sites that only need cache + HEADLESS fallback, but every modern QSCS-hosted site is expected to embed the substrate. There is no fallback rendering path: the page shell loads, the substrate connects, and the rest of the site is driven by the state stream the substrate exposes to JavaScript.
What the substrate does
- Speaks the QSCS protocol from the browser to the nearest thin client over the single encrypted port your terminator already serves.
- Fetches state from
/qscs/on the current page origin and keeps a local snapshot in WASM memory. - Applies deltas as they arrive so the page never re-downloads unchanged bytes.
- Detects HEADLESS mode by reading
X-QSCS-Modeand dispatching a DOM event the page can use to render a banner, disable mutations, or queue writes for replay. - Recovers automatically when the origin returns, emitting a "fresh content available" event so the page can re-hydrate in place.
- Backs off intelligently: 5 s steady-state interval, 500 ms retry while disconnected.
1. Get the module
The module is a single static file: qscs-substrate.wasm. Place
a copy somewhere your thin-client (or single-node) host serves over HTTPS. Two
common patterns:
- Host it alongside your origin. Drop the file into your
existing static asset directory (e.g.
/var/www/.../wasm/). Your TLS terminator already serves the rest of the site from there, so no new configuration is needed beyond a correctapplication/wasmMIME type. - Use the apt package path. If you installed via
qscs-bootstrap, the binary is already at/opt/qscs/qscs-substrate.wasm, no download or manual copy needed. The version is always aligned with the running daemon, so this is the preferred path when the page and the QSCS daemon live on the same host.
The module URL referenced from your page is up to you, but a conventional path is:
https://<your-site>/wasm/qscs-substrate.wasm
2. Injection on every page (nginx sub_filter)
QSCS now performs full-page delta sync, so the substrate must be present on every HTML response, it is no longer embedded by hand on individual pages. Instead the TLS edge injects it automatically into every proxied HTML response using nginx's built-in sub_filter directive. No changes to the application are required, and it works uniformly for static pages, PHP apps, dashboards, and legacy software placed behind QSCS.
nginx intercepts each proxied HTML response, finds the closing </head> tag, and splices in the three substrate modules before it:
qscs-config.js, publishes the client configuration (path exclusions etc.); must load first.bootstrap.js, patchesfetchandXMLHttpRequestso same-origin requests transparently negotiate and decode QSCS deltas.app.js, loadsqscs-substrate.wasm(the delta decoder) and attaches the client UUID; safe to loadasync.
nginx configuration
Add the injection to the location / block that proxies to QSCS, and clear Accept-Encoding so nginx sees uncompressed HTML to match against. Serve the bundle (installed by the qscs-bootstrap package) from /wasm/:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Accept-Encoding "";
sub_filter_types text/html;
sub_filter_once on;
sub_filter '</head>'
'<script src="/wasm/qscs-config.js"></script><script src="/wasm/bootstrap.js"></script><script src="/wasm/app.js" async></script></head>';
}
location ^~ /wasm/ {
alias /usr/share/qscs/wasm/;
types { application/wasm wasm; application/javascript js; }
}
- The
ngx_http_sub_modulemust be compiled into nginx, verify withnginx -V 2>&1 | grep sub. It is included by default in most distribution packages. - Only responses with
Content-Type: text/htmlare processed; JSON, images, and WASM binaries pass through untouched.
3. Configure the thin client's HTTPS proxy
The substrate fetches a relative path (/qscs/) which lands on
whatever HTTPS server is fronting your thin client. That fronting server
(nginx, Caddy, Apache, HAProxy, anything that terminates TLS) must forward
/qscs/* to the QSCS daemon's HTTP listener, by default
127.0.0.1:8080. The QSCS daemon never terminates TLS itself, and
everything else on the hostname should be proxied through the same daemon so
HEADLESS protection covers the whole site, not just the substrate's poll
endpoint.
Caddy
A minimal Caddyfile for a thin client. Replace edge.example.com
with your hostname:
edge.example.com {
# Substrate / state-poll endpoint — required by the WASM substrate.
handle_path /qscs/* {
reverse_proxy 127.0.0.1:8080
}
# The WASM module itself (if hosted from disk, not from QSCS's WasmPath).
handle /wasm/qscs-substrate.wasm {
root * /var/www/<your-site>
file_server
header Content-Type application/wasm
header Cache-Control "public, max-age=86400"
}
# Everything else goes through QSCS so HEADLESS protection applies
# site-wide and the substrate has a consistent state surface.
reverse_proxy 127.0.0.1:8080
}
nginx
server {
listen 443 ssl http2;
server_name edge.example.com;
# ssl_certificate / ssl_certificate_key managed externally
# (certbot, your own pipeline, etc).
# Substrate / state-poll endpoint — required by the WASM substrate.
location /qscs/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
}
# The WASM module itself (if hosted from disk).
location = /wasm/qscs-substrate.wasm {
root /var/www/<your-site>;
types { } default_type application/wasm;
add_header Cache-Control "public, max-age=86400";
}
# Everything else goes through QSCS too so HEADLESS protection applies
# site-wide and the substrate sees a consistent state surface.
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
}
}
/qscs/ through the daemon creates split-brain conditions where
the page believes the origin is healthy but the substrate sees HEADLESS,
or vice versa.
4. Verify the substrate is talking to QSCS
Load the page in a browser, open the network tab, and look for:
- A request for
/wasm/qscs-substrate.wasmreturning200withContent-Type: application/wasm. - A
GET /qscs/request returning200with headerX-QSCS-Mode: nochange(steady state) orfull(first request). - The
qscs:readyDOM event firing ondocumentbefore your application UI mounts. - The substrate's debug log line (if you wired up
js_log) once every few seconds.
To verify HEADLESS behaviour locally, stop the upstream the master proxies
to and watch the substrate continue polling successfully, the response
headers should switch to X-QSCS-Mode: headless while the page
keeps working from cached state.
Module API reference
| Export | Purpose |
|---|---|
init() | Entry point. Call once after instantiation. Schedules the first tick(). |
qscs_tick(ptr, len) | JS calls this with the JSON body of the most recent /qscs/ response copied into WASM memory. |
qscs_alloc(size) -> i32 | Bump allocator for the JS → WASM buffer exchange. |
memory | Exported linear memory; required for Uint8Array access. |
| Required JS import | Purpose |
|---|---|
env.js_log(ptr, len) | Write a debug message to the page console. |
env.js_schedule(interval_ms) | Schedule the next tick(). |
Common pitfalls
- Wrong MIME type.
WebAssembly.instantiateStreamingrequiresContent-Type: application/wasm. Most servers do not set this by default for an unknown extension, and the substrate will simply fail to instantiate, taking the site down with it. - No HTTP/2 or TLS. Streaming compilation requires a secure context. Serve the page over HTTPS.
- Caching too aggressively. A long max-age on the WASM
file makes substrate upgrades slow to reach users.
max-age=86400is a sensible default; lower it (or use a versioned filename) if you push frequent substrate upgrades. - Forgetting to proxy
/qscs/. If your terminator does not forward/qscs/to the QSCS daemon, the substrate will repeatedly hit a 404 from your origin and the page will never reachqscs:ready. - Rendering the app before
qscs:ready. Your application code must wait for the substrate to come online; otherwise the first interactions race the initial state fetch and you will see flicker or stale reads.