@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 +0 -0
- package/index.mjs +0 -1
- package/package.json +3 -2
- package/runtime.mjs +149 -32
- package/web.mjs +77 -0
package/boot.wasm
CHANGED
|
Binary file
|
package/index.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinkle-lang/twinkle",
|
|
3
|
-
"version": "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
|
|
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
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
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
|
-
|
|
242
|
+
const k = spec?.[i];
|
|
243
|
+
if (k === "ref" || k === "raw") return arg;
|
|
195
244
|
return decodeString(b, arg);
|
|
196
245
|
});
|
|
197
246
|
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|