dommy-js-quickjs 0.1.0 → 0.10.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -1
- data/README.md +38 -9
- data/lib/dommy/js/quickjs/backend.rb +161 -17
- data/lib/dommy/js/quickjs/browser_environment.rb +93 -0
- data/lib/dommy/js/quickjs/capybara.rb +126 -30
- data/lib/dommy/js/quickjs/config.rb +62 -0
- data/lib/dommy/js/quickjs/error_translator.rb +137 -0
- data/lib/dommy/js/quickjs/event_loop.rb +149 -0
- data/lib/dommy/js/quickjs/js/browser_globals.js +36 -0
- data/lib/dommy/js/quickjs/js/error_rebuild.js +22 -0
- data/lib/dommy/js/quickjs/js/intl_polyfill.js +56 -0
- data/lib/dommy/js/quickjs/js/timer_globals.js +46 -0
- data/lib/dommy/js/quickjs/js/wasm_stub.js +26 -0
- data/lib/dommy/js/quickjs/js/window_builtins.js +14 -0
- data/lib/dommy/js/quickjs/rack.rb +15 -0
- data/lib/dommy/js/quickjs/runtime.rb +331 -122
- data/lib/dommy/js/quickjs/script_cache.rb +40 -0
- data/lib/dommy/js/quickjs/version.rb +1 -1
- data/lib/dommy/js/quickjs/wasm_bridge.rb +21 -15
- data/lib/dommy/js/quickjs.rb +17 -5
- metadata +27 -19
- data/Rakefile +0 -49
- data/docs/bridge-redesign.md +0 -559
- data/docs/wpt-conformance.md +0 -752
- data/lib/dommy/js/constructor_registry.rb +0 -40
- data/lib/dommy/js/custom_elements.rb +0 -55
- data/lib/dommy/js/dom_interfaces.rb +0 -139
- data/lib/dommy/js/handle_table.rb +0 -52
- data/lib/dommy/js/host_bridge.rb +0 -400
- data/lib/dommy/js/host_runtime.js +0 -922
- data/lib/dommy/js/observable_runtime.js +0 -728
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 56e600a474810ea778587cf249cbb7c200d9947c28270449837550971e5674de
|
|
4
|
+
data.tar.gz: ff9e6cf37ed37fb89f148163e38ee6fdd63e8bd5fc1e81669f8538eebc84d3ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ffa632c98fe6c8bedba9bc8e189cda13a1c9baef1c4bd0224fa1f7f9ffd31bdcaad8bcb84ffdbdf2b2436ec3a29bb68d0d8ceaa7879602f5cc45d8edbc1633ba
|
|
7
|
+
data.tar.gz: 22272e499d364204ecba6edb91cb6c01953d72d46990652b3f14907e2141686e8bbc8d94de3d5125e2f96b21e9e1a34b8d5c887c41af3dbe8cd693e33a2370bd
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,121 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 — 2026-10-01
|
|
4
|
+
|
|
5
|
+
Requires `dommy >= 0.14.0, < 0.15` and `quickjs ~> 0.21.0`.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Rejections are handed to a JS hook (`promise_rejection_hook=`) when the engine has one, so the promise and the reason cross as the values the page threw rather than an exception the engine already converted. That is what makes `event.reason`, `event.promise` and `rejectionhandled` work at all. `on_unhandled_rejection` goes quiet while the hook is installed, since both would report the same rejection.
|
|
10
|
+
- `Runtime#rebuild_error` builds a real `Error` inside the realm from an exception QuickJS raised for a script's throw, so `event.error` reaches the page with the name, message and stack it threw instead of an object with no properties. The engine discards the JS value when it converts the throw, so the rebuilt error matches on everything observable except identity.
|
|
11
|
+
- `rake capybara:compliance` runs Capybara's driver compliance suite against capybara-dommy with JavaScript on (`spec/capybara_compliance_spec.rb`). It is not part of `rake test`: it still fails in places (120 of 1413 examples today — asynchronous alerts, waiting on `current_path`, `attach_file`'s change events).
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Requires `dommy >= 0.14.0, < 0.15` (was `>= 0.9.0`). 0.14.0 moved the wire tags from `Dommy::Js` to `Dommy::Bridge`, which the wasm bridge now reads, and 0.13.0 is the release carrying the JS error-report API this gem's suite drives (`Window#__internal_on_unhandled_error__`, `Browser#error_log`, `Dommy::JsError`). The upper bound is new: dommy's 0.x minors change the bridge contract, as 0.14.0 did — 0.9.0, which had none, breaks against it.
|
|
16
|
+
- Requires `quickjs ~> 0.21.0` (was `~> 0.18.0`). Until both fixes are released upstream, the Gemfile pins [takahashim/quickjs.rb#feat/rejection-js-hook](https://github.com/takahashim/quickjs.rb/tree/feat/rejection-js-hook). It carries the JS rejection hook above, and sits on upstream's main with the checkpoint-timing fix ([hmsk/quickjs.rb#141](https://github.com/hmsk/quickjs.rb/pull/141), merged but not yet released) that reports an unhandled rejection at the end of the microtask checkpoint as HTML requires, rather than the moment a promise rejects. Without the latter, `Promise.reject(x).catch(...)` and `try { await rejecting() } catch {}` are both misreported as unhandled — correct code that a strict host then fails a test on. CI also runs the suite on the released 0.21.0 (`gemfiles/quickjs-released.gemfile`), which is what a RubyGems install gets.
|
|
17
|
+
- `SourceGuard` is gone. It rewrote a `for...of` whose iterable contains a `yield` and retried, working around a QuickJS codegen bug ("stack underflow") that broke real SPA bundles. The QuickJS that 0.21 vendors compiles the construct, and the gemspec requires `~> 0.21.0`, so nothing could reach the retry — and nothing could test it either. Revert the removal if the bug ever comes back.
|
|
18
|
+
- The gem's per-crossing `Timeout.timeout` is skipped by prepending to `::Quickjs`'s singleton rather than redefining `_with_timeout` outright, so the original stays reachable and the patch is visible in `ancestors`.
|
|
19
|
+
- The last JavaScript built as a Ruby string moved into `js/error_rebuild.js`: rebuilding a thrown Error in the realm now calls a helper installed once per VM instead of evaluating freshly interpolated source on every reported error.
|
|
20
|
+
- Every Backend entry point that runs something in the VM is guarded against a poisoned one, not just three of the six. After an out-of-memory an inline script quietly did nothing while an external (bytecode) script and `#evaluate` raised "VM is poisoned" from a different place each time; all of them now no-op, which is the "browsing never crashes" contract the guarded three already followed.
|
|
21
|
+
- `DOMMY_JS_MEMORY_LIMIT_MB` sets the VM's memory ceiling, as `DOMMY_JS_TIMEOUT_MSEC` does the per-eval timeout. Lowering it is how the out-of-memory path gets exercised deliberately.
|
|
22
|
+
- The realm's JavaScript (the Intl polyfill, the WebAssembly stub, the aliased browser globals, the mirrored built-ins, the timer instrumentation) moved out of Ruby heredocs into `.js` files under `lib/dommy/js/quickjs/js/`, installed by a new `BrowserEnvironment` through `Backend#run_bundle` — so each payload is compiled once per process rather than reparsed for every VM.
|
|
23
|
+
- `Runtime` delegates to `EventLoop` (the microtask queue and Dommy's scheduler as one loop, with the three drivers sharing a pump), `ErrorTranslator` (an engine exception as what the page should see) and `Config` (every `DOMMY_JS_*` variable). Its public surface is now the Runtime port plus what hosts call; `evaluate_settled`, `bump_dom_epoch` and the other internals are private.
|
|
24
|
+
- `Runtime#load_module` and `Backend#import_module` are gone, unused: an inline `<script type="module">` reaches the engine through `load_module_url`.
|
|
25
|
+
- `install_wasm_memory_shim` installs the same WebAssembly stub as `install_browser_globals` rather than a second, near-identical one.
|
|
26
|
+
- The vendored WPT tree is pinned to a single upstream revision (`test/fixtures/wpt/UPSTREAM_REVISION`) and refreshed with `script/vendor_wpt.sh`, rather than growing file by file from whatever upstream was that day. Refreshing to `2f7c700` moved 24 files; `url-constructor.any.js` is green again, since upstream now expects an undecodable A-label like `https://xn--/` to parse.
|
|
27
|
+
- The gem ships `lib/`, `sig/`, the README, the changelog and the license, and nothing else. It used to take everything git tracks bar a few paths, which by now meant the maintainers' design notes, the WPT comparison scripts, the Capybara compliance spec and the CI workflow.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- On the released quickjs (0.21.0), a promise rejection a handler catches no longer fails a strict `Dommy::Browser`. That engine reports a rejection the moment it happens, before a `.catch` or `try { await … } catch` attached later in the same microtask checkpoint can handle it, so correct code came out as an unhandled rejection. When the engine behaves that way — probed once per process, since a fixed engine still says 0.21.0 — `on_unhandled_rejection` reports nothing and says so once with a warning; a rejection the page truly leaves unhandled then goes unreported, to the host and to the page alike — `unhandledrejection` and `window.onunhandledrejection` do not fire. A quickjs with [hmsk/quickjs.rb#141](https://github.com/hmsk/quickjs.rb/pull/141) reports them as before.
|
|
32
|
+
- A classic script whose completion value is a pending Promise — `window.p = new Promise(...)` as its last statement, the idiom for publishing one a later script awaits — no longer reaches the page as an uncaught error. The gem refuses to convert such a value, and the resulting host exception was reported at the window; a testharness page that saw it reported no results at all. `#load_script` and `#load_script_cached` now discard the completion value, which `#execute` already did by wrapping in an IIFE.
|
|
33
|
+
- `Runtime#rebuild_error` no longer copies a host exception's Ruby backtrace into the rebuilt Error's `stack`, which published this gem's file paths to any page that reads it. Engine frames are kept, everything else dropped, and a backtrace with no JS frames leaves the stack empty rather than naming a place the page did not fail.
|
|
34
|
+
|
|
35
|
+
## 0.9.0 — 2026-06-22
|
|
36
|
+
|
|
37
|
+
The first substantial release since `0.1.0`. The version jumps to `0.9.0` to
|
|
38
|
+
line up with the rest of the Dommy monorepo (`dommy`, `dommy-rack`,
|
|
39
|
+
`capybara-dommy`). The headline change is architectural: the engine-agnostic
|
|
40
|
+
host layer and bridge now live in `dommy` core, leaving this gem as the QuickJS
|
|
41
|
+
backend that plugs into it. On top of that foundation sits real ESM and
|
|
42
|
+
JavaScript-framework support, an event-loop-aware runtime, and a large WHATWG /
|
|
43
|
+
WPT conformance pass.
|
|
44
|
+
|
|
45
|
+
Requires `dommy >= 0.9.0` and `quickjs ~> 0.18.0`.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
#### Browser & page lifecycle
|
|
50
|
+
- `Dommy::Browser`, a lightweight test browser that boots a page, runs its
|
|
51
|
+
scripts through a shared `ScriptBoot`, and exposes interaction verbs with a
|
|
52
|
+
conservative `settle` step
|
|
53
|
+
- `Browser.open` settles after boot by default (opt out / tune with the
|
|
54
|
+
`settle:` option)
|
|
55
|
+
- QuickJS is wired into Dommy page loads
|
|
56
|
+
- `SessionRuntime`, a JS host for the dommy-rack `Session`
|
|
57
|
+
|
|
58
|
+
#### ES Modules
|
|
59
|
+
- Full ESM support: `importmap`, the module loader, and `type=module` boot
|
|
60
|
+
- Inline modules' `import.meta.url` is pinned to the clean page URL
|
|
61
|
+
- External `<script src>` inserted into the DOM defers correctly rather than
|
|
62
|
+
running synchronously during append
|
|
63
|
+
|
|
64
|
+
#### JavaScript frameworks
|
|
65
|
+
- Host and conformance coverage for **Stimulus** (with a ported QUnit suite),
|
|
66
|
+
**React 18** (JSX, SSR, hydration), and **Vue 3** (global-scope script loading
|
|
67
|
+
with tolerant handles)
|
|
68
|
+
- Integration suites for **Alpine**, **htmx**, **Solid**, and **Lit**
|
|
69
|
+
|
|
70
|
+
#### Event loop, timers & promises
|
|
71
|
+
- `evaluate` / `await` are event-loop-aware and settle task-resolved results
|
|
72
|
+
- The scheduler's microtask-checkpoint hook is wired up so host-side microtasks
|
|
73
|
+
interleave with JS promises in FIFO order
|
|
74
|
+
- A throwing JS timer callback is isolated so it can no longer crash the host; a
|
|
75
|
+
runaway (force-killed) callback is recorded rather than fatal, and a throwing
|
|
76
|
+
callback is traced back to its scheduling site
|
|
77
|
+
- Ported the official **Promises/A+** suite against the host `PromiseValue`
|
|
78
|
+
|
|
79
|
+
#### Engine surface
|
|
80
|
+
- Polyfilled `Intl` and stubbed `WebAssembly` (the engine ships neither)
|
|
81
|
+
- More bare browser globals (`Image`, `Audio`, `Option`, `console`, `Object`, …)
|
|
82
|
+
are aliased onto the global scope as the native globals
|
|
83
|
+
- The per-eval timeout is configurable via `DOMMY_JS_TIMEOUT_MSEC`
|
|
84
|
+
- Bridge crossing counts are exposed; opaque unhandled rejections are enriched
|
|
85
|
+
|
|
86
|
+
#### WPT / WHATWG conformance
|
|
87
|
+
- A resource-driven WPT runner plus a large vendored corpus: DOM nodes /
|
|
88
|
+
traversal / ranges / collections, URL & URLSearchParams, Encoding, Selectors,
|
|
89
|
+
CSS (syntax, variables, color, CSSOM), WAI-ARIA roles, and accessible-name
|
|
90
|
+
(accname). The heavy thousands-of-subtests files are gated behind `WPT_HEAVY`.
|
|
91
|
+
|
|
92
|
+
### Changed
|
|
93
|
+
|
|
94
|
+
- **Pluggable runtime (breaking):** the engine-agnostic bridge and host layer
|
|
95
|
+
(`Browser` + the Js port) moved into `dommy` core; the JS runtime is now
|
|
96
|
+
pluggable and `Browser` is decoupled from QuickJS. This gem registers QuickJS
|
|
97
|
+
as a backend. Bridge wire-protocol tags are centralized and `JSValue` unified.
|
|
98
|
+
- **Bridge contract (breaking):** defensive `Dommy::Bridge` guards were dropped
|
|
99
|
+
in favor of requiring the backend contract; `dommy >= 0.9.0` is now required.
|
|
100
|
+
- Proxy identity and expando lifetime are preserved across crossings so reactive
|
|
101
|
+
frameworks observe stable object identity; a `NodeList` crosses as a `NodeList`
|
|
102
|
+
(not a plain array); `DOMException` subclasses cross as the single
|
|
103
|
+
`DOMException` interface (`constructor === DOMException`).
|
|
104
|
+
- Callback exceptions propagate across the bridge; `NodeFilter` objects cross as
|
|
105
|
+
live references.
|
|
106
|
+
|
|
107
|
+
### Fixed
|
|
108
|
+
|
|
109
|
+
- Survive a VM out-of-memory instead of crashing the browser
|
|
110
|
+
- Work around a QuickJS `for-of`-with-`yield`-in-iterable codegen bug
|
|
111
|
+
- Report a present-but-undefined IDL attribute via the `in` operator
|
|
112
|
+
- Scrub lone surrogates in dehydrated object keys
|
|
113
|
+
|
|
114
|
+
### Performance
|
|
115
|
+
|
|
116
|
+
- Skip the per-crossing Ruby `Timeout` (≈40% faster DOM crossings)
|
|
117
|
+
- Bytecode-cache the host runtime and external scripts
|
|
118
|
+
|
|
3
119
|
## 0.1.0
|
|
4
120
|
|
|
5
121
|
initial release.
|
|
6
|
-
|
data/README.md
CHANGED
|
@@ -7,15 +7,21 @@ JavaScript executes in an embedded QuickJS VM (via the
|
|
|
7
7
|
[`quickjs`](https://github.com/hmsk/quickjs.rb) gem). Dommy DOM nodes are bridged
|
|
8
8
|
to JS as objects whose property/method access routes into Dommy's
|
|
9
9
|
`__js_get__` / `__js_set__` / `__js_call__` / `__js_new__` ABI, so JS drives a
|
|
10
|
-
real Dommy document.
|
|
11
|
-
|
|
10
|
+
real Dommy document. This gem provides the QuickJS `Backend`; the
|
|
11
|
+
engine-agnostic bridge it plugs into (the `HostBridge` marshalling core and the
|
|
12
|
+
JS-side runtime) lives in the [`dommy`](https://github.com/takahashim/dommy) gem,
|
|
13
|
+
so other engines can reuse it.
|
|
12
14
|
|
|
13
15
|
The bridge presents a **spec-shaped JS DOM**, not bare proxies: `instanceof`,
|
|
14
16
|
prototype chains, `Object.prototype.toString` brands, constructable interfaces
|
|
15
17
|
(`new Event(...)`), custom elements (`class extends HTMLElement`), live
|
|
16
18
|
collections, and expandos all work — enough that the real
|
|
17
|
-
[`@hotwired/turbo`](https://github.com/hotwired/turbo)
|
|
18
|
-
|
|
19
|
+
[`@hotwired/turbo`](https://github.com/hotwired/turbo) and
|
|
20
|
+
[`@hotwired/stimulus`](https://github.com/hotwired/stimulus) bundles load and
|
|
21
|
+
drive the DOM (turbo-stream + turbo-frame; Stimulus controllers, targets,
|
|
22
|
+
values, classes, actions, and outlets — see
|
|
23
|
+
`test/dommy/js/test_turbo_integration.rb` and
|
|
24
|
+
`test/dommy/js/test_stimulus_integration.rb`).
|
|
19
25
|
|
|
20
26
|
## Installation
|
|
21
27
|
|
|
@@ -104,6 +110,13 @@ The pieces it relies on are public Runtime API:
|
|
|
104
110
|
- `Runtime#on_unhandled_rejection { |err| }` — surface promise rejections that
|
|
105
111
|
reach the microtask queue with no handler. Frameworks swallow these; `err.backtrace`
|
|
106
112
|
carries the JS stack, which is the difference between blind and one-shot debugging.
|
|
113
|
+
The released quickjs (0.21.0) reports a rejection before a handler attached in
|
|
114
|
+
the same microtask checkpoint can catch it, so `.catch` and `try { await … }
|
|
115
|
+
catch` would look unhandled; on such an engine nothing is reported here (a
|
|
116
|
+
warning says so once), so a strict `Dommy::Browser` does not fail on
|
|
117
|
+
rejections and the page's `unhandledrejection` event (and
|
|
118
|
+
`window.onunhandledrejection`) never fires. A quickjs with
|
|
119
|
+
[hmsk/quickjs.rb#141](https://github.com/hmsk/quickjs.rb/pull/141) reports them.
|
|
107
120
|
- `Runtime#on_log { |log| }` — observe `console.*` (`log.severity` / `log.to_s`).
|
|
108
121
|
|
|
109
122
|
### Capybara
|
|
@@ -123,9 +136,9 @@ require "dommy/js/quickjs/capybara"
|
|
|
123
136
|
Selenium-style `done()` / real-time waits are not supported.
|
|
124
137
|
- **`fetch` is stub-based** via Dommy's `__fetchy_stub__` (a `{ url => entry }` map);
|
|
125
138
|
there is no real network.
|
|
126
|
-
- **Event listeners
|
|
127
|
-
|
|
128
|
-
|
|
139
|
+
- **Event listeners** — both forms work: a function (`addEventListener("...", fn)`,
|
|
140
|
+
closures intact) and the EventListener *object* form (`{ handleEvent }`, used by
|
|
141
|
+
Stimulus). `removeEventListener` detaches by the same function/object identity.
|
|
129
142
|
- **Expandos are scoped to elements**, and the JS callback table is not evicted, so
|
|
130
143
|
a very long-lived VM can grow unbounded.
|
|
131
144
|
- **DOM coverage is Dommy's.** A JS method/property works only where Dommy exposes
|
|
@@ -137,14 +150,30 @@ require "dommy/js/quickjs/capybara"
|
|
|
137
150
|
Run `bin/setup` to install dependencies, then `rake test`. `bin/console` opens an
|
|
138
151
|
interactive prompt.
|
|
139
152
|
|
|
140
|
-
The real-framework integration
|
|
141
|
-
|
|
153
|
+
The real-framework integration tests are skipped unless their bundle is
|
|
154
|
+
vendored under `test/fixtures/`:
|
|
142
155
|
|
|
143
156
|
```bash
|
|
144
157
|
curl -sL https://unpkg.com/@hotwired/turbo@8/dist/turbo.es2017-umd.js \
|
|
145
158
|
-o test/fixtures/turbo.umd.js
|
|
159
|
+
curl -sL https://unpkg.com/@hotwired/stimulus@3/dist/stimulus.umd.js \
|
|
160
|
+
-o test/fixtures/stimulus.umd.js
|
|
146
161
|
```
|
|
147
162
|
|
|
163
|
+
### Conformance suites
|
|
164
|
+
|
|
165
|
+
Two tasks run real third-party test corpora against the bridge and report a
|
|
166
|
+
pass rate — the lens that pins how faithfully the bridge hosts the platform:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
rake wpt:conformance[filter] # Web Platform Tests (vendored under test/fixtures/wpt)
|
|
170
|
+
rake stimulus:conformance[filter] # @hotwired/stimulus's own QUnit suite
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The Stimulus suite is vendored as a single bundle (`test/fixtures/
|
|
174
|
+
stimulus-tests.umd.js`) run through a small QUnit shim; each test runs in its
|
|
175
|
+
own fresh VM. Regenerate the bundle with `script/build_stimulus_tests.sh`.
|
|
176
|
+
|
|
148
177
|
## Contributing
|
|
149
178
|
|
|
150
179
|
Bug reports and pull requests are welcome at https://github.com/takahashim/dommy-js-quickjs.
|
|
@@ -5,38 +5,164 @@ require "quickjs"
|
|
|
5
5
|
module Dommy
|
|
6
6
|
module Js
|
|
7
7
|
module Quickjs
|
|
8
|
+
# Skips the per-crossing Ruby `Timeout.timeout` the quickjs gem wraps EVERY
|
|
9
|
+
# host-function call in — every JS->Ruby DOM crossing (__rb_host_get /
|
|
10
|
+
# _call / _set, …). That costs ~4us per crossing (≈40% of a DOM property
|
|
11
|
+
# read: 9.8us -> 5.1us without it), and a DOM-heavy SPA (React/Apollo)
|
|
12
|
+
# makes MILLIONS of them while hydrating — tens of seconds of pure Timeout
|
|
13
|
+
# overhead, the dominant cost behind a slow page.
|
|
14
|
+
#
|
|
15
|
+
# It is redundant here: QuickJS's own C interrupt handler still
|
|
16
|
+
# force-aborts a runaway JS execution at the eval timeout (independent of
|
|
17
|
+
# this Ruby wrapper), and Dommy's host functions are bounded DOM
|
|
18
|
+
# operations that never hang. DOMMY_JS_CROSSING_TIMEOUT=1 keeps the gem's
|
|
19
|
+
# behavior, re-enabling the Ruby-callback-hang guard.
|
|
20
|
+
#
|
|
21
|
+
# Prepended to `::Quickjs`'s singleton rather than redefining the method:
|
|
22
|
+
# the gem's own implementation stays in the ancestor chain (reachable with
|
|
23
|
+
# `super`, visible in `::Quickjs.singleton_class.ancestors`), so the patch
|
|
24
|
+
# can be seen and undone. Redefining it outright left no way to tell the
|
|
25
|
+
# method had been replaced.
|
|
26
|
+
module SkipCrossingTimeout
|
|
27
|
+
def _with_timeout(_msec, proc, args)
|
|
28
|
+
proc.call(*args)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if ::Quickjs.respond_to?(:_with_timeout) && !Config.crossing_timeout?
|
|
33
|
+
::Quickjs.singleton_class.prepend(SkipCrossingTimeout)
|
|
34
|
+
end
|
|
35
|
+
|
|
8
36
|
# Binds HostBridge's abstract backend contract to the `quickjs` gem.
|
|
37
|
+
#
|
|
38
|
+
# Value-representation conformance: host_runtime.js now tags a top-level JS
|
|
39
|
+
# `undefined` itself (`dehydrateTop` -> `{__rb_undefined:true}`) at the
|
|
40
|
+
# JS->Ruby crossings, so the protocol no longer relies on the backend to
|
|
41
|
+
# marshal a bare `undefined` to a sentinel — keeping it engine-neutral.
|
|
42
|
+
# The `quickjs` gem happens to also deliver a bare `undefined` as the Ruby
|
|
43
|
+
# symbol `:undefined`, which HostBridge#unwrap still accepts as a defensive
|
|
44
|
+
# fallback (e.g. the `evaluate`/`tag` return path, which dehydrates without
|
|
45
|
+
# the top-level tag); either way it maps to Dommy::Bridge::UNDEFINED. No
|
|
46
|
+
# normalization is needed here.
|
|
9
47
|
class Backend
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
48
|
+
# Whether this quickjs reports a promise rejection the moment it
|
|
49
|
+
# happens, before a handler attached later in the same microtask
|
|
50
|
+
# checkpoint gets the chance — the released quickjs (0.21.0) does, so
|
|
51
|
+
# `Promise.reject(x).catch(f)` and `try { await p } catch {}` both
|
|
52
|
+
# come out as unhandled. HTML reports at the end of the checkpoint
|
|
53
|
+
# (hmsk/quickjs.rb#141, merged upstream but unreleased).
|
|
54
|
+
#
|
|
55
|
+
# Decided by behavior, not by version — a git checkout of the fixed
|
|
56
|
+
# engine still says 0.21.0 — on a throwaway VM, once per process.
|
|
57
|
+
def self.premature_rejection_reports?
|
|
58
|
+
return @premature_rejection_reports if defined?(@premature_rejection_reports)
|
|
59
|
+
|
|
60
|
+
@premature_rejection_reports = probe_premature_rejection_reports
|
|
61
|
+
end
|
|
13
62
|
|
|
63
|
+
def self.probe_premature_rejection_reports
|
|
64
|
+
vm = ::Quickjs::VM.new
|
|
65
|
+
reported = false
|
|
66
|
+
vm.on_unhandled_rejection { |_error| reported = true }
|
|
67
|
+
vm.eval_code(<<~JS, async: false)
|
|
68
|
+
Promise.reject(1).catch(function () {});
|
|
69
|
+
(async function () { try { await Promise.reject(2); } catch (e) {} })();
|
|
70
|
+
0
|
|
71
|
+
JS
|
|
72
|
+
vm.drain_jobs!
|
|
73
|
+
reported
|
|
74
|
+
rescue ::StandardError
|
|
75
|
+
# A probe that cannot run says nothing either way; keep reporting.
|
|
76
|
+
false
|
|
77
|
+
ensure
|
|
78
|
+
vm&.dispose!
|
|
79
|
+
end
|
|
80
|
+
private_class_method :probe_premature_rejection_reports
|
|
81
|
+
|
|
82
|
+
# Timeout and memory ceiling both differ from the gem's defaults, for
|
|
83
|
+
# reasons Config documents.
|
|
14
84
|
def initialize(**vm_opts)
|
|
15
|
-
vm_opts = {timeout_msec:
|
|
85
|
+
vm_opts = {timeout_msec: Config.timeout_msec, memory_limit: Config.memory_limit}.merge(vm_opts)
|
|
16
86
|
@vm = ::Quickjs::VM.new(**vm_opts)
|
|
17
87
|
end
|
|
18
88
|
|
|
19
|
-
|
|
20
|
-
|
|
89
|
+
# True once the VM can no longer run JS safely: it hit out-of-memory (the
|
|
90
|
+
# gem flags it "poisoned" — further eval may segfault) or was disposed.
|
|
91
|
+
# Callers stop driving a poisoned VM (the page's JS is dead) instead of
|
|
92
|
+
# letting the error crash the whole browser — browsing survives a page
|
|
93
|
+
# whose JS ran out of memory, showing whatever rendered before it died.
|
|
94
|
+
def poisoned?
|
|
95
|
+
(@vm.respond_to?(:memory_poisoned?) && @vm.memory_poisoned?) ||
|
|
96
|
+
(@vm.respond_to?(:disposed?) && @vm.disposed?)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Every entry point that RUNS something in the VM goes through #guarded,
|
|
100
|
+
# so a dead VM answers the same way whichever one a host reached for. It
|
|
101
|
+
# used to guard three of the six: an inline script quietly did nothing
|
|
102
|
+
# after an out-of-memory while an external (bytecode) one and #evaluate
|
|
103
|
+
# raised "VM is poisoned" from a different place each time.
|
|
104
|
+
#
|
|
105
|
+
# Registering a host function or a listener is not guarded: none of them
|
|
106
|
+
# runs JS, and they all happen while the VM is still alive.
|
|
107
|
+
def eval(js) = guarded { @vm.eval_code(js, async: false) }
|
|
108
|
+
|
|
109
|
+
# Compile JS source to reusable bytecode (parsed once, via a throwaway
|
|
110
|
+
# VM). Run it on any number of fresh VMs with #run_compiled — far cheaper
|
|
111
|
+
# than re-parsing the source per VM (the large host runtime / vendored
|
|
112
|
+
# bundles are identical across VMs).
|
|
113
|
+
def self.compile(source, filename: "<compiled>")
|
|
114
|
+
::Quickjs.compile(source, filename: filename)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Process-global cache for the engine-internal runtime bundles run via
|
|
118
|
+
# #run_bundle (host_runtime.js, observable_runtime.js). Kept separate
|
|
119
|
+
# from ScriptCache (user-facing external scripts) so the two concerns
|
|
120
|
+
# don't share a count/namespace.
|
|
121
|
+
@bundle_cache = {}
|
|
122
|
+
@bundle_mutex = Mutex.new
|
|
123
|
+
|
|
124
|
+
def self.compiled_bundle(cache_key, source)
|
|
125
|
+
@bundle_mutex.synchronize { @bundle_cache[cache_key] ||= compile(source, filename: cache_key.to_s) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Execute precompiled bytecode (a Quickjs::Runnable) on this VM in global
|
|
129
|
+
# scope — equivalent to #eval of its source, without the parse cost.
|
|
130
|
+
def run_compiled(runnable) = guarded { runnable.run(on: @vm) }
|
|
131
|
+
|
|
132
|
+
# Run a source bundle that is identical across VMs (the bridge's host
|
|
133
|
+
# runtime, the Observable polyfill): compile it to bytecode once per
|
|
134
|
+
# process — keyed by `cache_key` — and run that on this VM. Lets the
|
|
135
|
+
# engine-agnostic bridge reuse big bundles without knowing about
|
|
136
|
+
# bytecode; the compile-once optimization stays here in the engine layer.
|
|
137
|
+
def run_bundle(cache_key, source)
|
|
138
|
+
run_compiled(self.class.compiled_bundle(cache_key, source))
|
|
21
139
|
end
|
|
22
140
|
|
|
23
141
|
# Async eval: the gem awaits the top-level result and drains the
|
|
24
142
|
# microtask queue, so JS `await`/Promises resolve before returning.
|
|
25
|
-
def eval_awaited(js)
|
|
26
|
-
|
|
143
|
+
def eval_awaited(js) = guarded { @vm.eval_code(js, async: true) }
|
|
144
|
+
|
|
145
|
+
# Install the ESM module resolver: a callable `(specifier, importer) ->
|
|
146
|
+
# source String | { code:, as: } | nil` the engine consults for every
|
|
147
|
+
# static/dynamic `import`. nil clears it (engine default loader).
|
|
148
|
+
def module_loader=(callable)
|
|
149
|
+
@vm.module_loader = callable
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Evaluate the module at `url` (resolved + fetched by the module loader).
|
|
153
|
+
# The importer of its relative imports is `url`, so they resolve
|
|
154
|
+
# correctly — unlike an inline module's synthetic filename.
|
|
155
|
+
def import_module_url(url)
|
|
156
|
+
guarded { @vm.import("* as __dommy_mod", filename: url, code_to_expose: "") }
|
|
27
157
|
end
|
|
28
158
|
|
|
29
159
|
def define_host_function(name, &block)
|
|
30
160
|
@vm.define_function(name, &block)
|
|
31
161
|
end
|
|
32
162
|
|
|
33
|
-
def call_js(path, *args)
|
|
34
|
-
@vm.call(path, *args)
|
|
35
|
-
end
|
|
163
|
+
def call_js(path, *args) = guarded { @vm.call(path, *args) }
|
|
36
164
|
|
|
37
|
-
def drain_microtasks
|
|
38
|
-
@vm.drain_jobs!
|
|
39
|
-
end
|
|
165
|
+
def drain_microtasks = guarded { @vm.drain_jobs! }
|
|
40
166
|
|
|
41
167
|
# Register a handler for promise rejections that reach the microtask
|
|
42
168
|
# queue with no `.catch` — frameworks (Turbo, …) often swallow these,
|
|
@@ -45,19 +171,37 @@ module Dommy
|
|
|
45
171
|
@vm.on_unhandled_rejection(&block)
|
|
46
172
|
end
|
|
47
173
|
|
|
174
|
+
# Point the engine's promise-rejection hook at a JS function, named by an
|
|
175
|
+
# expression evaluated once in global scope. Only defined when the engine
|
|
176
|
+
# supports it, so callers can feature-detect with respond_to?.
|
|
177
|
+
if ::Quickjs::VM.method_defined?(:promise_rejection_hook=)
|
|
178
|
+
def promise_rejection_hook=(expression)
|
|
179
|
+
@vm.promise_rejection_hook = expression
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
48
183
|
# Register a handler for console.(log|info|debug|warn|error). The block
|
|
49
184
|
# receives a log object (#severity / #to_s / #raw).
|
|
50
185
|
def on_log(&block)
|
|
51
186
|
@vm.on_log(&block)
|
|
52
187
|
end
|
|
53
188
|
|
|
54
|
-
def run_gc
|
|
55
|
-
@vm.gc!
|
|
56
|
-
end
|
|
189
|
+
def run_gc = guarded { @vm.gc! }
|
|
57
190
|
|
|
58
191
|
def dispose
|
|
59
192
|
@vm.dispose!
|
|
60
193
|
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
# Run `block` unless the VM is dead, in which case the answer is nil —
|
|
198
|
+
# the "browsing never crashes" contract: the page's JS has stopped, and
|
|
199
|
+
# the host has already been told once (see EventLoop#note_halted).
|
|
200
|
+
def guarded
|
|
201
|
+
return if poisoned?
|
|
202
|
+
|
|
203
|
+
yield
|
|
204
|
+
end
|
|
61
205
|
end
|
|
62
206
|
end
|
|
63
207
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Js
|
|
5
|
+
module Quickjs
|
|
6
|
+
# What this QuickJS build is missing before a page can run in it: the bare
|
|
7
|
+
# browser globals aliased onto the window, the timer globals routed into
|
|
8
|
+
# Dommy's scheduler, and stand-ins for the two things the build omits
|
|
9
|
+
# (Intl, WebAssembly).
|
|
10
|
+
#
|
|
11
|
+
# Separate from Runtime because it changes for its own reasons — a page
|
|
12
|
+
# reaching for a global nobody aliased yet — which has nothing to do with
|
|
13
|
+
# how the event loop is driven or how an engine exception becomes the
|
|
14
|
+
# page's error.
|
|
15
|
+
#
|
|
16
|
+
# Each payload is a .js file next door rather than a Ruby heredoc, the way
|
|
17
|
+
# the core bridge keeps host_runtime.js: it stays readable as JavaScript,
|
|
18
|
+
# and #run_bundle compiles it to bytecode ONCE per process instead of
|
|
19
|
+
# reparsing it for every VM.
|
|
20
|
+
class BrowserEnvironment
|
|
21
|
+
JS_DIR = ::File.join(__dir__, "js")
|
|
22
|
+
private_constant :JS_DIR
|
|
23
|
+
|
|
24
|
+
# Read at load time and frozen: the files never change at runtime, and a
|
|
25
|
+
# per-VM read would undo the point of caching the compile.
|
|
26
|
+
def self.payload(name)
|
|
27
|
+
::File.read(::File.join(JS_DIR, name)).freeze
|
|
28
|
+
end
|
|
29
|
+
private_class_method :payload
|
|
30
|
+
|
|
31
|
+
TIMER_GLOBALS_JS = payload("timer_globals.js")
|
|
32
|
+
BROWSER_GLOBALS_JS = payload("browser_globals.js")
|
|
33
|
+
WINDOW_BUILTINS_JS = payload("window_builtins.js")
|
|
34
|
+
INTL_POLYFILL_JS = payload("intl_polyfill.js")
|
|
35
|
+
WASM_STUB_JS = payload("wasm_stub.js")
|
|
36
|
+
ERROR_REBUILD_JS = payload("error_rebuild.js")
|
|
37
|
+
|
|
38
|
+
# Nothing outside installs a payload by hand; the methods below are the
|
|
39
|
+
# way in.
|
|
40
|
+
private_constant :TIMER_GLOBALS_JS, :BROWSER_GLOBALS_JS, :WINDOW_BUILTINS_JS,
|
|
41
|
+
:INTL_POLYFILL_JS, :WASM_STUB_JS, :ERROR_REBUILD_JS
|
|
42
|
+
|
|
43
|
+
def initialize(backend)
|
|
44
|
+
@backend = backend
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The helper ErrorTranslator calls to rebuild a thrown Error inside the
|
|
48
|
+
# realm. Installed with the runtime rather than with the page globals:
|
|
49
|
+
# an engine exception can reach a host that never asked for a browser.
|
|
50
|
+
def install_error_rebuilder
|
|
51
|
+
run("error_rebuild.js", ERROR_REBUILD_JS)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# setTimeout / setInterval / requestAnimationFrame / queueMicrotask, as
|
|
55
|
+
# bare globals routed through the window (so they reach Dommy's
|
|
56
|
+
# deterministic scheduler), plus the scheduling-site capture a throwing
|
|
57
|
+
# callback is traced back through. Needs the window installed first.
|
|
58
|
+
def install_timers
|
|
59
|
+
run("timer_globals.js", TIMER_GLOBALS_JS)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Everything a real frontend bundle expects to find on its own: the
|
|
63
|
+
# aliased window globals, then the two polyfills, then the built-ins
|
|
64
|
+
# mirrored onto the window. Order matters only in that the window must
|
|
65
|
+
# already be installed.
|
|
66
|
+
def install_globals
|
|
67
|
+
run("browser_globals.js", BROWSER_GLOBALS_JS)
|
|
68
|
+
run("intl_polyfill.js", INTL_POLYFILL_JS)
|
|
69
|
+
install_wasm_stub
|
|
70
|
+
run("window_builtins.js", WINDOW_BUILTINS_JS)
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The WebAssembly stand-in on its own. Idempotent: the payload defines
|
|
75
|
+
# nothing when `WebAssembly` already exists, so a host that asks for it
|
|
76
|
+
# explicitly (the WPT harness wants the SharedArrayBuffer that
|
|
77
|
+
# `Memory({shared:true})` yields) gets the same object install_globals
|
|
78
|
+
# would have built.
|
|
79
|
+
def install_wasm_stub
|
|
80
|
+
run("wasm_stub.js", WASM_STUB_JS)
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def run(cache_key, source)
|
|
87
|
+
@backend.run_bundle(cache_key, source)
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|