quickjs 0.21.0 → 0.22.0.rc1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74989b6a83f8e672d71f2a160e9bbca02435a2ec7f2a3ccb79fdd4096750274a
4
- data.tar.gz: 7560ccc9a6baaddb2c76450975b116adf7ca02684424ae529be766792c063da4
3
+ metadata.gz: a5cd2ee71809218bf982138c0ad34a4a354527b9db70fdbb84119cc8b34fc480
4
+ data.tar.gz: cc38b076bbc185bf25e696590cbef20516987d3239e013340044433983cdc763
5
5
  SHA512:
6
- metadata.gz: bc4dd54ef4556ac4485a18efc65429819b2f170fdb4396468f456bc188f618f69be65899a1dbd9aca5649baf9bee68c6e31bc10095dd3ce318914b1d0031cb06
7
- data.tar.gz: 4f5abf70de9d534745a318101f8aee3026f299ad04a9ba16b2296c33dec9816cf44b32c001a20c7ea94378a3389a8f83b36244cac3ef408ac57d75691c490098
6
+ metadata.gz: f388ca1c40e15a113c1b44d4a315967ea708aece786fb7b2f0b0e8d1637743b89aee560f573d4ffb3525cc5ae1694c07e1e8bc75b0dba90964b0f7a7e1e94082
7
+ data.tar.gz: 70ab008e61a78eec51a02c665b00560c8a092ed2ee19fe22f099025d8226cf16cbbc73c041f20dd9560f8a049eac4a92ffaeeb342d8dfa628463dfb29a53dc19
data/README.md CHANGED
@@ -190,9 +190,20 @@ Return value:
190
190
 
191
191
  - A `String` — that's the source. The canonical name used for QuickJS's module cache is the specifier itself.
192
192
  - A `Hash` `{ code:, as: }` — `code:` is the source, `as:` becomes the canonical name. Use this when the same specifier should resolve to different modules depending on the importer (importmap "scopes"), since QuickJS caches by canonical name and changing the canonical is what isolates the two modules.
193
+ - A `Hash` `{ as: }` with no `code:` — a redirect: "resolve this specifier to `as:`". No source is provided, so the target has to be a module the VM already has, whether preloaded with `preload_modules:` or loaded by an earlier import. Pointing at anything else raises `Quickjs::ReferenceError`, since there is nothing left to load.
193
194
  - `nil` or `false` — raises `Quickjs::ReferenceError` on the JS side ("module not found").
194
195
  - Anything else — `Quickjs::TypeError`.
195
196
 
197
+ ```rb
198
+ # Give a preloaded module a name of your choosing, without shipping its source twice
199
+ vm = Quickjs::VM.new(preload_modules: ['/vendor/lodash.js'])
200
+ vm.module_loader = ->(specifier, _importer) {
201
+ {as: '/vendor/lodash.js'} if specifier == 'lodash'
202
+ }
203
+ ```
204
+
205
+ Watch out for one consequence: `{code: modules[specifier], as: name}` where the lookup misses is a redirect to `name` rather than a `TypeError`, so it fails later with a `ReferenceError` instead of at the point of the mistake. Return `nil` for an unknown specifier rather than a `Hash` with a missing `code:`.
206
+
196
207
  Importmap scope example:
197
208
 
