| 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 | |
| 6 | export interface TypeField { |
| 7 | offset: number; |
| 8 | size: number; |
| 9 | type: string; |
| 10 | } |
| 11 | |
| 12 | export interface TypeLayout { |
| 13 | [struct: string]: { size: number; align: number; fields: Record<string, TypeField> }; |
| 14 | } |
| 15 | |
| 16 | type Fn = (...args: Array<number | bigint>) => number; |
| 17 | |
| 18 | export interface WasmTrampoline { |
| 19 | readonly index: number; |
| 20 | free(): void; |
| 21 | } |
| 22 | |
| 23 | export interface GhosttyExports { |
| 24 | memory: WebAssembly.Memory; |
| 25 | __indirect_function_table: WebAssembly.Table; |
| 26 | [name: string]: Fn | WebAssembly.Memory | WebAssembly.Table; |
| 27 | } |
| 28 | |
| 29 | const encoder = new TextEncoder(); |
| 30 | const 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 | */ |
| 38 | function 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 | |
| 90 | export 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 | } |