@twinkle-lang/twinkle 0.2.0 → 0.4.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/index.mjs CHANGED
@@ -10,6 +10,7 @@ import { readFileSync, writeFileSync, rmSync, mkdtempSync } from "node:fs";
10
10
  import { resolve, dirname, join, basename } from "node:path";
11
11
  import { tmpdir } from "node:os";
12
12
  import { runWasmBytesAsync } from "./runtime.mjs";
13
+ import { nodeHost } from "./node_host.mjs";
13
14
 
14
15
  const here = import.meta.dirname;
15
16
 
@@ -95,6 +96,7 @@ export async function compile(input, opts = {}) {
95
96
  stdout: out,
96
97
  stderr: err,
97
98
  bridgeBytes,
99
+ host: nodeHost,
98
100
  });
99
101
  if (code !== 0) {
100
102
  throw new Error(`Twinkle compilation failed (exit ${code}):\n${err.text() || out.text()}`);
@@ -121,6 +123,7 @@ export async function run(wasmBytes, opts = {}) {
121
123
  stdout: opts.stdout ?? process.stdout,
122
124
  stderr: opts.stderr ?? process.stderr,
123
125
  bridgeBytes: loadBridgeWasm(),
126
+ host: nodeHost,
124
127
  imports: opts.imports ?? {},
125
128
  });
126
129
  }
package/node.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  import { readFileSync, writeSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
11
  import { runWasmBytesAsync } from "./runtime.mjs";
12
+ import { nodeHost } from "./node_host.mjs";
12
13
 
13
14
  const textEncoder = new TextEncoder();
14
15
  const here = import.meta.dirname;
@@ -104,6 +105,7 @@ async function main() {
104
105
  stdout: nodeStream(1),
105
106
  stderr: nodeStream(2),
106
107
  bridgeBytes: loadBridgeWasm(),
108
+ host: nodeHost,
107
109
  });
108
110
  process.exit(exitCode);
109
111
  }
