lilac-wasm-bin 0.2.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.
@@ -0,0 +1,461 @@
1
+ // JS host adapter for the mruby-wasm-js mrbgem.
2
+ //
3
+ // Provides the JS core via a single factory:
4
+ //
5
+ // import { createVM, Directory, File } from "mruby-wasm-js";
6
+ //
7
+ // const vm = await createVM({
8
+ // wasm: "/path/to/mruby-js.wasm",
9
+ // env: { LOCALE: "ja" },
10
+ // fs: new Directory({ "data": new Directory({ "poem.vtt": new File(bytes) }) }),
11
+ // });
12
+ //
13
+ // vm.eval('puts ENV["LOCALE"]');
14
+ // vm.evalScript('#ruby-source'); // eval textContent of <script id="ruby-source">
15
+ // vm.fs.set("/late.txt", bytes);
16
+ // vm.env["DEBUG"] = "1";
17
+ //
18
+ // Each createVM() call instantiates an independent mruby — separate
19
+ // handle table, separate WASI state. Multiple VMs can coexist in one
20
+ // process (useful for tests, sandboxing, hot reload).
21
+ //
22
+ // Internal layout:
23
+ // - createHandleTable(): per-VM handle table (alloc/get/release/count)
24
+ // - createErrorSlot(): per-VM JS exception capture
25
+ // - inspectValue(v): pure debug-string formatter
26
+ // - createJsImports({…}): builds the 25 js.* methods
27
+ // - createVM(options): orchestrator — creates state,
28
+ // builds imports, instantiates wasm,
29
+ // runs _start, returns VM handle.
30
+
31
+ import { createWasiPreview1, createFsFacade, Directory, File } from "./wasi-preview1.js";
32
+ import { createMemoryHelpers, encoder } from "./_memory.js";
33
+ import { debug } from "./debug.js";
34
+
35
+ export { Directory, File, createFsFacade, debug };
36
+
37
+ /**
38
+ * Thrown by `vm.eval` / `vm.loadBytecode` / `vm.evalScript` when mruby
39
+ * raises an unhandled exception. The fields mirror what mruby itself
40
+ * exposes — `rubyClass` is `exc.class.name`, `backtrace` is what
41
+ * `exc.backtrace` returned (file:line:in method, one entry per frame).
42
+ *
43
+ * Stack is best-effort: the JS engine's own stack trace shows where
44
+ * `vm.eval` was called from; the Ruby backtrace is the real one.
45
+ */
46
+ export class RubyError extends Error {
47
+ constructor({ class: rubyClass, message, backtrace } = {}) {
48
+ super(message || rubyClass || "mruby exception");
49
+ this.name = "RubyError";
50
+ this.rubyClass = rubyClass || "Exception";
51
+ this.backtrace = Array.isArray(backtrace) ? backtrace : [];
52
+ }
53
+ }
54
+
55
+ // `env` import object for instantiateStreaming. Empty in current builds:
56
+ // the gem's mruby-js.wasm uses hal-wasi-io (mrbgem/hal-wasi-io/) for the
57
+ // IO HAL backend, and mruby-wasi-stubs (mrbgem/mruby-wasi-stubs/) for
58
+ // the few POSIX symbols mruby-io's io.c references directly (dup,
59
+ // waitpid). Both are linked into the wasm itself, leaving the `env`
60
+ // import module with nothing to satisfy.
61
+ const envImports = {};
62
+
63
+ // --- Pure helpers ---------------------------------------------------------
64
+
65
+ // Best-effort debug string for a JS value. JSON for plain objects so
66
+ // `p value` shows structure; tag DOM nodes / functions specially since
67
+ // JSON.stringify drops them.
68
+ function inspectValue(v) {
69
+ if (v === null) return "null";
70
+ if (v === undefined) return "undefined";
71
+ const t = typeof v;
72
+ if (t === "string") return JSON.stringify(v);
73
+ if (t === "number" || t === "boolean") return String(v);
74
+ if (t === "function") return `#<JS function ${v.name || "(anonymous)"}>`;
75
+ if (t === "symbol") return v.toString();
76
+ if (v && typeof v.nodeType === "number" && typeof v.nodeName === "string") {
77
+ return `#<JS ${v.nodeName.toLowerCase()}${v.id ? ` id=${JSON.stringify(v.id)}` : ""}>`;
78
+ }
79
+ try { return JSON.stringify(v); }
80
+ catch (_err) { return `#<JS ${Object.prototype.toString.call(v)}>`; }
81
+ }
82
+
83
+ // --- Per-VM state factories -----------------------------------------------
84
+
85
+ /** Per-VM handle table. Index 0 is reserved as a "null" sentinel.
86
+ * Allocations recycle from a free list to keep handle numbers small. */
87
+ function createHandleTable() {
88
+ const handles = [null];
89
+ const free = [];
90
+ return {
91
+ alloc(value) {
92
+ if (free.length > 0) {
93
+ const h = free.pop();
94
+ handles[h] = value;
95
+ return h;
96
+ }
97
+ handles.push(value);
98
+ return handles.length - 1;
99
+ },
100
+ get(h) { return handles[h]; },
101
+ release(h) {
102
+ if (h === 0) return;
103
+ if (handles[h] === null) return;
104
+ handles[h] = null;
105
+ free.push(h);
106
+ },
107
+ count() { return handles.length - 1 - free.length; },
108
+ isNull(h) { return h === 0 || handles[h] == null; },
109
+ };
110
+ }
111
+
112
+ /** Per-VM "latest JS exception" slot. The C side calls js_take_error()
113
+ * right after each potentially-throwing op; if a non-null Error is
114
+ * pending, it becomes a JS::Error on the Ruby side. Non-Error
115
+ * throws (`throw "string"`, `throw 42`, ...) are wrapped so callers
116
+ * always get an object with `.message`. */
117
+ function createErrorSlot() {
118
+ let pending = null;
119
+ return {
120
+ capture(err) { pending = err; },
121
+ take() {
122
+ if (pending == null) return null;
123
+ let err = pending;
124
+ pending = null;
125
+ if (!(err instanceof Error)) err = new Error(String(err));
126
+ return err;
127
+ },
128
+ };
129
+ }
130
+
131
+ /** Build the 25 `js.*` import methods, closing over the supplied
132
+ * per-VM state. Splitting this out from createVM means the imports can
133
+ * be unit-tested or rebuilt independently of the wasm fetch/instantiate
134
+ * cycle. */
135
+ function createJsImports({ handles, errorSlot, getInstance }) {
136
+ const { readUtf8, writeUtf8, readHandleArray } = createMemoryHelpers(getInstance);
137
+ return {
138
+ // Evaluate JS source and return a handle to the resulting value.
139
+ // NOTE: uses `Function` constructor for simplicity; not a sandbox.
140
+ js_eval(ptr, len) {
141
+ const src = readUtf8(ptr, len);
142
+ let result;
143
+ try { result = new Function(`return (${src});`)(); }
144
+ catch (err) { errorSlot.capture(err); return 0; }
145
+ return handles.alloc(result);
146
+ },
147
+ js_global() { return handles.alloc(globalThis); },
148
+ js_release(h) {
149
+ if (debug.trace && h !== 0 && handles.get(h) !== null) {
150
+ console.log(`[trace] js_release h=${h} (was ${typeof handles.get(h)})`);
151
+ }
152
+ handles.release(h);
153
+ },
154
+ js_get(h, keyPtr, keyLen) {
155
+ const key = readUtf8(keyPtr, keyLen);
156
+ const obj = handles.get(h);
157
+ if (obj == null) {
158
+ errorSlot.capture(new TypeError(`cannot read property '${key}' of ${obj}`));
159
+ return 0;
160
+ }
161
+ try { return handles.alloc(obj[key]); }
162
+ catch (err) { errorSlot.capture(err); return 0; }
163
+ },
164
+ js_set(h, keyPtr, keyLen, valueHandle) {
165
+ const key = readUtf8(keyPtr, keyLen);
166
+ const obj = handles.get(h);
167
+ if (obj == null) {
168
+ errorSlot.capture(new TypeError(`cannot set property '${key}' of ${obj}`));
169
+ return;
170
+ }
171
+ try { obj[key] = handles.get(valueHandle); }
172
+ catch (err) { errorSlot.capture(err); }
173
+ },
174
+ js_call(h, methodPtr, methodLen, argsPtr, argCount) {
175
+ const method = readUtf8(methodPtr, methodLen);
176
+ const obj = handles.get(h);
177
+ if (obj == null) {
178
+ errorSlot.capture(new TypeError(`cannot call '${method}' on ${obj}`));
179
+ return 0;
180
+ }
181
+ const argHandles = readHandleArray(argsPtr, argCount);
182
+ const args = argHandles.map((a) => handles.get(a));
183
+ try { return handles.alloc(obj[method].apply(obj, args)); }
184
+ catch (err) { errorSlot.capture(err); return 0; }
185
+ },
186
+ js_new(h, argsPtr, argCount) {
187
+ const ctor = handles.get(h);
188
+ if (typeof ctor !== "function") {
189
+ errorSlot.capture(new TypeError(`handle ${h} is not a constructor`));
190
+ return 0;
191
+ }
192
+ const argHandles = readHandleArray(argsPtr, argCount);
193
+ const args = argHandles.map((a) => handles.get(a));
194
+ try { return handles.alloc(new ctor(...args)); }
195
+ catch (err) { errorSlot.capture(err); return 0; }
196
+ },
197
+ js_handle_count() { return handles.count(); },
198
+ js_take_error() {
199
+ const err = errorSlot.take();
200
+ return err == null ? 0 : handles.alloc(err);
201
+ },
202
+ js_to_string_len(h) {
203
+ const v = handles.get(h);
204
+ return v == null ? 0 : encoder.encode(String(v)).length;
205
+ },
206
+ js_to_string_copy(h, ptr, bufLen) {
207
+ const v = handles.get(h);
208
+ if (v == null) return;
209
+ writeUtf8(String(v), ptr, bufLen);
210
+ },
211
+ js_from_string(ptr, len) { return handles.alloc(readUtf8(ptr, len)); },
212
+ js_to_int(h) {
213
+ const v = handles.get(h);
214
+ return v == null ? 0 : (v | 0);
215
+ },
216
+ js_from_int(v) { return handles.alloc(v); },
217
+ js_to_float(h) {
218
+ const v = handles.get(h);
219
+ return v == null ? 0 : Number(v);
220
+ },
221
+ js_from_float(v) { return handles.alloc(v); },
222
+ js_is_null(h) { return handles.isNull(h) ? 1 : 0; },
223
+ js_strict_equal(a, b) { return handles.get(a) === handles.get(b) ? 1 : 0; },
224
+ js_typeof_len(h) { return encoder.encode(typeof handles.get(h)).length; },
225
+ js_typeof_copy(h, ptr, bufLen) { writeUtf8(typeof handles.get(h), ptr, bufLen); },
226
+ js_inspect_len(h) { return encoder.encode(inspectValue(handles.get(h))).length; },
227
+ js_inspect_copy(h, ptr, bufLen) { writeUtf8(inspectValue(handles.get(h)), ptr, bufLen); },
228
+ js_instanceof(instanceH, ctorH) {
229
+ const ctor = handles.get(ctorH);
230
+ if (typeof ctor !== "function") return 0;
231
+ try { return handles.get(instanceH) instanceof ctor ? 1 : 0; }
232
+ catch (_err) { return 0; }
233
+ },
234
+ js_make_callback(callbackId) {
235
+ const wrapper = (...args) => {
236
+ if (debug.trace) console.log(`[trace] wrapper id=${callbackId} fired with`, args);
237
+ const argsHandle = handles.alloc(args);
238
+ try {
239
+ // js_invoke_proc returns a fresh handle for the block's return
240
+ // value (0 = undefined). We own it, so read + release.
241
+ const resultHandle = getInstance().exports.js_invoke_proc(callbackId, argsHandle);
242
+ if (resultHandle === 0) return undefined;
243
+ const result = handles.get(resultHandle);
244
+ handles.release(resultHandle);
245
+ return result;
246
+ } finally { handles.release(argsHandle); }
247
+ };
248
+ return handles.alloc(wrapper);
249
+ },
250
+ js_clone(h) {
251
+ // Fresh handle pointing at the same JS value — used so JS-bound
252
+ // copies don't share ownership with Ruby's original handle.
253
+ if (h === 0) return 0;
254
+ return handles.alloc(handles.get(h));
255
+ },
256
+ };
257
+ }
258
+
259
+ // --- Public factory -------------------------------------------------------
260
+
261
+ /**
262
+ * Instantiate a fresh mruby VM and return a handle for driving it.
263
+ *
264
+ * @param {object} options
265
+ * @param {string} options.wasm URL to mruby-js.wasm
266
+ * @param {Record<string, string>} [options.env] initial ENV
267
+ * @param {string[]} [options.args] initial ARGV (defaults to ["mruby-wasm-js"])
268
+ * @param {string|Uint8Array} [options.stdin] initial stdin payload
269
+ * @param {Directory} [options.fs] initial root Directory for the VFS
270
+ * @param {object} [options.wasi] replacement `wasi_snapshot_preview1` import object;
271
+ * defaults to a fresh in-memory WASI preview1 impl
272
+ * @param {(instance: WebAssembly.Instance) => void} [options.onStart]
273
+ * called once after instantiation; defaults to
274
+ * calling `instance.exports._start()`
275
+ *
276
+ * @returns {Promise<{
277
+ * instance: WebAssembly.Instance,
278
+ * eval: (source: string) => number, // 0 on success, 1 on parse/runtime error. Throws NotImplementedError in compiler-less builds.
279
+ * loadBytecode: (bytes: Uint8Array | ArrayBuffer) => number, // load pre-compiled mrbc bytecode. 0 on success, 1 on runtime error.
280
+ * alloc: (value: any) => number, // power-user handle table
281
+ * get: (handle: number) => any,
282
+ * release: (handle: number) => void,
283
+ * handleCount: () => number,
284
+ * fs?: object, // present iff bundled WASI is in use
285
+ * env?: Record<string, string>, // present iff bundled WASI is in use
286
+ * args?: string[], // present iff bundled WASI is in use
287
+ * stdin?: { bytes: Uint8Array, pushText: (s: string) => void }, // present iff bundled WASI is in use
288
+ * }>}
289
+ *
290
+ * Note: when `options.wasi` is provided, the returned VM does NOT include
291
+ * `fs` / `env` / `args` / `stdin` — those keys describe the bundled
292
+ * WASI preview1's state, and the caller's WASI replacement owns its own
293
+ * state instead. This keeps the typed surface of the returned object
294
+ * consistent with which WASI is actually backing it.
295
+ *
296
+ * Swap WASI for `@bjorn3/browser_wasi_shim`:
297
+ *
298
+ * import { WASI } from "@bjorn3/browser_wasi_shim";
299
+ * const wasi = new WASI([], [], preopens);
300
+ * const vm = await createVM({
301
+ * wasm: "/path/to/mruby-js.wasm",
302
+ * wasi: wasi.wasiImport,
303
+ * onStart: (instance) => wasi.start(instance),
304
+ * });
305
+ *
306
+ * The `js.*` imports (the JS layer itself) are always
307
+ * provided by this adapter regardless of which WASI is used.
308
+ */
309
+ export async function createVM(options = {}) {
310
+ const { wasm, onStart } = options;
311
+ if (!wasm) throw new Error("createVM: options.wasm (URL to mruby-js.wasm) is required");
312
+
313
+ const handles = createHandleTable();
314
+ const errorSlot = createErrorSlot();
315
+ let instance = null;
316
+ const getInstance = () => instance;
317
+
318
+ const jsImports = createJsImports({ handles, errorSlot, getInstance });
319
+
320
+ const customWasi = options.wasi;
321
+ const wasiImpl = customWasi ? null : createWasiPreview1({
322
+ env: options.env,
323
+ args: options.args,
324
+ stdin: options.stdin,
325
+ fs: options.fs,
326
+ });
327
+ const wasiImports = customWasi ?? wasiImpl.imports;
328
+
329
+ const response = await fetch(wasm);
330
+ if (!response.ok) {
331
+ throw new Error(`createVM: failed to fetch ${wasm}: ${response.status}`);
332
+ }
333
+ const result = await WebAssembly.instantiateStreaming(response, {
334
+ env: envImports,
335
+ js: jsImports,
336
+ wasi_snapshot_preview1: wasiImports,
337
+ });
338
+ instance = result.instance;
339
+ if (wasiImpl) wasiImpl.bindInstance(instance);
340
+
341
+ if (onStart) {
342
+ onStart(instance);
343
+ } else if (typeof instance.exports._initialize === "function") {
344
+ // Reactor module: runs ctors (including the gem's mrb_open + ARGV
345
+ // boot ctor) and returns. No exit-pseudo-exception to catch.
346
+ instance.exports._initialize();
347
+ } else if (typeof instance.exports._start === "function") {
348
+ // Command module fallback (e.g. caller-supplied wasm built without
349
+ // -mexec-model=reactor). _start can throw a pseudo-exception on exit;
350
+ // swallow that one path and let real errors propagate.
351
+ try { instance.exports._start(); }
352
+ catch (err) {
353
+ if (err.message && !err.message.includes("exit")) throw err;
354
+ }
355
+ }
356
+
357
+ // Pull the structured exception left by the previous failing eval /
358
+ // loadBytecode and surface it as a RubyError. The wasm side stashes a
359
+ // JS object handle in g_last_error_handle when mrb->exc is set; we
360
+ // drain + release it here. Returns null when no error is pending
361
+ // (e.g., a parse fail before mruby ever got to raise).
362
+ function takeRubyError() {
363
+ const h = instance.exports.js_take_last_error();
364
+ if (!h) return null;
365
+ const info = handles.get(h);
366
+ handles.release(h);
367
+ return info ? new RubyError(info) : null;
368
+ }
369
+
370
+ function evalRuby(source, options = {}) {
371
+ const { filename, lineOffset = 0, throw: shouldThrow = true } = options;
372
+ const srcH = handles.alloc(source);
373
+ const fileH = filename ? handles.alloc(String(filename)) : 0;
374
+ let rc;
375
+ try {
376
+ rc = instance.exports.js_eval_handle(srcH, fileH, lineOffset | 0);
377
+ } finally {
378
+ handles.release(srcH);
379
+ if (fileH) handles.release(fileH);
380
+ }
381
+ // rc === 2: compiler-less build signalled that source eval is not
382
+ // available. Surface as NotImplementedError so the caller learns to
383
+ // pre-compile with mrbc and use loadBytecode instead.
384
+ if (rc === 2) {
385
+ const err = new Error(
386
+ "vm.eval(source) is not available in this mruby build " +
387
+ "(compiled without mruby-compiler). Pre-compile with mrbc and use vm.loadBytecode(bytes) instead.",
388
+ );
389
+ err.name = "NotImplementedError";
390
+ throw err;
391
+ }
392
+ if (rc !== 0 && shouldThrow) {
393
+ const err = takeRubyError();
394
+ if (err) throw err;
395
+ throw new Error("mruby eval failed with no structured error info");
396
+ }
397
+ // throw: false branch — keep the legacy rc=0/1 contract and let the
398
+ // caller decide whether to inspect the (now-drained) error slot.
399
+ if (!shouldThrow && rc !== 0) takeRubyError();
400
+ return rc;
401
+ }
402
+
403
+ // Load pre-compiled mruby bytecode (output of `mrbc`). The bytes must
404
+ // already contain whatever fiber wrapping the source needs — this path
405
+ // does NOT auto-wrap, unlike `eval(source)`. Available in all build
406
+ // variants; primary use is the compiler-less / production variant.
407
+ //
408
+ // Accepts `Uint8Array` or `ArrayBuffer` (auto-wrapped as a zero-copy
409
+ // view), since `await fetch(...).arrayBuffer()` returns the latter.
410
+ function loadBytecode(bytes, options = {}) {
411
+ if (bytes instanceof ArrayBuffer) bytes = new Uint8Array(bytes);
412
+ if (!(bytes instanceof Uint8Array)) {
413
+ throw new TypeError("loadBytecode: expected Uint8Array or ArrayBuffer");
414
+ }
415
+ const { throw: shouldThrow = true } = options;
416
+ const handle = handles.alloc(bytes);
417
+ let rc;
418
+ try { rc = instance.exports.js_load_irep_handle(handle); }
419
+ finally { handles.release(handle); }
420
+ if (rc !== 0 && shouldThrow) {
421
+ const err = takeRubyError();
422
+ if (err) throw err;
423
+ throw new Error("mruby loadBytecode failed with no structured error info");
424
+ }
425
+ if (!shouldThrow && rc !== 0) takeRubyError();
426
+ return rc;
427
+ }
428
+
429
+ // Eval the textContent of a DOM element matched by `selector`.
430
+ // Pairs with `<script type="text/ruby">` blocks. Browser-only.
431
+ function evalScript(selector, options = {}) {
432
+ if (typeof document === "undefined") {
433
+ throw new Error("evalScript: requires a DOM (document is undefined)");
434
+ }
435
+ const el = document.querySelector(selector);
436
+ if (!el) throw new Error(`evalScript: no element matches ${JSON.stringify(selector)}`);
437
+ return evalRuby(el.textContent, options);
438
+ }
439
+
440
+ // Core VM surface plus, when we own the WASI side, the bundled VFS
441
+ // state (fs / env / args / stdin). Keys are omitted entirely when
442
+ // the caller passed their own `wasi` — that object controls fs/env/
443
+ // args/stdin, and `undefined` placeholders are harder to typecheck
444
+ // and easier to misread than absent properties.
445
+ return {
446
+ instance,
447
+ eval: evalRuby,
448
+ loadBytecode,
449
+ evalScript,
450
+ alloc: handles.alloc,
451
+ get: handles.get,
452
+ release: handles.release,
453
+ handleCount: () => handles.count(),
454
+ ...(wasiImpl && {
455
+ fs: wasiImpl.fs,
456
+ env: wasiImpl.env,
457
+ args: wasiImpl.args,
458
+ stdin: wasiImpl.stdin,
459
+ }),
460
+ };
461
+ }