From a07b224869f2edacbdf430bdc0c533ff02da1c10 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 05:26:53 +0200 Subject: [PATCH] feat(wasm): use zig-js's exact JS interop ABI Ghostty's wasm code talks to JavaScript through the zig-js library (mitchellh/zig-js, pinned in build.zig.zon). The first port invented its own import ABI because the dependency wasn't available; now: - ghostty-os/src/wasm/js.rs is a Rust port of zig-js's Zig side (Ref, Value, Object get/set/call/new, global, runtime, string) using its "zig-js" import module, function names and NaN-boxed value refs. - web_canvas face and the atlas debug canvas make the same JS calls as Ghostty's Zig code. - zig-js's unmodified JS host (+ LICENSE) is vendored under crates/ghostty-wasm/js/zig-js and drives the node test. Test Plan: - cargo test -p ghostty-wasm (node >= 24; asserts zig-js imports and no leaked host values) --- crates/ghostty-font/src/face/web_canvas.rs | 129 ++- crates/ghostty-os/src/wasm/js.rs | 841 +++++++++++++----- crates/ghostty-wasm/js/ghostty_host.js | 106 --- crates/ghostty-wasm/js/zig-js/LICENSE | 21 + crates/ghostty-wasm/js/zig-js/README.md | 21 + crates/ghostty-wasm/js/zig-js/package.json | 30 + crates/ghostty-wasm/js/zig-js/src/index.ts | 1 + crates/ghostty-wasm/js/zig-js/src/zigjs.ts | 286 ++++++ .../js/zig-js/tests/zigjs.test.ts | 303 +++++++ crates/ghostty-wasm/js/zig-js/tsconfig.json | 25 + crates/ghostty-wasm/src/atlas.rs | 97 +- crates/ghostty-wasm/src/face.rs | 5 +- crates/ghostty-wasm/src/lib.rs | 2 +- crates/ghostty-wasm/tests/node.rs | 22 +- crates/ghostty-wasm/tests/node_test.js | 73 +- docs/rewrite/notes/wasm-W1.md | 49 +- 16 files changed, 1532 insertions(+), 479 deletions(-) delete mode 100644 crates/ghostty-wasm/js/ghostty_host.js create mode 100644 crates/ghostty-wasm/js/zig-js/LICENSE create mode 100644 crates/ghostty-wasm/js/zig-js/README.md create mode 100644 crates/ghostty-wasm/js/zig-js/package.json create mode 100644 crates/ghostty-wasm/js/zig-js/src/index.ts create mode 100644 crates/ghostty-wasm/js/zig-js/src/zigjs.ts create mode 100644 crates/ghostty-wasm/js/zig-js/tests/zigjs.test.ts create mode 100644 crates/ghostty-wasm/js/zig-js/tsconfig.json diff --git a/crates/ghostty-font/src/face/web_canvas.rs b/crates/ghostty-font/src/face/web_canvas.rs index 3ec5aa8..8827842 100644 --- a/crates/ghostty-font/src/face/web_canvas.rs +++ b/crates/ghostty-font/src/face/web_canvas.rs @@ -31,6 +31,8 @@ pub enum Error { UnsupportedAtlasFormat, #[error("invalid codepoint")] InvalidCodepoint, + #[error("canvas container not found")] + CanvasContainerNotFound, #[error(transparent)] Atlas(#[from] atlas::Error), #[cfg(target_arch = "wasm32")] @@ -217,7 +219,7 @@ pub use face_js::Face; #[cfg(target_arch = "wasm32")] mod face_js { - use ghostty_os::wasm::js::{self, Arg, Object}; + use ghostty_os::wasm::js::{self, Object}; use super::{ Error, @@ -260,11 +262,11 @@ mod face_js { fn text_metrics(o: &Object) -> Result { Ok(TextMetrics { - width: o.get_f32("width")?, - actual_bounding_box_left: o.get_f32("actualBoundingBoxLeft")?, - actual_bounding_box_right: o.get_f32("actualBoundingBoxRight")?, - actual_bounding_box_ascent: o.get_f32("actualBoundingBoxAscent")?, - actual_bounding_box_descent: o.get_f32("actualBoundingBoxDescent")?, + width: o.get::("width")?, + actual_bounding_box_left: o.get::("actualBoundingBoxLeft")?, + actual_bounding_box_right: o.get::("actualBoundingBoxRight")?, + actual_bounding_box_ascent: o.get::("actualBoundingBoxAscent")?, + actual_bounding_box_descent: o.get::("actualBoundingBoxDescent")?, }) } @@ -282,8 +284,8 @@ mod face_js { presentation: Presentation, ) -> Result { // Create our canvas that we're going to continue to reuse. - let doc = js::global()?.get_object("document")?; - let canvas = doc.call("createElement", &[Arg::Str("canvas")])?; + let doc = js::global().get::("document")?; + let canvas = doc.call::("createElement", &[&js::string("canvas")])?; let mut result = Face { font_str: raw.to_owned(), @@ -369,11 +371,11 @@ mod face_js { let ctx = self.context()?; // Cell width is the width of our M text - let m = text_metrics(&ctx.call("measureText", &[Arg::Str("M")])?)?; + let m = text_metrics(&ctx.call::("measureText", &[&js::string("M")])?)?; // To get the cell height we render a high and low character and // get the total of the ascent and descent. - let hm = text_metrics(&ctx.call("measureText", &[Arg::Str("M_")])?)?; + let hm = text_metrics(&ctx.call::("measureText", &[&js::string("M_")])?)?; self.metrics = calc_metrics(m, hm); log::debug!("metrics font={} value={:?}", self.font_str, self.metrics); @@ -384,31 +386,25 @@ mod face_js { fn context(&self) -> Result { // This will return the same context on subsequent calls so it // is important to reset it. - let ctx = self.canvas.call("getContext", &[Arg::Str("2d")])?; + let ctx = self + .canvas + .call::("getContext", &[&js::string("2d")])?; // Clear the canvas { - let width = self.canvas.get_f64("width")?; - let height = self.canvas.get_f64("height")?; - ctx.call( - "clearRect", - &[ - Arg::Num(0.0), - Arg::Num(0.0), - Arg::Num(width), - Arg::Num(height), - ], - )?; + let width = self.canvas.get::("width")?; + let height = self.canvas.get::("height")?; + ctx.call::<()>("clearRect", &[&0.0_f64, &0.0_f64, &width, &height])?; } // Set our context font let font_val = format!("{}px {}", self.size.points, self.font_str); - ctx.set("font", Arg::Str(&font_val))?; + ctx.set("font", js::string(&font_val))?; // If the font property didn't change, then the font set didn't // work. We do this check because it is very easy to put an // invalid font in and this at least makes it show up in the logs. - let check = ctx.get_string("font")?; + let check = ctx.get::("font")?; if font_val != check { log::warn!( "canvas font didn't set, fonts may be broken, expected={font_val} got={check}" @@ -437,17 +433,19 @@ mod face_js { // Get our drawing context and the size of the render let measure_ctx = self.context()?; - let m = text_metrics(&measure_ctx.call("measureText", &[Arg::Str(&glyph_str)])?)?; + let m = text_metrics( + &measure_ctx.call::("measureText", &[&js::string(&glyph_str)])?, + )?; let (width, height, broken_bbox) = glyph_size(m); // Resize canvas to match the glyph size exactly { - self.canvas.set("width", Arg::from(width))?; - self.canvas.set("height", Arg::from(height))?; + self.canvas.set("width", width)?; + self.canvas.set("height", height)?; - let style = self.canvas.get_object("style")?; - style.set("width", Arg::Str(&format!("{width}px")))?; - style.set("height", Arg::Str(&format!("{height}px")))?; + let style = self.canvas.get::("style")?; + style.set("width", js::string(&format!("{width}px")))?; + style.set("height", js::string(&format!("{height}px")))?; } // Reload our context since we resized the canvas @@ -457,50 +455,50 @@ mod face_js { // baselines so that we just render the glyph fully in the box // with no offsets. if broken_bbox { - ctx.set("textBaseline", Arg::Str("ideographic"))?; + ctx.set("textBaseline", js::string("ideographic"))?; } // Draw background - ctx.set("fillStyle", Arg::Str("transparent"))?; - ctx.call( - "fillRect", - &[ - Arg::Num(0.0), - Arg::Num(0.0), - Arg::from(width), - Arg::from(height), - ], - )?; + ctx.set("fillStyle", js::string("transparent"))?; + ctx.call::<()>("fillRect", &[&0.0_f64, &0.0_f64, &width, &height])?; // Draw glyph - ctx.set("fillStyle", Arg::Str("black"))?; - ctx.call( + ctx.set("fillStyle", js::string("black"))?; + ctx.call::<()>( "fillText", &[ - Arg::Str(&glyph_str), - Arg::from(m.actual_bounding_box_left + 1.0), - if broken_bbox { - Arg::from(height) + &js::string(&glyph_str), + &(m.actual_bounding_box_left + 1.0), + &if broken_bbox { + height as f32 } else { - Arg::from(m.actual_bounding_box_ascent + 1.0) + m.actual_bounding_box_ascent + 1.0 }, ], )?; // Read the image data and get it into a Vec on our side - let data = ctx.call( - "getImageData", - &[ - Arg::Num(0.0), - Arg::Num(0.0), - Arg::from(width), - Arg::from(height), - ], - )?; - let src_array = data.get_object("data")?; - let len = src_array.get_f64("length")? as usize; + let data = + ctx.call::("getImageData", &[&0.0_f64, &0.0_f64, &width, &height])?; + let src_array = data.get::("data")?; + + // Allocate our local memory to copy the data to. + let len = src_array.get::("length")? as usize; let mut bitmap = vec![0u8; len]; - src_array.copy_bytes_to(&mut bitmap)?; + + // Create our target Uint8Array that we can use to copy from src. + let mem_array = { + // Get our runtime memory + let mem = js::runtime().get::("memory")?; + let buf = mem.get::("buffer")?; + + // Construct our array to peer into our memory + let uint8_array = js::global().get::("Uint8Array")?; + uint8_array.new_instance(&[&buf, &(bitmap.as_mut_ptr() as usize)])? + }; + + // Copy + mem_array.call::<()>("set", &[&src_array])?; Ok(RenderedGlyph { width, @@ -512,12 +510,11 @@ mod face_js { /// Appends our canvas to the element with id `face-canvas` (the /// debugging hook behind the `face_debug_canvas` export). pub fn debug_canvas(&self) -> Result<(), Error> { - let doc = js::global()?.get_object("document")?; - let elem = doc.call("getElementById", &[Arg::Str("face-canvas")])?; - if elem.is_nullish() { - return Err(js::Error::Nullish.into()); - } - elem.call("append", &[Arg::Object(&self.canvas)])?; + let doc = js::global().get::("document")?; + let elem = doc + .call::>("getElementById", &[&js::string("face-canvas")])? + .ok_or(Error::CanvasContainerNotFound)?; + elem.call::<()>("append", &[&self.canvas])?; Ok(()) } } diff --git a/crates/ghostty-os/src/wasm/js.rs b/crates/ghostty-os/src/wasm/js.rs index ec28c8a..6759373 100644 --- a/crates/ghostty-os/src/wasm/js.rs +++ b/crates/ghostty-os/src/wasm/js.rs @@ -1,77 +1,77 @@ -//! JS interop for wasm. Stands in for the `zig-js` package -//! (`mitchellh/zig-js`) that Ghostty's Zig wasm code (`font/face/web_canvas`, -//! `font/Atlas`) imports as `js`. +//! JS interop for wasm. A Rust port of the `zig-js` package +//! (`mitchellh/zig-js`, `src/{main,ref,value,Object,extern}.zig`) which +//! Ghostty's Zig wasm code imports as `js`. //! -//! The `zig-js` source is not part of the Ghostty tree, so its exact import -//! names cannot be mirrored; this module defines an equivalent handle based -//! protocol in the import module `ghostty_js` and keeps the *Rust-side API* -//! shaped like `zig-js` (`Object`, `get`, `set`, `call`, `new`, string and -//! number arguments, global object lookup). The host (JS) keeps a table of -//! values; wasm only holds `u32` handles. Handle `0` is "invalid" and is -//! what every import returns when the JS operation threw. +//! This speaks exactly zig-js's ABI so the module runs with zig-js's own JS +//! host (`ZigJS` from `zig-js-glue`; vendored under +//! `crates/ghostty-wasm/js/zig-js`): //! -//! Imports (module `ghostty_js`): +//! - import module `"zig-js"`, functions `valueGet`, `valueSet`, +//! `valueObjectCreate`, `valueStringCreate`, `valueStringLen`, +//! `valueStringCopy`, `valueDeinit`, `valueNew`, `funcApply` (see [`ffi`]); +//! - a JS value crosses the boundary as a 64-bit [`Ref`]: a plain `f64` for +//! numbers, otherwise a NaN whose low 32 bits are an id into the host's +//! value table and bits 32-34 a type id (see [`Ref`]); +//! - the same semantics for [`Value`] (low level) and [`Object`] (high level): +//! `get`/`set` only on object refs, `apply`/`new` only on function refs, +//! arguments that are not already an [`Object`] are created for the call and +//! released after it, results are converted per requested type and released +//! unless the type retains the JS value (`Object`/`Value`). //! -//! | import | signature | -//! |---|---| -//! | `global` | `() -> handle` of `globalThis` | -//! | `get` | `(obj, name_ptr, name_len) -> handle` | -//! | `set` | `(obj, name_ptr, name_len, value) -> i32` (1 = ok) | -//! | `call` | `(obj, name_ptr, name_len, args_ptr, args_len) -> handle` | -//! | `new` | `(ctor, args_ptr, args_len) -> handle` | -//! | `string` | `(ptr, len) -> handle` (UTF-8 -> JS string) | -//! | `number` | `(f64) -> handle` | -//! | `boolean` | `(i32) -> handle` | -//! | `to_f64` | `(handle) -> f64` | -//! | `string_len` | `(handle) -> usize` (UTF-8 byte length) | -//! | `string_copy` | `(handle, ptr, cap) -> usize` (writes UTF-8) | -//! | `is_nullish` | `(handle) -> i32` | -//! | `bytes` | `(ptr, len) -> handle` (a `Uint8ClampedArray` copy of wasm memory) | -//! | `copy_bytes` | `(array_handle, dst_ptr, len) -> i32` (copies a `Uint8Array`-like into wasm memory) | -//! | `release` | `(handle)` | +//! The wasm host also needs `memory` assigned on the `ZigJS` instance (that is +//! what [`runtime`]`.get::("memory")` reads). +//! +//! Differences from the Zig API are only those forced by Rust: generics and +//! traits ([`Get`], [`IntoValue`]) instead of `comptime T`, `Drop` instead of +//! explicit `deinit()` (use [`Object::into_id`] to hand ownership to the host +//! like Zig's `atlas_debug_canvas` does), and a `&[&dyn IntoValue]` argument +//! list instead of an anonymous tuple. #![allow(unsafe_code)] use std::fmt; +/// Raw imports, module `"zig-js"` (`extern.zig`). mod ffi { - #[link(wasm_import_module = "ghostty_js")] + #[link(wasm_import_module = "zig-js")] unsafe extern "C" { - pub fn global() -> u32; - pub fn get(obj: u32, name: *const u8, len: usize) -> u32; - pub fn set(obj: u32, name: *const u8, len: usize, value: u32) -> i32; - pub fn call(obj: u32, name: *const u8, len: usize, args: *const u32, argc: usize) -> u32; - pub fn new(ctor: u32, args: *const u32, argc: usize) -> u32; - pub fn string(ptr: *const u8, len: usize) -> u32; - pub fn number(v: f64) -> u32; - pub fn boolean(v: i32) -> u32; - pub fn to_f64(h: u32) -> f64; - pub fn string_len(h: u32) -> usize; - pub fn string_copy(h: u32, ptr: *mut u8, cap: usize) -> usize; - pub fn is_nullish(h: u32) -> i32; - pub fn bytes(ptr: *const u8, len: usize) -> u32; - pub fn copy_bytes(h: u32, dst: *mut u8, len: usize) -> i32; - pub fn release(h: u32); + #[link_name = "valueGet"] + pub fn value_get(out: *mut u64, id: u32, addr: *const u8, len: usize); + #[link_name = "valueSet"] + pub fn value_set(id: u32, addr: *const u8, len: usize, ref_ptr: *const u64); + #[link_name = "valueObjectCreate"] + pub fn value_object_create(out: *mut u64); + #[link_name = "valueStringCreate"] + pub fn value_string_create(out: *mut u64, addr: *const u8, len: usize); + #[link_name = "valueStringLen"] + pub fn value_string_len(id: u32) -> u32; + #[link_name = "valueStringCopy"] + pub fn value_string_copy(id: u32, addr: *mut u8, max: usize); + #[link_name = "valueDeinit"] + pub fn value_deinit(id: u32); + #[link_name = "valueNew"] + pub fn value_new(out: *mut u64, id: u32, args_ptr: *const u64, args_len: usize); + #[link_name = "funcApply"] + pub fn func_apply( + out: *mut u64, + func: u32, + this_ptr: *const u64, + args_ptr: *const u64, + args_len: usize, + ); } } -/// Errors from JS interop (`zig-js` `error.JSError` and friends). +/// Errors that can occur in this package (`js.Error`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Error { - /// The JS operation threw (or the handle was invalid). - Js, - /// The value is `null`/`undefined` where an object was required. - Nullish, - /// A returned string was not valid UTF-8. - Utf8, + /// The wrong type for the receiver or the result type can't be assigned + /// from the JS type when using the higher-level APIs like [`Object`]. + InvalidType, } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::Js => f.write_str("javascript error"), - Error::Nullish => f.write_str("value is null or undefined"), - Error::Utf8 => f.write_str("invalid utf-8 from javascript"), - } + f.write_str("InvalidType") } } @@ -79,229 +79,586 @@ impl std::error::Error for Error {} pub type Result = std::result::Result; -/// A reference to a JS value. Released on drop (zig-js `Object.deinit`). -#[derive(Debug, PartialEq, Eq)] -pub struct Object(u32); +/// Possible types of values (`js.Type`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Type { + Undefined, + Null, + Boolean, + Number, + Object, + String, + Symbol, + Function, +} -/// An argument for [`Object::call`] / [`Object::new_instance`]. +/// Ref uniquely identifies a JS value (`ref.zig`). A JS value can't be +/// physically copied to the WASM environment so we represent it using a +/// 64-bit number into a table that is maintained on the JS side. +/// +/// For a JS number (float64), the ref is literally the float64 (bitcasted). +/// For other types, we utilize the unused bits of the IEEE 754 NaN +/// representation to encode additional data into the ref. Bits 0-31 are used +/// as an ID and bits 32-34 are used as a type ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Ref(pub u64); + +/// NaN in IEEE-754 is `0b0111_1111_1111_`. We +/// always force NaN to have a 1-bit set in the 4th byte from the MSB so that +/// we can use the lower 51 bits. This is the top 29 bits of the ref. +const NAN_HEAD: u64 = 0x7FF8_0000 >> 3; + +/// The `type_id` field (bits 32-34). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u64)] +enum TypeId { + Inferred = 0, + Object = 1, + String = 2, + Symbol = 3, + Function = 4, +} + +impl Ref { + /// Predefined refs. + pub const NAN: Ref = Ref::predefined(0); + pub const NULL: Ref = Ref::predefined(1); + pub const TRUE: Ref = Ref::predefined(2); + pub const FALSE: Ref = Ref::predefined(3); + pub const UNDEFINED: Ref = Ref::predefined(4); + pub const GLOBAL: Ref = Ref::predefined(5); + pub const RUNTIME: Ref = Ref::predefined(6); + + const fn predefined(id: u32) -> Ref { + Ref::new(id, TypeId::Inferred) + } + + const fn new(id: u32, type_id: TypeId) -> Ref { + Ref((NAN_HEAD << 35) | ((type_id as u64) << 32) | id as u64) + } + + /// Bits 0-31. + pub const fn id(self) -> u32 { + self.0 as u32 + } + + const fn head(self) -> u64 { + self.0 >> 35 + } + + /// Bits 32-34, `None` for the unused values 5-7. + const fn type_id(self) -> Option { + match (self.0 >> 32) & 0b111 { + 0 => Some(TypeId::Inferred), + 1 => Some(TypeId::Object), + 2 => Some(TypeId::String), + 3 => Some(TypeId::Symbol), + 4 => Some(TypeId::Function), + _ => None, + } + } + + /// Returns the type of a ref. + pub fn type_of(self) -> Type { + // If we aren't a NaN then we have to be a number + if self.head() != NAN_HEAD { + return Type::Number; + } + + match self.type_id() { + Some(TypeId::Object) => Type::Object, + Some(TypeId::String) => Type::String, + Some(TypeId::Symbol) => Type::Symbol, + Some(TypeId::Function) => Type::Function, + Some(TypeId::Inferred) => match self.id() { + 0 => Type::Number, + 1 => Type::Null, + 2 | 3 => Type::Boolean, + 4 => Type::Undefined, + 5 | 6 => Type::Object, + // Zig: `unreachable`. + _ => Type::Undefined, + }, + None => Type::Undefined, + } + } + + /// Returns true if the ref should be released. + /// + /// We don't need to release raw numbers because they're copied directly + /// into our environment. And we don't need to release the predefined + /// values because they're always retained. + pub fn is_releasable(self) -> bool { + self.head() == NAN_HEAD && self.type_id() != Some(TypeId::Inferred) + } + + pub fn to_f64(self) -> f64 { + debug_assert_eq!(self.type_of(), Type::Number); + f64::from_bits(self.0) + } +} + +/// Only used with [`Value::string`]/[`IntoValue`] to denote a string type +/// (`js.String`; named `JsString` to not clash with `std::string::String`). +/// +/// This is NOT a JS string. This is just a sentinel type so that we can +/// differentiate a byte slice and a "string" when trying to convert a Rust +/// value into a JS value. #[derive(Debug, Clone, Copy)] -pub enum Arg<'a> { - Object(&'a Object), - Str(&'a str), - Num(f64), - Bool(bool), -} +pub struct JsString<'a>(&'a [u8]); -impl<'a> From<&'a Object> for Arg<'a> { - fn from(v: &'a Object) -> Self { - Arg::Object(v) +impl<'a> JsString<'a> { + /// `js.String.init`. + pub fn init(x: &'a (impl AsRef<[u8]> + ?Sized)) -> Self { + JsString(x.as_ref()) } } -impl<'a> From<&'a str> for Arg<'a> { - fn from(v: &'a str) -> Self { - Arg::Str(v) +/// Shortcut for [`JsString::init`] since this is a common operation +/// (`js.string`). +pub fn string(v: &(impl AsRef<[u8]> + ?Sized)) -> JsString<'_> { + JsString::init(v) +} + +/// Only used to denote an undefined value (`js.Undefined`). +#[derive(Debug, Clone, Copy)] +pub struct Undefined; + +/// Denotes creating a new empty object (`js.Object{ .value = undefined }` +/// passed to `Value.init`). +#[derive(Debug, Clone, Copy)] +pub struct NewObject; + +/// A value represents a JS value (`js.Value`). This is the low-level +/// "untyped" interface to any generic JS value. It is more ergonomic to use +/// the higher level wrappers such as [`Object`]. It is `Copy` and does not +/// release itself; call [`Value::deinit`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(transparent)] +pub struct Value(pub u64); + +impl Value { + pub const NAN: Value = Value(Ref::NAN.0); + pub const NULL: Value = Value(Ref::NULL.0); + pub const TRUE: Value = Value(Ref::TRUE.0); + pub const FALSE: Value = Value(Ref::FALSE.0); + pub const UNDEFINED: Value = Value(Ref::UNDEFINED.0); + pub const GLOBAL: Value = Value(Ref::GLOBAL.0); + /// Gives access to the "this" value for the ZigJS JS class. This makes it + /// possible to access the memory for example. + pub const RUNTIME: Value = Value(Ref::RUNTIME.0); + + /// `Value.init` for floats (any numeric type goes through `f64`). + pub fn number(x: f64) -> Value { + if x.is_nan() { + return Value::NAN; + } + Value(x.to_bits()) + } + + /// `Value.init` for bools. + pub fn boolean(x: bool) -> Value { + if x { Value::TRUE } else { Value::FALSE } + } + + /// `Value.init(Object{})`: creates a new empty object. + pub fn object() -> Value { + let mut result = 0u64; + // SAFETY: `result` is a valid out pointer. + unsafe { ffi::value_object_create(&raw mut result) }; + Value(result) + } + + /// `Value.init(String)`. The bytes are copied to the JS side. + pub fn string(s: JsString<'_>) -> Value { + let mut result = 0u64; + // SAFETY: `s.0` is a live slice (host copies it); `result` is valid. + unsafe { ffi::value_string_create(&raw mut result, s.0.as_ptr(), s.0.len()) }; + Value(result) + } + + fn as_ref(self) -> Ref { + Ref(self.0) + } + + /// Deinitializes the value, allowing the JS environment to GC the value. + pub fn deinit(self) { + // We avoid releasing values that aren't releasable. This avoids + // crossing the js/wasm boundary for a bit of performance. + if self.as_ref().is_releasable() { + // SAFETY: plain call. + unsafe { ffi::value_deinit(self.as_ref().id()) }; + } + } + + /// Get the value of a property of an object. + pub fn get(self, n: &str) -> Result { + if self.type_of() != Type::Object { + return Err(Error::InvalidType); + } + let mut result = 0u64; + // SAFETY: `n` is live for the call; `result` is a valid out pointer. + unsafe { ffi::value_get(&raw mut result, self.as_ref().id(), n.as_ptr(), n.len()) }; + Ok(Value(result)) + } + + /// Set the value of a property on an object. + pub fn set(self, n: &str, v: Value) -> Result<()> { + if self.type_of() != Type::Object { + return Err(Error::InvalidType); + } + // SAFETY: `n` and `v` are live for the call. + unsafe { ffi::value_set(self.as_ref().id(), n.as_ptr(), n.len(), &raw const v.0) }; + Ok(()) + } + + /// Call this value as a function. + pub fn apply(self, this: Value, args: &[Value]) -> Result { + if self.type_of() != Type::Function { + return Err(Error::InvalidType); + } + let mut result = 0u64; + // SAFETY: `Value` is `repr(transparent)` over u64; the slice is live + // for the call. + unsafe { + ffi::func_apply( + &raw mut result, + self.as_ref().id(), + &raw const this.0, + args.as_ptr().cast::(), + args.len(), + ); + } + Ok(Value(result)) + } + + /// Call "new" on this value like a constructor. + pub fn new_instance(self, args: &[Value]) -> Result { + if self.type_of() != Type::Function { + return Err(Error::InvalidType); + } + let mut result = 0u64; + // SAFETY: as in `apply`. + unsafe { + ffi::value_new( + &raw mut result, + self.as_ref().id(), + args.as_ptr().cast::(), + args.len(), + ); + } + Ok(Value(result)) + } + + /// Returns the bool value if this is a boolean. + pub fn as_bool(self) -> Result { + if self.type_of() != Type::Boolean { + return Err(Error::InvalidType); + } + Ok(self == Value::TRUE) + } + + /// Returns the float value if this is a number. + pub fn float(self) -> Result { + if self.type_of() != Type::Number { + return Err(Error::InvalidType); + } + Ok(f64::from_bits(self.0)) + } + + /// Returns the UTF-8 encoded string value. + pub fn to_bytes(self) -> Result> { + if self.type_of() != Type::String { + return Err(Error::InvalidType); + } + + // Get the length and allocate our buffer + let id = self.as_ref().id(); + // SAFETY: plain call. + let len = unsafe { ffi::value_string_len(id) } as usize; + let mut buf = vec![0u8; len]; + + // Copy the string into the buffer + // SAFETY: `buf` has `len` writable bytes. + unsafe { ffi::value_string_copy(id, buf.as_mut_ptr(), buf.len()) }; + Ok(buf) + } + + /// Returns the type of this value. + pub fn type_of(self) -> Type { + self.as_ref().type_of() } } -impl From for Arg<'_> { - fn from(v: f64) -> Self { - Arg::Num(v) +/// Anything `Value.init` accepts. [`IntoValue::owned`] says whether the +/// resulting value is created for the call (and so must be released after it, +/// like Zig's `Object.call` does for every argument that isn't a `js.Object`). +pub trait IntoValue { + fn to_value(&self) -> Value; + + fn owned(&self) -> bool { + true } } -impl From for Arg<'_> { - fn from(v: f32) -> Self { - Arg::Num(f64::from(v)) - } -} - -impl From for Arg<'_> { - fn from(v: u32) -> Self { - Arg::Num(f64::from(v)) - } -} - -impl From for Arg<'_> { - fn from(v: bool) -> Self { - Arg::Bool(v) - } -} - -fn check(h: u32) -> Result { - if h == 0 { - Err(Error::Js) - } else { - Ok(Object(h)) - } -} - -/// Converts arguments to handles. Temporaries are released by [`Args::drop`]. -struct Args { - handles: Vec, - owned: Vec, -} - -impl Args { - fn new(args: &[Arg<'_>]) -> Result { - let mut out = Args { - handles: Vec::with_capacity(args.len()), - owned: Vec::new(), - }; - for a in args { - let h = match a { - Arg::Object(o) => o.0, - // SAFETY: pointer/length of a live &str; host copies it. - Arg::Str(s) => unsafe { ffi::string(s.as_ptr(), s.len()) }, - // SAFETY: plain value call. - Arg::Num(n) => unsafe { ffi::number(*n) }, - // SAFETY: plain value call. - Arg::Bool(b) => unsafe { ffi::boolean(i32::from(*b)) }, - }; - if h == 0 { - return Err(Error::Js); +macro_rules! into_value_number { + ($($t:ty),*) => {$( + impl IntoValue for $t { + fn to_value(&self) -> Value { + #[allow(clippy::cast_lossless, clippy::unnecessary_cast)] + Value::number(*self as f64) } - if !matches!(a, Arg::Object(_)) { - out.owned.push(h); - } - out.handles.push(h); } - Ok(out) + )*}; +} +// Ints, floats and (via `usize`) pointers: all JS numbers are f64. +into_value_number!(f64, f32, u8, u16, u32, u64, usize, i8, i16, i32, i64, isize); + +impl IntoValue for bool { + fn to_value(&self) -> Value { + Value::boolean(*self) + } +} +impl IntoValue for Undefined { + fn to_value(&self) -> Value { + Value::UNDEFINED + } +} +impl IntoValue for NewObject { + fn to_value(&self) -> Value { + Value::object() + } +} +impl IntoValue for JsString<'_> { + fn to_value(&self) -> Value { + Value::string(*self) + } +} +impl IntoValue for Value { + fn to_value(&self) -> Value { + *self + } + + // Deviation: Zig releases a plain `js.Value` argument of `call`/`new` + // but not in `set`; we treat a raw `Value` as caller-owned. + fn owned(&self) -> bool { + false + } +} +impl IntoValue for Object { + fn to_value(&self) -> Value { + self.value + } + + fn owned(&self) -> bool { + false + } +} +impl IntoValue for &T { + fn to_value(&self) -> Value { + (**self).to_value() + } + + fn owned(&self) -> bool { + (**self).owned() } } -impl Drop for Args { - fn drop(&mut self) { - for h in &self.owned { - // SAFETY: handle was created by us and not yet released. - unsafe { ffi::release(*h) }; - } +/// Result types for [`Object::get`]/[`Object::call`] (zig `Get(T)`). +pub trait Get: Sized { + /// True if we want to retain the js value. + const RETAINS: bool = false; + + /// Convert a non-null/undefined value. + fn from_value(v: Value) -> Result; + + /// What a null/undefined value converts to (`InvalidType` unless the + /// type is optional or void). + fn from_nullish() -> Result { + Err(Error::InvalidType) } } -/// `js.global`: the `globalThis` object. -pub fn global() -> Result { - // SAFETY: plain call into the host. - check(unsafe { ffi::global() }) +impl Get for () { + fn from_value(_: Value) -> Result { + Err(Error::InvalidType) + } + + fn from_nullish() -> Result { + Ok(()) + } } +impl Get for Value { + const RETAINS: bool = true; -impl Object { - /// Creates a JS string value. - pub fn string(s: &str) -> Result { - // SAFETY: pointer/length of a live &str; host copies it. - check(unsafe { ffi::string(s.as_ptr(), s.len()) }) - } - - /// Creates a `Uint8ClampedArray` holding a copy of `data` (what zig-js - /// callers built by viewing wasm memory with `new Uint8ClampedArray( - /// memory.buffer, ptr, len)`; copying also works for shared memory). - pub fn clamped_array(data: &[u8]) -> Result { - // SAFETY: pointer/length of a live slice; host copies it. - check(unsafe { ffi::bytes(data.as_ptr(), data.len()) }) - } - - /// Gives up ownership of the handle without releasing it (the host is - /// expected to release it, like zig-js `deleteValue`). - pub fn into_raw(self) -> u32 { - let h = self.0; - std::mem::forget(self); - h - } - - /// Gets a property as an object reference (also used for primitives, - /// which are boxed on the host side). - pub fn get(&self, name: &str) -> Result { - // SAFETY: pointer/length of a live &str; host copies it. - check(unsafe { ffi::get(self.0, name.as_ptr(), name.len()) }) - } - - /// Gets a property that must be an object (not null/undefined). - pub fn get_object(&self, name: &str) -> Result { - let v = self.get(name)?; - if v.is_nullish() { - return Err(Error::Nullish); - } + fn from_value(v: Value) -> Result { Ok(v) } +} +impl Get for Object { + const RETAINS: bool = true; - /// Gets a numeric property. - pub fn get_f64(&self, name: &str) -> Result { - Ok(self.get(name)?.as_f64()) + fn from_value(v: Value) -> Result { + match v.type_of() { + Type::Object | Type::Function => Ok(Object { value: v }), + _ => Err(Error::InvalidType), + } + } +} +impl Get for bool { + fn from_value(v: Value) -> Result { + v.as_bool() + } +} +impl Get for String { + /// zig `js.String` => `[]u8` (here lossily decoded UTF-8). + fn from_value(v: Value) -> Result { + Ok(String::from_utf8_lossy(&v.to_bytes()?).into_owned()) + } +} +macro_rules! get_number { + ($($t:ty),*) => {$( + impl Get for $t { + fn from_value(v: Value) -> Result { + // Zig: `@floatCast` / `@intFromFloat`. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + Ok(v.float()? as $t) + } + } + )*}; +} +get_number!(f32, f64, u8, u16, u32, u64, usize, i8, i16, i32, i64, isize); + +/// An optional result: null or undefined become `None`. +impl Get for Option { + const RETAINS: bool = T::RETAINS; + + fn from_value(v: Value) -> Result { + T::from_value(v).map(Some) } - /// Gets a numeric property as `f32` (zig `get(f32, ...)`). - pub fn get_f32(&self, name: &str) -> Result { - Ok(self.get_f64(name)? as f32) + fn from_nullish() -> Result { + Ok(None) } +} - /// Gets a string property. - pub fn get_string(&self, name: &str) -> Result { - self.get(name)?.to_string_value() +/// Zig `Object.convertValue`. +fn convert_value(v: Value) -> Result { + let r = match v.type_of() { + Type::Null | Type::Undefined => T::from_nullish(), + _ => T::from_value(v), + }; + // Release the value unless the result retains it (and conversion worked; + // Zig's `errdefer` releases it on error). + if !T::RETAINS || r.is_err() { + v.deinit(); } + r +} - /// Sets a property. - pub fn set(&self, name: &str, value: Arg<'_>) -> Result<()> { - let args = Args::new(&[value])?; - // SAFETY: pointer/length of a live &str; handle is valid. - let ok = unsafe { ffi::set(self.0, name.as_ptr(), name.len(), args.handles[0]) }; - if ok == 1 { Ok(()) } else { Err(Error::Js) } +/// Object is a JS object (`js.Object`). This is a higher-level abstraction +/// over [`Value`] and is the recommended interface. It releases the JS value +/// on drop (Zig: `deinit()`). +#[derive(Debug, PartialEq, Eq)] +pub struct Object { + pub value: Value, +} + +/// The global "this" value as the high-level Object API (`js.global`). +pub fn global() -> Object { + Object { + value: Value::GLOBAL, } +} - /// Calls a method and returns the result (which may be nullish). - pub fn call(&self, name: &str, args: &[Arg<'_>]) -> Result { - let args = Args::new(args)?; - // SAFETY: name and args pointers are live for the call. - check(unsafe { - ffi::call( - self.0, - name.as_ptr(), - name.len(), - args.handles.as_ptr(), - args.handles.len(), - ) - }) - } - - /// `new this(...args)` (zig-js `Object.new`). - pub fn new_instance(&self, args: &[Arg<'_>]) -> Result { - let args = Args::new(args)?; - // SAFETY: args pointer is live for the call. - check(unsafe { ffi::new(self.0, args.handles.as_ptr(), args.handles.len()) }) - } - - pub fn is_nullish(&self) -> bool { - // SAFETY: plain handle call. - unsafe { ffi::is_nullish(self.0) != 0 } - } - - /// Numeric value of this object (`Number(v)` on the host). - pub fn as_f64(&self) -> f64 { - // SAFETY: plain handle call. - unsafe { ffi::to_f64(self.0) } - } - - /// String value of this object (`String(v)` on the host). - pub fn to_string_value(&self) -> Result { - // SAFETY: plain handle call. - let len = unsafe { ffi::string_len(self.0) }; - let mut buf = vec![0u8; len]; - // SAFETY: `buf` has `len` writable bytes. - let n = unsafe { ffi::string_copy(self.0, buf.as_mut_ptr(), buf.len()) }; - buf.truncate(n); - String::from_utf8(buf).map_err(|_| Error::Utf8) - } - - /// Copies this (`Uint8Array`/`Uint8ClampedArray`) object into `dst`. - pub fn copy_bytes_to(&self, dst: &mut [u8]) -> Result<()> { - // SAFETY: `dst` is `dst.len()` writable bytes in wasm memory. - let ok = unsafe { ffi::copy_bytes(self.0, dst.as_mut_ptr(), dst.len()) }; - if ok == 1 { Ok(()) } else { Err(Error::Js) } +/// The runtime value is the "this" value for the ZigJS class in JS. This is +/// useful to directly accessing the `WebAssembly.Memory` property to +/// implement things like JS-to-WASM memcpy (`js.runtime`). +pub fn runtime() -> Object { + Object { + value: Value::RUNTIME, } } impl Drop for Object { fn drop(&mut self) { - // SAFETY: we own this handle. - unsafe { ffi::release(self.0) }; + self.value.deinit(); + } +} + +impl Object { + /// Gives up ownership without releasing, returning the host ref id (Zig + /// code does this by reading `ref.id` and skipping `deinit`). The host + /// releases it with `ZigJS.deleteValue(id)`. + pub fn into_id(self) -> u32 { + let id = Ref(self.value.0).id(); + std::mem::forget(self); + id + } + + /// Get a property value of type `T` (`get` / `getAlloc`). + /// + /// If the type is `Option`, then null or undefined JS values will return + /// `None`. If it is not, null or undefined will return an error. + pub fn get(&self, n: &str) -> Result { + let v = self.value.get(n)?; + convert_value(v) + } + + /// Set a value of a property. The type of `v` must be able to turn into + /// a value (see [`IntoValue`]). + pub fn set(&self, n: &str, v: impl IntoValue) -> Result<()> { + let js_value = v.to_value(); + let r = self.value.set(n, js_value); + if v.owned() { + js_value.deinit(); + } + r + } + + /// Call a function on a object. This will set the "this" parameter to + /// the object properly (`call` / `callAlloc`). + pub fn call(&self, n: &str, args: &[&dyn IntoValue]) -> Result { + // Build our arguments. + let js_args = Args::new(args); + + // Invoke + let f = self.value.get(n)?; + let r = f.apply(self.value, &js_args.values); + f.deinit(); + convert_value(r?) + } + + /// Construct an object from this value. + pub fn new_instance(&self, args: &[&dyn IntoValue]) -> Result { + let js_args = Args::new(args); + let v = self.value.new_instance(&js_args.values)?; + Ok(Object { value: v }) + } +} + +/// Converted call arguments; the ones created for the call are released when +/// this drops, those already objects are left to the caller. +struct Args<'a> { + values: Vec, + src: &'a [&'a dyn IntoValue], +} + +impl<'a> Args<'a> { + fn new(src: &'a [&'a dyn IntoValue]) -> Self { + Args { + values: src.iter().map(|a| IntoValue::to_value(*a)).collect(), + src, + } + } +} + +impl Drop for Args<'_> { + fn drop(&mut self) { + for (v, a) in self.values.iter().zip(self.src) { + if a.owned() { + v.deinit(); + } + } } } diff --git a/crates/ghostty-wasm/js/ghostty_host.js b/crates/ghostty-wasm/js/ghostty_host.js deleted file mode 100644 index 3dc577b..0000000 --- a/crates/ghostty-wasm/js/ghostty_host.js +++ /dev/null @@ -1,106 +0,0 @@ -// Host-side glue for the `ghostty_js` imports (see -// crates/ghostty-os/src/wasm/js.rs for the protocol) and `env.log`. -// Usable from a browser (ES module) or node (CommonJS). -// -// const host = createHost({ globalThis, log }); -// const { instance } = await WebAssembly.instantiate(bytes, host.imports); -// host.setInstance(instance); - -function createHost({ global = globalThis, log = console.log } = {}) { - const dec = new TextDecoder(); - const enc = new TextEncoder(); - // Handle table. Handle 0 is reserved for "invalid / threw". - const table = [undefined]; - const free = []; - let instance = null; - - const put = (v) => { - if (free.length > 0) { - const h = free.pop(); - table[h] = v; - return h; - } - table.push(v); - return table.length - 1; - }; - const mem = () => new Uint8Array(instance.exports.memory.buffer); - const str = (ptr, len) => dec.decode(mem().subarray(ptr, ptr + len)); - const guard = (f) => { - try { - return f(); - } catch (e) { - host.lastError = e; - return 0; - } - }; - - const ghostty_js = { - global: () => put(global), - get: (obj, ptr, len) => guard(() => put(table[obj][str(ptr, len)])), - set: (obj, ptr, len, val) => - guard(() => { - table[obj][str(ptr, len)] = table[val]; - return 1; - }), - call: (obj, ptr, len, argsPtr, argc) => - guard(() => { - const args = Array.from(new Uint32Array(instance.exports.memory.buffer, argsPtr, argc), (h) => table[h]); - const o = table[obj]; - return put(o[str(ptr, len)](...args)); - }), - new: (ctor, argsPtr, argc) => - guard(() => { - const args = Array.from(new Uint32Array(instance.exports.memory.buffer, argsPtr, argc), (h) => table[h]); - return put(new table[ctor](...args)); - }), - string: (ptr, len) => put(str(ptr, len)), - number: (v) => put(v), - boolean: (v) => put(v !== 0), - to_f64: (h) => Number(table[h]), - string_len: (h) => enc.encode(String(table[h])).length, - string_copy: (h, ptr, cap) => { - const bytes = enc.encode(String(table[h])); - const n = Math.min(bytes.length, cap); - mem().set(bytes.subarray(0, n), ptr); - return n; - }, - is_nullish: (h) => (table[h] === undefined || table[h] === null ? 1 : 0), - bytes: (ptr, len) => put(new Uint8ClampedArray(mem().slice(ptr, ptr + len).buffer)), - copy_bytes: (h, dst, len) => - guard(() => { - const src = table[h]; - if (src.length !== len) throw new Error(`length mismatch ${src.length} != ${len}`); - mem().set(new Uint8Array(src.buffer, src.byteOffset, len), dst); - return 1; - }), - release: (h) => { - if (h > 0) { - table[h] = undefined; - free.push(h); - } - }, - }; - - const host = { - lastError: null, - imports: { ghostty_js, env: { log: (ptr, len) => log(str(ptr, len)) } }, - setInstance(i) { - instance = i; - }, - // Helpers for callers of the exports. - writeString(s) { - const bytes = enc.encode(s); - const ptr = instance.exports.wasm_alloc(bytes.length); - mem().set(bytes, ptr); - return { ptr, len: bytes.length, free: () => instance.exports.wasm_free(ptr, bytes.length) }; - }, - readString(ptr, len) { - return str(ptr, len); - }, - value: (h) => table[h], - handleCount: () => table.length - 1 - free.length, - }; - return host; -} - -if (typeof module !== 'undefined') module.exports = { createHost }; diff --git a/crates/ghostty-wasm/js/zig-js/LICENSE b/crates/ghostty-wasm/js/zig-js/LICENSE new file mode 100644 index 0000000..e2c1a9e --- /dev/null +++ b/crates/ghostty-wasm/js/zig-js/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ghostty-wasm/js/zig-js/README.md b/crates/ghostty-wasm/js/zig-js/README.md new file mode 100644 index 0000000..8f77820 --- /dev/null +++ b/crates/ghostty-wasm/js/zig-js/README.md @@ -0,0 +1,21 @@ +# zig-js Glue + +This is the JS library that glues with the Zig library so that Zig +can communicate with the host JS environment when compiled to WebAssembly. +Learn more about the zig-js library at the +[zig-js repository](https://github.com/mitchellh/zig-js). + +## Installation + +There are two methods for using the JS side of zig-js: + +1. This library is a standard npm package, so you can install and use it + as you normally would any other package. Add it to your package.json and + integrate it with the bundler you're already using. + +2. You can run `npm run build` in this directory and the library will be + bundled into a single JS file in `dist/`. You can then copy the single JS + file into your web project. We bundle both a normal CJS + module at `dist/index.js` as well as an ES module at `dist/module.js`. + TypeScript type definitions are available in `dist/types.d.ts`. + diff --git a/crates/ghostty-wasm/js/zig-js/package.json b/crates/ghostty-wasm/js/zig-js/package.json new file mode 100644 index 0000000..96924be --- /dev/null +++ b/crates/ghostty-wasm/js/zig-js/package.json @@ -0,0 +1,30 @@ +{ + "name": "zig-js-glue", + "version": "0.1.3", + "description": "JS glue for Zig to interface with JS from Wasm", + "repository": { + "type": "git", + "url": "https://github.com/mitchellh/zig-js.git" + }, + "source": "src/index.ts", + "main": "dist/index.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "scripts": { + "prepare": "parcel build", + "build": "parcel build", + "test": "jest", + "check": "tsc --noEmit" + }, + "author": "Mitchell Hashimoto", + "license": "MIT", + "devDependencies": { + "@parcel/packager-ts": "^2.8.0", + "@parcel/transformer-typescript-types": "^2.8.0", + "@types/jest": "^29.2.3", + "jest": "^29.3.1", + "parcel": "^2.8.0", + "ts-jest": "^29.0.3", + "typescript": "^4.9.3" + } +} diff --git a/crates/ghostty-wasm/js/zig-js/src/index.ts b/crates/ghostty-wasm/js/zig-js/src/index.ts new file mode 100644 index 0000000..3a9b625 --- /dev/null +++ b/crates/ghostty-wasm/js/zig-js/src/index.ts @@ -0,0 +1 @@ +export * from './zigjs'; diff --git a/crates/ghostty-wasm/js/zig-js/src/zigjs.ts b/crates/ghostty-wasm/js/zig-js/src/zigjs.ts new file mode 100644 index 0000000..9cc813e --- /dev/null +++ b/crates/ghostty-wasm/js/zig-js/src/zigjs.ts @@ -0,0 +1,286 @@ +// The prefix we use for all NaN values. +const NAN_PREFIX = 0x7FF8_0000; + +/** + * Predefined references. + * */ +export const predefined = { + "nan": 0, + "null": 1, + "true": 2, + "false": 3, + "undefined": 4, + "globalThis": 5, + "runtime": 6, +}; +const PREDEFINED_ID_MAX = 6; + +// Other globals we need +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8"); + +/** + * The main state. + * */ +export class ZigJS { + /** + * Set this to the memory of your WebAssembly instance prior to making + * any Wasm calls that might interface with JS. + * + * We have to use a Memory object directly (rather than an ArrayBuffer) + * so that we can always access memory even if it is resized. Otherwise, + * a resize could detach the array buffer. + * */ + memory?: WebAssembly.Memory; + + /** + * The values, indexed by ID (number). Duplicate values can be in this + * if they are loaded multiple times. That just acts as duplicate references. + * */ + private values: Array = [NaN, null, true, false, undefined, globalThis, this]; + + /** + * When a value is removed from the values array, its ID is put + * in the pool so that we can use it next. This avoids fragmentation in + * our array keys. + * */ + private idPool: Array = []; + + /** + * Returns the import object that should be merged with your import + * object when instantiating your wasm instance. This injects the required + * functions into the wasm environment. + * */ + importObject(): ImportObject { + return { + "zig-js": { + valueGet: this.valueGet.bind(this), + valueSet: this.valueSet.bind(this), + valueDeinit: this.valueDeinit.bind(this), + valueObjectCreate: this.valueObjectCreate.bind(this), + valueStringCreate: this.valueStringCreate.bind(this), + valueStringLen: this.valueStringLen.bind(this), + valueStringCopy: this.valueStringCopy.bind(this), + valueNew: this.valueNew.bind(this), + funcApply: this.funcApply.bind(this), + }, + }; + } + + /** + * Get a value from the JS environment. + * */ + protected valueGet(out: number, id: number, ptr: number, len: number): void { + const val = this.loadValue(id); + const str = this.loadString(ptr, len); + const result = Reflect.get(val, str); + this.storeValue(out, result); + } + + /** + * Set a value on an object. + * */ + protected valueSet(id: number, ptr: number, len: number, refAddr: number): void { + const obj = this.loadValue(id); + const str = this.loadString(ptr, len); + const val = this.loadRef(refAddr); + Reflect.set(obj, str, val); + } + + /** + * Dereference a value, allowing the JS environment to potentially GC it. + * */ + protected valueDeinit(id: number): void { + // Do not allow deinitializing our predefined values + if (id > PREDEFINED_ID_MAX) { + this.values[id] = null; + this.idPool.push(id); + } + } + + /** + * Create an empty object. + * */ + protected valueObjectCreate(out: number): void { + this.storeValue(out, new Object()); + } + + /** + * Creates a string on the JS side from a UTF-8 encoded string in wasm memory. + * */ + protected valueStringCreate(out: number, ptr: number, len: number): void { + const str = this.loadString(ptr, len); + this.storeValue(out, str); + } + + + /** + * Returns the length of the string given by id. + * */ + protected valueStringLen(id: number): number { + const val = this.loadValue(id); + const buf = encoder.encode(val); + return buf.byteLength; + } + + /** + * Copy the string at id "id" into the shared memory at ptr. + * */ + protected valueStringCopy(id: number, ptr: number, max: number): void { + if (this.memory == null) return; + + const val = this.loadValue(id); + const bytes = encoder.encode(val); + if (bytes.byteLength > max) return; + new Uint8Array(this.memory.buffer, ptr, bytes.length).set(bytes); + } + + /** + * Call a constructor given by id. + * */ + protected valueNew(out: number, id: number, argsAddr: number, argsLen: number): void { + const fn = this.loadValue(id); + const args = []; + for (let i = 0; i < argsLen; i++) { + args.push(this.loadRef(argsAddr + (i * 8))); + } + + const result = Reflect.construct(fn, args); + this.storeValue(out, result); + } + + /** + * Call a function given by id. + * */ + protected funcApply(out: number, id: number, thisRefAddr: number, argsAddr: number, argsLen: number): void { + const fn = this.loadValue(id); + const thisVal = this.loadRef(thisRefAddr); + const args = []; + for (let i = 0; i < argsLen; i++) { + args.push(this.loadRef(argsAddr + (i * 8))); + } + + const result = Reflect.apply(fn, thisVal, args); + this.storeValue(out, result); + } + + /** + * This can be used to load a value given by an ID. A WASM function + * might return a value as an ID. This can be used to retrieve it. + * */ + loadValue(id: number): any { + return this.values[id]; + } + + /** + * This removes the value from the values list and returns it. This + * will not remove predefined values but can still be used to retrieve + * them. + * */ + deleteValue(id: number): any { + const val = this.values[id]; + this.valueDeinit(id); + return val; + } + + loadRef(refAddr: number): any { + if (this.memory == null) return; + const view = new DataView(this.memory.buffer); + + // If the value at the memory location is not a NaN, return it directly. + const floatVal = view.getFloat64(refAddr, true); + if (!isNaN(floatVal)) return floatVal; + + // If it is a NaN, we need to get the ID. + const id = this.loadRefId(refAddr); + return this.values[id]; + } + + loadRefId(refAddr: number): number { + if (this.memory == null) return 0; + return new DataView(this.memory.buffer).getUint32(refAddr, true); + } + + storeValue(out: number, val: any): void { + if (this.memory == null) return; + const view = new DataView(this.memory.buffer); + + if (typeof val === "number") { + // We have to turn NaNs into a single value (since NaN can be + // represented by multiple encodings). + if (isNaN(val)) { + view.setUint32(out, predefined.nan, true); + view.setUint32(out + 4, NAN_PREFIX, true); + } else { + view.setFloat64(out, val, true); + } + + return; + } + + if (val === null) { + view.setUint32(out, predefined.null, true); + view.setUint32(out + 4, NAN_PREFIX, true); + return; + } + + if (val === undefined) { + view.setUint32(out, predefined.undefined, true); + view.setUint32(out + 4, NAN_PREFIX, true); + return; + } + + // Determine our ID + let id = this.idPool.pop(); + if (id === undefined) { + id = this.values.length; + } + this.values[id] = val; + + // All other values have to have a type set. + let typeId = 0; + switch (typeof val) { + case "object": + typeId = 1; + break; + case "string": + typeId = 2; + break; + case "symbol": + typeId = 3; + break; + case "function": + typeId = 4; + break; + } + + // Set the fields + view.setUint32(out, Number(id), true); + view.setUint32(out + 4, NAN_PREFIX | typeId, true); + } + + loadString(ptr: number, len: number): string { + if (this.memory == null) return ""; + + // We slice a clamped array instead of using a DataView so that + // we can also support SharedArrayBuffers. It would probably be slightly + // more performant (maybe?) to check and use either. + const arr = new Uint8ClampedArray(this.memory.buffer, ptr, Number(len)); + const data = arr.slice(); + return decoder.decode(data); + } +} + +export interface ImportObject { + "zig-js": { + valueGet: (out: number, id: number, ptr: number, len: number) => void; + valueSet: (id: number, ptr: number, len: number, valueRef: number) => void; + valueObjectCreate: (out: number) => void; + valueStringCreate: (out: number, ptr: number, len: number) => void; + valueStringLen: (id: number) => number; + valueStringCopy: (id: number, ptr: number, max: number) => void; + valueDeinit: (id: number) => void; + valueNew: (out: number, funcId: number, argsPtr: number, argsLen: number) => void; + funcApply: (out: number, funcId: number, thisRef: number, argsPtr: number, argsLen: number) => void; + }; +}; diff --git a/crates/ghostty-wasm/js/zig-js/tests/zigjs.test.ts b/crates/ghostty-wasm/js/zig-js/tests/zigjs.test.ts new file mode 100644 index 0000000..010311d --- /dev/null +++ b/crates/ghostty-wasm/js/zig-js/tests/zigjs.test.ts @@ -0,0 +1,303 @@ +import { ZigJS, predefined } from '../src'; + +test('predefined values', () => { + const st = new ZigJS(); + expect(st.loadValue(0)).toBeNaN(); + expect(st.loadValue(1)).toEqual(null); + expect(st.loadValue(2)).toEqual(true); + expect(st.loadValue(3)).toEqual(false); + expect(st.loadValue(4)).toEqual(undefined); + expect(st.loadValue(5)).toEqual(globalThis); +}); + +test('valueGet', () => { + const st = new ZigJS(); + const obj = st.importObject(); + const f = obj["zig-js"].valueGet; + + // Set our memory + const memory = new WebAssembly.Memory({ initial: 1 }); + const view = new DataView(memory.buffer); + st.memory = memory; + + // Write our string + const key = "__zigjs_number"; + const encoder = new TextEncoder(); + const write = encoder.encodeInto(key, new Uint8Array(memory.buffer)); + expect(write.written).toBeGreaterThan(0); + + // Write our key into the global value + globalThis[key] = 1234; + + // Read it + f(64, predefined.globalThis, 0, write.written ?? 0); + expect(view.getFloat64(64, true)).toEqual(1234); +}); + +test('valueGet with shared memory', () => { + const st = new ZigJS(); + const obj = st.importObject(); + const f = obj["zig-js"].valueGet; + + // Set our memory + const memory = new WebAssembly.Memory({ initial: 1, maximum: 10, shared: true }); + const view = new DataView(memory.buffer); + st.memory = memory; + + // Write our string + const key = "__zigjs_number"; + const encoder = new TextEncoder(); + const write = encoder.encodeInto(key, new Uint8Array(memory.buffer)); + expect(write.written).toBeGreaterThan(0); + + // Write our key into the global value + globalThis[key] = 1234; + + // Read it + f(64, predefined.globalThis, 0, write.written ?? 0); + expect(view.getFloat64(64, true)).toEqual(1234); +}); + +test('valueGet: runtime', () => { + const st = new ZigJS(); + const obj = st.importObject(); + const f = obj["zig-js"].valueGet; + + // Set our memory + const memory = new WebAssembly.Memory({ initial: 1 }); + const view = new DataView(memory.buffer); + st.memory = memory; + + // Write our string + const key = "memory"; + const encoder = new TextEncoder(); + const write = encoder.encodeInto(key, new Uint8Array(memory.buffer)); + expect(write.written).toBeGreaterThan(0); + + // Read it + f(64, predefined.runtime, 0, write.written ?? 0); + expect(st.loadValue(st.loadRefId(64))).toEqual(memory); +}); + +test('valueGet: string', () => { + const st = new ZigJS(); + const obj = st.importObject(); + + // Set our memory + const memory = new WebAssembly.Memory({ initial: 1 }); + const view = new DataView(memory.buffer); + st.memory = memory; + + // Write our string + const key = "__zigjs_string"; + const encoder = new TextEncoder(); + const write = encoder.encodeInto(key, new Uint8Array(memory.buffer)); + expect(write.written).toBeGreaterThan(0); + + // Write our key into the global value + globalThis[key] = "橋本"; + + // Read it + const refAddr = 64; + const f = obj["zig-js"].valueGet; + f(refAddr, predefined.globalThis, 0, write.written ?? 0); + expect(st.loadRef(refAddr)).toEqual("橋本"); + + // Read the string length + { + const stringLen = obj["zig-js"].valueStringLen; + const len = stringLen(st.loadRefId(refAddr)); + expect(len).not.toBeNaN(); + expect(len).toEqual(6); + } + + // Copy the string into memory + const offset = 12; + { + const stringCopy = obj["zig-js"].valueStringCopy; + stringCopy(st.loadRefId(refAddr), offset, 64); + } + + // Read it + const str = new TextDecoder('utf-8').decode(new DataView(memory.buffer, 12, 6)); + expect(str).toEqual("橋本"); +}); + +test('valueSet: number', () => { + const st = new ZigJS(); + const obj = st.importObject(); + const f = obj["zig-js"].valueSet; + + // Set our memory + const memory = new WebAssembly.Memory({ initial: 1 }); + const view = new DataView(memory.buffer); + st.memory = memory; + + // Write our string + const key = "__zigjs_number"; + const encoder = new TextEncoder(); + const write = encoder.encodeInto(key, new Uint8Array(memory.buffer)); + expect(write.written).toBeGreaterThan(0); + + // Write our argument + const refAddr = 64; + view.setFloat64(refAddr, 42, true); + + // Write our key into the global value + globalThis[key] = 12; + + // Set it + f(predefined.globalThis, 0, write.written ?? 0, refAddr); + expect(globalThis[key]).toEqual(42); +}); + +test('valueSet: ref', () => { + const st = new ZigJS(); + const obj = st.importObject(); + const f = obj["zig-js"].valueSet; + + // Set our memory + const memory = new WebAssembly.Memory({ initial: 1 }); + const view = new DataView(memory.buffer); + st.memory = memory; + + // Write our string + const key = "__zigjs_boolean"; + const encoder = new TextEncoder(); + const write = encoder.encodeInto(key, new Uint8Array(memory.buffer)); + expect(write.written).toBeGreaterThan(0); + + // Write our argument + const refAddr = 64; + st.storeValue(refAddr, true); + + // Write our key into the global value + globalThis[key] = false; + + // Set it + f(predefined.globalThis, 0, write.written ?? 0, refAddr); + expect(globalThis[key]).toEqual(true); +}); + +test('valueStringCreate', () => { + const st = new ZigJS(); + const obj = st.importObject(); + + // Set our memory + const memory = new WebAssembly.Memory({ initial: 1 }); + const view = new DataView(memory.buffer); + st.memory = memory; + + // Write our string into memory + const value = "hello, world!"; + const encoder = new TextEncoder(); + const write = encoder.encodeInto(value, new Uint8Array(memory.buffer)); + expect(write.written).toBeGreaterThan(0); + + // Read it + let f = obj["zig-js"].valueStringCreate; + const refAddr = 64; + f(refAddr, 0, write.written ?? 0); + expect(st.loadValue(st.loadRefId(refAddr))).toEqual(value); +}); + +test('valueObjectCreate', () => { + const st = new ZigJS(); + const obj = st.importObject(); + + // Set our memory + const memory = new WebAssembly.Memory({ initial: 1 }); + const view = new DataView(memory.buffer); + st.memory = memory; + + // Read it + let f = obj["zig-js"].valueObjectCreate; + const refAddr = 64; + f(refAddr); + expect(st.loadValue(st.loadRefId(refAddr))).toEqual({}); +}); + +test('valueNew', () => { + const st = new ZigJS(); + const obj = st.importObject(); + + // Set our memory + const memory = new WebAssembly.Memory({ initial: 1 }); + const view = new DataView(memory.buffer); + st.memory = memory; + + // Set our function + const key = "Uint8Array"; + + // Write our string + const encoder = new TextEncoder(); + const write = encoder.encodeInto(key, new Uint8Array(memory.buffer)); + expect(write.written).toBeGreaterThan(0); + + // Construct + const funcAddr = 64; + const f = obj["zig-js"].valueGet; + f(funcAddr, predefined.globalThis, 0, write.written ?? 0); + + // Setup our args + view.setFloat64(0, 24, true); + + // Call it! + const resultAddr = 0; + obj["zig-js"].valueNew( + resultAddr, + st.loadRefId(funcAddr), + 0, + 1, + ); + + const arr = st.loadRef(resultAddr); + expect(arr.length).toEqual(24); +}); + +test('funcApply', () => { + const st = new ZigJS(); + const obj = st.importObject(); + + // Set our memory + const memory = new WebAssembly.Memory({ initial: 1 }); + const view = new DataView(memory.buffer); + st.memory = memory; + + // Set our function + const key = "__zigjs_func"; + globalThis[key] = (x: number): number => x * 2; + + // Write our string + const encoder = new TextEncoder(); + const write = encoder.encodeInto(key, new Uint8Array(memory.buffer)); + expect(write.written).toBeGreaterThan(0); + + // Read the func + const funcAddr = 64; + const f = obj["zig-js"].valueGet; + f(funcAddr, predefined.globalThis, 0, write.written ?? 0); + expect(st.loadValue(st.loadRefId(funcAddr))).toBeInstanceOf(Function); + + // Setup our args + view.setFloat64(0, 24, true); + + // Call it! + const resultAddr = 0; + obj["zig-js"].funcApply( + resultAddr, + st.loadRefId(funcAddr), + predefined.undefined, + 0, + 1, + ); + expect(st.loadRef(resultAddr)).toEqual(48); +}); + +// We need to extend our global value for test keys +declare global { + var __zigjs_boolean: boolean; + var __zigjs_number: number; + var __zigjs_string: string; + var __zigjs_func: any; +} diff --git a/crates/ghostty-wasm/js/zig-js/tsconfig.json b/crates/ghostty-wasm/js/zig-js/tsconfig.json new file mode 100644 index 0000000..b70077d --- /dev/null +++ b/crates/ghostty-wasm/js/zig-js/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "alwaysStrict": true, + "sourceMap": false, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "strictBindCallApply": true, + "module": "es2015", + "jsx": "react", + "target": "es5", + "moduleResolution": "node", + "experimentalDecorators": true, + "esModuleInterop": true, + "declaration": true, + "lib": ["dom", "es2017", "es5", "es6", "es7"], + "outDir": ".", + "strict": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "skipLibCheck": true + }, + "exclude": ["*.js", "tests", "examples", "src/*.test.tsx"] +} diff --git a/crates/ghostty-wasm/src/atlas.rs b/crates/ghostty-wasm/src/atlas.rs index 5ffa569..0f8675b 100644 --- a/crates/ghostty-wasm/src/atlas.rs +++ b/crates/ghostty-wasm/src/atlas.rs @@ -1,7 +1,7 @@ //! `font/Atlas.zig` `Wasm`: the wasm-compatible atlas API. use ghostty_font::atlas::{Atlas, Format, Region}; -use ghostty_os::wasm::js::{self, Arg, Object}; +use ghostty_os::wasm::js::{self, Object}; use crate::{ensure_init, host_bytes}; @@ -120,40 +120,42 @@ pub extern "C" fn atlas_size(atlas: *const Atlas) -> u32 { unsafe { &*atlas }.size } -/// This creates a Canvas element identified by the handle returned that the -/// caller can draw into the DOM to visualize the atlas. The returned handle -/// must be released from the JS runtime (`ghostty_js` release / zigjs -/// `deleteValue`). Returns 0 on error. +/// This creates a Canvas element identified by the id returned that the +/// caller can draw into the DOM to visualize the atlas. The returned ID must +/// be freed from the JS runtime by calling `zigjs.deleteValue`. Returns 0 on +/// error (that is also never a valid id: 0 is the predefined `NaN`). #[unsafe(no_mangle)] pub extern "C" fn atlas_debug_canvas(atlas: *const Atlas) -> u32 { // SAFETY: live pointer from `atlas_new`. let atlas = unsafe { &*atlas }; match debug_canvas(atlas) { - Ok(c) => c.into_raw(), + Ok(id) => id, Err(err) => { - log::warn!("error dumping atlas canvas err={err}"); + log::warn!("error dumping atlas canvas err={err:?}"); 0 } } } -fn debug_canvas(atlas: &Atlas) -> Result { +fn debug_canvas(atlas: &Atlas) -> Result { // Create our canvas - let doc = js::global()?.get_object("document")?; - let canvas = doc.call("createElement", &[Arg::Str("canvas")])?; + let doc = js::global().get::("document")?; + let canvas = doc.call::("createElement", &[&js::string("canvas")])?; // Setup our canvas size { - canvas.set("width", Arg::from(atlas.size))?; - canvas.set("height", Arg::from(atlas.size))?; - let style = canvas.get_object("style")?; - let px = format!("{}px", atlas.size); - style.set("width", Arg::Str(&px))?; - style.set("height", Arg::Str(&px))?; + canvas.set("width", atlas.size)?; + canvas.set("height", atlas.size)?; + + let width_str = format!("{}px", atlas.size); + let style = canvas.get::("style")?; + style.set("width", js::string(&width_str))?; + style.set("height", js::string(&width_str))?; } - // This will return the same context on subsequent calls. - let ctx = canvas.call("getContext", &[Arg::Str("2d")])?; + // This will return the same context on subsequent calls so it is + // important to reset it. + let ctx = canvas.call::("getContext", &[&js::string("2d")])?; // We need to draw pixels so this is format dependent. let buf: Vec = match atlas.format { @@ -173,24 +175,51 @@ fn debug_canvas(atlas: &Atlas) -> Result { } buf } - Format::Bgr => { - log::warn!("error dumping atlas canvas err=UnsupportedAtlasFormat"); - return Err(js::Error::Js); - } + Format::Bgr => return Err(DebugCanvasError::UnsupportedAtlasFormat), }; // Create an ImageData from our buffer and then write it to the canvas - let arr = Object::clamped_array(&buf)?; - let image_data_ctor = js::global()?.get_object("ImageData")?; - let image_data = image_data_ctor.new_instance(&[ - Arg::Object(&arr), - Arg::from(atlas.size), - Arg::from(atlas.size), - ])?; - ctx.call( - "putImageData", - &[Arg::Object(&image_data), Arg::Num(0.0), Arg::Num(0.0)], - )?; + let image_data = { + // Get our runtime memory + let mem = js::runtime().get::("memory")?; + let mem_buf = mem.get::("buffer")?; - Ok(canvas) + // Create an array that points to our buffer + let arr = { + let uint8_clamped_array = js::global().get::("Uint8ClampedArray")?; + let arr = uint8_clamped_array.new_instance(&[ + &mem_buf, + &(buf.as_ptr() as usize), + &buf.len(), + ])?; + if ghostty_os::wasm::SHARED_MEM { + // If we're sharing memory then we have to copy the data since + // we can't set ImageData directly using a SharedArrayBuffer. + arr.call::("slice", &[])? + } else { + arr + } + }; + + // Create the image data from our array + let image_data = js::global().get::("ImageData")?; + image_data.new_instance(&[&arr, &atlas.size, &atlas.size])? + }; + + // Draw it + ctx.call::<()>("putImageData", &[&image_data, &0, &0])?; + + Ok(canvas.into_id()) +} + +#[derive(Debug)] +enum DebugCanvasError { + Js(js::Error), + UnsupportedAtlasFormat, +} + +impl From for DebugCanvasError { + fn from(e: js::Error) -> Self { + DebugCanvasError::Js(e) + } } diff --git a/crates/ghostty-wasm/src/face.rs b/crates/ghostty-wasm/src/face.rs index bb770ca..fd03ac5 100644 --- a/crates/ghostty-wasm/src/face.rs +++ b/crates/ghostty-wasm/src/face.rs @@ -53,7 +53,10 @@ pub extern "C" fn face_new(ptr: *const u8, len: usize, pts: u16, p: u16) -> *mut }; match Face::init_named(raw, DesiredSize::new(f32::from(pts)), p) { Ok(face) => Box::into_raw(Box::new(face)), - Err(_) => std::ptr::null_mut(), + Err(err) => { + log::warn!("error creating face err={err}"); + std::ptr::null_mut() + } } } diff --git a/crates/ghostty-wasm/src/lib.rs b/crates/ghostty-wasm/src/lib.rs index 45e60c7..8653dd9 100644 --- a/crates/ghostty-wasm/src/lib.rs +++ b/crates/ghostty-wasm/src/lib.rs @@ -11,7 +11,7 @@ //! - module `config`, `config/Wasm.zig` (`config_*`) //! - module `app`, `App.zig` `Wasm` (commented out upstream, nothing exported) //! -//! The host provides the `ghostty_js` imports (see +//! The host provides the `zig-js` imports (see //! `ghostty_os::wasm::js`) and `env.log`. Strings are passed as //! pointer+length pairs into wasm memory allocated by the host with //! [`wasm_alloc`] / released with [`wasm_free`]. diff --git a/crates/ghostty-wasm/tests/node.rs b/crates/ghostty-wasm/tests/node.rs index 5db0a92..7f64eef 100644 --- a/crates/ghostty-wasm/tests/node.rs +++ b/crates/ghostty-wasm/tests/node.rs @@ -1,14 +1,32 @@ //! Builds the wasm module and runs `tests/node_test.js` against it with -//! node, using stub JS imports (a fake canvas measuring API). Skipped when +//! node, with zig-js's own JS host and a fake `document`/canvas. Skipped when //! node or the `wasm32-unknown-unknown` target is not available. use std::{path::PathBuf, process::Command}; #[test] fn wasm_module_runs_under_node() { - if Command::new("node").arg("--version").output().is_err() { + // zig-js's host is TypeScript; node must be able to strip types (>= 23.6). + let Some(major) = Command::new("node") + .arg("--version") + .output() + .ok() + .and_then(|o| { + String::from_utf8_lossy(&o.stdout) + .trim() + .trim_start_matches('v') + .split('.') + .next()? + .parse::() + .ok() + }) + else { eprintln!("skipping: node not found"); return; + }; + if major < 24 { + eprintln!("skipping: node {major} cannot load the zig-js TypeScript host"); + return; } let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); diff --git a/crates/ghostty-wasm/tests/node_test.js b/crates/ghostty-wasm/tests/node_test.js index af8f278..eee29b9 100644 --- a/crates/ghostty-wasm/tests/node_test.js +++ b/crates/ghostty-wasm/tests/node_test.js @@ -4,7 +4,8 @@ const fs = require('fs'); const assert = require('assert'); const path = require('path'); -const { createHost } = require(path.join(__dirname, '..', 'js', 'ghostty_host.js')); +// zig-js's own JS host (vendored, unmodified TypeScript; node strips types). +const { ZigJS } = require(path.join(__dirname, '..', 'js', 'zig-js', 'src', 'zigjs.ts')); // A fake canvas: every glyph is a 7px wide, 12px ascent, 4px descent box // (emoji are 14px wide and "colored"). measureText records the font in use. @@ -19,8 +20,12 @@ class FakeCanvas { font: '', textBaseline: 'alphabetic', fillStyle: '', - clearRect: (...a) => self.calls.push(['clearRect', ...a]), - fillRect: (...a) => self.calls.push(['fillRect', ...a]), + clearRect: (...a) => { + self.calls.push(['clearRect', ...a]); + }, + fillRect: (...a) => { + self.calls.push(['fillRect', ...a]); + }, fillText: (t, x, y) => { self.lastText = { t, x, y }; self.calls.push(['fillText', t, x, y]); @@ -51,7 +56,9 @@ class FakeCanvas { } return { data }; }, - putImageData: (...a) => self.calls.push(['putImageData', ...a]), + putImageData: (...a) => { + self.calls.push(['putImageData', ...a]); + }, }; } getContext(kind) { @@ -67,25 +74,52 @@ class FakeImageData { } } const canvases = []; -const fakeGlobal = { - document: { - createElement: (tag) => { - assert.strictEqual(tag, 'canvas'); - const c = new FakeCanvas(); - canvases.push(c); - return c; - }, - getElementById: () => null, +// zig-js reads `globalThis` directly (predefined ref 5). +globalThis.document = { + createElement: (tag) => { + assert.strictEqual(tag, 'canvas'); + const c = new FakeCanvas(); + canvases.push(c); + return c; }, - ImageData: FakeImageData, + getElementById: () => null, }; +globalThis.ImageData = FakeImageData; + +// Live (non-predefined) entries in the zig-js value table, to catch leaks. +const liveValues = (zigjs) => zigjs.values.slice(7).filter((v) => v !== null).length; (async () => { const logs = []; - const host = createHost({ global: fakeGlobal, log: (s) => logs.push(s) }); + const zigjs = new ZigJS(); + const enc = new TextEncoder(); + const dec = new TextDecoder(); + let instance; + const mem = () => new Uint8Array(instance.exports.memory.buffer); + const readString = (ptr, len) => dec.decode(mem().subarray(ptr, ptr + len)); + const imports = { + ...zigjs.importObject(), + env: { log: (ptr, len) => logs.push(readString(ptr, len)) }, + }; + // The import module must be exactly zig-js's. + assert.deepStrictEqual(Object.keys(imports).sort(), ['env', 'zig-js']); const bytes = fs.readFileSync(process.argv[2]); - const { instance } = await WebAssembly.instantiate(bytes, host.imports); - host.setInstance(instance); + ({ instance } = await WebAssembly.instantiate(bytes, imports)); + zigjs.memory = instance.exports.memory; + const wanted = Object.keys(zigjs.importObject()['zig-js']).sort(); + const got = WebAssembly.Module.imports(new WebAssembly.Module(bytes)) + .filter((i) => i.module === 'zig-js') + .map((i) => i.name); + assert.ok(got.every((n) => wanted.includes(n)), `unexpected zig-js imports: ${got}`); + const host = { + writeString(s) { + const b = enc.encode(s); + const ptr = instance.exports.wasm_alloc(b.length); + mem().set(b, ptr); + return { ptr, len: b.length, free: () => instance.exports.wasm_free(ptr, b.length) }; + }, + readString, + }; const x = instance.exports; x.ghostty_wasm_init(); @@ -117,7 +151,7 @@ const fakeGlobal = { // ---- face ---- const f = host.writeString('monospace'); const face = x.face_new(f.ptr, f.len, 12, 0); - assert.ok(face, 'face_new'); + assert.ok(face, 'face_new ' + logs.join('|')); const m = x.wasm_alloc(12); x.face_metrics(face, m); const metrics = new Uint32Array(instance.exports.memory.buffer, m, 3).slice(); @@ -184,7 +218,7 @@ const fakeGlobal = { // ---- atlas debug canvas ---- const h = x.atlas_debug_canvas(atlas); assert.ok(h > 0); - const dc = host.value(h); + const dc = zigjs.deleteValue(h); assert.strictEqual(dc.width, 64); assert.ok(dc.calls.some((c) => c[0] === 'putImageData')); @@ -193,6 +227,7 @@ const fakeGlobal = { x.face_free(face); x.face_free(eface); x.atlas_free(atlas); + assert.strictEqual(liveValues(zigjs), 0, 'leaked zig-js values'); f.free(); ef.free(); st.free(); diff --git a/docs/rewrite/notes/wasm-W1.md b/docs/rewrite/notes/wasm-W1.md index 91a2e03..be040a2 100644 --- a/docs/rewrite/notes/wasm-W1.md +++ b/docs/rewrite/notes/wasm-W1.md @@ -10,7 +10,7 @@ unchanged. | `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` + host `ghostty-wasm/js/ghostty_host.js` | +| 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` | @@ -38,13 +38,39 @@ unchanged. and `shaper::{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-wasm` passes (debug + release); clippy `-D warnings` clean on both targets. -## JS protocol (deviation) -`zig-js` (mitchellh/zig-js) is **not vendored** in the Ghostty tree, so its -import names could not be mirrored. `js.rs` documents a small handle-based -ABI in import module `ghostty_js` (`global/get/set/call/new/string/number/ -boolean/to_f64/string_len/string_copy/is_nullish/bytes/copy_bytes/release`) -plus `env.log(ptr,len)` (same as Zig's log). The Rust-side API mirrors -`js.Object`. `ghostty-wasm/js/ghostty_host.js` is a reference host. +## 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::`, `set`, `call::`, +`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` 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`). @@ -84,3 +110,10 @@ Differences/extras: `region_free`/`glyph_free` (Zig: "global free"), ## Known gaps - `ghostty-os/tests/common/flatpak_mock.rs` (another agent's, untracked) fails `clippy --all-targets -D warnings` (needless_pass_by_value); unrelated. + +- Node test (`tests/node_test.js`) now loads zig-js's `ZigJS` straight from the + vendored `.ts` (needs node >= 24 type stripping; skipped otherwise), uses a + fake `document`/canvas on `globalThis`, 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_hostname` and wasm builds + warn on `SEP` unused in `ghostty-os/src/lib.rs` (other agents' areas).