1// Loader and low-level memory helpers for the vendored libghostty-vt wasm module.
2// One GhosttyModule can host many terminals; all pointers are offsets into the
3// module's single linear memory. Never cache a DataView/TypedArray across an
4// exported call — memory.grow() detaches the buffer.
5
6export interface TypeField {
7 offset: number;
8 size: number;
9 type: string;
10}
11
12export interface TypeLayout {
13 [struct: string]: { size: number; align: number; fields: Record<string, TypeField> };
14}
15
16type Fn = (...args: Array<number | bigint>) => number;
17
18export interface WasmTrampoline {
19 readonly index: number;
20 free(): void;
21}
22
23export interface GhosttyExports {
24 memory: WebAssembly.Memory;
25 __indirect_function_table: WebAssembly.Table;
26 [name: string]: Fn | WebAssembly.Memory | WebAssembly.Table;
27}
28
29const encoder = new TextEncoder();
30const decoder = new TextDecoder();
31
32/**
33 * Byte layout of an n-arg trampoline module: (import "e" "cb") + (export "f")
34 * forwarding. The import and the export share one type, so declaring a result
35 * makes the call's value the exported function's result — which is what
36 * effects that answer the core (clipboard writes) need.
37 */
38function trampolineBytes(paramCount: number, resultCount: number): Uint8Array {
39 const params = Array.from({ length: paramCount }, () => 0x7f);
40 const results = resultCount > 0 ? [0x01, 0x7f] : [0x00];
41 const body: number[] = [0x00];
42 for (let i = 0; i < paramCount; i++) body.push(0x20, i);
43 body.push(0x10, 0x00, 0x0b);
44 const bytes = [
45 0x00,
46 0x61,
47 0x73,
48 0x6d,
49 0x01,
50 0x00,
51 0x00,
52 0x00,
53 0x01,
54 paramCount + 3 + results.length,
55 0x01,
56 0x60,
57 paramCount,
58 ...params,
59 ...results,
60 0x02,
61 0x08,
62 0x01,
63 0x01,
64 0x65,
65 0x02,
66 0x63,
67 0x62,
68 0x00,
69 0x00,
70 0x03,
71 0x02,
72 0x01,
73 0x00,
74 0x07,
75 0x05,
76 0x01,
77 0x01,
78 0x66,
79 0x00,
80 0x01,
81 0x0a,
82 body.length + 2,
83 0x01,
84 body.length,
85 ...body,
86 ];
87 return new Uint8Array(bytes);
88}
89
90export class GhosttyModule {
91 readonly exports: GhosttyExports;
92 readonly layout: TypeLayout;
93 private freeTableSlots: number[] = [];
94
95 private constructor(exports: GhosttyExports, layout: TypeLayout) {
96 this.exports = exports;
97 this.layout = layout;
98 }
99
100 /**
101 * Instantiate from wasm bytes or a precompiled module. Callers own fetching
102 * the artifact (vite: `new URL("@clo/terminal/ghostty-vt.wasm", import.meta.url)`;
103 * node: `fs.readFileSync`). Kept as a parameter so the package works in any
104 * bundler and in tests without a network seam.
105 */
106 static async load(source: BufferSource | WebAssembly.Module): Promise<GhosttyModule> {
107 const imports = {
108 env: {
109 log: (ptr: number, len: number) => {
110 const ex = instance.exports as unknown as GhosttyExports;
111 const text = decoder.decode(new Uint8Array(ex.memory.buffer, ptr, len));
112 console.debug("[ghostty-vt]", text);
113 },
114 },
115 };
116 let instance: WebAssembly.Instance;
117 if (source instanceof WebAssembly.Module) {
118 instance = await WebAssembly.instantiate(source, imports);
119 } else {
120 instance = (await WebAssembly.instantiate(source, imports)).instance;
121 }
122 const ex = instance.exports as unknown as GhosttyExports;
123 const jsonPtr = (ex.ghostty_type_json as Fn)();
124 const raw = new Uint8Array(ex.memory.buffer, jsonPtr);
125 const layout = JSON.parse(decoder.decode(raw.subarray(0, raw.indexOf(0)))) as TypeLayout;
126 return new GhosttyModule(ex, layout);
127 }
128
129 /** Fetch and instantiate a wasm artifact, rejecting non-success HTTP responses. */
130 static async fetch(source: RequestInfo | URL, init?: RequestInit): Promise<GhosttyModule> {
131 const response = await globalThis.fetch(source, init);
132 if (!response.ok) {
133 throw new Error(`ghostty wasm failed to load (${response.status})`);
134 }
135 return GhosttyModule.load(await response.arrayBuffer());
136 }
137
138 fn(name: string): Fn {
139 const f = this.exports[name];
140 if (typeof f !== "function") throw new Error(`missing wasm export: ${name}`);
141 return f;
142 }
143
144 view(): DataView {
145 return new DataView(this.exports.memory.buffer);
146 }
147
148 bytes(ptr: number, len: number): Uint8Array {
149 return new Uint8Array(this.exports.memory.buffer, ptr, len);
150 }
151
152 /** Copy of wasm memory — safe to hold across calls. */
153 copyBytes(ptr: number, len: number): Uint8Array {
154 return this.bytes(ptr, len).slice();
155 }
156
157 readCString(ptr: number, len: number): string {
158 return decoder.decode(this.bytes(ptr, len));
159 }
160
161 allocBytes(data: Uint8Array): { ptr: number; len: number; free: () => void } {
162 const allocation = this.allocScratch(data.length);
163 try {
164 this.bytes(allocation.ptr, data.length).set(data);
165 } catch (error) {
166 allocation.free();
167 throw error;
168 }
169 return { ptr: allocation.ptr, len: data.length, free: allocation.free };
170 }
171
172 allocScratch(len: number): { ptr: number; free: () => void } {
173 if (!Number.isInteger(len) || len < 0 || len > 0xffff_ffff) {
174 throw new RangeError(`ghostty wasm allocation length must be a uint32: ${len}`);
175 }
176 if (len === 0) return { ptr: 0, free: () => {} };
177 const ptr = this.fn("ghostty_wasm_alloc_u8_array")(len);
178 if (ptr === 0) throw new Error("ghostty wasm alloc failed");
179 let freed = false;
180 return {
181 ptr,
182 free: () => {
183 if (freed) return;
184 freed = true;
185 this.fn("ghostty_wasm_free_u8_array")(ptr, len);
186 },
187 };
188 }
189
190 encodeString(text: string) {
191 return this.allocBytes(encoder.encode(text));
192 }
193
194 /**
195 * Zeroed struct allocation with the sized-struct ABI convention applied:
196 * if the struct has a `size` field, it is pre-set to the struct's size.
197 */
198 allocStruct(name: string): { ptr: number; size: number; free: () => void } {
199 const info = this.layout[name];
200 if (!info) throw new Error(`unknown struct in type layout: ${name}`);
201 const scratch = this.allocScratch(info.size);
202 try {
203 this.bytes(scratch.ptr, info.size).fill(0);
204 const sizeField = info.fields["size"];
205 if (sizeField) this.view().setUint32(scratch.ptr + sizeField.offset, info.size, true);
206 return { ptr: scratch.ptr, size: info.size, free: scratch.free };
207 } catch (error) {
208 scratch.free();
209 throw error;
210 }
211 }
212
213 field(struct: string, field: string): TypeField {
214 const f = this.layout[struct]?.fields[field];
215 if (!f) throw new Error(`unknown field ${struct}.${field}`);
216 return f;
217 }
218
219 /** Call an out-param constructor: fn(allocator=0/default, outPtr, ...args) -> handle. */
220 create(name: string, ...args: number[]): number {
221 const out = this.fn("ghostty_wasm_alloc_opaque")();
222 if (out === 0) throw new Error("ghostty wasm alloc failed");
223 try {
224 const result = this.fn(name)(0, out, ...args);
225 if (result !== 0) throw new Error(`${name} failed: ${result}`);
226 const handle = this.view().getUint32(out, true);
227 if (handle === 0) throw new Error(`${name} returned a null handle`);
228 return handle;
229 } finally {
230 this.fn("ghostty_wasm_free_opaque")(out);
231 }
232 }
233
234 /**
235 * Register a JS callback as a wasm function-table entry (a C function
236 * pointer). The disposable slot is reused after release. libghostty-vt calls
237 * effects synchronously during vt_write; the JS function receives raw i32 args.
238 */
239 trampoline(
240 paramCount: number,
241 jsFn: (...args: number[]) => void | number,
242 resultCount = 0,
243 ): WasmTrampoline {
244 const mod = new WebAssembly.Module(trampolineBytes(paramCount, resultCount) as BufferSource);
245 const inst = new WebAssembly.Instance(mod, { e: { cb: jsFn } });
246 const table = this.exports.__indirect_function_table;
247 const reused = this.freeTableSlots.pop();
248 const index = reused ?? table.grow(1);
249 try {
250 table.set(index, inst.exports["f"]);
251 } catch (error) {
252 this.freeTableSlots.push(index);
253 throw error;
254 }
255 let freed = false;
256 return {
257 index,
258 free: () => {
259 if (freed) return;
260 freed = true;
261 table.set(index, null);
262 this.freeTableSlots.push(index);
263 },
264 };
265 }
266}