package/node_host.mjs ADDED
@@ -0,0 +1,133 @@
1
+ // Node/Deno host adapter for the shared Twinkle runtime.
2
+ //
3
+ // runtime.mjs is host-agnostic: it routes every filesystem and stdin host
4
+ // import through an injected `host` object. This module provides that object
5
+ // for Node and Deno (Deno satisfies node:fs / node:path via its node-compat
6
+ // layer). A browser supplies its own adapter with the same shape.
7
+ //
8
+ // Host interface:
9
+ // resolvePath(cwd, p) -> string
10
+ // readFile(path) -> Uint8Array (throws on missing)
11
+ // writeFile(path, text)
12
+ // writeBytes(path, bytes: Uint8Array)
13
+ // exists(path) -> boolean
14
+ // listDir(path) -> string[]
15
+ // mkdirp(path)
16
+ // readStdin(maxBytes, timeoutMs, runtime) -> Uint8Array (sync)
17
+ // readStdinAsync(maxBytes, timeoutMs, runtime) -> Promise<Uint8Array>
18
+ //
19
+ // The stdin helpers set runtime.stdinEof when the stream reaches EOF.
20
+
21
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, readSync } from "node:fs";
22
+ import { resolve } from "node:path";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Stdin helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function sleepSyncMs(ms) {
29
+ if (ms <= 0) return;
30
+ const sab = new SharedArrayBuffer(4);
31
+ Atomics.wait(new Int32Array(sab), 0, 0, ms);
32
+ }
33
+
34
+ function readStdinTimeout(maxBytes, timeoutMs, runtime) {
35
+ const n = Number(maxBytes);
36
+ const timeout = Number(timeoutMs);
37
+ if (n <= 0) return Buffer.alloc(0);
38
+
39
+ // Accessing process.stdin asks Node/libuv to put fd 0 in non-blocking mode
40
+ // for pipes/ttys, which lets fs.readSync report EAGAIN instead of blocking
41
+ // forever when no LSP bytes are currently available.
42
+ void process.stdin;
43
+
44
+ const deadline = performance.now() + Math.max(0, timeout);
45
+ const buf = Buffer.allocUnsafe(n);
46
+ while (true) {
47
+ try {
48
+ const read = readSync(0, buf, 0, n, null);
49
+ if (read === 0) runtime.stdinEof = true;
50
+ return buf.subarray(0, read);
51
+ } catch (e) {
52
+ if (e?.code === "EAGAIN" || e?.code === "EWOULDBLOCK") {
53
+ const remaining = deadline - performance.now();
54
+ if (remaining <= 0) return Buffer.alloc(0);
55
+ sleepSyncMs(Math.min(10, remaining));
56
+ continue;
57
+ }
58
+ throw e;
59
+ }
60
+ }
61
+ }
62
+
63
+ function readStdinTimeoutAsync(maxBytes, timeoutMs, runtime) {
64
+ const n = Number(maxBytes);
65
+ const timeout = Number(timeoutMs);
66
+ if (n <= 0) return Promise.resolve(Buffer.alloc(0));
67
+
68
+ return new Promise((resolve) => {
69
+ let timer = null;
70
+ let settled = false;
71
+
72
+ const finish = (chunk) => {
73
+ if (settled) return;
74
+ settled = true;
75
+ if (timer !== null) { clearTimeout(timer); timer = null; }
76
+ process.stdin.removeListener("readable", onReadable);
77
+ process.stdin.removeListener("end", onEnd);
78
+ resolve(chunk);
79
+ };
80
+
81
+ const tryRead = () => {
82
+ // read() with no argument returns whatever is buffered (1..any bytes),
83
+ // matching the sync path's "read up to n" semantics.
84
+ const chunk = process.stdin.read();
85
+ if (chunk !== null) {
86
+ // If the stream returned more than n bytes, push the excess back.
87
+ if (chunk.length > n) {
88
+ process.stdin.unshift(chunk.subarray(n));
89
+ finish(chunk.subarray(0, n));
90
+ } else {
91
+ finish(chunk);
92
+ }
93
+ return true;
94
+ }
95
+ return false;
96
+ };
97
+
98
+ const onReadable = () => { tryRead(); };
99
+
100
+ const onEnd = () => {
101
+ runtime.stdinEof = true;
102
+ finish(Buffer.alloc(0));
103
+ };
104
+
105
+ if (tryRead()) return;
106
+
107
+ if (process.stdin.readableEnded) {
108
+ runtime.stdinEof = true;
109
+ resolve(Buffer.alloc(0));
110
+ return;
111
+ }
112
+
113
+ timer = setTimeout(() => finish(Buffer.alloc(0)), Math.max(0, timeout));
114
+ process.stdin.once("readable", onReadable);
115
+ process.stdin.once("end", onEnd);
116
+ });
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Adapter
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export const nodeHost = {
124
+ resolvePath(cwd, p) { return resolve(cwd, p); },
125
+ readFile(path) { return readFileSync(path); },
126
+ writeFile(path, text) { writeFileSync(path, text); },
127
+ writeBytes(path, bytes) { writeFileSync(path, bytes); },
128
+ exists(path) { return existsSync(path); },
129
+ listDir(path) { return readdirSync(path); },
130
+ mkdirp(path) { mkdirSync(path, { recursive: true }); },
131
+ readStdin: readStdinTimeout,
132
+ readStdinAsync: readStdinTimeoutAsync,
133
+ };
package/package.json CHANGED
@@ -1,11 +1,18 @@
1
1
  {
2
2
  "name": "@twinkle-lang/twinkle",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Twinkle — a statically typed language targeting WebAssembly GC. CLI (twk) plus an embeddable compile/run library.",
5
5
  "type": "module",
6
6
  "bin": { "twk": "node.mjs" },
7
- "exports": { ".": "./index.mjs" },
8
- "files": ["node.mjs", "index.mjs", "runtime.mjs", "boot.wasm", "bridge.wasm", "README.md"],
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./web": "./web.mjs",
10
+ "./runtime.mjs": "./runtime.mjs",
11
+ "./node_host.mjs": "./node_host.mjs",
12
+ "./boot.wasm": "./boot.wasm",
13
+ "./bridge.wasm": "./bridge.wasm"
14
+ },
15
+ "files": ["node.mjs", "index.mjs", "web.mjs", "runtime.mjs", "node_host.mjs", "boot.wasm", "bridge.wasm", "README.md"],
9
16
  "engines": { "node": ">=22" },
10
17
  "license": "MIT",
11
18
  "publishConfig": { "access": "public" },
package/runtime.mjs CHANGED
@@ -4,11 +4,15 @@
4
4
  // bridge Wasm module to create/read Wasm GC values (since JS cannot directly
5
5
  // construct or inspect Wasm GC arrays/structs).
6
6
  //
7
+ // Host-agnostic: all filesystem and stdin host imports are routed through an
8
+ // injected `host` adapter (see tools/js_runtime/node_host.mjs for Node/Deno,
9
+ // or the playground's browser adapter). This module contains no `node:`
10
+ // imports, so it loads unchanged in a browser/worker.
11
+ //
7
12
  // 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";
13
+ // - tools/js_runtime/deno_main.mjs (Deno standalone CLI, host: nodeHost)
14
+ // - tools/js_runtime/node_main.mjs (Node CLI, host: nodeHost)
15
+ // - tools/js_runtime/index.mjs (embeddable library, host: nodeHost)
12
16
 
13
17
  // ---------------------------------------------------------------------------
14
18
  // Constants
@@ -105,109 +109,11 @@ function decodeStringArray(b, arrRef) {
105
109
 
106
110
  function decodeByteArray(b, arrRef) {
107
111
  const len = b.array_len(arrRef);
108
- if (len === 0) return Buffer.alloc(0);
112
+ if (len === 0) return new Uint8Array(0);
109
113
  ensureMemory(b, len);
110
114
  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
- });
115
+ // ArrayBuffer.slice copies, so the returned view owns its bytes.
116
+ return new Uint8Array(b.memory.buffer.slice(0, len));
211
117
  }