198
209
  ```rb
@@ -223,6 +234,80 @@ When `module_loader=` is set, pass `filename:` to `import` instead of `from:` to
223
234
 
224
235
  `import` awaits the module's top-level evaluation — top-level `await`, synchronous body execution, and any chained dynamic `import()`. The call blocks until the module's settle promise resolves. A top-level throw, a failed dynamic `import()`, or a rejected top-level `await` propagates back to Ruby as the matching `Quickjs::*Error` instead of being silently dropped.
225
236
 
237
+ #### `Quickjs.compile_module`: 🧊 Compile a module once and import it anywhere
238
+
239
+ `import(from: source)` reparses the module in every VM you import it into. `compile_module` parses once and returns a `Quickjs::Importable` you can import into any number of VMs, which is what `compile` does for classic scripts.
240
+
241
+ ```rb
242
+ RENDERER = Quickjs.compile_module(File.read('vendor/renderer.js'), filename: 'renderer.js')
243
+
244
+ vm = Quickjs::VM.new
245
+ vm.import(['render'], from: RENDERER) # no parse cost, on this VM or any other
246
+ vm.eval_code('render({ id: 1 })')
247
+ ```
248
+
249
+ It takes the same import shapes and `code_to_expose:` as an inline source, so switching an existing `from:` a `String` to `from:` an `Importable` is the only change needed.
250
+
251
+ Hold the `Importable` wherever the JS belongs — a constant in a gem, an attribute on a service object — and import it into as many VMs as you like. Nothing about it is process-global, which matters when several libraries in one app each ship their own JS: none of them has to agree with the others about anything.
252
+
253
+ The module's name is generated, not taken from you. It is baked into the bytecode and becomes the module's identity in every VM that imports it, so generating it means two `Importable`s built independently can never collide. `filename:` only prefixes the generated name to keep stack traces readable; it is a label, not an identity. `Importable#canonical_name` returns the generated one (`renderer.js-3f9a...`), which is what a `module_loader` means by `as:`. Each VM still gets its own instance of the module with its own module-level state, and importing the same `Importable` into one VM more than once reads the bytecode only the first time.
254
+
255
+ Compilation happens eagerly, so defer it if the JS might never be used:
256
+
257
+ ```rb
258
+ def self.renderer
259
+ @renderer ||= Quickjs.compile_module(File.read('vendor/renderer.js'))
260
+ end
261
+ ```
262
+
263
+ An imported module's own `import`s still resolve through `module_loader` on first use, and `module_loader` is never asked about the `Importable` itself.
264
+
265
+ **Use this when the module's name is your own business.** If JS code elsewhere in the graph needs to write `import 'lib'` and have a `module_loader` resolve it, the module needs a name everyone agrees on, which is what `register_module` below is for. Or give the generated name a friendly alias on the VMs that want one, with a [redirect](#quickjsvmmodule_loader--resolve-import-specifiers-from-ruby):
266
+
267
+ ```rb
268
+ vm.import(['render'], from: RENDERER)
269
+ vm.module_loader = ->(specifier, _importer) {
270
+ {as: RENDERER.canonical_name} if specifier == 'renderer'
271
+ }
272
+
273
+ # JS elsewhere in the graph can now write: import { render } from 'renderer'
274
+ ```
275
+
276
+ #### `Quickjs.register_module`: 📦 Preload ES modules as bytecode
277
+
278
+ A module resolved through `module_loader` is parsed again by every VM that imports it. `register_module` parses it once per process and hands each VM the compiled bytecode instead, which is the same trick `compile` plays for classic scripts. On a 220KB module that takes importing from ~10.7ms to ~1.8ms per VM.
279
+
280
+ ```rb
281
+ Quickjs.register_module('lib', source: File.read('lib.js'))
282
+
283
+ vm = Quickjs::VM.new(preload_modules: ['lib'])
284
+ vm.import(['call'], filename: 'lib')
285
+ vm.eval_code('call(1, 2)')
286
+ ```
287
+
288
+ Registration is process-wide, `preload_modules:` is per VM. Registering only makes a module available; each VM says which ones it wants. That split is deliberate: reading a module into a VM costs about 1.26ms per 220KB whether or not it ends up being imported, so a registry that every VM read wholesale would be slower than plain source loading as soon as a VM used only part of it. It is the same reason browsers ask for `<link rel="modulepreload">` per document rather than preloading everything they know about.
289
+
290
+ `source:` also accepts a `Proc` returning a `String`, so a gem can register at `require` time without paying the file-read cost unless a VM actually preloads it:
291
+
292
+ ```rb
293
+ Quickjs.register_module('lib', source: -> { File.read('lib.js') })
294
+ ```
295
+
296
+ The first VM to preload a given module pays the compile cost (on a disposable VM with a generous timeout, so it doesn't consume the user VM's `timeout_msec`); later VMs reuse the cached bytecode. Each VM still gets its own instance of the module, with its own module-level state.
297
+
298
+ **`name` is the canonical name, not a label.** It is the string JS writes in its `import` statement, the name QuickJS keys its module map by, and it is baked into the compiled bytecode. If your `module_loader` resolves specifiers to absolute paths, register under the resolved path (`/vendor/lodash.js`), not the bare specifier your JS happens to write (`lodash`). A loader that maps a specifier onto a preloaded canonical via `as:` still lands on the preloaded module, so importmap-style scoping keeps working.
299
+
300
+ Relative-looking names are the one case where that bites without a loader in play. With no `module_loader` set, QuickJS's own normalization resolves `./lib.js` against the importing file before looking in the module map, so a module registered as `./lib.js` is never found and the import falls through to the filesystem loader. Register a bare or absolute name (`lib.js`, `/app/lib.js`) unless a `module_loader` is doing the normalizing.
301
+
302
+ Two consequences worth knowing:
303
+
304
+ - **A preloaded module wins over `module_loader`, which is never asked for that name.** The module is already in the VM's module map, exactly as if it had been imported earlier, so resolution finds it before any loader runs.
305
+ - **A preloaded module's own imports are not preloaded.** They resolve through `module_loader` on first import like any other specifier, so register each module you want cached. This differs from HTML's `modulepreload`, which walks the dependency graph.
306
+
307
+ Preloading a name that isn't registered raises `ArgumentError`; names must be `String`s (a `Symbol` raises `TypeError`). `Quickjs._unregister_module(name)` removes an entry, which is mostly useful for keeping tests isolated.
308
+
309
+ **Preloading grants the module to everything running in that VM**, including untrusted code reaching it with a dynamic `import()`, and whether or not your Ruby code ever imports it. `module_loader` is the authorization point, and preloading deliberately bypasses it for that name, so a loader that allows a module for some importers and denies it for others has no say over a preloaded one. If a module needs per-importer or per-scope authorization, resolve it through `module_loader` instead of preloading it, and accept the parse cost as the price of that control.
310
+
226
311
  #### `Quickjs::VM#on_unhandled_rejection`: 🚨 Catch promise rejections that have no handler
