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

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

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

No files matched your search

+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"]
}