212
118
 
213
119
  // ---------------------------------------------------------------------------
@@ -215,11 +121,18 @@ function readStdinTimeoutAsync(maxBytes, timeoutMs, runtime) {
215
121
  // ---------------------------------------------------------------------------
216
122
 
217
123
  /**
218
- * Resolve each wasm extern import to a JS function.
124
+ * Resolve each wasm extern import to a JS function (and its optional arg spec).
125
+ *
126
+ * A scoped `imports[module][name]` entry is either:
127
+ * - a function — the implementation, with default arg marshaling, or
128
+ * - `{ fn?, args? }` — `fn` is the implementation (omit it to fall back to
129
+ * `globals[module][name]`), and `args` is the per-arg marshal spec, an array
130
+ * of `"raw" | "string"` keyed by position.
131
+ *
219
132
  * Resolution order per import: scoped `imports[module][name]`, then
220
133
  * `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.
134
+ * are not functions, are skipped. Returns the resolved bindings (each with
135
+ * `fn`, `recv`, and `args`) plus a list of unresolved "module.name" strings.
223
136
  */
224
137
  export function resolveExternImports(importList, hostImports, imports = {}, globals = globalThis) {
225
138
  const found = [];
@@ -228,21 +141,33 @@ export function resolveExternImports(importList, hostImports, imports = {}, glob
228
141
  if (hostImports[imp.module]?.[imp.name] !== undefined) continue;
229
142
  if (imp.kind !== "function") continue;
230
143
 
231
- const scopedRecv = imports[imp.module];
232
- const scoped = scopedRecv?.[imp.name];
144
+ const scoped = imports[imp.module]?.[imp.name];
145
+ let fn;
146
+ let recv;
147
+ let args;
148
+
233
149
  if (typeof scoped === "function") {
234
- found.push({ module: imp.module, name: imp.name, fn: scoped, recv: scopedRecv });
235
- continue;
150
+ fn = scoped;
151
+ recv = imports[imp.module];
152
+ } else if (scoped && typeof scoped === "object") {
153
+ args = scoped.args;
154
+ if (typeof scoped.fn === "function") {
155
+ fn = scoped.fn;
156
+ recv = imports[imp.module];
157
+ } else {
158
+ fn = globals[imp.module]?.[imp.name];
159
+ recv = globals[imp.module];
160
+ }
161
+ } else {
162
+ fn = globals[imp.module]?.[imp.name];
163
+ recv = globals[imp.module];
236
164
  }
237
165
 
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;
166
+ if (typeof fn === "function") {
167
+ found.push({ module: imp.module, name: imp.name, fn, recv, args });
168
+ } else {
169
+ missing.push(`${imp.module}.${imp.name}`);
243
170
  }
244
-
245
- missing.push(`${imp.module}.${imp.name}`);
246
171
  }
247
172
  return { found, missing };
248
173
  }
@@ -276,10 +201,16 @@ function autoBridgeExternImports(wasmModule, hostImports, b, jspi = false, impor
276
201
  );
277
202
  }