227
312
 
228
313
  Register a block to be notified when a JS Promise rejects with no `.catch` / `then(_, onRejected)` attached at the time of rejection — fire-and-forget chains, failed dynamic imports without `try`, etc.
@@ -239,9 +324,11 @@ vm.eval_code("void Promise.reject(new TypeError('drift'));")
239
324
 
240
325
  Calling `on_unhandled_rejection` again with a new block replaces the previously registered one (matching `on_log`).
241
326
 
242
- The block receives a `Quickjs::*Error` matching the rejection reason (`Quickjs::TypeError` for `new TypeError`, etc.); non-`Error` rejections (`Promise.reject('str')`, `Promise.reject({})`) are wrapped in `Quickjs::RuntimeError`. The exception's `#backtrace` carries the JS-side stack frames (`at func (file:line:col)`) for `Error` rejections, so the rejection site shows up directly when you log or re-raise. Exceptions raised inside the block are swallowed — propagating them out would corrupt the QuickJS runtime.
327
+ The block receives a `Quickjs::*Error` matching the rejection reason (`Quickjs::TypeError` for `new TypeError`, etc.); non-`Error` rejections (`Promise.reject('str')`, `Promise.reject({})`) are wrapped in `Quickjs::RuntimeError`. One exception: if reading the reason reaches one of your own bridges and that bridge raises — a `define_function` block failing inside a `get name()` on the rejected error, say — the block receives *your* exception instead, since the rejection itself was reportable and only one read of it was not. The exception's `#backtrace` carries the JS-side stack frames (`at func (file:line:col)`) for `Error` rejections, so the rejection site shows up directly when you log or re-raise. Exceptions raised inside the block are swallowed so the remaining notifications of the batch still go out; `dispose!` from inside the block raises `ThreadError` like any dispose during JS execution.
243
328
 
