@twinkle-lang/twinkle 0.3.0 → 0.5.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/boot.wasm CHANGED
Binary file
package/index.mjs CHANGED
@@ -125,7 +125,6 @@ export async function run(wasmBytes, opts = {}) {
125
125
  bridgeBytes: loadBridgeWasm(),
126
126
  host: nodeHost,
127
127
  imports: opts.imports ?? {},
128
- marshalSpec: opts.marshalSpec ?? {},
129
128
  });
130
129
  }
131
130
 
package/package.json CHANGED
@@ -1,17 +1,18 @@
1
1
  {
2
2
  "name": "@twinkle-lang/twinkle",
3
- "version": "0.3.0",
3
+ "version": "0.5.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
7
  "exports": {
8
8
  ".": "./index.mjs",
9
+ "./web": "./web.mjs",
9
10
  "./runtime.mjs": "./runtime.mjs",
10
11
  "./node_host.mjs": "./node_host.mjs",
11
12
  "./boot.wasm": "./boot.wasm",
12
13
  "./bridge.wasm": "./bridge.wasm"
13
14
  },
14
- "files": ["node.mjs", "index.mjs", "runtime.mjs", "node_host.mjs", "boot.wasm", "bridge.wasm", "README.md"],
15
+ "files": ["node.mjs", "index.mjs", "web.mjs", "runtime.mjs", "node_host.mjs", "boot.wasm", "bridge.wasm", "README.md"],
15
16
  "engines": { "node": ">=22" },
16
17
  "license": "MIT",
17
18
  "publishConfig": { "access": "public" },
package/runtime.mjs CHANGED
@@ -121,11 +121,18 @@ function decodeByteArray(b, arrRef) {
121
121
  // ---------------------------------------------------------------------------
122
122
 
123
123
  /**
124
- * 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
+ *
125
132
  * Resolution order per import: scoped `imports[module][name]`, then
126
133
  * `globals[module][name]`. Imports already satisfied by `hostImports`, or that
127
- * are not functions, are skipped. Returns the resolved bindings plus a list of
128
- * 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.
129
136
  */
130
137
  export function resolveExternImports(importList, hostImports, imports = {}, globals = globalThis) {
131
138
  const found = [];
@@ -134,21 +141,33 @@ export function resolveExternImports(importList, hostImports, imports = {}, glob
134
141
  if (hostImports[imp.module]?.[imp.name] !== undefined) continue;
135
142
  if (imp.kind !== "function") continue;
136
143
 
137
- const scopedRecv = imports[imp.module];
138
- const scoped = scopedRecv?.[imp.name];
144
+ const scoped = imports[imp.module]?.[imp.name];
145
+ let fn;
146
+ let recv;
147
+ let args;
148
+
139
149
  if (typeof scoped === "function") {
140
- found.push({ module: imp.module, name: imp.name, fn: scoped, recv: scopedRecv });
141
- 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];
142
164
  }
143
165
 
144
- const globalRecv = globals[imp.module];
145
- const global = globalRecv?.[imp.name];
146
- if (typeof global === "function") {
147
- found.push({ module: imp.module, name: imp.name, fn: global, recv: globalRecv });
148
- 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}`);
149
170
  }
150
-
151
- missing.push(`${imp.module}.${imp.name}`);
152
171
  }
153
172
  return { found, missing };
154
173
  }
@@ -162,7 +181,34 @@ export function resolveExternImports(importList, hostImports, imports = {}, glob
162
181
  * - Int (bigint), Float (number), Bool (i32) pass through
163
182
  * Throws a single aggregated error if any extern import is unsatisfied.
164
183
  */
165
- function autoBridgeExternImports(wasmModule, hostImports, b, jspi = false, imports = {}, marshalSpec = {}) {
184
+ /**
185
+ * Read the compiler-emitted `twinkle.externs` custom section into a
186
+ * `module → name → { args, ret }` map. Best-effort: `customSections` may throw
187
+ * on GC modules in some engines (same risk class as `Module.imports`), and the
188
+ * section is absent for non-Twinkle wasm — either way we return `{}` and the
189
+ * manual override / string-default path takes over.
190
+ */
191
+ function readExternMeta(wasmModule) {
192
+ let sections;
193
+ try {
194
+ sections = WebAssembly.Module.customSections(wasmModule, "twinkle.externs");
195
+ } catch {
196
+ return {};
197
+ }
198
+ if (!sections || sections.length === 0) return {};
199
+ try {
200
+ const list = JSON.parse(new TextDecoder().decode(new Uint8Array(sections[0])));
201
+ const map = {};
202
+ for (const e of list) {
203
+ (map[e.module] ??= {})[e.name] = { args: e.args, ret: e.ret };
204
+ }
205
+ return map;
206
+ } catch {
207
+ return {};
208
+ }
209
+ }
210
+
211
+ function autoBridgeExternImports(wasmModule, hostImports, b, jspi = false, imports = {}, externMeta = {}) {
166
212
  let importList;
167
213
  try {
168
214
  importList = WebAssembly.Module.imports(wasmModule);
@@ -182,35 +228,51 @@ function autoBridgeExternImports(wasmModule, hostImports, b, jspi = false, impor
182
228
  );
183
229
  }
184
230
 
185
- // Per-import arg marshaling honors an optional spec: `marshalSpec[module][name]`
186
- // is an array of `"raw" | "string"` keyed by arg position. `"raw"` passes the
187
- // value through untouched essential for externref args (e.g. a canvas 2D
188
- // context), since calling decodeString on an opaque host object recurses until
189
- // a stack overflow in some engines (notably Safari). Without a spec entry, a
190
- // non-numeric arg is assumed to be a Wasm GC string and decoded.
231
+ // Per-import arg marshaling honors a per-position kind spec. Two vocabularies
232
+ // are accepted and treated identically: the compiler-emitted twinkle.externs
233
+ // kinds ("str" | "ref" | "i64" | "f64" | "i32") and the manual override's
234
+ // ("raw" | "string"). Numbers pass through (handled before the spec). "ref" /
235
+ // "raw" pass the value untouched essential for externref args (e.g. a canvas
236
+ // 2D context), since decodeString on an opaque host object recurses until a
237
+ // stack overflow in some engines (notably Safari). Anything else (incl. no
238
+ // entry) is assumed to be a Wasm GC string and decoded.
191
239
  const makeMarshalArgs = (spec) => (args) => args.map((arg, i) => {
192
240
  if (typeof arg === "bigint") return Number(arg);
193
241
  if (typeof arg === "number") return arg;
194
- if (spec?.[i] === "raw") return arg;
242
+ const k = spec?.[i];
243
+ if (k === "ref" || k === "raw") return arg;
195
244
  return decodeString(b, arg);
196
245
  });
197
246
 
198
- const marshalReturn = (result) => {
247
+ // Return marshaling uses the compiler-emitted `ret` kind when available, so
248
+ // we never guess from the JS value's type. Falls back to a generic guess when
249
+ // there is no section (manual-only callers).
250
+ const marshalReturn = (result, ret) => {
199
251
  if (result === undefined || result === null) return;
252
+ switch (ret) {
253
+ case "ref": return result;
254
+ case "str": return typeof result === "string" ? encodeString(b, result) : result;
255
+ case "i64": return typeof result === "number" ? BigInt(result) : result;
256
+ case "f64": case "i32": return result;
257
+ case "void": return undefined;
258
+ }
200
259
  if (typeof result === "string") return encodeString(b, result);
201
260
  if (typeof result === "number") return result;
202
261
  if (typeof result === "bigint") return result;
203
262
  return result;
204
263
  };
205
264
 
206
- for (const { module, name, fn, recv } of found) {
207
- const marshalArgs = makeMarshalArgs(marshalSpec[module]?.[name]);
265
+ for (const { module, name, fn, recv, args } of found) {
266
+ // Precedence: a manual `args` override wins over the section's kinds.
267
+ const meta = externMeta[module]?.[name];
268
+ const marshalArgs = makeMarshalArgs(args ?? meta?.args);
269
+ const ret = meta?.ret;
208
270
  let bridgedFn;
209
271
  if (jspi) {
210
272
  // JSPI mode: async wrapper so Promise-returning JS functions suspend
211
273
  // Wasm. Non-Promise returns pass through without suspension.
212
274
  const asyncWrapper = async (...args) =>
213
- marshalReturn(await fn.apply(recv, marshalArgs(args)));
275
+ marshalReturn(await fn.apply(recv, marshalArgs(args)), ret);
214
276
  bridgedFn = new WebAssembly.Suspending(asyncWrapper);
215
277
  } else {
216
278
  // Sync mode: detect and reject Promise returns
@@ -222,7 +284,7 @@ function autoBridgeExternImports(wasmModule, hostImports, b, jspi = false, impor
222
284
  `Promise-returning externs require a runtime with WebAssembly.Suspending/promising support.`,
223
285
  );
224
286
  }
225
- return marshalReturn(result);
287
+ return marshalReturn(result, ret);
226
288
  };
227
289
  }
228
290
  if (!hostImports[module]) hostImports[module] = {};
@@ -280,7 +342,6 @@ function makeHostImports(b, runtime, bridgeBytes) {
280
342
  stdout: runtime.stdout,
281
343
  stderr: runtime.stderr,
282
344
  imports: runtime.imports,
283
- marshalSpec: runtime.marshalSpec,
284
345
  host: runtime.host,
285
346
  bridgeBytes,
286
347
  });
@@ -343,6 +404,64 @@ function makeHostImports(b, runtime, bridgeBytes) {
343
404
  };
344
405
  }
345
406
 
407
+ // ---------------------------------------------------------------------------
408
+ // In-memory host adapter
409
+ // ---------------------------------------------------------------------------
410
+
411
+ /**
412
+ * A host adapter backed by an in-memory file map — the default for browsers and
413
+ * other environments without a real filesystem. Reads/writes a `Map<string,
414
+ * Uint8Array>`; stdin is always EOF. Pure JS (no `node:` deps), so it is safe to
415
+ * load anywhere.
416
+ *
417
+ * @param {Map<string,Uint8Array> | Iterable<[string,Uint8Array]>} [initialFiles]
418
+ */
419
+ export function createMemoryHost(initialFiles) {
420
+ const files = initialFiles instanceof Map ? initialFiles : new Map(initialFiles ?? []);
421
+ const norm = (p) => (p.startsWith("/") ? p : "/" + p).replace(/\/+/g, "/");
422
+ return {
423
+ files,
424
+ resolvePath(cwd, p) {
425
+ if (p.startsWith("/")) return norm(p);
426
+ return norm((cwd.endsWith("/") ? cwd : cwd + "/") + p);
427
+ },
428
+ readFile(path) {
429
+ const data = files.get(norm(path));
430
+ if (data === undefined) throw new Error(`file not found: ${path}`);
431
+ return data;
432
+ },
433
+ writeFile(path, text) { files.set(norm(path), textEncoder.encode(text)); },
434
+ writeBytes(path, bytes) { files.set(norm(path), bytes); },
435
+ exists(path) {
436
+ const np = norm(path);
437
+ if (files.has(np)) return true;
438
+ const prefix = np.endsWith("/") ? np : np + "/";
439
+ for (const k of files.keys()) if (k.startsWith(prefix)) return true;
440
+ return false;
441
+ },
442
+ listDir(path) {
443
+ const prefix = norm(path).replace(/\/?$/, "/");
444
+ const names = new Set();
445
+ for (const k of files.keys()) {
446
+ if (k.startsWith(prefix)) {
447
+ const name = k.slice(prefix.length).split("/")[0];
448
+ if (name) names.add(name);
449
+ }
450
+ }
451
+ return [...names].sort();
452
+ },
453
+ mkdirp() { /* virtual dirs are implicit */ },
454
+ readStdin(_maxBytes, _timeoutMs, runtime) {
455
+ runtime.stdinEof = true;
456
+ return new Uint8Array(0);
457
+ },
458
+ readStdinAsync(_maxBytes, _timeoutMs, runtime) {
459
+ runtime.stdinEof = true;
460
+ return Promise.resolve(new Uint8Array(0));
461
+ },
462
+ };
463
+ }
464
+
346
465
  // ---------------------------------------------------------------------------
347
466
  // Bridge instantiation
348
467
  // ---------------------------------------------------------------------------
@@ -376,7 +495,6 @@ function prepareWasm(wasmBytes, opts, { jspi = false } = {}) {
376
495
  bridgeBytes,
377
496
  host,
378
497
  imports = {},
379
- marshalSpec = {},
380
498
  } = opts;
381
499
 
382
500
  if (!bridgeBytes) {
@@ -396,12 +514,12 @@ function prepareWasm(wasmBytes, opts, { jspi = false } = {}) {
396
514
  stdinEof: false,
397
515
  host,
398
516
  imports,
399
- marshalSpec,
400
517
  };
401
518
 
402
519
  const hostImports = makeHostImports(b, runtime, bridgeBytes);
403
520
  const mainModule = new WebAssembly.Module(wasmBytes);
404
- autoBridgeExternImports(mainModule, hostImports, b, jspi, imports, marshalSpec);
521
+ const externMeta = readExternMeta(mainModule);
522
+ autoBridgeExternImports(mainModule, hostImports, b, jspi, imports, externMeta);
405
523
 
406
524
  return { mainModule, hostImports, b, runtime };
407
525
  }
@@ -466,7 +584,6 @@ export async function runWasmBytesAsync(wasmBytes, opts = {}) {
466
584
  stdout: runtime.stdout,
467
585
  stderr: runtime.stderr,
468
586
  imports: runtime.imports,
469
- marshalSpec: runtime.marshalSpec,
470
587
  host: runtime.host,
471
588
  bridgeBytes: childBridgeBytes,
472
589
  });
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
+ }