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)
This commit is contained in:
ddidderr committed 2026-10-12 05:26:53 +02:00
1 parent cbc023a633
commit 516c780f84
16 files changed
+1527 -474

No files matched your search

+63 -66
View File
@@ -31,6 +31,8 @@ pub enum Error {
UnsupportedAtlasFormat, UnsupportedAtlasFormat,
#[error("invalid codepoint")] #[error("invalid codepoint")]
InvalidCodepoint, InvalidCodepoint,
#[error("canvas container not found")]
CanvasContainerNotFound,
#[error(transparent)] #[error(transparent)]
Atlas(#[from] atlas::Error), Atlas(#[from] atlas::Error),
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@@ -217,7 +219,7 @@ pub use face_js::Face;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
mod face_js { mod face_js {
use ghostty_os::wasm::js::{self, Arg, Object}; use ghostty_os::wasm::js::{self, Object};
use super::{ use super::{
Error, Error,
@@ -260,11 +262,11 @@ mod face_js {
fn text_metrics(o: &Object) -> Result<TextMetrics, Error> { fn text_metrics(o: &Object) -> Result<TextMetrics, Error> {
Ok(TextMetrics { Ok(TextMetrics {
width: o.get_f32("width")?, width: o.get::<f32>("width")?,
actual_bounding_box_left: o.get_f32("actualBoundingBoxLeft")?, actual_bounding_box_left: o.get::<f32>("actualBoundingBoxLeft")?,
actual_bounding_box_right: o.get_f32("actualBoundingBoxRight")?, actual_bounding_box_right: o.get::<f32>("actualBoundingBoxRight")?,
actual_bounding_box_ascent: o.get_f32("actualBoundingBoxAscent")?, actual_bounding_box_ascent: o.get::<f32>("actualBoundingBoxAscent")?,
actual_bounding_box_descent: o.get_f32("actualBoundingBoxDescent")?, actual_bounding_box_descent: o.get::<f32>("actualBoundingBoxDescent")?,
}) })
} }
@@ -282,8 +284,8 @@ mod face_js {
presentation: Presentation, presentation: Presentation,
) -> Result<Face, Error> { ) -> Result<Face, Error> {
// Create our canvas that we're going to continue to reuse. // Create our canvas that we're going to continue to reuse.
let doc = js::global()?.get_object("document")?; let doc = js::global().get::<Object>("document")?;
let canvas = doc.call("createElement", &[Arg::Str("canvas")])?; let canvas = doc.call::<Object>("createElement", &[&js::string("canvas")])?;
let mut result = Face { let mut result = Face {
font_str: raw.to_owned(), font_str: raw.to_owned(),
@@ -369,11 +371,11 @@ mod face_js {
let ctx = self.context()?; let ctx = self.context()?;
// Cell width is the width of our M text // 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::<Object>("measureText", &[&js::string("M")])?)?;
// To get the cell height we render a high and low character and // To get the cell height we render a high and low character and
// get the total of the ascent and descent. // get the total of the ascent and descent.
let hm = text_metrics(&ctx.call("measureText", &[Arg::Str("M_")])?)?; let hm = text_metrics(&ctx.call::<Object>("measureText", &[&js::string("M_")])?)?;
self.metrics = calc_metrics(m, hm); self.metrics = calc_metrics(m, hm);
log::debug!("metrics font={} value={:?}", self.font_str, self.metrics); log::debug!("metrics font={} value={:?}", self.font_str, self.metrics);
@@ -384,31 +386,25 @@ mod face_js {
fn context(&self) -> Result<Object, Error> { fn context(&self) -> Result<Object, Error> {
// This will return the same context on subsequent calls so it // This will return the same context on subsequent calls so it
// is important to reset it. // is important to reset it.
let ctx = self.canvas.call("getContext", &[Arg::Str("2d")])?; let ctx = self
.canvas
.call::<Object>("getContext", &[&js::string("2d")])?;
// Clear the canvas // Clear the canvas
{ {
let width = self.canvas.get_f64("width")?; let width = self.canvas.get::<f64>("width")?;
let height = self.canvas.get_f64("height")?; let height = self.canvas.get::<f64>("height")?;
ctx.call( ctx.call::<()>("clearRect", &[&0.0_f64, &0.0_f64, &width, &height])?;
"clearRect",
&[
Arg::Num(0.0),
Arg::Num(0.0),
Arg::Num(width),
Arg::Num(height),
],
)?;
} }
// Set our context font // Set our context font
let font_val = format!("{}px {}", self.size.points, self.font_str); 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 // 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 // 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. // invalid font in and this at least makes it show up in the logs.
let check = ctx.get_string("font")?; let check = ctx.get::<String>("font")?;
if font_val != check { if font_val != check {
log::warn!( log::warn!(
"canvas font didn't set, fonts may be broken, expected={font_val} got={check}" "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 // Get our drawing context and the size of the render
let measure_ctx = self.context()?; let measure_ctx = self.context()?;
let m = text_metrics(&measure_ctx.call("measureText", &[Arg::Str(&glyph_str)])?)?; let m = text_metrics(
&measure_ctx.call::<Object>("measureText", &[&js::string(&glyph_str)])?,
)?;
let (width, height, broken_bbox) = glyph_size(m); let (width, height, broken_bbox) = glyph_size(m);
// Resize canvas to match the glyph size exactly // Resize canvas to match the glyph size exactly
{ {
self.canvas.set("width", Arg::from(width))?; self.canvas.set("width", width)?;
self.canvas.set("height", Arg::from(height))?; self.canvas.set("height", height)?;
let style = self.canvas.get_object("style")?; let style = self.canvas.get::<Object>("style")?;
style.set("width", Arg::Str(&format!("{width}px")))?; style.set("width", js::string(&format!("{width}px")))?;
style.set("height", Arg::Str(&format!("{height}px")))?; style.set("height", js::string(&format!("{height}px")))?;
} }
// Reload our context since we resized the canvas // 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 // baselines so that we just render the glyph fully in the box
// with no offsets. // with no offsets.
if broken_bbox { if broken_bbox {
ctx.set("textBaseline", Arg::Str("ideographic"))?; ctx.set("textBaseline", js::string("ideographic"))?;
} }
// Draw background // Draw background
ctx.set("fillStyle", Arg::Str("transparent"))?; ctx.set("fillStyle", js::string("transparent"))?;
ctx.call( ctx.call::<()>("fillRect", &[&0.0_f64, &0.0_f64, &width, &height])?;
"fillRect",
&[
Arg::Num(0.0),
Arg::Num(0.0),
Arg::from(width),
Arg::from(height),
],
)?;
// Draw glyph // Draw glyph
ctx.set("fillStyle", Arg::Str("black"))?; ctx.set("fillStyle", js::string("black"))?;
ctx.call( ctx.call::<()>(
"fillText", "fillText",
&[ &[
Arg::Str(&glyph_str), &js::string(&glyph_str),
Arg::from(m.actual_bounding_box_left + 1.0), &(m.actual_bounding_box_left + 1.0),
if broken_bbox { &if broken_bbox {
Arg::from(height) height as f32
} else { } 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<u8> on our side // Read the image data and get it into a Vec<u8> on our side
let data = ctx.call( let data =
"getImageData", ctx.call::<Object>("getImageData", &[&0.0_f64, &0.0_f64, &width, &height])?;
&[ let src_array = data.get::<Object>("data")?;
Arg::Num(0.0),
Arg::Num(0.0), // Allocate our local memory to copy the data to.
Arg::from(width), let len = src_array.get::<u32>("length")? as usize;
Arg::from(height),
],
)?;
let src_array = data.get_object("data")?;
let len = src_array.get_f64("length")? as usize;
let mut bitmap = vec![0u8; len]; 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::<Object>("memory")?;
let buf = mem.get::<Object>("buffer")?;
// Construct our array to peer into our memory
let uint8_array = js::global().get::<Object>("Uint8Array")?;
uint8_array.new_instance(&[&buf, &(bitmap.as_mut_ptr() as usize)])?
};
// Copy
mem_array.call::<()>("set", &[&src_array])?;
Ok(RenderedGlyph { Ok(RenderedGlyph {
width, width,
@@ -512,12 +510,11 @@ mod face_js {
/// Appends our canvas to the element with id `face-canvas` (the /// Appends our canvas to the element with id `face-canvas` (the
/// debugging hook behind the `face_debug_canvas` export). /// debugging hook behind the `face_debug_canvas` export).
pub fn debug_canvas(&self) -> Result<(), Error> { pub fn debug_canvas(&self) -> Result<(), Error> {
let doc = js::global()?.get_object("document")?; let doc = js::global().get::<Object>("document")?;
let elem = doc.call("getElementById", &[Arg::Str("face-canvas")])?; let elem = doc
if elem.is_nullish() { .call::<Option<Object>>("getElementById", &[&js::string("face-canvas")])?
return Err(js::Error::Nullish.into()); .ok_or(Error::CanvasContainerNotFound)?;
} elem.call::<()>("append", &[&self.canvas])?;
elem.call("append", &[Arg::Object(&self.canvas)])?;
Ok(()) Ok(())
} }
} }
+600 -243
View File
@@ -1,77 +1,77 @@
//! JS interop for wasm. Stands in for the `zig-js` package //! JS interop for wasm. A Rust port of the `zig-js` package
//! (`mitchellh/zig-js`) that Ghostty's Zig wasm code (`font/face/web_canvas`, //! (`mitchellh/zig-js`, `src/{main,ref,value,Object,extern}.zig`) which
//! `font/Atlas`) imports as `js`. //! Ghostty's Zig wasm code imports as `js`.
//! //!
//! The `zig-js` source is not part of the Ghostty tree, so its exact import //! This speaks exactly zig-js's ABI so the module runs with zig-js's own JS
//! names cannot be mirrored; this module defines an equivalent handle based //! host (`ZigJS` from `zig-js-glue`; vendored under
//! protocol in the import module `ghostty_js` and keeps the *Rust-side API* //! `crates/ghostty-wasm/js/zig-js`):
//! 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.
//! //!
//! 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 | //! The wasm host also needs `memory` assigned on the `ZigJS` instance (that is
//! |---|---| //! what [`runtime`]`.get::<Object>("memory")` reads).
//! | `global` | `() -> handle` of `globalThis` | //!
//! | `get` | `(obj, name_ptr, name_len) -> handle` | //! Differences from the Zig API are only those forced by Rust: generics and
//! | `set` | `(obj, name_ptr, name_len, value) -> i32` (1 = ok) | //! traits ([`Get`], [`IntoValue`]) instead of `comptime T`, `Drop` instead of
//! | `call` | `(obj, name_ptr, name_len, args_ptr, args_len) -> handle` | //! explicit `deinit()` (use [`Object::into_id`] to hand ownership to the host
//! | `new` | `(ctor, args_ptr, args_len) -> handle` | //! like Zig's `atlas_debug_canvas` does), and a `&[&dyn IntoValue]` argument
//! | `string` | `(ptr, len) -> handle` (UTF-8 -> JS string) | //! list instead of an anonymous tuple.
//! | `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)` |
#![allow(unsafe_code)] #![allow(unsafe_code)]
use std::fmt; use std::fmt;
/// Raw imports, module `"zig-js"` (`extern.zig`).
mod ffi { mod ffi {
#[link(wasm_import_module = "ghostty_js")] #[link(wasm_import_module = "zig-js")]
unsafe extern "C" { unsafe extern "C" {
pub fn global() -> u32; #[link_name = "valueGet"]
pub fn get(obj: u32, name: *const u8, len: usize) -> u32; pub fn value_get(out: *mut u64, id: u32, addr: *const u8, len: usize);
pub fn set(obj: u32, name: *const u8, len: usize, value: u32) -> i32; #[link_name = "valueSet"]
pub fn call(obj: u32, name: *const u8, len: usize, args: *const u32, argc: usize) -> u32; pub fn value_set(id: u32, addr: *const u8, len: usize, ref_ptr: *const u64);
pub fn new(ctor: u32, args: *const u32, argc: usize) -> u32; #[link_name = "valueObjectCreate"]
pub fn string(ptr: *const u8, len: usize) -> u32; pub fn value_object_create(out: *mut u64);
pub fn number(v: f64) -> u32; #[link_name = "valueStringCreate"]
pub fn boolean(v: i32) -> u32; pub fn value_string_create(out: *mut u64, addr: *const u8, len: usize);
pub fn to_f64(h: u32) -> f64; #[link_name = "valueStringLen"]
pub fn string_len(h: u32) -> usize; pub fn value_string_len(id: u32) -> u32;
pub fn string_copy(h: u32, ptr: *mut u8, cap: usize) -> usize; #[link_name = "valueStringCopy"]
pub fn is_nullish(h: u32) -> i32; pub fn value_string_copy(id: u32, addr: *mut u8, max: usize);
pub fn bytes(ptr: *const u8, len: usize) -> u32; #[link_name = "valueDeinit"]
pub fn copy_bytes(h: u32, dst: *mut u8, len: usize) -> i32; pub fn value_deinit(id: u32);
pub fn release(h: 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error { pub enum Error {
/// The JS operation threw (or the handle was invalid). /// The wrong type for the receiver or the result type can't be assigned
Js, /// from the JS type when using the higher-level APIs like [`Object`].
/// The value is `null`/`undefined` where an object was required. InvalidType,
Nullish,
/// A returned string was not valid UTF-8.
Utf8,
} }
impl fmt::Display for Error { impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { f.write_str("InvalidType")
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"),
}
} }
} }
@@ -79,229 +79,586 @@ impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
/// A reference to a JS value. Released on drop (zig-js `Object.deinit`). /// Possible types of values (`js.Type`).
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Object(u32); 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_<anything other than all zeroes>`. 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<TypeId> {
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)] #[derive(Debug, Clone, Copy)]
pub enum Arg<'a> { pub struct JsString<'a>(&'a [u8]);
Object(&'a Object),
Str(&'a str),
Num(f64),
Bool(bool),
}
impl<'a> From<&'a Object> for Arg<'a> { impl<'a> JsString<'a> {
fn from(v: &'a Object) -> Self { /// `js.String.init`.
Arg::Object(v) pub fn init(x: &'a (impl AsRef<[u8]> + ?Sized)) -> Self {
JsString(x.as_ref())
} }
} }
impl<'a> From<&'a str> for Arg<'a> { /// Shortcut for [`JsString::init`] since this is a common operation
fn from(v: &'a str) -> Self { /// (`js.string`).
Arg::Str(v) 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<Value> {
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<Value> {
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::<u64>(),
args.len(),
);
}
Ok(Value(result))
}
/// Call "new" on this value like a constructor.
pub fn new_instance(self, args: &[Value]) -> Result<Value> {
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::<u64>(),
args.len(),
);
}
Ok(Value(result))
}
/// Returns the bool value if this is a boolean.
pub fn as_bool(self) -> Result<bool> {
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<f64> {
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<Vec<u8>> {
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<f64> for Arg<'_> { /// Anything `Value.init` accepts. [`IntoValue::owned`] says whether the
fn from(v: f64) -> Self { /// resulting value is created for the call (and so must be released after it,
Arg::Num(v) /// 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<f32> for Arg<'_> { macro_rules! into_value_number {
fn from(v: f32) -> Self { ($($t:ty),*) => {$(
Arg::Num(f64::from(v)) impl IntoValue for $t {
fn to_value(&self) -> Value {
#[allow(clippy::cast_lossless, clippy::unnecessary_cast)]
Value::number(*self as f64)
}
}
)*};
}
// 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<T: IntoValue + ?Sized> IntoValue for &T {
fn to_value(&self) -> Value {
(**self).to_value()
}
fn owned(&self) -> bool {
(**self).owned()
} }
} }
impl From<u32> for Arg<'_> { /// Result types for [`Object::get`]/[`Object::call`] (zig `Get(T)`).
fn from(v: u32) -> Self { pub trait Get: Sized {
Arg::Num(f64::from(v)) /// 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<Self>;
/// What a null/undefined value converts to (`InvalidType` unless the
/// type is optional or void).
fn from_nullish() -> Result<Self> {
Err(Error::InvalidType)
} }
} }
impl From<bool> for Arg<'_> { impl Get for () {
fn from(v: bool) -> Self { fn from_value(_: Value) -> Result<Self> {
Arg::Bool(v) Err(Error::InvalidType)
}
fn from_nullish() -> Result<Self> {
Ok(())
} }
} }
impl Get for Value {
const RETAINS: bool = true;
fn check(h: u32) -> Result<Object> { fn from_value(v: Value) -> Result<Self> {
if h == 0 {
Err(Error::Js)
} else {
Ok(Object(h))
}
}
/// Converts arguments to handles. Temporaries are released by [`Args::drop`].
struct Args {
handles: Vec<u32>,
owned: Vec<u32>,
}
impl Args {
fn new(args: &[Arg<'_>]) -> Result<Args> {
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);
}
if !matches!(a, Arg::Object(_)) {
out.owned.push(h);
}
out.handles.push(h);
}
Ok(out)
}
}
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) };
}
}
}
/// `js.global`: the `globalThis` object.
pub fn global() -> Result<Object> {
// SAFETY: plain call into the host.
check(unsafe { ffi::global() })
}
impl Object {
/// Creates a JS string value.
pub fn string(s: &str) -> Result<Object> {
// 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<Object> {
// 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<Object> {
// 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<Object> {
let v = self.get(name)?;
if v.is_nullish() {
return Err(Error::Nullish);
}
Ok(v) Ok(v)
} }
}
impl Get for Object {
const RETAINS: bool = true;
/// Gets a numeric property. fn from_value(v: Value) -> Result<Self> {
pub fn get_f64(&self, name: &str) -> Result<f64> { match v.type_of() {
Ok(self.get(name)?.as_f64()) Type::Object | Type::Function => Ok(Object { value: v }),
_ => Err(Error::InvalidType),
}
}
}
impl Get for bool {
fn from_value(v: Value) -> Result<Self> {
v.as_bool()
}
}
impl Get for String {
/// zig `js.String` => `[]u8` (here lossily decoded UTF-8).
fn from_value(v: Value) -> Result<Self> {
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<Self> {
// 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<T: Get> Get for Option<T> {
const RETAINS: bool = T::RETAINS;
fn from_value(v: Value) -> Result<Self> {
T::from_value(v).map(Some)
} }
/// Gets a numeric property as `f32` (zig `get(f32, ...)`). fn from_nullish() -> Result<Self> {
pub fn get_f32(&self, name: &str) -> Result<f32> { Ok(None)
Ok(self.get_f64(name)? as f32)
} }
}
/// Gets a string property. /// Zig `Object.convertValue`.
pub fn get_string(&self, name: &str) -> Result<String> { fn convert_value<T: Get>(v: Value) -> Result<T> {
self.get(name)?.to_string_value() 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. /// Object is a JS object (`js.Object`). This is a higher-level abstraction
pub fn set(&self, name: &str, value: Arg<'_>) -> Result<()> { /// over [`Value`] and is the recommended interface. It releases the JS value
let args = Args::new(&[value])?; /// on drop (Zig: `deinit()`).
// SAFETY: pointer/length of a live &str; handle is valid. #[derive(Debug, PartialEq, Eq)]
let ok = unsafe { ffi::set(self.0, name.as_ptr(), name.len(), args.handles[0]) }; pub struct Object {
if ok == 1 { Ok(()) } else { Err(Error::Js) } 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). /// The runtime value is the "this" value for the ZigJS class in JS. This is
pub fn call(&self, name: &str, args: &[Arg<'_>]) -> Result<Object> { /// useful to directly accessing the `WebAssembly.Memory` property to
let args = Args::new(args)?; /// implement things like JS-to-WASM memcpy (`js.runtime`).
// SAFETY: name and args pointers are live for the call. pub fn runtime() -> Object {
check(unsafe { Object {
ffi::call( value: Value::RUNTIME,
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<Object> {
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<String> {
// 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) }
} }
} }
impl Drop for Object { impl Drop for Object {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: we own this handle. self.value.deinit();
unsafe { ffi::release(self.0) }; }
}
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<T: Get>(&self, n: &str) -> Result<T> {
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<T: Get>(&self, n: &str, args: &[&dyn IntoValue]) -> Result<T> {
// 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<Object> {
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<Value>,
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();
}
}
} }
} }
-106
View File
@@ -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 };
+21
View File
@@ -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.
+21
View File
@@ -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`.
@@ -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"
}
}
@@ -0,0 +1 @@
export * from './zigjs';
+286
View File
@@ -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<any> = [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<number> = [];
/**
* 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;
};
};
@@ -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;
}
@@ -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"]
}
+63 -34
View File
@@ -1,7 +1,7 @@
//! `font/Atlas.zig` `Wasm`: the wasm-compatible atlas API. //! `font/Atlas.zig` `Wasm`: the wasm-compatible atlas API.
use ghostty_font::atlas::{Atlas, Format, Region}; 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}; use crate::{ensure_init, host_bytes};
@@ -120,40 +120,42 @@ pub extern "C" fn atlas_size(atlas: *const Atlas) -> u32 {
unsafe { &*atlas }.size unsafe { &*atlas }.size
} }
/// This creates a Canvas element identified by the handle returned that the /// This creates a Canvas element identified by the id returned that the
/// caller can draw into the DOM to visualize the atlas. The returned handle /// caller can draw into the DOM to visualize the atlas. The returned ID must
/// must be released from the JS runtime (`ghostty_js` release / zigjs /// be freed from the JS runtime by calling `zigjs.deleteValue`. Returns 0 on
/// `deleteValue`). Returns 0 on error. /// error (that is also never a valid id: 0 is the predefined `NaN`).
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub extern "C" fn atlas_debug_canvas(atlas: *const Atlas) -> u32 { pub extern "C" fn atlas_debug_canvas(atlas: *const Atlas) -> u32 {
// SAFETY: live pointer from `atlas_new`. // SAFETY: live pointer from `atlas_new`.
let atlas = unsafe { &*atlas }; let atlas = unsafe { &*atlas };
match debug_canvas(atlas) { match debug_canvas(atlas) {
Ok(c) => c.into_raw(), Ok(id) => id,
Err(err) => { Err(err) => {
log::warn!("error dumping atlas canvas err={err}"); log::warn!("error dumping atlas canvas err={err:?}");
0 0
} }
} }
} }
fn debug_canvas(atlas: &Atlas) -> Result<Object, js::Error> { fn debug_canvas(atlas: &Atlas) -> Result<u32, DebugCanvasError> {
// Create our canvas // Create our canvas
let doc = js::global()?.get_object("document")?; let doc = js::global().get::<Object>("document")?;
let canvas = doc.call("createElement", &[Arg::Str("canvas")])?; let canvas = doc.call::<Object>("createElement", &[&js::string("canvas")])?;
// Setup our canvas size // Setup our canvas size
{ {
canvas.set("width", Arg::from(atlas.size))?; canvas.set("width", atlas.size)?;
canvas.set("height", Arg::from(atlas.size))?; canvas.set("height", atlas.size)?;
let style = canvas.get_object("style")?;
let px = format!("{}px", atlas.size); let width_str = format!("{}px", atlas.size);
style.set("width", Arg::Str(&px))?; let style = canvas.get::<Object>("style")?;
style.set("height", Arg::Str(&px))?; style.set("width", js::string(&width_str))?;
style.set("height", js::string(&width_str))?;
} }
// This will return the same context on subsequent calls. // This will return the same context on subsequent calls so it is
let ctx = canvas.call("getContext", &[Arg::Str("2d")])?; // important to reset it.
let ctx = canvas.call::<Object>("getContext", &[&js::string("2d")])?;
// We need to draw pixels so this is format dependent. // We need to draw pixels so this is format dependent.
let buf: Vec<u8> = match atlas.format { let buf: Vec<u8> = match atlas.format {
@@ -173,24 +175,51 @@ fn debug_canvas(atlas: &Atlas) -> Result<Object, js::Error> {
} }
buf buf
} }
Format::Bgr => { Format::Bgr => return Err(DebugCanvasError::UnsupportedAtlasFormat),
log::warn!("error dumping atlas canvas err=UnsupportedAtlasFormat");
return Err(js::Error::Js);
}
}; };
// Create an ImageData from our buffer and then write it to the canvas // Create an ImageData from our buffer and then write it to the canvas
let arr = Object::clamped_array(&buf)?; let image_data = {
let image_data_ctor = js::global()?.get_object("ImageData")?; // Get our runtime memory
let image_data = image_data_ctor.new_instance(&[ let mem = js::runtime().get::<Object>("memory")?;
Arg::Object(&arr), let mem_buf = mem.get::<Object>("buffer")?;
Arg::from(atlas.size),
Arg::from(atlas.size),
])?;
ctx.call(
"putImageData",
&[Arg::Object(&image_data), Arg::Num(0.0), Arg::Num(0.0)],
)?;
Ok(canvas) // Create an array that points to our buffer
let arr = {
let uint8_clamped_array = js::global().get::<Object>("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::<Object>("slice", &[])?
} else {
arr
}
};
// Create the image data from our array
let image_data = js::global().get::<Object>("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<js::Error> for DebugCanvasError {
fn from(e: js::Error) -> Self {
DebugCanvasError::Js(e)
}
} }
+4 -1
View File
@@ -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) { match Face::init_named(raw, DesiredSize::new(f32::from(pts)), p) {
Ok(face) => Box::into_raw(Box::new(face)), 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()
}
} }
} }
+1 -1
View File
@@ -11,7 +11,7 @@
//! - module `config`, `config/Wasm.zig` (`config_*`) //! - module `config`, `config/Wasm.zig` (`config_*`)
//! - module `app`, `App.zig` `Wasm` (commented out upstream, nothing exported) //! - 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 //! `ghostty_os::wasm::js`) and `env.log`. Strings are passed as
//! pointer+length pairs into wasm memory allocated by the host with //! pointer+length pairs into wasm memory allocated by the host with
//! [`wasm_alloc`] / released with [`wasm_free`]. //! [`wasm_alloc`] / released with [`wasm_free`].
+20 -2
View File
@@ -1,14 +1,32 @@
//! Builds the wasm module and runs `tests/node_test.js` against it with //! 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. //! node or the `wasm32-unknown-unknown` target is not available.
use std::{path::PathBuf, process::Command}; use std::{path::PathBuf, process::Command};
#[test] #[test]
fn wasm_module_runs_under_node() { 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::<u32>()
.ok()
})
else {
eprintln!("skipping: node not found"); eprintln!("skipping: node not found");
return; return;
};
if major < 24 {
eprintln!("skipping: node {major} cannot load the zig-js TypeScript host");
return;
} }
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+48 -13
View File
@@ -4,7 +4,8 @@
const fs = require('fs'); const fs = require('fs');
const assert = require('assert'); const assert = require('assert');
const path = require('path'); 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 // 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. // (emoji are 14px wide and "colored"). measureText records the font in use.
@@ -19,8 +20,12 @@ class FakeCanvas {
font: '', font: '',
textBaseline: 'alphabetic', textBaseline: 'alphabetic',
fillStyle: '', fillStyle: '',
clearRect: (...a) => self.calls.push(['clearRect', ...a]), clearRect: (...a) => {
fillRect: (...a) => self.calls.push(['fillRect', ...a]), self.calls.push(['clearRect', ...a]);
},
fillRect: (...a) => {
self.calls.push(['fillRect', ...a]);
},
fillText: (t, x, y) => { fillText: (t, x, y) => {
self.lastText = { t, x, y }; self.lastText = { t, x, y };
self.calls.push(['fillText', t, x, y]); self.calls.push(['fillText', t, x, y]);
@@ -51,7 +56,9 @@ class FakeCanvas {
} }
return { data }; return { data };
}, },
putImageData: (...a) => self.calls.push(['putImageData', ...a]), putImageData: (...a) => {
self.calls.push(['putImageData', ...a]);
},
}; };
} }
getContext(kind) { getContext(kind) {
@@ -67,8 +74,8 @@ class FakeImageData {
} }
} }
const canvases = []; const canvases = [];
const fakeGlobal = { // zig-js reads `globalThis` directly (predefined ref 5).
document: { globalThis.document = {
createElement: (tag) => { createElement: (tag) => {
assert.strictEqual(tag, 'canvas'); assert.strictEqual(tag, 'canvas');
const c = new FakeCanvas(); const c = new FakeCanvas();
@@ -76,16 +83,43 @@ const fakeGlobal = {
return c; return c;
}, },
getElementById: () => null, getElementById: () => null,
},
ImageData: FakeImageData,
}; };
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 () => { (async () => {
const logs = []; 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 bytes = fs.readFileSync(process.argv[2]);
const { instance } = await WebAssembly.instantiate(bytes, host.imports); ({ instance } = await WebAssembly.instantiate(bytes, imports));
host.setInstance(instance); 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; const x = instance.exports;
x.ghostty_wasm_init(); x.ghostty_wasm_init();
@@ -117,7 +151,7 @@ const fakeGlobal = {
// ---- face ---- // ---- face ----
const f = host.writeString('monospace'); const f = host.writeString('monospace');
const face = x.face_new(f.ptr, f.len, 12, 0); 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); const m = x.wasm_alloc(12);
x.face_metrics(face, m); x.face_metrics(face, m);
const metrics = new Uint32Array(instance.exports.memory.buffer, m, 3).slice(); const metrics = new Uint32Array(instance.exports.memory.buffer, m, 3).slice();
@@ -184,7 +218,7 @@ const fakeGlobal = {
// ---- atlas debug canvas ---- // ---- atlas debug canvas ----
const h = x.atlas_debug_canvas(atlas); const h = x.atlas_debug_canvas(atlas);
assert.ok(h > 0); assert.ok(h > 0);
const dc = host.value(h); const dc = zigjs.deleteValue(h);
assert.strictEqual(dc.width, 64); assert.strictEqual(dc.width, 64);
assert.ok(dc.calls.some((c) => c[0] === 'putImageData')); assert.ok(dc.calls.some((c) => c[0] === 'putImageData'));
@@ -193,6 +227,7 @@ const fakeGlobal = {
x.face_free(face); x.face_free(face);
x.face_free(eface); x.face_free(eface);
x.atlas_free(atlas); x.atlas_free(atlas);
assert.strictEqual(liveValues(zigjs), 0, 'leaked zig-js values');
f.free(); f.free();
ef.free(); ef.free();
st.free(); st.free();
+41 -8
View File
@@ -10,7 +10,7 @@ unchanged.
| `main_wasm.zig` | `crates/ghostty-wasm` (new, `cdylib`; empty on native) | | `main_wasm.zig` | `crates/ghostty-wasm` (new, `cdylib`; empty on native) |
| `config/Wasm.zig` | `ghostty-wasm/src/config.rs` | | `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` | | `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/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/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/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. 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. - 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) ## JS protocol (zig-js, W1b)
`zig-js` (mitchellh/zig-js) is **not vendored** in the Ghostty tree, so its The wasm module now speaks **exactly zig-js's ABI** (mitchellh/zig-js @
import names could not be mirrored. `js.rs` documents a small handle-based 04db83c): import module `"zig-js"` with `valueGet/valueSet/valueObjectCreate/
ABI in import module `ghostty_js` (`global/get/set/call/new/string/number/ valueStringCreate/valueStringLen/valueStringCopy/valueDeinit/valueNew/
boolean/to_f64/string_len/string_copy/is_nullish/bytes/copy_bytes/release`) funcApply`, 64-bit NaN-boxed refs (f64 for numbers; NaN head `0x7FF8_0000`,
plus `env.log(ptr,len)` (same as Zig's log). The Rust-side API mirrors bits 0-31 id, bits 32-34 type id; predefined ids nan/null/true/false/
`js.Object`. `ghostty-wasm/js/ghostty_host.js` is a reference host. 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 Allocation helpers `wasm_alloc/wasm_free` (host-owned buffers) replace the
stale `toHostOwned` (does not exist in current `os/wasm.zig`). 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 ## Known gaps
- `ghostty-os/tests/common/flatpak_mock.rs` (another agent's, untracked) fails - `ghostty-os/tests/common/flatpak_mock.rs` (another agent's, untracked) fails
`clippy --all-targets -D warnings` (needless_pass_by_value); unrelated. `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).