244
- The tracker fires synchronously when QuickJS first observes the rejection. A `.catch` attached later in the same tick does **not** suppress the notification, and a chain like `Promise.reject(x).then(y).then(z)` without a terminating `.catch` may emit a notification per intermediate promise. If that noise is a problem, attach handlers synchronously or dedupe by reason identity in your block. The block runs on the QuickJS stack — heavy work blocks JS execution.
329
+ As in HTML, a handler attached later in the same microtask checkpoint counts: a rejection is reported at the **end of the checkpoint**, and only if it is still unhandled then. Unlike HTML, which queues the notification as a separate task, the block is called before the call returns to Ruby. So `Promise.reject(x).catch(...)`, `new Promise((_, reject) => reject(x)).catch(...)`, and `const p = Promise.reject(x); await other; try { await p } catch {}` are silent, while a genuinely unhandled rejection — or an intermediate one in `Promise.reject(x).then(y)` that nothing catches — is reported once. The checkpoint ends when a call that runs JS (`eval_code`, `call`, `import`, `eval_bytecode`, `drain_jobs!`) returns to Ruby with the job queue empty; when such calls are nested (e.g. `eval_code` from a `define_function` block), only the outermost one ends it. Because `eval_code` does not drain the queue (see `drain_jobs!`), a rejection that only occurs in a queued `.then` continuation — or one whose handler would be attached there — is decided when `drain_jobs!` (or a later call that drains) ends that checkpoint. At most `max_pending_rejections` (a `VM.new` option, default 1000) are carried past a call that leaves jobs queued; beyond that the oldest are reported then, and `0` reports everything at the end of every call. `dispose!` reports whatever is still pending, since the queued jobs will never run. A promise the host itself awaits (the result of an async `eval_code` or `call`) is never reported: its rejection surfaces as the Ruby exception. The block runs on the QuickJS stack — heavy work blocks JS execution.
330
+
331
+ With `FEATURE_TIMEOUT`, a pending `setTimeout` counts as a queued job, so a report can be deferred until the timers drain, and a handler attached inside a timer callback still counts as attached in the same checkpoint ([#145](https://github.com/hmsk/quickjs.rb/issues/145)). Hosts that need reports not to wait for timers can use `max_pending_rejections: 0`.
245
332
 
246
333
  #### `Quickjs::VM#define_function`: 💎 Define a global function for JS by Ruby
247
334
 
@@ -269,7 +356,7 @@ vm.define_function(["a", "b", "c", "double"]) { |x| x * 2 }
269
356
  vm.eval_code("a.b.c.double(21)") #=> 42
270
357
  ```
271
358
 
272
- `define_function` returns the registered name as a `Symbol` (or an `Array` of `Symbol`s for array paths).
359
+ `define_function` returns the registered name as a `Symbol` (or an `Array` of `Symbol`s for array paths). Resolving an array path runs JS — the first segment is evaluated, so a `const` binding works as well as a global, and any segment can be a getter — so an error met on the way is reported as itself: a getter that throws raises the matching `Quickjs::*Error`, one of your own bridges raising inside it raises your exception, and a lapsed `timeout_msec` raises `Quickjs::InterruptedError`. A segment that resolves to a non-object raises `ArgumentError`. A target that refuses the property — a frozen object, or a setter that throws — raises the JS error rather than reporting success, and nothing is registered.
273
360
 
274
361
  A Ruby exception raised inside the block is catchable in JS as an `Error`, and propagates back to Ruby as the original exception type if uncaught in JS.
275
362
 
@@ -291,6 +378,118 @@ vm.eval_code("get_file().size") #=> Integer (byte size)
291
378
  vm.eval_code("await get_file().text()") #=> file content as String
292
379
  ```
293
380
 
381
+ #### `Quickjs::VM#define_const` / `#define_let` / `#define_var`: 📥 Pass Ruby values into JS
382
+
383
+ Expose a Ruby value to JS without interpolating it into your source. You pick the binding form, and it behaves exactly as the matching JavaScript declaration does:
384
+
385
+ ```rb
386
+ vm = Quickjs::VM.new
387
+ vm.define_const(:user, { name: 'Itadori', tags: ['strong', 'kind'] })
388
+
389
+ vm.eval_code("user.name + ': ' + user.tags.join(', ')") #=> "Itadori: strong, kind"
390
+ ```
391
+
392
+ | | JS can reassign it | Redeclaring it in JS | On `globalThis` |
393
+ | --- | --- | --- | --- |
394
+ | `define_const` | no, raises `Quickjs::TypeError` | `Quickjs::SyntaxError` | no |
395
+ | `define_let` | yes | `Quickjs::SyntaxError` | no |
396
+ | `define_var` | yes | allowed | **yes** |
397
+
398
+ Reach for `define_var` when the JS you're running expects a global to already exist — a third-party bundle reading `globalThis.APP_CONFIG`, for instance. It's the only one of the three that's visible there:
399
+
400
+ ```rb
401
+ vm.define_var(:APP_CONFIG, { retries: 3 })
402
+
403
+ vm.eval_code('globalThis.APP_CONFIG.retries') #=> 3
404
+ ```
405
+
406
+ **Define before you run code you do not control.** `var` is the only form that lands on `globalThis`, which is what makes it useful here and also the only one an existing property can intercept. If JavaScript has already run in the VM and left an accessor or a non-writable property under that name, the assignment would go to its setter or be discarded, so `define_var` raises `ArgumentError` rather than reporting a success that did not happen.
407
+
408
+ That check reads the VM through JavaScript, so treat it as a guard against a global that is already unusable rather than as a defence: code that has run in a VM owns that VM's environment, and a value handed to it afterwards cannot be hidden from it. `define_const` and `define_let` are unaffected, since neither touches `globalThis`.
409
+
410
+ **A value is written out once per occurrence.** A Ruby structure that reaches the same object twice serializes it twice rather than sharing it, so a graph whose branches repeat expands as it nests. Values built from YAML aliases or a `Marshal` round-trip are the ones that hit this without meaning to. Serializing stops if the result would exceed the VM's `memory_limit`, since a source larger than the whole JS heap budget could not be evaluated anyway, and raises `ArgumentError` naming the option. A `memory_limit` at or above `2 ** 63` reads back negative through QuickJS and is treated as no limit, which turns that bound off along with it.
411
+
412
+ This is a ceiling and not a prediction. Object-heavy JavaScript costs several times its source size once parsed, so a value well under `memory_limit` can still exhaust the VM when it runs.
413
+
414
+ The bound is on the JavaScript being built, not on the Ruby memory used to build it, and serializing stops partway rather than after the whole thing exists. Peak host memory is a multiple of `memory_limit` rather than equal to it, since the source exists in Ruby before it is evaluated and a structure is walked before it is refused. How deep a value may nest is whatever the calling thread's stack allows, which is why no number is quoted here: a Ruby thread gets a fraction of the main thread's stack, so the same structure can convert on one and not the other. QuickJS clamps its own parser limit to the same headroom, so on a thread the parser can be the tighter of the two; either way the refusal is an `ArgumentError`. Running out raises `ArgumentError` rather than the `SystemStackError` that `rescue => e` would not catch.
415
+
416
+
417
+ Because these are real declarations rather than property assignments, a colliding declaration at the top level of your JS is a loud error instead of a silent shadow:
418
+
419
+ ```rb
420
+ vm.define_const(:user, 1)
421
+ vm.eval_code('let user = 2;') #=> raise Quickjs::SyntaxError ("redeclaration of 'user'")
422
+ ```
423
+
424
+ Scopes below the top level shadow as JavaScript always does, silently. That includes module scope, so a module you import can declare the same name without hearing about it:
425
+
426
+ ```rb
427
+ vm.define_const(:injected, 1)
428
+ vm.eval_code('(function () { let injected = 2; return injected })()') #=> 2, the outer one is untouched
429
+ ```
430
+
431
+ Defining an existing `let` or `var` again assigns to it. A `const` can't be redefined, and neither can a name switch binding form:
432
+
433
+ ```rb
434
+ vm.define_let(:counter, 1)
435
+ vm.define_let(:counter, 2)
436
+ vm.eval_code('counter') #=> 2
437
+
438
+ vm.define_const(:user, 1)
439
+ vm.define_const(:user, 2) #=> raise ArgumentError
440
+ vm.define_var(:counter, 3) #=> raise ArgumentError (already defined as a let)
441
+ ```
442
+
443
+ Those collisions are loud because `const` and `let` cannot be redeclared in JavaScript. `var` can, so a `define_var` onto a name guest code already declared as a `var` overwrites it without saying so:
444
+
445
+ ```rb
446
+ vm.eval_code("var cfg = { from: 'guest' };")
447
+ vm.define_var(:cfg, { from: 'host' })
448
+ vm.eval_code('cfg.from') #=> "host", and nothing reported the collision
449
+ ```
450
+
451
+ That is `var`'s own semantics rather than a check we skipped, and it is the price of the form that reaches `globalThis`. If you want to know about a collision, `define_let` or `define_const` will tell you.
452
+
453
+ The name is a `String` or `Symbol` and comes back as a `Symbol`. It has to match `/\A[A-Za-z_$][A-Za-z0-9_$]*\z/` and not be a reserved word. That is narrower than JavaScript itself, which also accepts Unicode identifiers like `値`: the name is concatenated into source that gets evaluated, so the pattern is what stops one from smuggling in arbitrary JS.
454
+
455
+ Values are `Hash`, `Array`, `String`, `Symbol`, `Integer`, `Float`, `true`/`false`/`nil`, plus `Quickjs::Value::UNDEFINED` and `Quickjs::Value::NAN`. Those last two are the plain Symbols `:undefined` and `:NaN`, so a Symbol value of either name arrives as JavaScript's `undefined` or `NaN` rather than as a string, matching what the converter does with a `define_function` return value. `Rational`, `Complex` and `BigDecimal` are not included, since JavaScript has nothing to receive them as. Anything else raises rather than being silently stringified, `TypeError` for a value that has a class to name:
456
+
457
+ ```rb
458
+ vm.define_const(:at, Time.now) #=> raise TypeError
459
+ ```
460
+
461
+ Two things to know about a define while it runs. The declaration holds off `Timeout` and `Thread#raise` for its duration, so a `Timeout.timeout` around a `define_const` of a very large value does not interrupt it and is delivered once the declaration finishes; `timeout_msec` does not cover it either, since it only fires where QuickJS polls interrupts. The work is bounded by the same budget as the value itself, so it is a bounded wait rather than an open one. And a redefine can emit one `Uncaught SyntaxError` through `on_log` while still succeeding, when the name's binding currently holds `undefined` or the guest has put a property of the same name on `globalThis`: the define returns normally and the value is correct, but a host routing `on_log` into error tracking sees a line it did not write ([#147](https://github.com/hmsk/quickjs.rb/issues/147)).
462
+
463
+ An `Integer` too large for a JavaScript `Number` is written as an integer literal and loses precision when JavaScript parses it, the same way the value converter behaves for a `define_function` return:
464
+
465
+ ```rb
466
+ vm.define_const(:big, 2**70)
467
+ vm.eval_code('String(big)') #=> "1.1805916207174113e+21", not "1180591620717411303424"
468
+ ```
469
+
470
+ Nothing raises, because the literal is valid JavaScript and the loss happens in the engine. Pass such a value as a `String` and parse it in JS if the digits matter.
471
+
472
+ This is a narrower set than the converter used for `define_function` return values, which also handles `File` and `Exception`. Defining is an input path, so an unsupported value is treated as a mistake worth hearing about rather than something to coerce.
473
+
474
+ A few edges are worth knowing before they surprise you:
475
+
476
+ - **Strings are taken as text, not as bytes.** An `ASCII-8BIT` string whose bytes happen to be valid UTF-8 is reinterpreted as those characters, so `"\xC3\xA9".b` arrives as `"é"` with length 1. Bytes that are not valid UTF-8 raise `JSON::GeneratorError`, and a name in an encoding that cannot be compared against ASCII raises `Encoding::CompatibilityError`; both are unsupported values reported by the layer that noticed rather than as `TypeError`. Which json ships with your Ruby decides how long the first of those holds: json warns from 2.9 on that passing a binary string will raise in json 3.0, and Ruby 3.4 already ships a json that warns.
477
+ - **Integers lose precision past `2 ** 53`, silently.** JavaScript has one number type, so `2 ** 53 + 1` arrives as `9007199254740992.0`, the same as `2 ** 53`. Past the range of a double it becomes `Infinity` rather than a rounded value: `10 ** 400` is `Infinity` on the JS side. This matches what the rest of the gem does with large integers.
478
+ - **A `__proto__` key stays a key.** In a JS object literal `__proto__: v` sets the prototype instead of defining a property, and quoting it does not opt out, so a `Hash` with that key would otherwise reach JS with no such key and read back as if it had never been sent. It is emitted as a computed key, which is not that special form: `Object.keys` lists it and it round-trips. JavaScript you write yourself is untouched, and `{__proto__: x}` in your own source still sets a prototype.
479
+ - **Hash keys are compared after `to_s`.** `{ 'a' => 1, a: 2 }` and `{ 1 => 'x', '1' => 'y' }` each produce one JS key, and the last value wins, as they would in a JS object literal.
480
+ - **A `const` or `let` defined here shadows a `define_function` of the same name**, in either order and without an error. `define_const(:svc, 1)` followed by `define_function('svc')` leaves `typeof svc` as `"number"`, with the function reachable only as `globalThis.svc`. `define_var` is different: it and `define_function` write the same `globalThis` property, so whichever runs second silently replaces the other.
481
+ - **Reserved words are rejected, built-ins are not.** The name check refuses `class`, `return` and the rest, because QuickJS would too and the failure reads better from Ruby. It does not stop you replacing something that already exists: `define_var(:eval, 1)` succeeds and `typeof eval` becomes `"number"`.
482
+
483
+ The value is a snapshot taken when you define it, not a live reference. Mutating the Ruby object afterwards won't change what JS sees. Use [`define_function`](#quickjsvmdefine_function--define-a-global-function-for-js-by-ruby) when you want JS to read the current Ruby value on every access:
484
+
485
+ ```rb
486
+ config = { retries: 3 }
487
+ vm.define_function('config') { config }
488
+
489
+ config[:retries] = 5
490
+ vm.eval_code('config().retries') #=> 5
491
+ ```
492
+
294
493
  #### `Quickjs::VM#on_log`: 📡 Handle console logs in real time
295
494
 
296
495
  Register a block to be called for each `console.(log|info|debug|warn|error)` call.
@@ -322,10 +521,13 @@ vm.memory_usage
322
521
  vm.gc! # trigger a QuickJS GC cycle; returns nil
323
522
 
324
523
  vm.memory_poisoned? #=> false (true once the VM has hit out-of-memory)
524
+ vm.poisoned? #=> false (true when the VM has stopped accepting work, for any reason)
325
525
  ```
326
526
 
327
527
  When the JS heap exhausts its memory limit, QuickJS enters a fragile state where further evaluation can segfault the process. `memory_poisoned?` flips to `true` after such an event, and subsequent `eval_code` / `call` calls raise `Quickjs::RuntimeError` immediately instead of risking a crash. Rescue it and recreate the VM.
328
528
 
529
+ A VM can also stop accepting work for a reason recreating it will not fix. `poisoned?` answers for any of them, `memory_poisoned?` only for out-of-memory, so the recycle path below tests the narrower one on purpose: if `poisoned?` is true while `memory_poisoned?` is false, a new VM will refuse in the same way and the failure belongs to the host. The one case today is `SecureRandom.random_number` no longer returning distinct integers, which leaves objects crossing into JS with no handle a guest cannot guess; the raised message says so.
530
+
329
531
  ```rb
330
532
  vm = Quickjs::VM.new(memory_limit: 256 * 1024 * 1024)
331
533
 
@@ -358,7 +560,7 @@ vm.eval_code('1 + 1') # raises Quickjs::RuntimeError "VM has been disposed"
358
560
  Thread.new { vm.dispose! }
359
561
  ```
360
562
 
361
- Disposing a VM that is mid-evaluation on another thread would free the runtime out from under the running JS, so `dispose!` raises `ThreadError` while JS is executing on the VM (`eval_code`, `call`, `import`, `drain_jobs!`, `Runnable#run`) — dispose after the call returns.
563
+ Disposing a VM that is mid-evaluation on another thread would free the runtime out from under the running JS, so `dispose!` raises `ThreadError` while JS is executing on the VM (`eval_code`, `call`, `import`, `drain_jobs!`, `compile`, `Runnable#run`) — dispose after the call returns.
362
564
 
363
565
  #### `Quickjs::VM#drain_jobs!`: Run pending JS jobs to completion
364
566
 
@@ -378,12 +580,23 @@ Useful when porting JS that assumed V8's implicit-drain semantics — V8 (and th
378
580
 
379
581
  #### Threads and parallelism
380
582
 
381
- `eval_code` releases Ruby's GVL while JS runs, as long as no JS→Ruby bridge is registered on the VM (no `define_function`, `module_loader`, `on_unhandled_rejection`, and none of `FEATURE_TIMEOUT` / `POLYFILL_FILE` / `POLYFILL_CRYPTO` — `console.log` is fine). Separate VMs on separate Ruby threads then evaluate genuinely in parallel on multi-core hosts; when a bridge is registered, the GVL stays held for that VM's evals and they serialize as usual.
583
+ `eval_code` and `Runnable#run` release Ruby's GVL while JS runs, as long as no JS→Ruby bridge is registered on the VM (no `define_function`, `module_loader`, `on_unhandled_rejection`, and none of `FEATURE_TIMEOUT` / `POLYFILL_FILE` / `POLYFILL_CRYPTO` — `console.log` is fine). Separate VMs on separate Ruby threads then evaluate genuinely in parallel on multi-core hosts — including the compile-once-run-everywhere pattern, where per-thread VMs execute the same `Runnable` concurrently. When a bridge is registered, the GVL stays held for that VM's evals and they serialize as usual.
584
+
585
+ `compile` releases the GVL for the parse regardless of what is registered on the VM — parsing to bytecode runs no JS, so no bridge can be reached and the no-bridge rule above doesn't apply to it. Serializing the result back into a Ruby String stays on the GVL, which costs about 5% of the available speedup. So a dedicated compile VM, on its own thread, parses in parallel with everything else running in the process. `compile_module` (and therefore `Quickjs.register_module` / `Quickjs.compile_module`) does not release the GVL.
382
586
 
383
587
  The rules for sharing VMs across threads:
384
588
 
385
- - **One VM, one thread at a time.** A `Quickjs::VM` is not safe for concurrent use from multiple threads — QuickJS contexts have no internal locking. Handing a VM off between threads (e.g. constructing it on a warmer thread and using it on another) is fine as long as only one thread touches it at a time.
386
- - **Create the VM on the thread that evaluates with it** when possible: QuickJS records the creating thread's stack bounds, and evaluating from a thread whose stack sits below them can trip a false stack-overflow error.
589
+ - **One VM, one thread at a time**, and this one is enforced. A `Quickjs::VM` is not safe for concurrent use from multiple threads — QuickJS contexts have no internal locking — so a second thread entering while another is mid-call raises `ThreadError` rather than corrupting the heap:
590
+
591
+ ```ruby
592
+ vm = Quickjs::VM.new
593
+ Thread.new { vm.eval_code('while (true) {}') }
594
+ vm.eval_code('1 + 1')
595
+ #=> ThreadError: cannot use a Quickjs::VM from two threads at once; it is already evaluating on #<Thread:...>
596
+ ```
597
+
598
+ It is refusal, not serialization: nothing queues and waits. Every method that touches the runtime is covered, `gc!` and `memory_usage` included. Handing a VM off between threads (e.g. constructing it on a warmer thread and using it on another) is still fine, since ownership is only held for the duration of a call. So is a bridge re-entering its own VM — a `define_function` proc or `on_log` listener calling `eval_code` is the same thread, and is allowed.
599
+ - **The stack budget follows the evaluating thread.** QuickJS records the creating thread's stack bounds once, which used to make a handoff trip a false stack-overflow error on trivial code. The limit is now re-based on each outermost entry against the stack of the thread about to run JS, so where the VM was built no longer matters. `max_stack_size` is a ceiling rather than a guarantee: a Ruby thread's machine stack is a fraction of the main thread's, so the budget in force is whatever that thread actually has, less a margin to report the overflow with.
387
600
  - **Register bridges before evaluating.** `define_function`, `module_loader=`, and `on_unhandled_rejection` raise `ThreadError` while a GVL-released eval is in flight (e.g. from inside an `on_log` listener) — the running JS was allowed to release the GVL precisely because no bridge existed when it started.
388
601
  - **`MODULE_OS` caveat:** `os.signal` and `os.ttySetRaw` mutate process-wide state inside quickjs-libc, so don't call those two from VMs running concurrently on different threads. The common APIs (`os.sleep`, `os.setTimeout`, file I/O) only touch per-runtime state and are safe.
389
602
 
@@ -404,6 +617,18 @@ The rules for sharing VMs across threads:
404
617
  | `File` | → | `Quickjs::File` — `.name`, `.last_modified` + Blob attrs | requires `POLYFILL_FILE` |
405
618
  | `File` proxy | ← | `::File` | requires `POLYFILL_FILE`; applies to `define_function` return values |
406
619
 
620
+ An object reached more than once within a single conversion becomes one Ruby object, mirroring the graph JavaScript built. Mutating one occurrence is therefore visible through the others:
621
+
622
+ ```rb
623
+ result = Quickjs.eval_code('const o = {a: 1}; [o, o]')
624
+ result[0].equal?(result[1]) #=> true
625
+
626
+ result[0]['a'] = 999
627
+ result #=> [{ 'a' => 999 }, { 'a' => 999 }]
628
+ ```
629
+
630
+ A cycle has no Ruby equivalent, so the reference that closes it converts to `nil`.
631
+
407
632
  ## Extending: registering polyfills
408
633
 
409
634
  `Quickjs.register_polyfill(name, source:, init: nil)` adds a polyfill to a process-wide registry. Any VM constructed with `name` in its `features:` list runs the registered bundle on top of the JS runtime. Companion gems use this hook to ship additional polyfills (e.g. `Intl.Collator`, `DisplayNames`) without bundling them into the main gem.
@@ -430,11 +655,16 @@ Quickjs.register_polyfill(
430
655
 
431
656
  The first VM with a given polyfill pays the parse cost (the source is compiled to QuickJS bytecode on a disposable VM with a generous timeout); subsequent VMs reuse the cached bytecode. The polyfill body runs without consuming the user VM's `timeout_msec` budget — that's reserved for user code.
432
657
 
658
+ The polyfill's top level must settle synchronously — no top-level `await`. `VM.new(features:)` guarantees a usable polyfill on return, but loads don't drain the job queue, so nothing past the first `await` would have run by then. A polyfill left pending raises a `Quickjs::NoAwaitError`, and any top-level throw raises the matching `Quickjs::RuntimeError` subclass, both naming the feature at construction, rather than handing back a VM with the polyfill silently half-applied.
659
+
660
+ To ship JS that user code `import`s rather than globals it reaches for, see [`Quickjs.register_module`](#quickjsregister_module--preload-es-modules-as-bytecode), which follows the same registry protocol at the module layer instead of the global one.
661
+
433
662
  Intl APIs (Collator, DateTimeFormat, NumberFormat, PluralRules, Locale, etc.) live in a separate companion gem: [`quickjs-polyfill-intl`](https://github.com/hmsk/quickjs-polyfill-intl). Granular, dependency-aware, opt-in per API.
434
663
 
435
664
  ## Acknowledgements
436
665
 
437
666
  - [@ursm](https://github.com/ursm) — for continuous contributions improving performance and developer experience
667
+ - [@takahashim](https://github.com/takahashim) — for aligning unhandled promise rejection reporting with the HTML specification
438
668
  - [@persona-id](https://github.com/persona-id) — for providing real-world use cases that shape the direction of this project
439
669
 
440
670
  ## License