@twinkle-lang/twinkle 0.3.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.
Files changed (4) hide show
  1. package/index.mjs +0 -1
  2. package/package.json +3 -2
  3. package/runtime.mjs +100 -27
  4. package/web.mjs +77 -0
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.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
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,7 @@ 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
+ function autoBridgeExternImports(wasmModule, hostImports, b, jspi = false, imports = {}) {
166
185
  let importList;
167
186
  try {
168
187
  importList = WebAssembly.Module.imports(wasmModule);
@@ -182,11 +201,11 @@ function autoBridgeExternImports(wasmModule, hostImports, b, jspi = false, impor
182
201
  );
183
202
  }
184
203
 
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
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
190
209
  // non-numeric arg is assumed to be a Wasm GC string and decoded.
191
210
  const makeMarshalArgs = (spec) => (args) => args.map((arg, i) => {
192
211
  if (typeof arg === "bigint") return Number(arg);
@@ -203,8 +222,8 @@ function autoBridgeExternImports(wasmModule, hostImports, b, jspi = false, impor
203
222
  return result;
204
223
  };
205
224
 
206
- for (const { module, name, fn, recv } of found) {
207
- const marshalArgs = makeMarshalArgs(marshalSpec[module]?.[name]);
225
+ for (const { module, name, fn, recv, args } of found) {
226
+ const marshalArgs = makeMarshalArgs(args);
208
227
  let bridgedFn;
209
228
  if (jspi) {
210
229
  // JSPI mode: async wrapper so Promise-returning JS functions suspend
@@ -280,7 +299,6 @@ function makeHostImports(b, runtime, bridgeBytes) {
280
299
  stdout: runtime.stdout,
281
300
  stderr: runtime.stderr,
282
301
  imports: runtime.imports,
283
- marshalSpec: runtime.marshalSpec,
284
302
  host: runtime.host,
285
303
  bridgeBytes,
286
304
  });
@@ -343,6 +361,64 @@ function makeHostImports(b, runtime, bridgeBytes) {
343
361
  };
344
362
  }
345
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
+
346
422
  // ---------------------------------------------------------------------------
347
423
  // Bridge instantiation
348
424
  // ---------------------------------------------------------------------------
@@ -376,7 +452,6 @@ function prepareWasm(wasmBytes, opts, { jspi = false } = {}) {
376
452
  bridgeBytes,
377
453
  host,
378
454
  imports = {},
379
- marshalSpec = {},
380
455
  } = opts;
381
456
 
382
457
  if (!bridgeBytes) {
@@ -396,12 +471,11 @@ function prepareWasm(wasmBytes, opts, { jspi = false } = {}) {
396
471
  stdinEof: false,
397
472
  host,
398
473
  imports,
399
- marshalSpec,
400
474
  };
401
475
 
402
476
  const hostImports = makeHostImports(b, runtime, bridgeBytes);
403
477
  const mainModule = new WebAssembly.Module(wasmBytes);
404
- autoBridgeExternImports(mainModule, hostImports, b, jspi, imports, marshalSpec);
478
+ autoBridgeExternImports(mainModule, hostImports, b, jspi, imports);
405
479
 
406
480
  return { mainModule, hostImports, b, runtime };
407
481
  }
@@ -466,7 +540,6 @@ export async function runWasmBytesAsync(wasmBytes, opts = {}) {
466
540
  stdout: runtime.stdout,
467
541
  stderr: runtime.stderr,
468
542
  imports: runtime.imports,
469
- marshalSpec: runtime.marshalSpec,
470
543
  host: runtime.host,
471
544
  bridgeBytes: childBridgeBytes,
472
545
  });
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
+ }