278
203
 
279
- const marshalArgs = (args) => args.map((arg) => {
204
+ // Per-import arg marshaling honors the extern's optional `args` spec — an
205
+ // array of `"raw" | "string"` keyed by arg position. `"raw"` passes the value
206
+ // through untouched, essential for externref args (e.g. a canvas 2D context),
207
+ // since calling decodeString on an opaque host object recurses until a stack
208
+ // overflow in some engines (notably Safari). Without a spec entry, a
209
+ // non-numeric arg is assumed to be a Wasm GC string and decoded.
210
+ const makeMarshalArgs = (spec) => (args) => args.map((arg, i) => {
280
211
  if (typeof arg === "bigint") return Number(arg);
281
212
  if (typeof arg === "number") return arg;
282
- // GC ref assume string
213
+ if (spec?.[i] === "raw") return arg;
283
214
  return decodeString(b, arg);
284
215
  });
285
216
 
@@ -291,7 +222,8 @@ function autoBridgeExternImports(wasmModule, hostImports, b, jspi = false, impor
291
222
  return result;
292
223
  };
293
224
 
294
- for (const { module, name, fn, recv } of found) {
225
+ for (const { module, name, fn, recv, args } of found) {
226
+ const marshalArgs = makeMarshalArgs(args);
295
227
  let bridgedFn;
296
228
  if (jspi) {
297
229
  // JSPI mode: async wrapper so Promise-returning JS functions suspend
@@ -321,17 +253,6 @@ function autoBridgeExternImports(wasmModule, hostImports, b, jspi = false, impor
321
253
  // Host imports
322
254
  // ---------------------------------------------------------------------------
323
255
 
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
256
  function write(stream, text) {
336
257
  stream.write(text);
337
258
  }
@@ -378,16 +299,17 @@ function makeHostImports(b, runtime, bridgeBytes) {
378
299
  stdout: runtime.stdout,
379
300
  stderr: runtime.stderr,
380
301
  imports: runtime.imports,
302
+ host: runtime.host,
381
303
  bridgeBytes,
382
304
  });
383
305
  return BigInt(exitCode);
384
306
  },
385
307
 
386
- // --- File system ---
308
+ // --- File system (routed through the injected host adapter) ---
387
309
  read_file: (pathRef) => {
388
- const filePath = resolve(runtime.cwd, decodeString(b, pathRef));
310
+ const filePath = runtime.host.resolvePath(runtime.cwd, decodeString(b, pathRef));
389
311
  try {
390
- const bytes = readFileSync(filePath);
312
+ const bytes = runtime.host.readFile(filePath);
391
313
  return makeResultOk(b, makeByteArray(b, bytes));
392
314
  } catch (e) {
393
315
  const msg = `host.read_file failed for '${filePath}': ${e.message}`;
@@ -395,35 +317,33 @@ function makeHostImports(b, runtime, bridgeBytes) {
395
317
  }
396
318
  },
397
319
  write_file: (pathRef, contentRef) => {
398
- const filePath = resolve(runtime.cwd, decodeString(b, pathRef));
399
- writeFileSync(filePath, decodeString(b, contentRef));
320
+ const filePath = runtime.host.resolvePath(runtime.cwd, decodeString(b, pathRef));
321
+ runtime.host.writeFile(filePath, decodeString(b, contentRef));
400
322
  },
401
323
  write_bytes: (pathRef, bytesRef) => {
402
- const filePath = resolve(runtime.cwd, decodeString(b, pathRef));
403
- writeFileSync(filePath, decodeByteArray(b, bytesRef));
324
+ const filePath = runtime.host.resolvePath(runtime.cwd, decodeString(b, pathRef));
325
+ runtime.host.writeBytes(filePath, decodeByteArray(b, bytesRef));
404
326
  },
405
- stdin_read_chunk: (maxBytes) => makeByteArray(b, readStdinTimeout(maxBytes, 2147483647, runtime)),
406
- stdin_read_timeout: (maxBytes, timeoutMs) => makeByteArray(b, readStdinTimeout(maxBytes, timeoutMs, runtime)),
327
+ stdin_read_chunk: (maxBytes) => makeByteArray(b, runtime.host.readStdin(maxBytes, 2147483647, runtime)),
328
+ stdin_read_timeout: (maxBytes, timeoutMs) => makeByteArray(b, runtime.host.readStdin(maxBytes, timeoutMs, runtime)),
407
329
  stdin_eof: () => runtime.stdinEof ? 1 : 0,
408
330
  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
- }
331
+ // Streams accept a Uint8Array chunk; each adapter's write() handles the
332
+ // platform write (fd write on Node, writeSync on Deno, postMessage in a
333
+ // worker), so no Buffer/fd handling is needed here.
334
+ runtime.stdout.write(decodeByteArray(b, bytesRef));
415
335
  },
416
336
  mkdirp: (pathRef) => {
417
- const dirPath = resolve(runtime.cwd, decodeString(b, pathRef));
418
- mkdirSync(dirPath, { recursive: true });
337
+ const dirPath = runtime.host.resolvePath(runtime.cwd, decodeString(b, pathRef));
338
+ runtime.host.mkdirp(dirPath);
419
339
  },
420
340
  list_dir: (pathRef) => {
421
- const dirPath = resolve(runtime.cwd, decodeString(b, pathRef));
422
- return makeStringArray(b, readdirSync(dirPath));
341
+ const dirPath = runtime.host.resolvePath(runtime.cwd, decodeString(b, pathRef));
342
+ return makeStringArray(b, runtime.host.listDir(dirPath));
423
343
  },
424
344
  exists: (pathRef) => {
425
- const filePath = resolve(runtime.cwd, decodeString(b, pathRef));
426
- return existsSync(filePath) ? 1 : 0;
345
+ const filePath = runtime.host.resolvePath(runtime.cwd, decodeString(b, pathRef));
346
+ return runtime.host.exists(filePath) ? 1 : 0;
427
347
  },
428
348
 
429
349
  // --- Parsing ---
@@ -441,6 +361,64 @@ function makeHostImports(b, runtime, bridgeBytes) {
441
361
  };
442
362
  }
443
363
 
364
+ // ---------------------------------------------------------------------------
365
+ // In-memory host adapter
366
+ // ---------------------------------------------------------------------------
367
+
368
+ /**
369
+ * A host adapter backed by an in-memory file map — the default for browsers and
370
+ * other environments without a real filesystem. Reads/writes a `Map<string,
371
+ * Uint8Array>`; stdin is always EOF. Pure JS (no `node:` deps), so it is safe to
372
+ * load anywhere.
373
+ *
374
+ * @param {Map<string,Uint8Array> | Iterable<[string,Uint8Array]>} [initialFiles]
375
+ */
376
+ export function createMemoryHost(initialFiles) {
377
+ const files = initialFiles instanceof Map ? initialFiles : new Map(initialFiles ?? []);
378
+ const norm = (p) => (p.startsWith("/") ? p : "/" + p).replace(/\/+/g, "/");
379
+ return {
380
+ files,
381
+ resolvePath(cwd, p) {
382
+ if (p.startsWith("/")) return norm(p);
383
+ return norm((cwd.endsWith("/") ? cwd : cwd + "/") + p);
384
+ },
385
+ readFile(path) {
386
+ const data = files.get(norm(path));
387
+ if (data === undefined) throw new Error(`file not found: ${path}`);
388
+ return data;
389
+ },
390
+ writeFile(path, text) { files.set(norm(path), textEncoder.encode(text)); },
391
+ writeBytes(path, bytes) { files.set(norm(path), bytes); },
392
+ exists(path) {
393
+ const np = norm(path);
394
+ if (files.has(np)) return true;
395
+ const prefix = np.endsWith("/") ? np : np + "/";
396
+ for (const k of files.keys()) if (k.startsWith(prefix)) return true;
397
+ return false;
398
+ },
399
+ listDir(path) {
400
+ const prefix = norm(path).replace(/\/?$/, "/");
401
+ const names = new Set();
402
+ for (const k of files.keys()) {
403
+ if (k.startsWith(prefix)) {
404
+ const name = k.slice(prefix.length).split("/")[0];
405
+ if (name) names.add(name);
406
+ }
407
+ }
408
+ return [...names].sort();
409
+ },
410
+ mkdirp() { /* virtual dirs are implicit */ },
411
+ readStdin(_maxBytes, _timeoutMs, runtime) {
412
+ runtime.stdinEof = true;
413
+ return new Uint8Array(0);
414
+ },
415
+ readStdinAsync(_maxBytes, _timeoutMs, runtime) {
416
+ runtime.stdinEof = true;
417
+ return Promise.resolve(new Uint8Array(0));
418
+ },
419
+ };
420
+ }
421
+
444
422
  // ---------------------------------------------------------------------------
445
423
  // Bridge instantiation
446
424
  // ---------------------------------------------------------------------------
@@ -467,17 +445,21 @@ function prepareWasm(wasmBytes, opts, { jspi = false } = {}) {
467
445
  const {
468
446
  programPath = "<memory>.wasm",
469
447
  guestArgs = [],
470
- cwd = process.cwd(),
471
- env = process.env,
472
- stdout = process.stdout,
473
- stderr = process.stderr,
448
+ cwd = "/",
449
+ env = {},
450
+ stdout,
451
+ stderr,
474
452
  bridgeBytes,
453
+ host,
475
454
  imports = {},
476
455
  } = opts;
477
456
 
478
457
  if (!bridgeBytes) {
479
458
  throw new Error("runWasmBytes: bridgeBytes is required");
480
459
  }
460
+ if (!host) {
461
+ throw new Error("runWasmBytes: host adapter is required (see node_host.mjs)");
462
+ }
481
463
 
482
464
  const b = instantiateBridge(bridgeBytes);
483
465
  const runtime = {
@@ -487,6 +469,7 @@ function prepareWasm(wasmBytes, opts, { jspi = false } = {}) {
487
469
  stdout,
488
470
  stderr,
489
471
  stdinEof: false,
472
+ host,
490
473
  imports,
491
474
  };
492
475
 
@@ -520,13 +503,6 @@ export function runWasmBytes(wasmBytes, opts = {}) {
520
503
  }
521
504
  }
522
505
 
523
- export function runWasmFile(wasmPath, opts = {}) {
524
- return runWasmBytes(readFileSync(wasmPath), {
525
- programPath: resolve(wasmPath),
526
- ...opts,
527
- });
528
- }
529
-
530
506
  // ---------------------------------------------------------------------------
531
507
  // Public API — async (JSPI-aware)
532
508
  // ---------------------------------------------------------------------------
@@ -535,21 +511,21 @@ export async function runWasmBytesAsync(wasmBytes, opts = {}) {
535
511
  const { mainModule, hostImports, b, runtime } = prepareWasm(wasmBytes, opts, { jspi: hasJspi });
536
512
 
537
513
  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.
514
+ // Wrap stdin reads as suspending imports so the event loop stays free while
515
+ // Twinkle waits for LSP input. Keep chunk and timeout reads on the same
516
+ // stream-based path; mixing process.stdin.read() with fs.readSync(0, ...)
517
+ // can strand bytes in Node's stream buffer.
542
518
  hostImports.host.stdin_read_chunk = new WebAssembly.Suspending(
543
519
  async (maxBytes) =>
544
- makeByteArray(b, await readStdinTimeoutAsync(maxBytes, 2147483647, runtime)),
520
+ makeByteArray(b, await runtime.host.readStdinAsync(maxBytes, 2147483647, runtime)),
545
521
  );
546
522
  hostImports.host.stdin_read_timeout = new WebAssembly.Suspending(
547
523
  async (maxBytes, timeoutMs) =>
548
- makeByteArray(b, await readStdinTimeoutAsync(maxBytes, timeoutMs, runtime)),
524
+ makeByteArray(b, await runtime.host.readStdinAsync(maxBytes, timeoutMs, runtime)),
549
525
  );
550
526
 
551
- // Phase 4: wrap run_wasm as a suspending import so child programs can
552
- // themselves use JSPI suspending imports.
527
+ // Wrap run_wasm as a suspending import so child programs can themselves use
528
+ // JSPI suspending imports.
553
529
  const childBridgeBytes = opts.bridgeBytes;
554
530
  hostImports.host.run_wasm = new WebAssembly.Suspending(
555
531
  async (bytesRef, argvRef) => {
@@ -564,6 +540,7 @@ export async function runWasmBytesAsync(wasmBytes, opts = {}) {
564
540
  stdout: runtime.stdout,
565
541
  stderr: runtime.stderr,
566
542
  imports: runtime.imports,
543
+ host: runtime.host,
567
544
  bridgeBytes: childBridgeBytes,
568
545
  });
569
546
  return BigInt(exitCode);
@@ -589,10 +566,3 @@ export async function runWasmBytesAsync(wasmBytes, opts = {}) {
589
566
  throw e;
590
567
  }
591
568
  }
592
-
593
- export async function runWasmFileAsync(wasmPath, opts = {}) {
594
- return runWasmBytesAsync(readFileSync(wasmPath), {
595
- programPath: resolve(wasmPath),
596
- ...opts,
597
- });
598
- }
package/web.mjs ADDED
@@ -0,0 +1,77 @@
1
+ // Browser entry for embedding Twinkle.
2
+ //
3
+ // import { run } from "@twinkle-lang/twinkle/web";
4
+ // await run(source, { stdout, stderr });
5
+ //
6
+ // Unlike index.mjs (Node: temp files, node:fs), this is browser-first: it
7
+ // self-loads the compiler wasm that ships beside it and uses an in-memory
8
+ // filesystem by default, so callers never touch boot.wasm/bridge.wasm or
9
+ // construct a host adapter.
10
+
11
+ import { runWasmBytesAsync, createMemoryHost } from "./runtime.mjs";
12
+
13
+ const textEncoder = new TextEncoder();
14
+
15
+ // boot.wasm / bridge.wasm are published next to this module. `new URL(...,
16
+ // import.meta.url)` lets the consumer's bundler emit them as assets (Vite,
17
+ // webpack, native ESM all understand this), so no `?url` import is needed.
18
+ let assetsPromise;
19
+
20
+ function loadAssets() {
21
+ if (!assetsPromise) {
22
+ assetsPromise = Promise.all([
23
+ fetch(new URL("./boot.wasm", import.meta.url)).then((r) => r.arrayBuffer()),
24
+ fetch(new URL("./bridge.wasm", import.meta.url)).then((r) => r.arrayBuffer()),
25
+ ]).then(([boot, bridge]) => ({
26
+ bootBytes: new Uint8Array(boot),
27
+ bridgeBytes: new Uint8Array(bridge),
28
+ }));
29
+ }
30
+ return assetsPromise;
31
+ }
32
+
33
+ /** Pre-fetch the compiler wasm so the first run() doesn't pay the load latency. */
34
+ export function load() {
35
+ return loadAssets();
36
+ }
37
+
38
+ const defaultStream = (sink) => ({
39
+ write(chunk) {
40
+ sink(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
41
+ return true;
42
+ },
43
+ });
44
+
45
+ /**
46
+ * Compile and run Twinkle source in the browser.
47
+ *
48
+ * @param {string | Uint8Array} source Twinkle source for the entry module.
49
+ * @param {object} [opts]
50
+ * @param {object} [opts.stdout] Stream with write(chunk); defaults to console.log.
51
+ * @param {object} [opts.stderr] Stream with write(chunk); defaults to console.error.
52
+ * @param {object} [opts.env] Environment map exposed to the program.
53
+ * @param {object} [opts.imports] Extern imports — `module → fn | { fn?, args? }`
54
+ * (else resolved via globalThis). `args` is the per-arg spec, e.g.
55
+ * `{ canvas: { fill_rect: { fn, args: ['raw','raw','raw','raw','raw'] } } }`.
56
+ * @param {Iterable<[string,Uint8Array]>} [opts.files] Extra in-memory files (multi-file projects).
57
+ * @param {object} [opts.host] Override the host adapter entirely (advanced).
58
+ * @returns {Promise<number>} exit code
59
+ */
60
+ export async function run(source, opts = {}) {
61
+ const { bootBytes, bridgeBytes } = await loadAssets();
62
+
63
+ const files = new Map(opts.files ?? []);
64
+ files.set("/input/main.tw", typeof source === "string" ? textEncoder.encode(source) : source);
65
+
66
+ return runWasmBytesAsync(bootBytes, {
67
+ programPath: "twk.wasm",
68
+ guestArgs: ["run", "/input/main.tw"],
69
+ cwd: "/",
70
+ env: opts.env ?? {},
71
+ stdout: opts.stdout ?? defaultStream((s) => console.log(s)),
72
+ stderr: opts.stderr ?? defaultStream((s) => console.error(s)),
73
+ bridgeBytes,
74
+ host: opts.host ?? createMemoryHost(files),
75
+ imports: opts.imports ?? {},
76
+ });
77
+ }