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 28f55ff2dd
commit a07b224869
16 files changed
+1532 -479

No files matched your search

+63 -66
View File
@@ -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<TextMetrics, Error> {
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::<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")?,
})
}
@@ -282,8 +284,8 @@ mod face_js {
presentation: Presentation,
) -> Result<Face, Error> {
// 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::<Object>("document")?;
let canvas = doc.call::<Object>("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::<Object>("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::<Object>("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<Object, Error> {
// 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::<Object>("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::<f64>("width")?;
let height = self.canvas.get::<f64>("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::<String>("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::<Object>("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::<Object>("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<u8> 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::<Object>("getImageData", &[&0.0_f64, &0.0_f64, &width, &height])?;
let src_array = data.get::<Object>("data")?;
// Allocate our local memory to copy the data to.
let len = src_array.get::<u32>("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::<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 {
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::<Object>("document")?;
let elem = doc
.call::<Option<Object>>("getElementById", &[&js::string("face-canvas")])?
.ok_or(Error::CanvasContainerNotFound)?;
elem.call::<()>("append", &[&self.canvas])?;
Ok(())
}
}
+599 -242
View File
@@ -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::<Object>("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<T> = std::result::Result<T, Error>;
/// 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_<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)]
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<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<'_> {
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<f32> for Arg<'_> {
fn from(v: f32) -> Self {
Arg::Num(f64::from(v))
}
}
impl From<u32> for Arg<'_> {
fn from(v: u32) -> Self {
Arg::Num(f64::from(v))
}
}
impl From<bool> for Arg<'_> {
fn from(v: bool) -> Self {
Arg::Bool(v)
}
}
fn check(h: u32) -> Result<Object> {
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);
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<T: IntoValue + ?Sized> 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<Self>;
/// What a null/undefined value converts to (`InvalidType` unless the
/// type is optional or void).
fn from_nullish() -> Result<Self> {
Err(Error::InvalidType)
}
}
/// `js.global`: the `globalThis` object.
pub fn global() -> Result<Object> {
// SAFETY: plain call into the host.
check(unsafe { ffi::global() })
impl Get for () {
fn from_value(_: Value) -> Result<Self> {
Err(Error::InvalidType)
}
fn from_nullish() -> Result<Self> {
Ok(())
}
}
impl Get for Value {
const RETAINS: bool = true;
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);
}
fn from_value(v: Value) -> Result<Self> {
Ok(v)
}
}
impl Get for Object {
const RETAINS: bool = true;
/// Gets a numeric property.
pub fn get_f64(&self, name: &str) -> Result<f64> {
Ok(self.get(name)?.as_f64())
fn from_value(v: Value) -> Result<Self> {
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<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, ...)`).
pub fn get_f32(&self, name: &str) -> Result<f32> {
Ok(self.get_f64(name)? as f32)
fn from_nullish() -> Result<Self> {
Ok(None)
}
}
/// Gets a string property.
pub fn get_string(&self, name: &str) -> Result<String> {
self.get(name)?.to_string_value()
/// Zig `Object.convertValue`.
fn convert_value<T: Get>(v: Value) -> Result<T> {
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<Object> {
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<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) }
/// 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<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.
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<Object, js::Error> {
fn debug_canvas(atlas: &Atlas) -> Result<u32, DebugCanvasError> {
// Create our canvas
let doc = js::global()?.get_object("document")?;
let canvas = doc.call("createElement", &[Arg::Str("canvas")])?;
let doc = js::global().get::<Object>("document")?;
let canvas = doc.call::<Object>("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::<Object>("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::<Object>("getContext", &[&js::string("2d")])?;
// We need to draw pixels so this is format dependent.
let buf: Vec<u8> = match atlas.format {
@@ -173,24 +175,51 @@ fn debug_canvas(atlas: &Atlas) -> Result<Object, js::Error> {
}
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::<Object>("memory")?;
let mem_buf = mem.get::<Object>("buffer")?;
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) {
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 `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`].
+20 -2
View File
@@ -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::<u32>()
.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"));
+54 -19
View File
@@ -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();
+41 -8
View File
@@ -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::<T>`, `set`, `call::<T>`,
`new_instance`), `global()`, `runtime()`, `string()`, `Undefined`. `Object` has
`Drop` (= `deinit`); `Object::into_id` hands the ref id to the host (as
`atlas_debug_canvas` does). Result conversion mirrors `Object.convertValue`
(`Option<T>` for null/undefined, `()` = void, `Object/Value` retain,
others release). Deviation: a raw `Value` call argument is caller-owned.
The vendored host is zig-js's own `js/` (`crates/ghostty-wasm/js/zig-js/`,
unmodified TypeScript + MIT LICENSE; the lockfile, jest config are omitted).
The earlier invented `ghostty_js` ABI and `ghostty_host.js` are gone. `env.log`
(Zig's `os/wasm/log.zig`) is still a separate import.
`web_canvas` face and `atlas_debug_canvas` now call JS like the Zig code:
`document.createElement("canvas")`, `getContext`, `measureText`, `set("font")`,
`getImageData`, then `Uint8Array(runtime.memory.buffer, ptr).set(imageData.data)`
to copy pixels (no host-specific `copy_bytes`/`bytes`), and
`Uint8ClampedArray(memory.buffer, ptr, len)` (+ `.slice()` under
`wasm-shared`) + `ImageData` for the atlas. `face_debug_canvas` returns
`CanvasContainerNotFound` like Zig. `atlas_debug_canvas` returns the zig-js
ref id; free with `zigjs.deleteValue(id)`.
Remaining small deviation: `text_metrics` reads all five TextMetrics fields
instead of only the ones needed.
Host usage: `const zigjs = new ZigJS(); instantiate(bytes, {...zigjs.
importObject(), env: {log}}); zigjs.memory = instance.exports.memory;`
`globalThis` must provide `document`/`ImageData` (the host binds `globalThis`).
Allocation helpers `wasm_alloc/wasm_free` (host-owned buffers) replace the
stale `toHostOwned` (does not exist in current `os/wasm.zig`).
@@ -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).