libghostty: simplify Wasm allocation API

Replace a bunch of type-specific Wasm allocation functions with a generic
byte allocator and reusable opaque out-parameters for pointers. This
makes it a lot more ergonomic (relatively) to use the Wasm interface
and removes a dozen or so exports.

This also updates the `ghostty_type_json` `abi` field with a maximum
alignment value that host sides can use to keep every allocation aligned
properly, easily, without hardcoding numbers.

This adds a test to verify this all works as intended and runs in CI.
This commit is contained in:
Mitchell Hashimoto
2026-08-16 12:37:16 -07:00
parent 0ba6250388
commit a8e9b413f1
17 changed files with 481 additions and 243 deletions

View File

@@ -163,6 +163,7 @@
let wasmMemory = null;
let encoderPtr = null;
let lastKeyEvent = null;
let typeLayout = null;
async function loadWasm() {
try {
@@ -184,6 +185,12 @@
wasmInstance = wasmModule.instance;
wasmMemory = wasmInstance.exports.memory;
const jsonPtr = wasmInstance.exports.ghostty_type_json();
const jsonStr = new TextDecoder().decode(
new Uint8Array(wasmMemory.buffer, jsonPtr, wasmMemory.buffer.byteLength - jsonPtr)
).split('\0')[0];
typeLayout = JSON.parse(jsonStr);
return true;
} catch (e) {
@@ -199,6 +206,15 @@
return wasmMemory.buffer;
}
function readUsize(ptr) {
const view = new DataView(getBuffer());
switch (typeLayout.abi.usize_size) {
case 4: return view.getUint32(ptr, true);
case 8: return Number(view.getBigUint64(ptr, true));
default: throw new Error('unsupported size_t width');
}
}
function formatHex(bytes) {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
@@ -415,16 +431,28 @@
function encodeKeyEvent(event) {
if (!encoderPtr) return null;
const usizeSize = typeLayout.abi.usize_size;
let eventPtrPtr = 0;
let eventPtr = 0;
let utf8Ptr = 0;
let utf8Length = 0;
let requiredPtr = 0;
let required = 0;
let bufPtr = 0;
let writtenPtr = 0;
try {
// Create key event
const eventPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
eventPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
const result = wasmInstance.exports.ghostty_key_event_new(0, eventPtrPtr);
if (result !== 0) {
throw new Error(`ghostty_key_event_new failed with result ${result}`);
}
const eventPtr = new DataView(getBuffer()).getUint32(eventPtrPtr, true);
eventPtr = wasmInstance.exports.ghostty_wasm_take_opaque(eventPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(eventPtrPtr);
eventPtrPtr = 0;
// Get action from radio buttons
const actionRadio = document.querySelector('input[name="action"]:checked');
@@ -458,9 +486,10 @@
// Set UTF-8 text from the key event (the actual character produced)
if (event.key.length === 1) {
const utf8Bytes = new TextEncoder().encode(event.key);
const utf8Ptr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(utf8Bytes.length);
utf8Length = utf8Bytes.length;
utf8Ptr = wasmInstance.exports.ghostty_wasm_alloc(utf8Length);
new Uint8Array(getBuffer()).set(utf8Bytes, utf8Ptr);
wasmInstance.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Bytes.length);
wasmInstance.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Length);
}
// Set unshifted codepoint
@@ -470,15 +499,15 @@
}
// Encode the key event
const requiredPtr = wasmInstance.exports.ghostty_wasm_alloc_usize();
requiredPtr = wasmInstance.exports.ghostty_wasm_alloc(usizeSize);
wasmInstance.exports.ghostty_key_encoder_encode(
encoderPtr, eventPtr, 0, 0, requiredPtr
);
const required = new DataView(getBuffer()).getUint32(requiredPtr, true);
required = readUsize(requiredPtr);
const bufPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(required);
const writtenPtr = wasmInstance.exports.ghostty_wasm_alloc_usize();
bufPtr = wasmInstance.exports.ghostty_wasm_alloc(required);
writtenPtr = wasmInstance.exports.ghostty_wasm_alloc(usizeSize);
const encodeResult = wasmInstance.exports.ghostty_key_encoder_encode(
encoderPtr, eventPtr, bufPtr, required, writtenPtr
);
@@ -487,7 +516,7 @@
return null; // No encoding for this key
}
const written = new DataView(getBuffer()).getUint32(writtenPtr, true);
const written = readUsize(writtenPtr);
const encoded = new Uint8Array(getBuffer()).slice(bufPtr, bufPtr + written);
return {
@@ -498,6 +527,13 @@
} catch (e) {
console.error('Encoding error:', e);
return null;
} finally {
wasmInstance.exports.ghostty_wasm_free(writtenPtr, usizeSize);
wasmInstance.exports.ghostty_wasm_free(bufPtr, required);
wasmInstance.exports.ghostty_wasm_free(requiredPtr, usizeSize);
wasmInstance.exports.ghostty_wasm_free(utf8Ptr, utf8Length);
wasmInstance.exports.ghostty_key_event_free(eventPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(eventPtrPtr);
}
}
@@ -555,13 +591,14 @@
if (!encoderPtr) return;
const flags = getKittyFlags();
const flagsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const flagsPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
new DataView(getBuffer()).setUint8(flagsPtr, flags);
wasmInstance.exports.ghostty_key_encoder_setopt(
encoderPtr,
5, // GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS
flagsPtr
);
wasmInstance.exports.ghostty_wasm_free(flagsPtr, 1);
// Re-encode last key with new flags
reencodeLastKey();
@@ -649,7 +686,8 @@
throw new Error(`ghostty_key_encoder_new failed with result ${result}`);
}
encoderPtr = new DataView(getBuffer()).getUint32(encoderPtrPtr, true);
encoderPtr = wasmInstance.exports.ghostty_wasm_take_opaque(encoderPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(encoderPtrPtr);
// Set kitty flags based on checkboxes
updateEncoderFlags();

View File

@@ -106,6 +106,7 @@
<script>
let wasmInstance = null;
let wasmMemory = null;
let typeLayout = null;
async function loadWasm() {
try {
@@ -124,6 +125,12 @@
wasmInstance = wasmModule.instance;
wasmMemory = wasmInstance.exports.memory;
const jsonPtr = wasmInstance.exports.ghostty_type_json();
const jsonStr = new TextDecoder().decode(
new Uint8Array(wasmMemory.buffer, jsonPtr, wasmMemory.buffer.byteLength - jsonPtr)
).split('\0')[0];
typeLayout = JSON.parse(jsonStr);
return true;
} catch (e) {
@@ -252,18 +259,22 @@
throw new Error(`ghostty_sgr_new failed with result ${result}`);
}
const parserPtr = new DataView(getBuffer()).getUint32(parserPtrPtr, true);
const parserPtr = wasmInstance.exports.ghostty_wasm_take_opaque(parserPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(parserPtrPtr);
// Allocate and set parameters
const paramsPtr = wasmInstance.exports.ghostty_wasm_alloc_u16_array(params.length);
const paramsByteLength = params.length * Uint16Array.BYTES_PER_ELEMENT;
const paramsPtr = wasmInstance.exports.ghostty_wasm_alloc(paramsByteLength);
const paramsView = new Uint16Array(getBuffer(), paramsPtr, params.length);
params.forEach((p, i) => paramsView[i] = p);
// Allocate and set separators (or use null if empty)
let sepsPtr = 0;
const sepsByteLength = separators.length > 0 ? params.length : 0;
if (separators.length > 0) {
sepsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(separators.length);
const sepsView = new Uint8Array(getBuffer(), sepsPtr, separators.length);
sepsPtr = wasmInstance.exports.ghostty_wasm_alloc(sepsByteLength);
const sepsView = new Uint8Array(getBuffer(), sepsPtr, sepsByteLength);
sepsView.fill(0);
separators.forEach((s, i) => sepsView[i] = s.charCodeAt(0));
}
@@ -289,7 +300,8 @@
output += 'm\n\n';
// Iterate through attributes
const attrPtr = wasmInstance.exports.ghostty_wasm_alloc_sgr_attribute();
const attrSize = typeLayout.types.GhosttySgrAttribute.size;
const attrPtr = wasmInstance.exports.ghostty_wasm_alloc(attrSize);
let count = 0;
while (wasmInstance.exports.ghostty_sgr_next(parserPtr, attrPtr)) {
@@ -313,9 +325,9 @@
case SGR_ATTR_TAGS.DIRECT_COLOR_FG: {
// Use ghostty_color_rgb_get to extract RGB components
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const rPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const gPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const bPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
@@ -325,17 +337,17 @@
output += `Foreground RGB = (${r}, ${g}, ${b})\n`;
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
wasmInstance.exports.ghostty_wasm_free(rPtr, 1);
wasmInstance.exports.ghostty_wasm_free(gPtr, 1);
wasmInstance.exports.ghostty_wasm_free(bPtr, 1);
break;
}
case SGR_ATTR_TAGS.DIRECT_COLOR_BG: {
// Use ghostty_color_rgb_get to extract RGB components
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const rPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const gPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const bPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
@@ -345,17 +357,17 @@
output += `Background RGB = (${r}, ${g}, ${b})\n`;
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
wasmInstance.exports.ghostty_wasm_free(rPtr, 1);
wasmInstance.exports.ghostty_wasm_free(gPtr, 1);
wasmInstance.exports.ghostty_wasm_free(bPtr, 1);
break;
}
case SGR_ATTR_TAGS.UNDERLINE_COLOR: {
// Use ghostty_color_rgb_get to extract RGB components
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
const rPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const gPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
const bPtr = wasmInstance.exports.ghostty_wasm_alloc(1);
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
@@ -365,9 +377,9 @@
output += `Underline color RGB = (${r}, ${g}, ${b})\n`;
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
wasmInstance.exports.ghostty_wasm_free(rPtr, 1);
wasmInstance.exports.ghostty_wasm_free(gPtr, 1);
wasmInstance.exports.ghostty_wasm_free(bPtr, 1);
break;
}
@@ -415,7 +427,9 @@
outputDiv.textContent = output;
// Cleanup
wasmInstance.exports.ghostty_wasm_free_sgr_attribute(attrPtr);
wasmInstance.exports.ghostty_wasm_free(attrPtr, attrSize);
wasmInstance.exports.ghostty_wasm_free(paramsPtr, paramsByteLength);
wasmInstance.exports.ghostty_wasm_free(sepsPtr, sepsByteLength);
wasmInstance.exports.ghostty_sgr_free(parserPtr);
} catch (e) {

View File

@@ -183,6 +183,15 @@
return wasmMemory.buffer;
}
function readUsize(ptr) {
const view = new DataView(getBuffer());
switch (typeLayout.abi.usize_size) {
case 4: return view.getUint32(ptr, true);
case 8: return Number(view.getBigUint64(ptr, true));
default: throw new Error('unsupported size_t width');
}
}
// Parse escape sequences in the input string (e.g. \x1b, \r, \n)
function parseEscapes(str) {
return str
@@ -204,6 +213,7 @@
const cols = parseInt(document.getElementById('cols').value, 10);
const rows = parseInt(document.getElementById('rows').value, 10);
const vtText = parseEscapes(document.getElementById('vtInput').value);
const usizeSize = typeLayout.abi.usize_size;
// Allocate pointer to receive the terminal handle
const termPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
@@ -219,19 +229,19 @@
throw new Error(`ghostty_terminal_new failed with result ${newResult}`);
}
const termPtr = new DataView(getBuffer()).getUint32(termPtrPtr, true);
const termPtr = wasmInstance.exports.ghostty_wasm_take_opaque(termPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(termPtrPtr);
// Write VT data to the terminal
const vtBytes = new TextEncoder().encode(vtText);
const dataPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(vtBytes.length);
const dataPtr = wasmInstance.exports.ghostty_wasm_alloc(vtBytes.length);
new Uint8Array(getBuffer()).set(vtBytes, dataPtr);
wasmInstance.exports.ghostty_terminal_vt_write(termPtr, dataPtr, vtBytes.length);
wasmInstance.exports.ghostty_wasm_free_u8_array(dataPtr, vtBytes.length);
wasmInstance.exports.ghostty_wasm_free(dataPtr, vtBytes.length);
// Create a plain-text formatter
const FMT_OPTS_SIZE = typeLayout.types.GhosttyFormatterTerminalOptions.size;
const fmtOptsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(FMT_OPTS_SIZE);
const fmtOptsPtr = wasmInstance.exports.ghostty_wasm_alloc(FMT_OPTS_SIZE);
new Uint8Array(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE).fill(0);
const fmtOptsView = new DataView(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE);
setField(fmtOptsView, 'GhosttyFormatterTerminalOptions', 'size', FMT_OPTS_SIZE);
@@ -254,19 +264,19 @@
const fmtResult = wasmInstance.exports.ghostty_formatter_terminal_new(
0, fmtPtrPtr, termPtr, fmtOptsPtr
);
wasmInstance.exports.ghostty_wasm_free_u8_array(fmtOptsPtr, FMT_OPTS_SIZE);
wasmInstance.exports.ghostty_wasm_free(fmtOptsPtr, FMT_OPTS_SIZE);
if (fmtResult !== GHOSTTY_SUCCESS) {
wasmInstance.exports.ghostty_terminal_free(termPtr);
throw new Error(`ghostty_formatter_terminal_new failed with result ${fmtResult}`);
}
const fmtPtr = new DataView(getBuffer()).getUint32(fmtPtrPtr, true);
const fmtPtr = wasmInstance.exports.ghostty_wasm_take_opaque(fmtPtrPtr);
wasmInstance.exports.ghostty_wasm_free_opaque(fmtPtrPtr);
// Format with alloc
const outPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
const outLenPtr = wasmInstance.exports.ghostty_wasm_alloc_usize();
const outLenPtr = wasmInstance.exports.ghostty_wasm_alloc(usizeSize);
const formatResult = wasmInstance.exports.ghostty_formatter_format_alloc(
fmtPtr, 0, outPtrPtr, outLenPtr
);
@@ -277,8 +287,8 @@
throw new Error(`ghostty_formatter_format_alloc failed with result ${formatResult}`);
}
const outPtr = new DataView(getBuffer()).getUint32(outPtrPtr, true);
const outLen = new DataView(getBuffer()).getUint32(outLenPtr, true);
const outPtr = wasmInstance.exports.ghostty_wasm_take_opaque(outPtrPtr);
const outLen = readUsize(outLenPtr);
const outBytes = new Uint8Array(getBuffer(), outPtr, outLen);
const outText = new TextDecoder().decode(outBytes);
@@ -294,7 +304,7 @@
// Clean up
wasmInstance.exports.ghostty_free(0, outPtr, outLen);
wasmInstance.exports.ghostty_wasm_free_opaque(outPtrPtr);
wasmInstance.exports.ghostty_wasm_free_usize(outLenPtr);
wasmInstance.exports.ghostty_wasm_free(outLenPtr, usizeSize);
wasmInstance.exports.ghostty_formatter_free(fmtPtr);
wasmInstance.exports.ghostty_terminal_free(termPtr);