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:
1 parent
cbc023a633
commit
516c780f84
16 files changed
+1532
-479
No files matched your search
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
@@ -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';
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user