dommy 0.12.0 → 0.13.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/lib/dommy/bridge.rb +16 -3
- data/lib/dommy/browser.rb +43 -47
- data/lib/dommy/callable_invoker.rb +16 -0
- data/lib/dommy/compression_streams.rb +12 -62
- data/lib/dommy/document.rb +5 -5
- data/lib/dommy/element.rb +2 -3
- data/lib/dommy/encodings/tables.rb +751 -0
- data/lib/dommy/encodings.rb +299 -0
- data/lib/dommy/event.rb +4 -5
- data/lib/dommy/fetch.rb +60 -49
- data/lib/dommy/form_data.rb +10 -0
- data/lib/dommy/html_elements.rb +136 -62
- data/lib/dommy/internal/exception_report.rb +123 -0
- data/lib/dommy/internal/idna.rb +4 -4
- data/lib/dommy/internal/idna_data.rb +513 -100
- data/lib/dommy/internal/url_parser.rb +70 -18
- data/lib/dommy/js/dom_interfaces.rb +1 -0
- data/lib/dommy/js/error_log.rb +137 -0
- data/lib/dommy/js/host_bridge.rb +13 -0
- data/lib/dommy/js/host_runtime.js +107 -17
- data/lib/dommy/js/marshaller.rb +21 -2
- data/lib/dommy/js/runtime.rb +8 -1
- data/lib/dommy/js/script_boot.rb +58 -14
- data/lib/dommy/js/wire_tags.rb +8 -0
- data/lib/dommy/location.rb +14 -4
- data/lib/dommy/navigator.rb +3 -1
- data/lib/dommy/promise.rb +4 -4
- data/lib/dommy/scheduler.rb +35 -6
- data/lib/dommy/streams.rb +1200 -187
- data/lib/dommy/text_codec.rb +110 -213
- data/lib/dommy/url.rb +34 -32
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +162 -34
- data/lib/dommy/xml_http_request.rb +18 -4
- data/lib/dommy.rb +2 -0
- metadata +5 -1
data/lib/dommy/js/script_boot.rb
CHANGED
|
@@ -17,10 +17,12 @@ module Dommy
|
|
|
17
17
|
# in the document (e.g. a Nuxt entry module placed above the inline
|
|
18
18
|
# `window.__NUXT__ = {...}` bootstrap it depends on). Running everything in
|
|
19
19
|
# one document-order pass would execute the module against half-initialized
|
|
20
|
-
# globals. A failed fetch or a throwing script is isolated
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
20
|
+
# globals. A failed fetch or a throwing script is isolated so the rest of the
|
|
21
|
+
# page still loads: a throwing script's exception is REPORTED at the window
|
|
22
|
+
# (WHATWG "report an exception", so `window.onerror` sees it and the host
|
|
23
|
+
# hears only what the page left unhandled), while `on_error` stays the
|
|
24
|
+
# fallback for a windowless document. Shared by `Dommy::Browser` and the
|
|
25
|
+
# Capybara driver so script boot lives in one place.
|
|
24
26
|
#
|
|
25
27
|
# The module is the stable entry point; the work lives on ScriptBooter, a
|
|
26
28
|
# short-lived instance that holds the runtime / document / resources /
|
|
@@ -145,24 +147,66 @@ module Dommy
|
|
|
145
147
|
ScriptBoot.wire_inline_handlers(@runtime, on_error: @on_error)
|
|
146
148
|
end
|
|
147
149
|
|
|
148
|
-
# Fetch + run a dynamically-inserted external script, then fire
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
150
|
+
# Fetch + run a dynamically-inserted external script, then fire the event a
|
|
151
|
+
# loader awaiting the element is listening for. The src was already taken
|
|
152
|
+
# from the element by the mutation coordinator, so this does not re-consume
|
|
153
|
+
# pending state.
|
|
154
|
+
#
|
|
155
|
+
# `error` means the FETCH failed — that is the only thing the element's
|
|
156
|
+
# event reports. A script that downloaded fine and then threw still fires
|
|
157
|
+
# `load`, because the load succeeded; its exception is reported at the
|
|
158
|
+
# global instead. Conflating the two tells a chunk loader (webpack, Vite)
|
|
159
|
+
# that the network failed, so it retries or gives up on a chunk that is
|
|
160
|
+
# sitting right there.
|
|
152
161
|
def run_inserted_external(element, src)
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
url = resolve_url(src) if @resources
|
|
163
|
+
response = (@resources.get(url) if url)
|
|
164
|
+
return dispatch_script_event(element, "error") unless response&.success?
|
|
165
|
+
|
|
166
|
+
begin
|
|
155
167
|
with_current_script(element) { @runtime.load_script_cached(response.body, cache_key: url) }
|
|
156
|
-
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
report_exception(e)
|
|
157
170
|
end
|
|
158
|
-
dispatch_script_event(element,
|
|
171
|
+
dispatch_script_event(element, "load")
|
|
159
172
|
rescue StandardError => e
|
|
160
|
-
|
|
173
|
+
# The fetch itself blew up (a resources adapter raising, not a page
|
|
174
|
+
# exception): nothing loaded, so the element reports a failure.
|
|
175
|
+
report_exception(e)
|
|
161
176
|
dispatch_script_event(element, "error")
|
|
162
177
|
end
|
|
163
178
|
|
|
164
179
|
private
|
|
165
180
|
|
|
181
|
+
# WHATWG "report an exception" for a script whose EVALUATION threw: the
|
|
182
|
+
# exception belongs to the page, so it is reported at the global (firing
|
|
183
|
+
# `window.onerror`, which a page's error reporting listens on) rather than
|
|
184
|
+
# handed straight to the host. Only a report the page leaves unhandled
|
|
185
|
+
# reaches the host, through the window's unhandled-error seam.
|
|
186
|
+
#
|
|
187
|
+
# `on_error` is the fallback for a document with no window to report to
|
|
188
|
+
# (nothing else could hear it) and for the internal failures that are not
|
|
189
|
+
# page exceptions at all.
|
|
190
|
+
def report_exception(error)
|
|
191
|
+
window = (@document.default_view if @document.respond_to?(:default_view))
|
|
192
|
+
return @on_error&.call(error) unless window.respond_to?(:__internal_report_exception__)
|
|
193
|
+
|
|
194
|
+
Dommy::Internal::ExceptionReport.report_at(window, error, value: page_value_for(error))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# What the page should see as `event.error`. An engine that raises a host
|
|
198
|
+
# exception for a script\'s throw has already discarded the JS value, so
|
|
199
|
+
# there is nothing left to hand over: the page would get an opaque husk
|
|
200
|
+
# with no `message` or `stack`, and a handler reading either would itself
|
|
201
|
+
# throw — taking its `preventDefault()` down with it. A runtime that can
|
|
202
|
+
# rebuild an equivalent Error inside the realm gets to; nil falls back to
|
|
203
|
+
# whatever was caught.
|
|
204
|
+
def page_value_for(error)
|
|
205
|
+
return nil unless @runtime.respond_to?(:rebuild_error)
|
|
206
|
+
|
|
207
|
+
@runtime.rebuild_error(error)
|
|
208
|
+
end
|
|
209
|
+
|
|
166
210
|
# Fire the script's load/error event ASYNCHRONOUSLY (a microtask), like a
|
|
167
211
|
# real browser. Code commonly does `head.appendChild(s); s.onload = …`
|
|
168
212
|
# (handlers set AFTER insertion), so a synchronous dispatch — during the
|
|
@@ -215,7 +259,7 @@ module Dommy
|
|
|
215
259
|
def run_one(element)
|
|
216
260
|
@on_script.call(element, nil) if run_pending(element) && @on_script
|
|
217
261
|
rescue StandardError => e
|
|
218
|
-
|
|
262
|
+
report_exception(e)
|
|
219
263
|
@on_script&.call(element, e)
|
|
220
264
|
end
|
|
221
265
|
|
data/lib/dommy/js/wire_tags.rb
CHANGED
|
@@ -33,6 +33,14 @@ module Dommy
|
|
|
33
33
|
JS_REF = "__rb_js_ref"
|
|
34
34
|
# A human-readable label captured alongside a JS ref (for #to_s/#inspect).
|
|
35
35
|
JS_LABEL = "__rb_js_label"
|
|
36
|
+
# The thrown value's JS stack, carried alongside a callback's throw so the
|
|
37
|
+
# host can report WHERE the page failed (see ErrorEvent's filename /
|
|
38
|
+
# lineno / colno). A JS value crosses as an opaque ref, so Ruby cannot
|
|
39
|
+
# read `.stack` off it afterwards; it has to come over with the throw.
|
|
40
|
+
JS_STACK = "__rb_js_stack"
|
|
41
|
+
# The value's JS constructor name ("TypeError"), so a host log can say what
|
|
42
|
+
# kind of thing it holds rather than only naming the Ruby wrapper.
|
|
43
|
+
JS_NAME = "__rb_js_name"
|
|
36
44
|
# Marks a JS ref that implements the EventListener interface (handleEvent).
|
|
37
45
|
HANDLE_EVENT = "__rb_handle_event"
|
|
38
46
|
# Marks a JS ref that implements the NodeFilter interface (acceptNode).
|
data/lib/dommy/location.rb
CHANGED
|
@@ -133,6 +133,13 @@ module Dommy
|
|
|
133
133
|
# regardless, so this only affects the no-op default.
|
|
134
134
|
def __internal_navigate_to__(raw, source:, replace: false, sync_cross_doc: true)
|
|
135
135
|
target = resolve(raw)
|
|
136
|
+
if target.nil?
|
|
137
|
+
# A URL the parser rejects: the Location API throws, following a
|
|
138
|
+
# hyperlink to one does nothing.
|
|
139
|
+
raise DOMException::SyntaxError, "#{raw.inspect} is not a valid URL" if source == :location
|
|
140
|
+
|
|
141
|
+
return
|
|
142
|
+
end
|
|
136
143
|
if same_document?(href, target)
|
|
137
144
|
__internal_set_url__(raw)
|
|
138
145
|
else
|
|
@@ -143,11 +150,14 @@ module Dommy
|
|
|
143
150
|
|
|
144
151
|
private
|
|
145
152
|
|
|
146
|
-
# Resolve a possibly-relative URL against the current full URL
|
|
153
|
+
# Resolve a possibly-relative URL against the current full URL with the
|
|
154
|
+
# URL parser; nil when it fails.
|
|
147
155
|
def resolve(raw)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
raw
|
|
156
|
+
base = href
|
|
157
|
+
base = nil unless Internal::UrlParser.parse(base) rescue nil
|
|
158
|
+
Internal::UrlParser.serialize(Internal::UrlParser.parse(raw, base))
|
|
159
|
+
rescue Internal::UrlParser::Failure
|
|
160
|
+
nil
|
|
151
161
|
end
|
|
152
162
|
|
|
153
163
|
# Two URLs address the same document when everything but the fragment matches.
|
data/lib/dommy/navigator.rb
CHANGED
|
@@ -49,7 +49,9 @@ module Dommy
|
|
|
49
49
|
# `navigator.sendBeacon(url, data)` — fire-and-forget POST used for analytics.
|
|
50
50
|
# Dommy does not emit the request (no background egress); it just reports
|
|
51
51
|
# success so callers that feature-detect `sendBeacon` take the beacon path.
|
|
52
|
-
def send_beacon(
|
|
52
|
+
def send_beacon(url, _data = nil)
|
|
53
|
+
raise Bridge::TypeError, "Invalid URL #{url.to_s.inspect}" if @window.__internal_parse_url__(url).nil?
|
|
54
|
+
|
|
53
55
|
true
|
|
54
56
|
end
|
|
55
57
|
|
data/lib/dommy/promise.rb
CHANGED
|
@@ -100,6 +100,10 @@ module Dommy
|
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
def settled?
|
|
104
|
+
@state != :pending
|
|
105
|
+
end
|
|
106
|
+
|
|
103
107
|
private
|
|
104
108
|
|
|
105
109
|
# Drive the event loop over the work that is ready NOW until this promise
|
|
@@ -202,10 +206,6 @@ module Dommy
|
|
|
202
206
|
self
|
|
203
207
|
end
|
|
204
208
|
|
|
205
|
-
def settled?
|
|
206
|
-
@state != :pending
|
|
207
|
-
end
|
|
208
|
-
|
|
209
209
|
def schedule_flush
|
|
210
210
|
@window.scheduler.queue_microtask(proc { flush_handlers })
|
|
211
211
|
nil
|
data/lib/dommy/scheduler.rb
CHANGED
|
@@ -86,6 +86,14 @@ module Dommy
|
|
|
86
86
|
# all — vanilla CRuby use) re-raises, so genuine host bugs still surface.
|
|
87
87
|
attr_accessor :timer_error_handler
|
|
88
88
|
|
|
89
|
+
# WHATWG "report an exception" for a task, supplied by the Window that owns
|
|
90
|
+
# this scheduler. A timer / rAF callback that throws is the page's bug, not
|
|
91
|
+
# the host's: the exception is reported at the global (firing
|
|
92
|
+
# `window.onerror`) and the event loop carries on with the remaining tasks,
|
|
93
|
+
# rather than tearing down whatever Ruby drove the clock. Absent in vanilla
|
|
94
|
+
# CRuby use, where a throwing callback keeps propagating.
|
|
95
|
+
attr_accessor :exception_reporter
|
|
96
|
+
|
|
89
97
|
# An optional hook (set by a JS runtime) that drains the ENGINE's microtask
|
|
90
98
|
# (promise-job) queue — the other half of a microtask checkpoint. The
|
|
91
99
|
# scheduler owns only its own `@microtasks`; the real microtask queue lives
|
|
@@ -284,18 +292,28 @@ module Dommy
|
|
|
284
292
|
end
|
|
285
293
|
|
|
286
294
|
|
|
287
|
-
# Run a single timer's callback.
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
#
|
|
291
|
-
#
|
|
295
|
+
# Run a single timer's callback. The callback is invoked in its RAISING form
|
|
296
|
+
# so a JS throw surfaces here instead of being swallowed by the bridge —
|
|
297
|
+
# WHATWG reports a task's exception at the global and keeps the event loop
|
|
298
|
+
# running, which a silently dropped throw cannot do. The raised error is then
|
|
299
|
+
# offered to `timer_error_handler` (set by the JS runtime); if it swallows the
|
|
300
|
+
# error the timer is dropped (so a runaway interval cannot re-stall every
|
|
301
|
+
# tick) and browsing continues. With no handler — or one that declines — the
|
|
302
|
+
# error propagates, preserving the default crash-on-bug behavior for a host
|
|
303
|
+
# bug, which is not a page exception and must not be reported as one.
|
|
292
304
|
def invoke_timer(timer, *args)
|
|
293
305
|
# Run the callback at this timer's nesting level, so a timer it schedules
|
|
294
306
|
# nests one deeper (driving the 4ms clamp). Restored even if it throws.
|
|
295
307
|
prev_nesting = @nesting_level
|
|
296
308
|
@nesting_level = timer.nesting || 0
|
|
297
|
-
CallableInvoker.
|
|
309
|
+
CallableInvoker.invoke_raising(timer.callback, *args)
|
|
298
310
|
rescue StandardError => e
|
|
311
|
+
# A page exception is reported and the timer keeps its schedule, like a
|
|
312
|
+
# browser: an interval whose callback throws goes on firing.
|
|
313
|
+
return nil if report_task_exception(e)
|
|
314
|
+
# Otherwise the runtime's handler decides. It swallows the errors it had to
|
|
315
|
+
# kill (an execution-timeout runaway, a poisoned VM), and those DO drop the
|
|
316
|
+
# timer so it cannot re-stall every tick. A declined error is a host bug.
|
|
299
317
|
raise unless @timer_error_handler&.call(e, timer)
|
|
300
318
|
|
|
301
319
|
timer.active = false
|
|
@@ -304,5 +322,16 @@ module Dommy
|
|
|
304
322
|
ensure
|
|
305
323
|
@nesting_level = prev_nesting
|
|
306
324
|
end
|
|
325
|
+
|
|
326
|
+
# Report a task's exception at the global, per WHATWG. Only a `ThrowValue`
|
|
327
|
+
# qualifies: it is the one shape that unambiguously came out of page JS, so a
|
|
328
|
+
# host-side bug (a NoMethodError in Dommy itself) is never disguised as a
|
|
329
|
+
# page error — it falls through and propagates.
|
|
330
|
+
def report_task_exception(error)
|
|
331
|
+
return false unless error.is_a?(Bridge::ThrowValue) && @exception_reporter
|
|
332
|
+
|
|
333
|
+
@exception_reporter.call(error)
|
|
334
|
+
true
|
|
335
|
+
end
|
|
307
336
|
end
|
|
308
337
|
end
|