Web platform package for the Labelle toolkit.
IndexedDB blob storage is implemented in src/web_storage.c with Zig bindings in src/web_storage.zig. The backend adds the C source with its emscripten sysroot and imports the storage module. Runtime service selection remains explicit. The default loading shell and its staging API/tool are implemented. The provider (contract 1.3 to 1.5, labelle-cli 2.1.0+) provisions the emscripten toolchain, defaults wasm builds to ReleaseSafe, stages build output, serves it (with browser live reload under labelle run --watch), exports it, and checks its requirements (labelle web doctor).
- Web toolchain provisioning (emsdk) through the generic provider contract.
- HTTP serving, browser reload and export of
wasmbuilds. - Browser storage services through the engine's storage interface.
The provider target remains wasm. Backend-specific rendering and emscripten linking remain with backend packages; building and file watching remain with the CLI. Custom game pages must remain supported. A default package selection is registry/scaffold data, not a hardcoded CLI package name.
- Provider architecture: CLI #406
- Contract decisions before implementation: CLI #411
- Manifest-declared texture capabilities: CLI #407
- Default loading shell: CLI #402
- Fullscreen canvas sizing: bgfx #130
- Persistent browser saves: engine #893
- Web/wasm leaves the CLI core: CLI #466 (this is its PR W)
Migration is breaking: existing web projects explicitly add and pin the provider. Verify browser startup, resize/fullscreen, custom-shell behavior, and save persistence when those features land.
The shell shows the LaBelle logo, a progress bar and a byte counter while the
browser downloads and compiles game.wasm. Fetch counts decompressed bytes,
so the total is the raw file size even with gzip/Brotli hosting. Compilation
streams from the same counted response; browsers without instantiateStreaming
use a buffered fallback. Without a known size the bar is indeterminate. The
loader hides on Module.onRuntimeInitialized and reports JS/download/compile/
abort failures visibly. Temporary window error handling also catches game-script
syntax errors and synchronous startup exceptions, and is removed once the runtime
initializes (later game errors do not reopen the loading screen). The canvas follows its CSS size and devicePixelRatio,
including backend buffer resets during startup. Reduced-motion preferences stop
the logo pulse.
Stage an existing web build (Zig 0.16.0):
zig build shell -- /absolute/path/to/built/web /absolute/path/to/project/web
# Omit the second directory when there is no project web directory.
# Or install a reusable host tool:
zig build install-shell
zig-out/bin/labelle-web-shell /absolute/path/to/built/webThis adds index.html, labelle-loader.js and labelle-logo.png. Those two
labelle-* filenames are reserved and replaced on every staging call. Other
files, including game.html, remain untouched. The tool operates on an existing
build/staging directory; it does not build a game or copy custom page assets.
Use labelle build to build games, then run this tool against the web output.
For an exported directory, stage after wasm-opt/other wasm changes and before
precompression or zipping. No deploy-script size substitution is needed.
Page precedence is project web/index.html, then an emitted index.html, then
the package default (ahead of emcc's game.html). Custom HTML is preserved apart
from explicit __WASM_BYTES__ placeholders, which become the actual raw
game.wasm length, or 0 if absent. Pass the original project web directory on
every staging call so custom placeholders are freshly substituted; copied custom
pages with an already substituted value cannot be restamped without their source.
The package default is recognized by its leading marker and restamped on rebuild.
Load labelle-loader.js before game.js, then configure the existing Module:
<div id="loading" data-wasm-bytes="__WASM_BYTES__" aria-busy="true">
<progress id="progress" max="1" aria-label="Downloading game"></progress>
<p id="status" role="status">Loading game…</p>
</div>
<script src="labelle-loader.js"></script>
<script>
var Module = LabelleLoader.install(window.Module || {}, {
canvas: document.getElementById('canvas'),
overlay: document.getElementById('loading'),
progress: document.getElementById('progress'),
status: document.getElementById('status')
});
</script>
<script src="game.js" onerror="Module.labelleLoader.fail(new Error('game.js download failed'))"></script>Your page supplies its own styles and canvas (the default page is an example).
For loader-script download failures, copy the inline labelleShellFail fallback
and guarded game-script startup from src/shell/index.html; that fallback works
even when LabelleLoader is unavailable.
Existing onRuntimeInitialized, onAbort and locateFile callbacks are retained.
The loader owns instantiateWasm; installation rejects an existing override.
Options include wasmURL (otherwise locateFile('game.wasm', scriptDirectory) or
game.wasm), scriptDirectory, wasmBytes (overrides the data attribute),
fitCanvas: true and onError. Canvas fitting is off by default for custom
pages: enable it only when CSS supplies width and height independently of the
canvas backing attributes (for example, width: 100vw; height: 100dvh). The
default shell supplies that CSS and explicitly enables fitting. URLs resolve relative to the document; pass an
explicit URL/prefix for CDN/subdirectory glue. Module.labelleLoader.dispose()
stops canvas fitting when a custom page removes the game. This hook supports
classic Emscripten Module builds, not modularized factories/ES modules.
Threaded builds ("threads": true) work: Emscripten's workers load game.js
themselves. A custom page that should pick the threaded export where it can
calls LabelleLoader.pickBuild(function (base) { ... }) first and loads
base + 'game.js' with wasmURL: base + 'game.wasm' (see src/shell/index.html).
b.dependency("labelle_web", ...).module("shell") exposes
shell.stage(allocator, io, output_dir, project_web_dir), returning the selected
page source and optional wasm byte count. Both directory arguments are open
std.Io.Dir handles (the project directory is nullable). The same API is intended
for local serve and export; stage custom assets first and pass their original
source directory. I/O errors propagate instead of silently falling back.
The provider uses this API automatically after build and before serving/export.
Its private .labelle-shell-state.json tracks copied custom files and the original emitted page so
repeated staging restamps it and removing a project override restores the
emitted/default page. Deleted custom files are removed when their staged bytes
still match the last copy; newly emitted backend replacements are preserved.
The server hides this file; exports omit it.
Requires Zig 0.16.0 and labelle-cli 2.1.0 or newer: the manifest admits
provider contract 1.3.x to 1.5.x (command_contract = ">=1.3.0 <1.6.0"; a
patch wire adds no keys, and the decoder still rejects unknown fields). It
uses 1.3's cache_dir, env_file and run.watch, and, on a CLI that speaks
1.5, run.outcome_file: when labelle run --timeout stops the served game,
the provider reports timeout there so the CLI skips the after run hooks,
as it does after its own watchdog. Below wire 1.5 the stop is a plain exit 0. Add this package explicitly
to project.labelle's .plugins, using a pinned release or commit (a
local:/absolute/path/to/labelle-web checkout works too). The dependency name
is web; the repository is labelle-toolkit/labelle-web. For remote pins,
run labelle providers resolve, review the proposed pins, then repeat with
--accept to update the lock. The package claims target wasm and namespace
web; do not declare another owner for the same target/namespace.
labelle build --platform=wasm # provision emsdk, build (ReleaseSafe), stage the sized shell
labelle run --platform=wasm -- --port=9000 --no-open # build, stage, and serve
labelle run --platform=wasm --watch # serve, rebuild on change, reload the browser
labelle bundle --platform=wasm # build and package through the hook
labelle web serve --port=8080 --no-open # serve an existing build
labelle web export --output=dist --zip --platform=github-pages
labelle web doctor [--json] # Python + emsdk requirements
labelle web toolchain which # which emsdk the next build uses
labelle web toolchain install [<version>] # install the managed emsdk ahead of a build| Hook | Slot | Does |
|---|---|---|
toolchain |
before generate |
Resolves (and, managed, installs) the emsdk and writes env_file |
toolchain-package |
after generate |
Package mode only: activates every zig-pkg/*/emsdk in place |
shell |
after build |
Stages the loading shell into zig-out/web |
serve |
replaces run (.watch = true) |
Serves the build; in a watch session, the published output |
export |
replaces bundle |
Packages the build into the bundle directory |
The manifest's target_defaults makes wasm builds ReleaseSafe unless
--optimize (or --release) says otherwise.
The toolchain hook runs before generation, so its contribution reaches the
CLI's generation-time fingerprint pass (zig build --list-steps), the compile
and every later hook. It writes env_file with EMSDK, EM_CONFIG (when the
emsdk has one), and upstream/emscripten then upstream/bin in front of
PATH, whichever source the emsdk comes from. upstream/bin carries
binaryen's wasm-opt, which the bundle export runs; a labelle web export
command, which gets no contribution, falls back to the wasm-opt in
$EMSDK, emsdk.root or the managed install when PATH has none. The
backends (bgfx, sokol, raylib) link with $EMSDK/upstream/emscripten/emcc
whenever it exists, so every backend shares one emsdk.
Which emsdk, by settings emsdk.source:
managed(default): an inheritedEMSDKthat is an activated emsdk (bothupstream/emscripten/emccand the.emscriptenconfig exist) is passed through untouched; otherwiseemsdk.root; otherwise the provider-managed install, fetched on first use:git clone --depth 1 --branch <version>of emscripten-core/emsdk, the pinned commit verified (4.0.9 is3bcf1dcd; another version is trusted by tag), thenemsdk installandemsdk activate.inherited: only the inheritedEMSDK;root: onlyemsdk.root. Either fails instead of falling back, including when the emsdk has emcc but no.emscripten(an interrupted activation: runemsdk activate).package: the emsdk the backends' Zig dependency fetched into<target>/zig-pkg/is activated in place after generation, every copy of it (a graph holding two emsdk hashes builds with either), and the one the target'sbuild.zig.zonnames is contributed from the compile onward. The provider records the version it activated in each tree (.labelle-web-activated); a tree activated for anotheremsdk.version, or by someone else (CLI 2.x activates one copy itself), is re-activated for the requested version (emsdk installadds it,emsdk activateswitches to it). Offline, that re-activation is refused.labelle web doctorreports package mode underLABELLE_OFFLINEas unavailable unless every emsdk fetched for the selected target is already activated for the requested version.
The managed install lives in the provider's cache (the CLI's cache_dir,
<LABELLE_HOME>/providers/<provider id>/), shared by every project that pins
this provider:
<cache_dir>/emsdk/v1/<arch>-<os>/<version>-<commit12>/ an activated emsdk (~1 GB)
Installs are keyed by host and SDK identity, serialized by a file lock beside
the directory, built in a staging sibling and renamed into place with a
completion marker, so two builds installing one version at once end with one
valid install. With LABELLE_OFFLINE set a missing install fails naming the
version; a cached one is used without network. The old CLI cache
(~/.labelle/emsdk/) is neither migrated nor deleted; remove it yourself once
no CLI 2.x project needs it.
emsdk and emcc need Python 3 on PATH (python3; python, then python3,
on Windows). The provider runs the candidate and accepts only Python 3; a
Python 2, or none, fails the hooks with the fix: labelle install python (the
CLI's managed interpreter) or a system Python 3. On Windows the verified
command is also passed as EMSDK_PYTHON to emsdk.bat and contributed to the
build, so emcc.bat runs the same interpreter.
labelle web doctor reports both requirements without installing anything and
exits non-zero only when one is missing; labelle doctor runs it after the core
checks. --json prints one line, the wasm capability object
{"id":"wasm","required":true,"ok":…,"items":[python, emsdk, git]} whose items keep
labelle-studio's shape (id, name, ok, fixable, size_mb, action,
detail, hint). git is required only while a managed install is
pending (the install runs git clone); with the managed emsdk installed, an
external EMSDK, emsdk.root or package mode it reports ok. Package mode
under LABELLE_OFFLINE is checked against the selected target's tree only
(.labelle/<backend>_wasm, the backend read from project.labelle).
labelle run --platform=wasm --watch keeps the serve replacement running
while the CLI watches the project and rebuilds. The CLI publishes each fully
successful build (every hook included) into a fresh directory, switches
run.watch.output_dir to it, then advances run.watch.generation_file. The
server serves only output_dir (resolved afresh for each request, never the
staging tree), polls the generation file and, when it changes, open pages
reload through the injected client polling /__labelle_livereload. Each page
is served with the generation it came from embedded in that client, so a
build published between serving the page and its first poll still reloads it. A failed
rebuild publishes nothing, so the last good build keeps being served. SIGTERM or
Ctrl+C stops the server with status 0.
Provider commands consume existing build output. They discover exactly one
.labelle/*_wasm/zig-out/web directory containing game.wasm; use
--input=/absolute/path/to/web when multiple backends have been built.
game.js and game.wasm are required with exactly that casing. Symlinked
build artifacts are rejected before staging, serving, or export. Arguments use --name=value syntax;
export platform is optional (itch or github-pages). GitHub Pages export
adds .nojekyll. ZIP archives contain the final staged files. The writer supports ZIP32
(up to 65,535 entries and a 4 GiB archive); larger archives return Zip64Required.
The serve hook reads --port=N and --no-open from the arguments after
labelle run ... --. With labelle run --timeout=<s> (run.timeout_ms) the
server stops at the deadline and exits 0, as on Ctrl+C.
labelle run options (--scene, --profile, --screenshot, --after)
reach the serve hook as run.env (LABELLE_SCENE, ...). A browser game
has no process environment, so every served HTML page gets them in a script
placed right after its leading <!doctype> (byte-order marks, whitespace
and comments ahead of it are skipped; with no doctype, at the very start).
The HTML parser puts a script found before <html> first in <head>, so it
runs before every page script, whatever the page's markup. Every < in the
values is escaped: window.LABELLE_RUN_ENV = {"LABELLE_SCENE": "intro"},
plus a Module.preRun step that copies them into Emscripten's ENV before
main. The game's getenv then sees them as on desktop; the engine's
requestedScene() reads LABELLE_SCENE through getenv. The script extends
an existing Module (or creates one), which classic Emscripten glue and
LabelleLoader.install(window.Module || {}) both keep. Custom pages may also
read window.LABELLE_RUN_ENV directly.
Project web/ resources are copied alongside the selected page. Root filenames
game.js, game.wasm, game.data, .labelle-export, labelle-loader.js, labelle-logo.png and
.labelle-shell-state.json are reserved (including case variants and compressed siblings); custom assets
cannot replace them.
Precompressed custom files are ignored when the original file is present;
regenerate compression after export. Exports run optional wasm-opt before size stamping, invalidate stale compressed
copies of changed artifacts, and report the shipped tree. Destination paths
cannot overlap inputs or replace the project/custom-page directory; unrelated
nonempty directories are refused. An existing ZIP is replaced only when its
SHA-256 matches the ownership record from an earlier export; unrelated or
modified neighboring archives are refused before the output directory changes.
Map a provider-owned JSON file in project.labelle:
.provider_config = .{
.{ .package = "web", .file = "providers/web.json" },
},Schema 1, strict (unknown keys, duplicate keys and wrong types are errors).
Only schema_version is required; the other values shown are the defaults:
{ "schema_version": 1, "port": 8080, "open_browser": true, "build_dir": null,
"emsdk": { "version": "4.0.9", "source": "managed", "root": null },
"export": { "platform": "none", "zip": false }, "threads": false }build_dir selects existing output for the serve/export commands; the hooks
receive the current target output directly. emsdk.root may be relative to the
project. export.platform is none, itch or github-pages. Command flags and
run arguments override settings. Upgrading from v0.2.0: add
"schema_version": 1 to an existing settings file.
Opt into the threaded web build (Emscripten pthreads, labelle-web#24), so
std.Thread works in the browser as it does natively:
- build / run: the
toolchainhook asks the generation for the threaded build (LABELLE_WASM_THREADS=1).servesendsCross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corpon every response; without them a threaded module can't start. - bundle / export: ships both builds: the threaded one in
threaded/and a single-threaded fallback at the root, built from the same generated tree (-Dwasm_threads=false). The page loadsthreaded/only where it is cross-origin isolated (LabelleLoader.pickBuild), so hosts that can't send the two headers (some embeds) still run the game, single-threaded. The build in.labelle/is never modified: the fallback is built into its own prefix and the export packages a composed copy. The plainlabelle web exportcommand can't build the fallback, so with threads on it refuses and points tolabelle bundle --platform=wasm. - Reserved:
threaded/belongs to the threaded build; a projectweb/threaded/is an error while threads are on. - Hosting: to get the threaded build, serve the export with the same two
headers (for example nginx
add_header Cross-Origin-Opener-Policy same-origin; add_header Cross-Origin-Embedder-Policy require-corp;). Every cross-origin resource the page loads must then allow it (CORP/CORS). - Requires labelle-cli with labelle-cli#525, labelle-assembler with labelle-assembler#818 and labelle-bgfx with labelle-bgfx#199. With an older link the build fails with a message naming them, instead of silently shipping a single-threaded build.
- Browsers: Chrome/Edge 92+, Firefox 79+, Safari 15.2+ (macOS and iOS), Samsung Internet; wasm32 has no 64-bit atomics (use 32-bit shared values).
Under --progress=json the CLI's stdout carries only its NDJSON feed, so the
provider writes diagnostics (and the output of git and emsdk) to stderr,
with streaming writers that append correctly when both streams go to one file.
Only a command's answer (toolchain which, doctor --json) uses stdout.
zig build test install-shell install-provider checks the Zig bindings, staging precedence,
size substitution, restamping and invalid inputs, and builds the host tool.
cd tests/shell
npm ci --ignore-scripts
npx playwright install chromium firefox webkit
npm testBrowser tests throttle a valid wasm download and exercise byte progress, gzip,
unknown size, buffered fallback, errors, callback preservation and canvas sizing
in Chromium, Firefox and WebKit. They also run the provider's serve hook
(zig-out/bin/labelle-web, or PROVIDER_EXE) with run.env on a page with
no <head> and early, <noscript>, <template> and SVG scripts, and check
that every executable one already sees LABELLE_RUN_ENV in standards mode.
CI also compiles tests/shell/main.c with
Emscripten 4.0.9 and verifies that its actual glue reaches main through the shell.
Set EMCC_FIXTURE to that output directory to repeat the real-glue test locally.
The logo is copied from labelle-assembler/src/assets/default_icon.png.
Provider integration checks use a CLI built from labelle-cli v2.1.0:
python3 tests/provider/e2e.py --cli /absolute/path/to/labelle --zig /absolute/path/to/zig
python3 tests/provider/toolchain_e2e.py --cli /absolute/path/to/labelle --zig /absolute/path/to/zigThe first exercises real command discovery/export/HTTP serving and strict build/bundle
hook contexts, custom-page removal, final optimized size stamping, ZIP contents,
multiple-backend selection, and destructive-path rejection. The second runs the
toolchain and watch hooks through the real pipeline with a fake assembler and a
fake emsdk (no network): the managed install reaching the fingerprint pass and
the compile, external-EMSDK passthrough, offline hits and misses, missing
Python, a concurrent install race, package mode, doctor --json, --progress=json
stdout purity, and live reload with failed-build preservation.
CI's wasm-from-zero job builds a fresh labelle init project for wasm on
Linux, macOS and Windows with a clean LABELLE_HOME, EMSDK unset and no emcc
on PATH, so the provider really provisions emsdk; a second step sets an
external EMSDK and asserts it is passed through without a download.