The Node smoke test expected shaper info logs after the default logger
became errors-only. Its failure also exposed config finalization calling
an unsupported passwd lookup on wasm and returning Unexpected before
normalization completed.
Skip shell defaults on wasm, matching upstream Zig. Assert successful
config finalization and window-width clamping in the Node test. Check
shaper output directly while verifying that info logs stay suppressed.
Document these behaviors in the wasm port notes.
Test Plan:
- cargo test -p ghostty-wasm --test node (passed)
- cargo test -p ghostty-config --lib (291 passed)
- cargo clippy --target wasm32-unknown-unknown -p ghostty-wasm -- -D warnings
- cargo clippy; cargo clippy --benches; cargo clippy --tests
- cargo +nightly fmt, followed by the native Clippy checks again
- git diff --check
Related-commit: 4fb9faa667
7.7 KiB
WASM port (W1)
Port of Ghostty's wasm-specific Zig. Everything is behind
cfg(target_arch = "wasm32") (or target-gated Cargo deps); native behaviour is
unchanged.
File map
| Zig | Rust |
|---|---|
main_wasm.zig |
crates/ghostty-wasm (new, cdylib; empty on native) |
config/Wasm.zig |
ghostty-wasm/src/config.rs |
os/wasm.zig, os/wasm/target.zig, os/wasm/log.zig |
ghostty-os/src/wasm/{mod,target,log}.rs |
zig-js (js.* used by Atlas/web_canvas) |
ghostty-os/src/wasm/js.rs + vendored zig-js host ghostty-wasm/js/zig-js/ |
font/face/web_canvas.zig |
ghostty-font/src/face/web_canvas.rs (+ exports in ghostty-wasm/src/face.rs) |
font/shaper/web_canvas.zig |
ghostty-font/src/shaper/web_canvas.rs (+ ghostty-wasm/src/shaper.rs) |
font/DeferredFace.zig (wc variant, Wasm) |
ghostty-font/src/deferred_face_web_canvas.rs |
font/Atlas.zig Wasm |
ghostty-wasm/src/atlas.rs |
font/backend.zig |
ghostty-font/src/backend.rs (OPTIONS/default_for_target = WebCanvas on wasm32) |
renderer/WebGL.zig (empty stub), App.zig Wasm (commented out upstream) |
nothing to export; ghostty-wasm/src/app.rs documents it |
os/passwd.zig (@compileError on wasm) |
ghostty-os/src/unsupported.rs shims |
Build gating
ghostty-os: nix/rustix/zbus/gettext target-gated; POSIX modules (env,file,flatpak, ...) are not compiled on wasm32.unsupported.rsgives API-compatible "unavailable" stand-ins (xdg,passwd,expand_home,resources_dir,is_flatpak) soghostty-configcompiles unchanged (Zig never analyzes them on wasm).unsafeisdeny(notforbid) for the wasm import blocks only.ghostty-config: onlystd::os::unixbits changed (file-type names,OsStrExt).ghostty-terminal:onig,ghostty-onig,rustixtarget-gated. Like Zig withoniguruma = false,StringMap::search_iterator/SearchIteratordo not exist on wasm (match types stay). Kitty shared-memory transmission returnsInvalidDataon wasm;OsStrExtshim for the file medium.ghostty-font: freetype/harfbuzz/fontconfig-systarget-gated; on wasm thecollection,codepoint_resolver,shared_grid(_set),library,discovery,fontconfig,embedded,face::freetype, harfbuzz shaper andshaper::{run,cache,noop}modules are compiled out.- Builds:
cargo build --target wasm32-unknown-unknown -p ghostty-font -p ghostty-config -p ghostty-terminal -p ghostty-vt -p ghostty-wasmpasses (debug + release); clippy-D warningsclean on both targets.
JS protocol (zig-js, W1b)
The wasm module now speaks exactly zig-js's ABI (mitchellh/zig-js @
04db83c): import module "zig-js" with valueGet/valueSet/valueObjectCreate/ valueStringCreate/valueStringLen/valueStringCopy/valueDeinit/valueNew/ funcApply, 64-bit NaN-boxed refs (f64 for numbers; NaN head 0x7FF8_0000,
bits 0-31 id, bits 32-34 type id; predefined ids nan/null/true/false/
undefined/global/runtime = 0..6) and the same get/set/apply/new/deinit
semantics. ghostty-os/src/wasm/js.rs is a Rust port of zig-js's Zig side:
Ref, Type, Value (low level), Object (get::<T>, set, call::<T>,
new_instance), global(), runtime(), string(), Undefined. Object has
Drop (= deinit); Object::into_id hands the ref id to the host (as
atlas_debug_canvas does). Result conversion mirrors Object.convertValue
(Option<T> for null/undefined, () = void, Object/Value retain,
others release). Deviation: a raw Value call argument is caller-owned.
The vendored host is zig-js's own js/ (crates/ghostty-wasm/js/zig-js/,
unmodified TypeScript + MIT LICENSE; the lockfile, jest config are omitted).
The earlier invented ghostty_js ABI and ghostty_host.js are gone. env.log
(Zig's os/wasm/log.zig) is still a separate import.
web_canvas face and atlas_debug_canvas now call JS like the Zig code:
document.createElement("canvas"), getContext, measureText, set("font"),
getImageData, then Uint8Array(runtime.memory.buffer, ptr).set(imageData.data)
to copy pixels (no host-specific copy_bytes/bytes), and
Uint8ClampedArray(memory.buffer, ptr, len) (+ .slice() under
wasm-shared) + ImageData for the atlas. face_debug_canvas returns
CanvasContainerNotFound like Zig. atlas_debug_canvas returns the zig-js
ref id; free with zigjs.deleteValue(id).
Remaining small deviation: text_metrics reads all five TextMetrics fields
instead of only the ones needed.
Host usage: const zigjs = new ZigJS(); instantiate(bytes, {...zigjs. importObject(), env: {log}}); zigjs.memory = instance.exports.memory;
globalThis must provide document/ImageData (the host binds globalThis).
Allocation helpers wasm_alloc/wasm_free (host-owned buffers) replace the
stale toHostOwned (does not exist in current os/wasm.zig).
Exports
Same names as Zig: atlas_{new,free,reserve,set,grow,clear,debug_canvas},
face_{new,free,render_glyph,debug_canvas}, deferred_face_{new,free,load},
shaper_{new,free,test}, config_{new,free,load_string,finalize}.
Differences/extras: region_free/glyph_free (Zig: "global free"),
atlas_data/atlas_size, face_metrics, face_glyph_index,
shaper_{prepare,add_codepoint,shape}, config_format,
config_diagnostics_count, ghostty_wasm_init, wasm_alloc/free.
shaper_test takes a Face (Zig takes a stale GroupCache) and prints into a
ghostty-terminal Terminal.
Config finalization skips desktop shell and home-directory lookup on wasm,
matching Zig, but still applies normalization such as minimum window dimensions.
The wasm logger forwards errors only, so shaper_test info records are suppressed.
Deviations from the (stale) Zig web_canvas code
- Zig refers to
Atlas.Format.rgbaand a 7-fieldMetricsthat no longer exist; here Bgra atlases get the canvas RGBA bitmap with R/B swapped, grayscale gets alpha, andMetrics::init()+ face size = cell size. - The
RunIteratorhook API is tied to the FreeTypeCollection(not built on wasm), soShaperexposesprepare/add_codepoint/finalize/shapedirectly and takes the grapheme-index callback (Face::grapheme_glyph_index). Collection/SharedGridare not generic over the face type in this port, so they are unavailable on wasm (Zig's wasm exports never use them).render_glyphdrops the unusedRenderOptionsparameter.
Tests
- Native: pure parts of web_canvas (grapheme table, metrics math, presentation
detection, glyph sizing, bitmap/atlas conversion; 9 tests) and the shaper
clustering (6 tests) run natively;
backend::web_canvas_capabilities. crates/ghostty-wasm/tests/node.rsbuilds the module (separate target dirtarget/wasm-test) and runstests/node_test.jsunder node with a fake canvas: config parse/finalize/format, atlas, face metrics/glyph_index/ render, emoji presentation, shaper clustering, deferred face, debug canvas. It checks that config finalization completes without errors and clamps window dimensions, and thatshaper_testprepares a run without emitting info logs. Skipped if node or the wasm32 target is missing.- Not ported: Zig tests in Atlas.Wasm (
toHostOwned, stale).
Known gaps
-
ghostty-os/tests/common/flatpak_mock.rs(another agent's, untracked) failsclippy --all-targets -D warnings(needless_pass_by_value); unrelated. -
Node test (
tests/node_test.js) now loads zig-js'sZigJSstraight from the vendored.ts(needs node >= 24 type stripping; skipped otherwise), uses a fakedocument/canvas onglobalThis, asserts the import names are zig-js's, and checks the host value table has no leaked entries at the end. -
Native clippy of ghostty-os currently fails in
our_hostnameand wasm builds warn onSEPunused inghostty-os/src/lib.rs(other agents' areas).