@twinkle-lang/twinkle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @twinkle-lang/twinkle
2
+
3
+ Twinkle is a statically typed language targeting WebAssembly GC. This package
4
+ ships both the `twk` command-line compiler and an embeddable JS library for
5
+ compiling and running Twinkle programs from Node.js.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @twinkle-lang/twinkle
11
+ ```
12
+
13
+ ## CLI
14
+
15
+ ```bash
16
+ npx twk run path/to/program.tw
17
+ npx twk build path/to/program.tw -o out.wasm
18
+ npx twk fmt path/to/program.tw
19
+ ```
20
+
21
+ ## Library
22
+
23
+ ```js
24
+ import { compile, run, runFile } from "@twinkle-lang/twinkle";
25
+
26
+ // Host functions declared in Twinkle as `extern canvas { fn draw_rect(...) }`
27
+ // are wired by passing a scoped imports object — no globalThis pollution.
28
+ await runFile("game.tw", {
29
+ imports: {
30
+ canvas: { draw_rect: (x, y, w, h) => { /* ... */ } },
31
+ },
32
+ });
33
+
34
+ // Host globals (Math, console, crypto, ...) resolve automatically:
35
+ await runFile("calc.tw");
36
+
37
+ // Compile once, run many times:
38
+ const wasm = await compile("game.tw");
39
+ await run(wasm, { imports: { canvas } });
40
+ ```
41
+
42
+ A missing extern import produces a clear error naming the exact `module.fn`.
43
+
44
+ Requires Node.js ≥ 22.
package/boot.wasm ADDED
Binary file
package/bridge.wasm ADDED
Binary file
package/index.mjs ADDED
@@ -0,0 +1,138 @@
1
+ // Library API for embedding Twinkle in JavaScript.
2
+ //
3
+ // import { compile, run, runFile } from "@twinkle-lang/twinkle";
4
+ //
5
+ // compile(input) -> Uint8Array (loads boot.wasm)
6
+ // run(wasmBytes, opts) -> exitCode (loads only bridge.wasm)
7
+ // runFile(path, opts) -> exitCode (compile + run)
8
+
9
+ import { readFileSync, writeFileSync, rmSync, mkdtempSync } from "node:fs";
10
+ import { resolve, dirname, join, basename } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { runWasmBytesAsync } from "./runtime.mjs";
13
+
14
+ const here = import.meta.dirname;
15
+
16
+ function readFirst(paths) {
17
+ let lastError;
18
+ for (const p of paths) {
19
+ try { return readFileSync(p); } catch (e) { lastError = e; }
20
+ }
21
+ throw lastError ?? new Error("no paths provided");
22
+ }
23
+
24
+ function loadBootWasm() {
25
+ const override = process.env.BOOT_WASM;
26
+ if (override) return readFileSync(resolve(override));
27
+ return readFirst([
28
+ `${here}/boot.wasm`,
29
+ `${here}/../../target/boot.wasm`,
30
+ ]);
31
+ }
32
+
33
+ function loadBridgeWasm() {
34
+ const override = process.env.BRIDGE_WASM;
35
+ if (override) return readFileSync(resolve(override));
36
+ return readFirst([
37
+ `${here}/bridge.wasm`,
38
+ `${here}/../bridge.wasm`,
39
+ ]);
40
+ }
41
+
42
+ function collectingStream() {
43
+ const chunks = [];
44
+ // Stream-decode so a multi-byte UTF-8 sequence split across writes is not
45
+ // corrupted; flush the decoder in text().
46
+ const dec = new TextDecoder();
47
+ return {
48
+ text() {
49
+ return chunks.join("") + dec.decode();
50
+ },
51
+ write(chunk) {
52
+ chunks.push(typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true }));
53
+ return true;
54
+ },
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Compile Twinkle source to wasm bytes.
60
+ * @param {string | {source: string, path?: string}} input
61
+ * A file path string — full project/import support (relative `use .sibling`,
62
+ * walk-up to `twinkle.toml`). Or `{ source, path? }` — written to a temp dir
63
+ * and compiled single-file only; relative imports and project-root discovery
64
+ * will NOT resolve as they would at the original location.
65
+ * @returns {Promise<Uint8Array>}
66
+ */
67
+ export async function compile(input, opts = {}) {
68
+ const bootBytes = loadBootWasm();
69
+ const bridgeBytes = loadBridgeWasm();
70
+
71
+ let srcPath;
72
+ let cleanupDir;
73
+ if (typeof input === "string") {
74
+ srcPath = resolve(input);
75
+ } else if (input && typeof input.source === "string") {
76
+ cleanupDir = mkdtempSync(join(tmpdir(), "twinkle-"));
77
+ srcPath = join(cleanupDir, basename(input.path ?? "main.tw"));
78
+ writeFileSync(srcPath, input.source);
79
+ } else {
80
+ throw new TypeError("compile: input must be a path string or { source, path? }");
81
+ }
82
+
83
+ // A dedicated temp dir per call: mkdtempSync's random suffix guarantees a
84
+ // unique output path even for concurrent same-process compiles.
85
+ const outDir = mkdtempSync(join(tmpdir(), "twinkle-out-"));
86
+ const outPath = join(outDir, "out.wasm");
87
+ const out = collectingStream();
88
+ const err = collectingStream();
89
+ try {
90
+ const code = await runWasmBytesAsync(bootBytes, {
91
+ programPath: "twk.wasm",
92
+ guestArgs: ["build", srcPath, "-o", outPath],
93
+ cwd: opts.cwd ?? dirname(srcPath),
94
+ env: process.env,
95
+ stdout: out,
96
+ stderr: err,
97
+ bridgeBytes,
98
+ });
99
+ if (code !== 0) {
100
+ throw new Error(`Twinkle compilation failed (exit ${code}):\n${err.text() || out.text()}`);
101
+ }
102
+ return new Uint8Array(readFileSync(outPath));
103
+ } finally {
104
+ try { rmSync(outDir, { recursive: true, force: true }); } catch {}
105
+ if (cleanupDir) { try { rmSync(cleanupDir, { recursive: true, force: true }); } catch {} }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Run pre-compiled wasm bytes with optional scoped extern imports.
111
+ * @param {Uint8Array} wasmBytes
112
+ * @param {{imports?, args?, cwd?, env?, stdout?, stderr?, path?}} opts
113
+ * @returns {Promise<number>} exit code
114
+ */
115
+ export async function run(wasmBytes, opts = {}) {
116
+ return runWasmBytesAsync(wasmBytes, {
117
+ programPath: opts.path ?? "<memory>.wasm",
118
+ guestArgs: opts.args ?? [],
119
+ cwd: opts.cwd ?? process.cwd(),
120
+ env: opts.env ?? process.env,
121
+ stdout: opts.stdout ?? process.stdout,
122
+ stderr: opts.stderr ?? process.stderr,
123
+ bridgeBytes: loadBridgeWasm(),
124
+ imports: opts.imports ?? {},
125
+ });
126
+ }
127
+
128
+ /** Compile a file then run it. */
129
+ export async function runFile(path, opts = {}) {
130
+ const wasm = await compile(path, opts);
131
+ return run(wasm, { ...opts, path: resolve(path) });
132
+ }
133
+
134
+ /** Compile source text then run it. */
135
+ export async function runSource(source, opts = {}) {
136
+ const wasm = await compile({ source, path: opts.path }, opts);
137
+ return run(wasm, opts);
138
+ }
package/node.mjs ADDED
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ // Node.js entry wrapper for the Twinkle CLI (twk).
3
+ //
4
+ // Mirrors tools/js_runtime/deno_main.mjs using Node APIs. The full self-hosted
5
+ // compiler (boot.wasm) handles every subcommand (build/run/ir/fmt/check/lsp);
6
+ // this wrapper only loads the embedded payloads, adapts stdio, and forwards
7
+ // process.argv into the shared runtime.
8
+
9
+ import { readFileSync, writeSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { runWasmBytesAsync } from "./runtime.mjs";
12
+
13
+ const textEncoder = new TextEncoder();
14
+ const here = import.meta.dirname;
15
+
16
+ function writeAllFd(fd, bytes) {
17
+ let offset = 0;
18
+ while (offset < bytes.byteLength) {
19
+ const written = writeSync(fd, bytes, offset, bytes.byteLength - offset);
20
+ if (written <= 0) throw new Error("stdout write made no progress");
21
+ offset += written;
22
+ }
23
+ }
24
+
25
+ function nodeStream(fd) {
26
+ return {
27
+ fd,
28
+ write(chunk) {
29
+ const bytes = typeof chunk === "string"
30
+ ? textEncoder.encode(chunk)
31
+ : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
32
+ writeAllFd(fd, bytes);
33
+ return true;
34
+ },
35
+ };
36
+ }
37
+
38
+ function readFirst(paths) {
39
+ let lastError;
40
+ for (const p of paths) {
41
+ try { return readFileSync(p); } catch (e) { lastError = e; }
42
+ }
43
+ throw lastError ?? new Error("no paths provided");
44
+ }
45
+
46
+ function loadBootWasm() {
47
+ const override = process.env.BOOT_WASM;
48
+ if (override) return readFileSync(resolve(override));
49
+ try {
50
+ return readFirst([
51
+ `${here}/boot.wasm`, // packaged (flat layout)
52
+ `${here}/../../target/boot.wasm`, // dev fallback
53
+ ]);
54
+ } catch (e) {
55
+ console.error(`Error: boot compiler wasm not found: ${e.message}`);
56
+ console.error("Build it with: make stage2");
57
+ process.exit(1);
58
+ }
59
+ }
60
+
61
+ function loadBridgeWasm() {
62
+ const override = process.env.BRIDGE_WASM;
63
+ if (override) return readFileSync(resolve(override));
64
+ try {
65
+ return readFirst([
66
+ `${here}/bridge.wasm`, // packaged
67
+ `${here}/../bridge.wasm`, // dev fallback (tools/bridge.wasm)
68
+ ]);
69
+ } catch (e) {
70
+ console.error(`Error: bridge wasm not found: ${e.message}`);
71
+ console.error("Regenerate with: ./target/release/twk run boot/tests/gen_bridge_wasm.tw");
72
+ process.exit(1);
73
+ }
74
+ }
75
+
76
+ async function main() {
77
+ const bootOverride = process.env.BOOT_WASM;
78
+ const exitCode = await runWasmBytesAsync(loadBootWasm(), {
79
+ programPath: bootOverride ? resolve(bootOverride) : "twk.wasm",
80
+ guestArgs: process.argv.slice(2),
81
+ cwd: process.cwd(),
82
+ env: process.env,
83
+ stdout: nodeStream(1),
84
+ stderr: nodeStream(2),
85
+ bridgeBytes: loadBridgeWasm(),
86
+ });
87
+ process.exit(exitCode);
88
+ }
89
+
90
+ main().catch((e) => {
91
+ if (e.message?.startsWith("host.error:")) process.exit(1);
92
+ console.error(e.stack || e.message || e);
93
+ process.exit(1);
94
+ });
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@twinkle-lang/twinkle",
3
+ "version": "0.1.0",
4
+ "description": "Twinkle — a statically typed language targeting WebAssembly GC. CLI (twk) plus an embeddable compile/run library.",
5
+ "type": "module",
6
+ "bin": { "twk": "node.mjs" },
7
+ "exports": { ".": "./index.mjs" },
8
+ "files": ["node.mjs", "index.mjs", "runtime.mjs", "boot.wasm", "bridge.wasm", "README.md"],
9
+ "engines": { "node": ">=22" },
10
+ "license": "MIT",
11
+ "publishConfig": { "access": "public" },
12
+ "repository": { "type": "git", "url": "git+https://github.com/curist/twinkle.git" }
13
+ }
package/runtime.mjs ADDED
@@ -0,0 +1,598 @@
1
+ // Shared Wasm GC runtime library for the Twinkle JavaScript host.
2
+ //
3
+ // Provides the "host" imports that Twinkle's compiler emits, using a small
4
+ // bridge Wasm module to create/read Wasm GC values (since JS cannot directly
5
+ // construct or inspect Wasm GC arrays/structs).
6
+ //
7
+ // Used by:
8
+ // - tools/js_runtime/deno_main.mjs (Deno standalone CLI)
9
+
10
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, readSync, writeSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Constants
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const RESULT_TYPE_ID = 1; // matches src/types/ty.rs RESULT_TYPE_ID
18
+ const RESULT_OK = 0;
19
+ const RESULT_ERR = 1;
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // HostExit
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export class HostExit extends Error {
26
+ constructor(code) {
27
+ super(`host.exit(${code})`);
28
+ this.name = "HostExit";
29
+ this.code = code;
30
+ }
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // String / array marshaling
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const textDecoder = new TextDecoder();
38
+ const textEncoder = new TextEncoder();
39
+
40
+ const PAGE_SIZE = 65536;
41
+
42
+ // Ensure the bridge's linear memory can hold at least `needed` bytes.
43
+ // Returns a Uint8Array view of the memory (may be invalidated by future grows).
44
+ function ensureMemory(b, needed) {
45
+ const buf = b.memory.buffer;
46
+ if (buf.byteLength >= needed) return new Uint8Array(buf);
47
+ const pages = Math.ceil((needed - buf.byteLength) / PAGE_SIZE);
48
+ b.memory.grow(pages);
49
+ return new Uint8Array(b.memory.buffer);
50
+ }
51
+
52
+ function decodeString(b, ref) {
53
+ if (!ref) return "";
54
+ const len = b.string_len(ref);
55
+ if (len === 0) return "";
56
+ ensureMemory(b, len);
57
+ b.bulk_string_read(ref);
58
+ const view = new Uint8Array(b.memory.buffer, 0, len);
59
+ return textDecoder.decode(view);
60
+ }
61
+
62
+ function encodeString(b, str) {
63
+ const bytes = textEncoder.encode(str);
64
+ if (bytes.length === 0) return b.string_new(0);
65
+ const mem = ensureMemory(b, bytes.length);
66
+ mem.set(bytes);
67
+ return b.bulk_string_new(bytes.length);
68
+ }
69
+
70
+ function makeResultOk(b, value) {
71
+ const payload = b.array_new(1);
72
+ b.array_set(payload, 0, value);
73
+ return b.variant_new(RESULT_TYPE_ID, RESULT_OK, payload);
74
+ }
75
+
76
+ function makeResultErr(b, value) {
77
+ const payload = b.array_new(1);
78
+ b.array_set(payload, 0, value);
79
+ return b.variant_new(RESULT_TYPE_ID, RESULT_ERR, payload);
80
+ }
81
+
82
+ function makeStringArray(b, strings) {
83
+ const arr = b.array_new(strings.length);
84
+ for (let i = 0; i < strings.length; i++) {
85
+ b.array_set(arr, i, encodeString(b, strings[i]));
86
+ }
87
+ return arr;
88
+ }
89
+
90
+ function makeByteArray(b, bytes) {
91
+ if (bytes.length === 0) return b.array_new(0);
92
+ const mem = ensureMemory(b, bytes.length);
93
+ mem.set(bytes);
94
+ return b.bulk_bytes_new(bytes.length);
95
+ }
96
+
97
+ function decodeStringArray(b, arrRef) {
98
+ const len = b.array_len(arrRef);
99
+ const out = new Array(len);
100
+ for (let i = 0; i < len; i++) {
101
+ out[i] = decodeString(b, b.array_get(arrRef, i));
102
+ }
103
+ return out;
104
+ }
105
+
106
+ function decodeByteArray(b, arrRef) {
107
+ const len = b.array_len(arrRef);
108
+ if (len === 0) return Buffer.alloc(0);
109
+ ensureMemory(b, len);
110
+ b.bulk_bytes_read(arrRef);
111
+ // ArrayBuffer.slice copies (unlike Buffer.slice which creates a view)
112
+ return Buffer.from(b.memory.buffer.slice(0, len));
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Stdin helpers
117
+ // ---------------------------------------------------------------------------
118
+
119
+ function sleepSyncMs(ms) {
120
+ if (ms <= 0) return;
121
+ const sab = new SharedArrayBuffer(4);
122
+ Atomics.wait(new Int32Array(sab), 0, 0, ms);
123
+ }
124
+
125
+ function readStdinTimeout(maxBytes, timeoutMs, runtime) {
126
+ const n = Number(maxBytes);
127
+ const timeout = Number(timeoutMs);
128
+ if (n <= 0) return Buffer.alloc(0);
129
+
130
+ // Accessing process.stdin asks Node/libuv to put fd 0 in non-blocking mode
131
+ // for pipes/ttys, which lets fs.readSync report EAGAIN instead of blocking
132
+ // forever when no LSP bytes are currently available.
133
+ void process.stdin;
134
+
135
+ const deadline = performance.now() + Math.max(0, timeout);
136
+ const buf = Buffer.allocUnsafe(n);
137
+ while (true) {
138
+ try {
139
+ const read = readSync(0, buf, 0, n, null);
140
+ if (read === 0) runtime.stdinEof = true;
141
+ return buf.subarray(0, read);
142
+ } catch (e) {
143
+ if (e?.code === "EAGAIN" || e?.code === "EWOULDBLOCK") {
144
+ const remaining = deadline - performance.now();
145
+ if (remaining <= 0) return Buffer.alloc(0);
146
+ sleepSyncMs(Math.min(10, remaining));
147
+ continue;
148
+ }
149
+ throw e;
150
+ }
151
+ }
152
+ }
153
+
154
+ function readStdinTimeoutAsync(maxBytes, timeoutMs, runtime) {
155
+ const n = Number(maxBytes);
156
+ const timeout = Number(timeoutMs);
157
+ if (n <= 0) return Promise.resolve(Buffer.alloc(0));
158
+
159
+ return new Promise((resolve) => {
160
+ let timer = null;
161
+
162
+ let settled = false;
163
+
164
+ const finish = (chunk) => {
165
+ if (settled) return;
166
+ settled = true;
167
+ if (timer !== null) { clearTimeout(timer); timer = null; }
168
+ process.stdin.removeListener("readable", onReadable);
169
+ process.stdin.removeListener("end", onEnd);
170
+ resolve(chunk);
171
+ };
172
+
173
+ const tryRead = () => {
174
+ // read() with no argument returns whatever is buffered (1..any bytes),
175
+ // matching the sync path's "read up to n" semantics.
176
+ const chunk = process.stdin.read();
177
+ if (chunk !== null) {
178
+ // If the stream returned more than n bytes, push the excess back.
179
+ if (chunk.length > n) {
180
+ process.stdin.unshift(chunk.subarray(n));
181
+ finish(chunk.subarray(0, n));
182
+ } else {
183
+ finish(chunk);
184
+ }
185
+ return true;
186
+ }
187
+ return false;
188
+ };
189
+
190
+ const onReadable = () => { tryRead(); };
191
+
192
+ const onEnd = () => {
193
+ runtime.stdinEof = true;
194
+ finish(Buffer.alloc(0));
195
+ };
196
+
197
+ // Try immediate read from the stream buffer
198
+ if (tryRead()) return;
199
+
200
+ if (process.stdin.readableEnded) {
201
+ runtime.stdinEof = true;
202
+ resolve(Buffer.alloc(0));
203
+ return;
204
+ }
205
+
206
+ timer = setTimeout(() => finish(Buffer.alloc(0)), Math.max(0, timeout));
207
+
208
+ process.stdin.once("readable", onReadable);
209
+ process.stdin.once("end", onEnd);
210
+ });
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Extern auto-bridging
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Resolve each wasm extern import to a JS function.
219
+ * Resolution order per import: scoped `imports[module][name]`, then
220
+ * `globals[module][name]`. Imports already satisfied by `hostImports`, or that
221
+ * are not functions, are skipped. Returns the resolved bindings plus a list of
222
+ * unresolved "module.name" strings.
223
+ */
224
+ export function resolveExternImports(importList, hostImports, imports = {}, globals = globalThis) {
225
+ const found = [];
226
+ const missing = [];
227
+ for (const imp of importList) {
228
+ if (hostImports[imp.module]?.[imp.name] !== undefined) continue;
229
+ if (imp.kind !== "function") continue;
230
+
231
+ const scopedRecv = imports[imp.module];
232
+ const scoped = scopedRecv?.[imp.name];
233
+ if (typeof scoped === "function") {
234
+ found.push({ module: imp.module, name: imp.name, fn: scoped, recv: scopedRecv });
235
+ continue;
236
+ }
237
+
238
+ const globalRecv = globals[imp.module];
239
+ const global = globalRecv?.[imp.name];
240
+ if (typeof global === "function") {
241
+ found.push({ module: imp.module, name: imp.name, fn: global, recv: globalRecv });
242
+ continue;
243
+ }
244
+
245
+ missing.push(`${imp.module}.${imp.name}`);
246
+ }
247
+ return { found, missing };
248
+ }
249
+
250
+ /**
251
+ * Auto-bridge extern imports by resolving each to `imports[module][name]` (a
252
+ * scoped per-run object) or `globalThis[module][name]`, wrapping with type
253
+ * conversions for Twinkle's extern-safe types:
254
+ * - String params (GC refs) are decoded via bridge
255
+ * - String returns from JS are encoded via bridge
256
+ * - Int (bigint), Float (number), Bool (i32) pass through
257
+ * Throws a single aggregated error if any extern import is unsatisfied.
258
+ */
259
+ function autoBridgeExternImports(wasmModule, hostImports, b, jspi = false, imports = {}) {
260
+ let importList;
261
+ try {
262
+ importList = WebAssembly.Module.imports(wasmModule);
263
+ } catch {
264
+ // Module.imports may fail on GC modules in some runtimes; nothing to bridge.
265
+ return;
266
+ }
267
+
268
+ const { found, missing } = resolveExternImports(importList, hostImports, imports);
269
+
270
+ if (missing.length > 0) {
271
+ const [m0, f0] = missing[0].split(".");
272
+ throw new Error(
273
+ `Missing host import(s): ${missing.join(", ")}\n` +
274
+ `Provide them via the run() "imports" option ` +
275
+ `(e.g. { imports: { ${m0}: { ${f0}: fn } } }) or define them on globalThis.`,
276
+ );
277
+ }
278
+
279
+ const marshalArgs = (args) => args.map((arg) => {
280
+ if (typeof arg === "bigint") return Number(arg);
281
+ if (typeof arg === "number") return arg;
282
+ // GC ref — assume string
283
+ return decodeString(b, arg);
284
+ });
285
+
286
+ const marshalReturn = (result) => {
287
+ if (result === undefined || result === null) return;
288
+ if (typeof result === "string") return encodeString(b, result);
289
+ if (typeof result === "number") return result;
290
+ if (typeof result === "bigint") return result;
291
+ return result;
292
+ };
293
+
294
+ for (const { module, name, fn, recv } of found) {
295
+ let bridgedFn;
296
+ if (jspi) {
297
+ // JSPI mode: async wrapper so Promise-returning JS functions suspend
298
+ // Wasm. Non-Promise returns pass through without suspension.
299
+ const asyncWrapper = async (...args) =>
300
+ marshalReturn(await fn.apply(recv, marshalArgs(args)));
301
+ bridgedFn = new WebAssembly.Suspending(asyncWrapper);
302
+ } else {
303
+ // Sync mode: detect and reject Promise returns
304
+ bridgedFn = (...args) => {
305
+ const result = fn.apply(recv, marshalArgs(args));
306
+ if (result instanceof Promise) {
307
+ throw new Error(
308
+ `Extern ${module}.${name} returned a Promise, but JSPI is not available. ` +
309
+ `Promise-returning externs require a runtime with WebAssembly.Suspending/promising support.`,
310
+ );
311
+ }
312
+ return marshalReturn(result);
313
+ };
314
+ }
315
+ if (!hostImports[module]) hostImports[module] = {};
316
+ hostImports[module][name] = bridgedFn;
317
+ }
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Host imports
322
+ // ---------------------------------------------------------------------------
323
+
324
+ function writeAllFdSync(fd, bytes) {
325
+ let offset = 0;
326
+ while (offset < bytes.byteLength) {
327
+ const written = writeSync(fd, bytes, offset, bytes.byteLength - offset);
328
+ if (written <= 0) {
329
+ throw new Error("stdout write made no progress");
330
+ }
331
+ offset += written;
332
+ }
333
+ }
334
+
335
+ function write(stream, text) {
336
+ stream.write(text);
337
+ }
338
+
339
+ function makeHostImports(b, runtime, bridgeBytes) {
340
+ return {
341
+ host: {
342
+ // --- I/O ---
343
+ print: (s) => write(runtime.stdout, decodeString(b, s)),
344
+ println: (s) => write(runtime.stdout, decodeString(b, s) + "\n"),
345
+ error: (s) => {
346
+ const msg = decodeString(b, s);
347
+ write(runtime.stderr, msg + "\n");
348
+ throw new Error("host.error: " + msg);
349
+ },
350
+ eprint: (s) => write(runtime.stderr, decodeString(b, s)),
351
+ eprintln: (s) => write(runtime.stderr, decodeString(b, s) + "\n"),
352
+
353
+ // --- String conversion ---
354
+ f64_to_string: (n) => encodeString(b, n.toString()),
355
+
356
+ // --- Process ---
357
+ args: () => makeStringArray(b, runtime.programArgs),
358
+ env: (keyRef) => {
359
+ const key = decodeString(b, keyRef);
360
+ const val = runtime.env[key];
361
+ return val === undefined ? makeStringArray(b, []) : makeStringArray(b, [val]);
362
+ },
363
+ cwd: () => encodeString(b, runtime.cwd),
364
+ exit: (code) => {
365
+ const c = typeof code === "bigint" ? Number(code) : code;
366
+ throw new HostExit(c);
367
+ },
368
+ now: () => performance.now(),
369
+ run_wasm: (bytesRef, argvRef) => {
370
+ const childBytes = decodeByteArray(b, bytesRef);
371
+ const childArgv = decodeStringArray(b, argvRef);
372
+ const [programPath, ...guestArgs] = childArgv;
373
+ const exitCode = runWasmBytes(childBytes, {
374
+ programPath: programPath ?? "<memory>.wasm",
375
+ guestArgs,
376
+ cwd: runtime.cwd,
377
+ env: runtime.env,
378
+ stdout: runtime.stdout,
379
+ stderr: runtime.stderr,
380
+ imports: runtime.imports,
381
+ bridgeBytes,
382
+ });
383
+ return BigInt(exitCode);
384
+ },
385
+
386
+ // --- File system ---
387
+ read_file: (pathRef) => {
388
+ const filePath = resolve(runtime.cwd, decodeString(b, pathRef));
389
+ try {
390
+ const bytes = readFileSync(filePath);
391
+ return makeResultOk(b, makeByteArray(b, bytes));
392
+ } catch (e) {
393
+ const msg = `host.read_file failed for '${filePath}': ${e.message}`;
394
+ return makeResultErr(b, encodeString(b, msg));
395
+ }
396
+ },
397
+ write_file: (pathRef, contentRef) => {
398
+ const filePath = resolve(runtime.cwd, decodeString(b, pathRef));
399
+ writeFileSync(filePath, decodeString(b, contentRef));
400
+ },
401
+ write_bytes: (pathRef, bytesRef) => {
402
+ const filePath = resolve(runtime.cwd, decodeString(b, pathRef));
403
+ writeFileSync(filePath, decodeByteArray(b, bytesRef));
404
+ },
405
+ stdin_read_chunk: (maxBytes) => makeByteArray(b, readStdinTimeout(maxBytes, 2147483647, runtime)),
406
+ stdin_read_timeout: (maxBytes, timeoutMs) => makeByteArray(b, readStdinTimeout(maxBytes, timeoutMs, runtime)),
407
+ stdin_eof: () => runtime.stdinEof ? 1 : 0,
408
+ stdout_write_bytes: (bytesRef) => {
409
+ const bytes = decodeByteArray(b, bytesRef);
410
+ if (runtime.stdout?.fd !== undefined) {
411
+ writeAllFdSync(runtime.stdout.fd, bytes);
412
+ } else {
413
+ runtime.stdout.write(Buffer.from(bytes));
414
+ }
415
+ },
416
+ mkdirp: (pathRef) => {
417
+ const dirPath = resolve(runtime.cwd, decodeString(b, pathRef));
418
+ mkdirSync(dirPath, { recursive: true });
419
+ },
420
+ list_dir: (pathRef) => {
421
+ const dirPath = resolve(runtime.cwd, decodeString(b, pathRef));
422
+ return makeStringArray(b, readdirSync(dirPath));
423
+ },
424
+ exists: (pathRef) => {
425
+ const filePath = resolve(runtime.cwd, decodeString(b, pathRef));
426
+ return existsSync(filePath) ? 1 : 0;
427
+ },
428
+
429
+ // --- Parsing ---
430
+ parse_int: (sRef) => {
431
+ const s = decodeString(b, sRef);
432
+ const n = parseInt(s, 10);
433
+ return isNaN(n) ? 0n : BigInt(n);
434
+ },
435
+ parse_float: (sRef) => {
436
+ const s = decodeString(b, sRef);
437
+ const f = parseFloat(s);
438
+ return isNaN(f) ? [0.0, 0] : [f, 1];
439
+ },
440
+ },
441
+ };
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // Bridge instantiation
446
+ // ---------------------------------------------------------------------------
447
+
448
+ function instantiateBridge(bridgeBytes) {
449
+ const bridgeModule = new WebAssembly.Module(bridgeBytes);
450
+ const bridgeInstance = new WebAssembly.Instance(bridgeModule);
451
+ return bridgeInstance.exports;
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // JSPI feature detection
456
+ // ---------------------------------------------------------------------------
457
+
458
+ export const hasJspi =
459
+ typeof WebAssembly.Suspending === "function" &&
460
+ typeof WebAssembly.promising === "function";
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Wasm preparation (shared by sync and async paths)
464
+ // ---------------------------------------------------------------------------
465
+
466
+ function prepareWasm(wasmBytes, opts, { jspi = false } = {}) {
467
+ const {
468
+ programPath = "<memory>.wasm",
469
+ guestArgs = [],
470
+ cwd = process.cwd(),
471
+ env = process.env,
472
+ stdout = process.stdout,
473
+ stderr = process.stderr,
474
+ bridgeBytes,
475
+ imports = {},
476
+ } = opts;
477
+
478
+ if (!bridgeBytes) {
479
+ throw new Error("runWasmBytes: bridgeBytes is required");
480
+ }
481
+
482
+ const b = instantiateBridge(bridgeBytes);
483
+ const runtime = {
484
+ programArgs: [programPath, ...guestArgs],
485
+ cwd,
486
+ env,
487
+ stdout,
488
+ stderr,
489
+ stdinEof: false,
490
+ imports,
491
+ };
492
+
493
+ const hostImports = makeHostImports(b, runtime, bridgeBytes);
494
+ const mainModule = new WebAssembly.Module(wasmBytes);
495
+ autoBridgeExternImports(mainModule, hostImports, b, jspi, imports);
496
+
497
+ return { mainModule, hostImports, b, runtime };
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // Public API — synchronous
502
+ // ---------------------------------------------------------------------------
503
+
504
+ export function runWasmBytes(wasmBytes, opts = {}) {
505
+ const { mainModule, hostImports } = prepareWasm(wasmBytes, opts);
506
+ try {
507
+ const instance = new WebAssembly.Instance(mainModule, hostImports);
508
+ // Boot-compiled modules export __twinkle_start instead of using a Wasm
509
+ // start section. Stage0-compiled modules still use the start section and
510
+ // run during instantiation above.
511
+ if (instance.exports.__twinkle_start) {
512
+ instance.exports.__twinkle_start();
513
+ }
514
+ return 0;
515
+ } catch (e) {
516
+ if (e instanceof HostExit) {
517
+ return e.code;
518
+ }
519
+ throw e;
520
+ }
521
+ }
522
+
523
+ export function runWasmFile(wasmPath, opts = {}) {
524
+ return runWasmBytes(readFileSync(wasmPath), {
525
+ programPath: resolve(wasmPath),
526
+ ...opts,
527
+ });
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Public API — async (JSPI-aware)
532
+ // ---------------------------------------------------------------------------
533
+
534
+ export async function runWasmBytesAsync(wasmBytes, opts = {}) {
535
+ const { mainModule, hostImports, b, runtime } = prepareWasm(wasmBytes, opts, { jspi: hasJspi });
536
+
537
+ if (hasJspi) {
538
+ // Phase 3: wrap stdin reads as suspending imports so the Node event loop
539
+ // stays free while Twinkle waits for LSP input. Keep chunk and timeout
540
+ // reads on the same stream-based path; mixing process.stdin.read() with
541
+ // fs.readSync(0, ...) can strand bytes in Node's stream buffer.
542
+ hostImports.host.stdin_read_chunk = new WebAssembly.Suspending(
543
+ async (maxBytes) =>
544
+ makeByteArray(b, await readStdinTimeoutAsync(maxBytes, 2147483647, runtime)),
545
+ );
546
+ hostImports.host.stdin_read_timeout = new WebAssembly.Suspending(
547
+ async (maxBytes, timeoutMs) =>
548
+ makeByteArray(b, await readStdinTimeoutAsync(maxBytes, timeoutMs, runtime)),
549
+ );
550
+
551
+ // Phase 4: wrap run_wasm as a suspending import so child programs can
552
+ // themselves use JSPI suspending imports.
553
+ const childBridgeBytes = opts.bridgeBytes;
554
+ hostImports.host.run_wasm = new WebAssembly.Suspending(
555
+ async (bytesRef, argvRef) => {
556
+ const childBytes = decodeByteArray(b, bytesRef);
557
+ const childArgv = decodeStringArray(b, argvRef);
558
+ const [programPath, ...guestArgs] = childArgv;
559
+ const exitCode = await runWasmBytesAsync(childBytes, {
560
+ programPath: programPath ?? "<memory>.wasm",
561
+ guestArgs,
562
+ cwd: runtime.cwd,
563
+ env: runtime.env,
564
+ stdout: runtime.stdout,
565
+ stderr: runtime.stderr,
566
+ imports: runtime.imports,
567
+ bridgeBytes: childBridgeBytes,
568
+ });
569
+ return BigInt(exitCode);
570
+ },
571
+ );
572
+ }
573
+
574
+ try {
575
+ const instance = new WebAssembly.Instance(mainModule, hostImports);
576
+ if (instance.exports.__twinkle_start) {
577
+ if (hasJspi) {
578
+ const start = WebAssembly.promising(instance.exports.__twinkle_start);
579
+ await start();
580
+ } else {
581
+ instance.exports.__twinkle_start();
582
+ }
583
+ }
584
+ return 0;
585
+ } catch (e) {
586
+ if (e instanceof HostExit) {
587
+ return e.code;
588
+ }
589
+ throw e;
590
+ }
591
+ }
592
+
593
+ export async function runWasmFileAsync(wasmPath, opts = {}) {
594
+ return runWasmBytesAsync(readFileSync(wasmPath), {
595
+ programPath: resolve(wasmPath),
596
+ ...opts,
597
+ });
598
+ }