feat(wasm): use zig-js's exact JS interop ABI

Ghostty's wasm code talks to JavaScript through the zig-js library
(mitchellh/zig-js, pinned in build.zig.zon). The first port invented its
own import ABI because the dependency wasn't available; now:
- ghostty-os/src/wasm/js.rs is a Rust port of zig-js's Zig side (Ref,
  Value, Object get/set/call/new, global, runtime, string) using its
  "zig-js" import module, function names and NaN-boxed value refs.
- web_canvas face and the atlas debug canvas make the same JS calls as
  Ghostty's Zig code.
- zig-js's unmodified JS host (+ LICENSE) is vendored under
  crates/ghostty-wasm/js/zig-js and drives the node test.

Test Plan:
- cargo test -p ghostty-wasm (node >= 24; asserts zig-js imports and no
  leaked host values)
This commit is contained in:
ddidderr committed 2026-10-12 05:26:53 +02:00
1 parent cbc023a633
commit 516c780f84
16 files changed
+1532 -479

No files matched your search

-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();