dommy 0.9.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/lib/dommy/attr.rb +46 -8
- data/lib/dommy/backend/makiri_adapter.rb +34 -0
- data/lib/dommy/backend.rb +29 -0
- data/lib/dommy/blob.rb +48 -6
- data/lib/dommy/browser.rb +239 -36
- data/lib/dommy/callable_invoker.rb +6 -2
- data/lib/dommy/document.rb +525 -57
- data/lib/dommy/element.rb +479 -239
- data/lib/dommy/event.rb +296 -15
- data/lib/dommy/fetch.rb +431 -25
- data/lib/dommy/history.rb +24 -3
- data/lib/dommy/html_collection.rb +145 -21
- data/lib/dommy/html_elements.rb +1675 -266
- data/lib/dommy/interaction/driver.rb +155 -14
- data/lib/dommy/interaction/event_synthesis.rb +115 -7
- data/lib/dommy/interaction/field_interactor.rb +60 -0
- data/lib/dommy/internal/child_node.rb +199 -0
- data/lib/dommy/internal/css/cascade.rb +44 -2
- data/lib/dommy/internal/global_functions.rb +23 -10
- data/lib/dommy/internal/mutation_coordinator.rb +27 -7
- data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
- data/lib/dommy/internal/observer_manager.rb +6 -0
- data/lib/dommy/internal/observer_matcher.rb +6 -8
- data/lib/dommy/internal/parent_node.rb +37 -73
- data/lib/dommy/internal/selector_matcher.rb +89 -0
- data/lib/dommy/internal/selector_parser.rb +44 -7
- data/lib/dommy/js/custom_element_bridge.rb +13 -2
- data/lib/dommy/js/dom_interfaces.rb +19 -4
- data/lib/dommy/js/host_bridge.rb +21 -0
- data/lib/dommy/js/host_runtime.js +926 -64
- data/lib/dommy/js/script_boot.rb +80 -0
- data/lib/dommy/location.rb +76 -13
- data/lib/dommy/mutation_observer.rb +3 -4
- data/lib/dommy/navigation.rb +263 -0
- data/lib/dommy/node.rb +180 -27
- data/lib/dommy/range.rb +15 -3
- data/lib/dommy/scheduler.rb +16 -0
- data/lib/dommy/shadow_root.rb +33 -0
- data/lib/dommy/storage.rb +61 -10
- data/lib/dommy/tree_walker.rb +18 -36
- data/lib/dommy/url.rb +4 -2
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +45 -2
- data/lib/dommy/window.rb +126 -7
- data/lib/dommy/xml_http_request.rb +30 -4
- data/lib/dommy.rb +2 -0
- metadata +6 -10
data/lib/dommy/fetch.rb
CHANGED
|
@@ -38,6 +38,11 @@ module Dommy
|
|
|
38
38
|
# URL (no per-handler resolution).
|
|
39
39
|
url = @window.__internal_resolve_url__(args[0].to_s)
|
|
40
40
|
init = normalize_init(args[1] || {})
|
|
41
|
+
# A cross-origin request always carries an Origin header (even for GET,
|
|
42
|
+
# which normalize_init omits it for same-origin GETs).
|
|
43
|
+
if cross_origin?(url) && !header?(init["headers"], "origin")
|
|
44
|
+
init["headers"]["Origin"] = request_origin
|
|
45
|
+
end
|
|
41
46
|
|
|
42
47
|
# `js_eval`'s JS installer increments these globals; mirror so
|
|
43
48
|
# specs that probe `__fetch_count__` / `__last_url__` / etc.
|
|
@@ -48,6 +53,18 @@ module Dommy
|
|
|
48
53
|
@window.globals["__last_body__"] = init["body"] if init.is_a?(Hash)
|
|
49
54
|
|
|
50
55
|
promise = PromiseValue.new(@window)
|
|
56
|
+
# `mode: "same-origin"` forbids a cross-origin request — it is a network
|
|
57
|
+
# error before any fetch happens.
|
|
58
|
+
if (init["mode"] || "cors").to_s == "same-origin" && cross_origin?(url)
|
|
59
|
+
deliver_task { promise.reject(fetch_type_error) }
|
|
60
|
+
return promise
|
|
61
|
+
end
|
|
62
|
+
# A non-simple cross-origin cors request is preceded by a CORS preflight
|
|
63
|
+
# (OPTIONS); a failed preflight is a network error before the real request.
|
|
64
|
+
if needs_preflight?(url, init) && !preflight_ok?(url, init)
|
|
65
|
+
deliver_task { promise.reject(fetch_type_error) }
|
|
66
|
+
return promise
|
|
67
|
+
end
|
|
51
68
|
result = resolve_entry(url, init)
|
|
52
69
|
# A handler may answer asynchronously (live network off-thread): it returns
|
|
53
70
|
# a deferred whose response arrives later and is applied on the page thread
|
|
@@ -56,13 +73,185 @@ module Dommy
|
|
|
56
73
|
if result.respond_to?(:on_complete)
|
|
57
74
|
result.on_complete { |entry| fulfill_from_entry(promise, entry, url, init) }
|
|
58
75
|
else
|
|
59
|
-
|
|
76
|
+
settle_with_redirects(promise, url, init, result)
|
|
60
77
|
end
|
|
61
78
|
promise
|
|
62
79
|
end
|
|
63
80
|
|
|
64
81
|
private
|
|
65
82
|
|
|
83
|
+
REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
84
|
+
MAX_REDIRECTS = 20
|
|
85
|
+
|
|
86
|
+
# Follow redirects on the SYNCHRONOUS resolve path (endpoints / stubs resolve
|
|
87
|
+
# inline). Honors the request's redirect mode: "follow" chases a 3xx that
|
|
88
|
+
# carries a valid http(s) Location — up to MAX_REDIRECTS, marking the result
|
|
89
|
+
# `redirected` — "manual" yields an opaqueredirect, "error" rejects. A 3xx
|
|
90
|
+
# with no Location, or any non-3xx, is the final response.
|
|
91
|
+
def settle_with_redirects(promise, url, init, entry)
|
|
92
|
+
mode = (init["redirect"] || "follow").to_s
|
|
93
|
+
current = url
|
|
94
|
+
redirected = false
|
|
95
|
+
hops = 0
|
|
96
|
+
loop do
|
|
97
|
+
status = (entry.is_a?(Hash) ? entry["status"] : nil).to_i
|
|
98
|
+
unless REDIRECT_STATUSES.include?(status)
|
|
99
|
+
return deliver_final(promise, mark_redirected(entry, redirected), current, init)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
case mode
|
|
103
|
+
when "manual" then return deliver_task { promise.fulfill(opaqueredirect_response) }
|
|
104
|
+
when "error" then return deliver_task { promise.reject(fetch_type_error) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
location = header_value(entry["headers"], "location")
|
|
108
|
+
# A 3xx with no Location is not a redirect to follow — it is the response.
|
|
109
|
+
return deliver_final(promise, mark_redirected(entry, redirected), current, init) if location.nil?
|
|
110
|
+
|
|
111
|
+
hops += 1
|
|
112
|
+
return deliver_task { promise.reject(fetch_type_error) } if hops > MAX_REDIRECTS
|
|
113
|
+
|
|
114
|
+
target = redirect_target(current, location)
|
|
115
|
+
return deliver_task { promise.reject(fetch_type_error) } if target.nil?
|
|
116
|
+
# A same-origin request may not be redirected across origins.
|
|
117
|
+
if (init["mode"] || "cors").to_s == "same-origin" && cross_origin?(target)
|
|
118
|
+
return deliver_task { promise.reject(fetch_type_error) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
current = target
|
|
122
|
+
redirected = true
|
|
123
|
+
init = redirect_init(init, status)
|
|
124
|
+
entry = resolve_entry(current, init)
|
|
125
|
+
if entry.respond_to?(:on_complete)
|
|
126
|
+
captured = current
|
|
127
|
+
captured_init = init
|
|
128
|
+
return entry.on_complete { |e| fulfill_from_entry(promise, mark_redirected(e, true), captured, captured_init) }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Deliver the final response, applying the CORS filtering a cross-origin
|
|
134
|
+
# fetch requires by request mode: "no-cors" yields an opaque response;
|
|
135
|
+
# "cors" (the default) demands the response allow the origin via
|
|
136
|
+
# Access-Control-Allow-Origin (else a network error) and tags it a cors
|
|
137
|
+
# response; a same-origin response passes through as basic.
|
|
138
|
+
# Marks an entry that came from a test stub / data: URL — exempt from the
|
|
139
|
+
# cross-origin CORS filtering that only models a real network + server.
|
|
140
|
+
CORS_EXEMPT = "__cors_exempt"
|
|
141
|
+
|
|
142
|
+
def deliver_final(promise, entry, url, init, redirected = false)
|
|
143
|
+
mode = (init["mode"] || "cors").to_s
|
|
144
|
+
if entry.is_a?(Hash) && !entry[CORS_EXEMPT] && cross_origin?(url)
|
|
145
|
+
case mode
|
|
146
|
+
when "no-cors"
|
|
147
|
+
return deliver_task { promise.fulfill(opaque_response) }
|
|
148
|
+
when "cors"
|
|
149
|
+
credentialed = credentialed?(init)
|
|
150
|
+
acao = header_value(entry["headers"], "access-control-allow-origin")
|
|
151
|
+
acac = header_value(entry["headers"], "access-control-allow-credentials")
|
|
152
|
+
ok = if credentialed
|
|
153
|
+
# A credentialed response must name the exact origin (never `*`) and
|
|
154
|
+
# grant credentials.
|
|
155
|
+
acao == request_origin && acac == "true"
|
|
156
|
+
else
|
|
157
|
+
acao == "*" || acao == request_origin
|
|
158
|
+
end
|
|
159
|
+
return deliver_task { promise.reject(fetch_type_error) } unless ok
|
|
160
|
+
|
|
161
|
+
entry = entry.merge("type" => "cors", "headers" => cors_filter_headers(entry["headers"], credentialed))
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
fulfill_from_entry(promise, mark_redirected(entry, redirected), url, init)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# CORS-safelisted response header names — always exposed on a cors response.
|
|
168
|
+
CORS_SAFELISTED_RESPONSE_HEADERS =
|
|
169
|
+
%w[cache-control content-language content-length content-type expires last-modified pragma].freeze
|
|
170
|
+
|
|
171
|
+
# Filter a cors response's headers to what CORS exposes: the safelisted
|
|
172
|
+
# response headers plus any named in Access-Control-Expose-Headers (`*`
|
|
173
|
+
# exposes everything). The CORS protocol headers themselves are dropped.
|
|
174
|
+
def cors_filter_headers(headers, credentialed = false)
|
|
175
|
+
return {} unless headers.is_a?(Hash)
|
|
176
|
+
|
|
177
|
+
exposed = (header_value(headers, "access-control-expose-headers") || "").split(",").map { |h| h.strip.downcase }
|
|
178
|
+
# The `*` wildcard does not apply to a credentialed response.
|
|
179
|
+
wildcard = !credentialed && exposed.include?("*")
|
|
180
|
+
headers.select do |name, _|
|
|
181
|
+
n = name.to_s.downcase
|
|
182
|
+
wildcard || CORS_SAFELISTED_RESPONSE_HEADERS.include?(n) || exposed.include?(n)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def credentialed?(init)
|
|
187
|
+
(init["credentials"] || "same-origin").to_s == "include"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# An opaque response for a no-cors cross-origin fetch: status 0, no headers
|
|
191
|
+
# or body exposed, type "opaque".
|
|
192
|
+
def opaque_response
|
|
193
|
+
Response.new(@window, body: "", status: 0, status_text: "", headers: {},
|
|
194
|
+
url: "", redirected: false, type: "opaque", has_body: false)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def header_value(headers, name)
|
|
198
|
+
return nil unless headers.is_a?(Hash)
|
|
199
|
+
|
|
200
|
+
headers.find { |k, _| k.to_s.casecmp?(name) }&.last
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def mark_redirected(entry, redirected)
|
|
204
|
+
return entry unless redirected && entry.is_a?(Hash)
|
|
205
|
+
|
|
206
|
+
entry.merge("redirected" => true)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# A redirect target resolved against the current URL, or nil when it is not a
|
|
210
|
+
# fetchable http(s) URL — an invalid URL, or a data:/other scheme (following a
|
|
211
|
+
# redirect to those is a network error).
|
|
212
|
+
def redirect_target(current, location)
|
|
213
|
+
# An empty Location value is a network error (not a self-redirect).
|
|
214
|
+
return nil if location.to_s.strip.empty?
|
|
215
|
+
|
|
216
|
+
target = URI.join(current, location).to_s
|
|
217
|
+
scheme = URI.parse(target).scheme&.downcase
|
|
218
|
+
%w[http https].include?(scheme) ? target : nil
|
|
219
|
+
rescue URI::Error
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Per Fetch: a 303 (and a 301/302 on POST) switches the method to GET and
|
|
224
|
+
# drops the body; 307/308 preserve them.
|
|
225
|
+
def redirect_init(init, status)
|
|
226
|
+
method = (init["method"] || "GET").to_s.upcase
|
|
227
|
+
return init unless status == 303 || ([301, 302].include?(status) && method == "POST")
|
|
228
|
+
|
|
229
|
+
init.merge("method" => "GET").tap { |h| h.delete("body") }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def opaqueredirect_response
|
|
233
|
+
Response.new(@window, body: "", status: 0, status_text: "", headers: {},
|
|
234
|
+
url: "", redirected: false, type: "opaqueredirect", has_body: false)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# A Bridge::TypeError (not a plain ErrorValue) so the rejection reason crosses
|
|
238
|
+
# as a real JS `TypeError` — `promise_rejects_js`/`assert_throws_js` check
|
|
239
|
+
# `instanceof TypeError`, which a flattened {name,message} object would fail.
|
|
240
|
+
def fetch_type_error
|
|
241
|
+
Bridge::TypeError.new("Failed to fetch")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Run work as a networking task on the event loop, matching fulfill_from_entry
|
|
245
|
+
# (so a fetch settles after the initiating script's microtask checkpoint).
|
|
246
|
+
def deliver_task(&blk)
|
|
247
|
+
if (sched = @window.respond_to?(:scheduler) ? @window.scheduler : nil)
|
|
248
|
+
sched.set_timeout(proc(&blk), 0)
|
|
249
|
+
else
|
|
250
|
+
blk.call
|
|
251
|
+
end
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
|
|
66
255
|
# Resolve `promise` from a response entry (nil -> 404), honoring a simulated
|
|
67
256
|
# `delay`. Used both for a synchronous entry and for an async one delivered
|
|
68
257
|
# later on the page thread.
|
|
@@ -105,7 +294,7 @@ module Dommy
|
|
|
105
294
|
else
|
|
106
295
|
promise.fulfill(
|
|
107
296
|
Response.new(@window, body: body, status: status, status_text: status_text,
|
|
108
|
-
headers: headers, url: response_url, redirected: redirected, type: "basic")
|
|
297
|
+
headers: headers, url: response_url, redirected: redirected, type: entry["type"] || "basic")
|
|
109
298
|
)
|
|
110
299
|
end
|
|
111
300
|
end
|
|
@@ -120,7 +309,7 @@ module Dommy
|
|
|
120
309
|
def resolve_entry(url, init)
|
|
121
310
|
if (decoded = DataUri.parse(url))
|
|
122
311
|
return {"body" => decoded[:body], "status" => 200, "statusText" => "OK",
|
|
123
|
-
"contentType" => decoded[:content_type]}
|
|
312
|
+
"contentType" => decoded[:content_type], CORS_EXEMPT => true}
|
|
124
313
|
end
|
|
125
314
|
|
|
126
315
|
handler = @window.globals["__fetch_handler__"]
|
|
@@ -135,26 +324,181 @@ module Dommy
|
|
|
135
324
|
return nil unless stub_map.is_a?(Hash)
|
|
136
325
|
|
|
137
326
|
# The URL is now absolute; a stub keyed by a path ("/api") still matches
|
|
138
|
-
# its resolved form ("http://host/api").
|
|
139
|
-
|
|
327
|
+
# its resolved form ("http://host/api"). A stub / data: response is the
|
|
328
|
+
# test's explicit intent, so it is exempt from cross-origin CORS filtering
|
|
329
|
+
# (which only models what a real network + server enforce, per the
|
|
330
|
+
# __fetch_handler__ path).
|
|
331
|
+
entry = stub_map[url] || stub_map[@window.__internal_url_path__(url)]
|
|
332
|
+
entry && entry.merge(CORS_EXEMPT => true)
|
|
140
333
|
end
|
|
141
334
|
|
|
142
335
|
# Coerce `init` into a Hash with string keys so the rest of the
|
|
143
336
|
# pipeline (and the `__last_init__` globals) sees a uniform shape.
|
|
144
337
|
# When the body is a Blob/File, fill in `Content-Type` from the
|
|
145
338
|
# blob's type unless the caller already provided a header for it.
|
|
339
|
+
# A default User-Agent — real browsers always send one; tests assert its
|
|
340
|
+
# presence, not its value.
|
|
341
|
+
USER_AGENT = "Mozilla/5.0 (Dommy)"
|
|
342
|
+
|
|
146
343
|
def normalize_init(init)
|
|
147
|
-
|
|
344
|
+
h = init.is_a?(Hash) ? init.transform_keys(&:to_s) : {}
|
|
345
|
+
method = (h["method"] || "GET").to_s.upcase
|
|
346
|
+
h["headers"] = request_headers(h["headers"], h["body"], method)
|
|
347
|
+
h
|
|
348
|
+
end
|
|
148
349
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
350
|
+
# The request's header set as a plain Hash (case preserved): the caller's
|
|
351
|
+
# headers (record / sequence of pairs / Headers) plus the defaults a browser
|
|
352
|
+
# adds — Accept, Accept-Language, User-Agent, and, for a body-bearing method,
|
|
353
|
+
# Origin and Content-Length; a Content-Type is derived from an extractable
|
|
354
|
+
# body when the caller set none. So a resolver / endpoint (and the
|
|
355
|
+
# __last_init__ diagnostic) sees the actual request headers. Names are
|
|
356
|
+
# case-insensitive, so a default is added only when absent under any casing.
|
|
357
|
+
def request_headers(raw, body, method)
|
|
358
|
+
headers = normalize_header_record(raw)
|
|
359
|
+
bytes, default_ct = body.nil? ? ["", nil] : Response.extract_body(body)
|
|
360
|
+
headers["Accept"] = "*/*" unless header?(headers, "accept")
|
|
361
|
+
headers["Accept-Language"] = "en-US,en;q=0.9" unless header?(headers, "accept-language")
|
|
362
|
+
headers["User-Agent"] = USER_AGENT unless header?(headers, "user-agent")
|
|
363
|
+
headers["Content-Type"] = default_ct if default_ct && !header?(headers, "content-type")
|
|
364
|
+
# Fetch adds Origin + Content-Length for methods that carry a body (i.e.
|
|
365
|
+
# anything but GET/HEAD); a bodyless such request still sends Content-Length: 0.
|
|
366
|
+
unless %w[GET HEAD].include?(method)
|
|
367
|
+
headers["Origin"] = request_origin unless header?(headers, "origin")
|
|
368
|
+
# Content-Length: the body's byte length; a null body still sends 0, but
|
|
369
|
+
# only for POST/PUT (not arbitrary methods), per Fetch's "extract body".
|
|
370
|
+
unless header?(headers, "content-length")
|
|
371
|
+
if !body.nil?
|
|
372
|
+
headers["Content-Length"] = bytes.bytesize.to_s
|
|
373
|
+
elsif %w[POST PUT].include?(method)
|
|
374
|
+
headers["Content-Length"] = "0"
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
headers
|
|
379
|
+
end
|
|
152
380
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
381
|
+
def request_origin
|
|
382
|
+
@window.location.__js_get__("origin").to_s
|
|
383
|
+
rescue StandardError
|
|
384
|
+
""
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
CORS_SAFELISTED_METHODS = %w[GET HEAD POST].freeze
|
|
388
|
+
CORS_SAFELISTED_HEADERS = %w[accept accept-language content-language].freeze
|
|
389
|
+
# Browser-controlled request headers that never count toward the preflight's
|
|
390
|
+
# Access-Control-Request-Headers.
|
|
391
|
+
CORS_BROWSER_HEADERS = %w[user-agent origin content-length host connection referer accept-encoding].freeze
|
|
392
|
+
CORS_SAFE_CONTENT_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"].freeze
|
|
393
|
+
|
|
394
|
+
# A cross-origin cors request needs a preflight when it uses a non-safelisted
|
|
395
|
+
# method or carries a non-safelisted header.
|
|
396
|
+
def needs_preflight?(url, init)
|
|
397
|
+
return false unless (init["mode"] || "cors").to_s == "cors" && cross_origin?(url)
|
|
398
|
+
|
|
399
|
+
method = (init["method"] || "GET").to_s.upcase
|
|
400
|
+
!CORS_SAFELISTED_METHODS.include?(method) || !cors_unsafe_headers(init["headers"]).empty?
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Sorted, lowercased names of the request headers a preflight must ask
|
|
404
|
+
# permission for (everything not CORS-safelisted or browser-controlled;
|
|
405
|
+
# Content-Type only when its value is not safelisted).
|
|
406
|
+
def cors_unsafe_headers(headers)
|
|
407
|
+
(headers || {}).filter_map do |name, value|
|
|
408
|
+
n = name.to_s.downcase
|
|
409
|
+
if n == "content-type"
|
|
410
|
+
safe_content_type?(value) ? nil : n
|
|
411
|
+
elsif CORS_SAFELISTED_HEADERS.include?(n) || CORS_BROWSER_HEADERS.include?(n)
|
|
412
|
+
nil
|
|
413
|
+
else
|
|
414
|
+
n
|
|
415
|
+
end
|
|
416
|
+
end.uniq.sort
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def safe_content_type?(value)
|
|
420
|
+
CORS_SAFE_CONTENT_TYPES.include?(value.to_s.split(";").first.to_s.strip.downcase)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Run the CORS preflight (an OPTIONS carrying Access-Control-Request-Method /
|
|
424
|
+
# -Headers) and check the response grants the method and each requested
|
|
425
|
+
# header for the origin. Returns whether the real request may proceed.
|
|
426
|
+
def preflight_ok?(url, init)
|
|
427
|
+
method = (init["method"] || "GET").to_s.upcase
|
|
428
|
+
acrh = cors_unsafe_headers(init["headers"])
|
|
429
|
+
options_headers = {
|
|
430
|
+
"Accept" => "*/*", "Origin" => request_origin,
|
|
431
|
+
"Access-Control-Request-Method" => method,
|
|
432
|
+
}
|
|
433
|
+
options_headers["Access-Control-Request-Headers"] = acrh.join(",") unless acrh.empty?
|
|
434
|
+
|
|
435
|
+
entry = resolve_entry(url, {"method" => "OPTIONS", "headers" => options_headers})
|
|
436
|
+
return false unless entry.is_a?(Hash) && (200..299).cover?((entry["status"] || 200).to_i)
|
|
437
|
+
|
|
438
|
+
credentialed = credentialed?(init)
|
|
439
|
+
acao = header_value(entry["headers"], "access-control-allow-origin")
|
|
440
|
+
if credentialed
|
|
441
|
+
return false unless acao == request_origin
|
|
442
|
+
return false unless header_value(entry["headers"], "access-control-allow-credentials") == "true"
|
|
443
|
+
else
|
|
444
|
+
return false unless acao == "*" || acao == request_origin
|
|
445
|
+
end
|
|
446
|
+
return false unless cors_method_allowed?(entry, method, credentialed)
|
|
447
|
+
|
|
448
|
+
cors_headers_allowed?(entry, acrh, credentialed)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def cors_method_allowed?(entry, method, credentialed)
|
|
452
|
+
return true if CORS_SAFELISTED_METHODS.include?(method)
|
|
453
|
+
|
|
454
|
+
allowed = (header_value(entry["headers"], "access-control-allow-methods") || "").split(",").map { |m| m.strip.upcase }
|
|
455
|
+
# `*` does not apply to a credentialed request.
|
|
456
|
+
allowed.include?(method) || (!credentialed && allowed.include?("*"))
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def cors_headers_allowed?(entry, acrh, credentialed)
|
|
460
|
+
return true if acrh.empty?
|
|
461
|
+
|
|
462
|
+
allowed = (header_value(entry["headers"], "access-control-allow-headers") || "").split(",").map { |h| h.strip.downcase }
|
|
463
|
+
# `*` covers all header names except `authorization`, and not at all for a
|
|
464
|
+
# credentialed request.
|
|
465
|
+
wildcard = !credentialed && allowed.include?("*")
|
|
466
|
+
acrh.all? { |h| allowed.include?(h) || (wildcard && h != "authorization") }
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Whether `url`'s origin differs from the document's. A non-absolute URL (no
|
|
470
|
+
|
|
471
|
+
# Whether `url`'s origin differs from the document's. A non-absolute URL (no
|
|
472
|
+
# scheme/host) is same-origin (already resolved against the document base).
|
|
473
|
+
def cross_origin?(url)
|
|
474
|
+
other = origin_of(url)
|
|
475
|
+
!other.nil? && other != request_origin
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def origin_of(url)
|
|
479
|
+
u = URI.parse(url.to_s)
|
|
480
|
+
return nil unless u.scheme && u.host
|
|
481
|
+
|
|
482
|
+
default = u.scheme == "https" ? 443 : 80
|
|
483
|
+
port = u.port && u.port != default ? ":#{u.port}" : ""
|
|
484
|
+
"#{u.scheme}://#{u.host}#{port}"
|
|
485
|
+
rescue URI::Error
|
|
486
|
+
nil
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# A header record from a Hash, a sequence of [name, value] pairs, or a
|
|
490
|
+
# Headers, as a plain Hash with string keys (original case preserved).
|
|
491
|
+
def normalize_header_record(raw)
|
|
492
|
+
case raw
|
|
493
|
+
when Headers then raw.to_h
|
|
494
|
+
when Array then raw.each_with_object({}) { |pair, h| h[pair[0].to_s] = pair[1].to_s if pair.is_a?(Array) }
|
|
495
|
+
when Hash then raw.transform_keys(&:to_s)
|
|
496
|
+
else {}
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def header?(headers, name)
|
|
501
|
+
headers.keys.any? { |k| k.to_s.casecmp?(name) }
|
|
158
502
|
end
|
|
159
503
|
|
|
160
504
|
# Diagnostic only (DOMMY_FETCH_DEBUG=<file>): append a record of what the
|
|
@@ -222,13 +566,25 @@ module Dommy
|
|
|
222
566
|
class Request
|
|
223
567
|
attr_reader :url, :method, :body
|
|
224
568
|
|
|
225
|
-
def initialize(url, init = nil)
|
|
569
|
+
def initialize(url, init = nil, window = nil)
|
|
226
570
|
opts = init.is_a?(Hash) ? init : {}
|
|
571
|
+
@window = window
|
|
227
572
|
@url = url.to_s
|
|
228
573
|
@method = (opts["method"] || opts[:method] || "GET").to_s.upcase
|
|
229
574
|
@body = opts["body"] || opts[:body]
|
|
230
575
|
raw_headers = opts["headers"] || opts[:headers] || {}
|
|
231
576
|
@headers = Headers.new(raw_headers)
|
|
577
|
+
# WHATWG "extract a body": normalize the body source to a byte string once
|
|
578
|
+
# (shared with Response), so the Body consume methods (text/arrayBuffer/…)
|
|
579
|
+
# read from it. A default Content-Type from the extraction is applied only
|
|
580
|
+
# when the caller supplied none.
|
|
581
|
+
unless @body.nil?
|
|
582
|
+
@body_bytes, default_ct = Response.extract_body(@body)
|
|
583
|
+
if default_ct && !@headers.__js_call__("has", ["content-type"])
|
|
584
|
+
@headers.__js_call__("set", ["content-type", default_ct])
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
@body_bytes ||= ""
|
|
232
588
|
@credentials = (opts["credentials"] || opts[:credentials] || "same-origin").to_s
|
|
233
589
|
@mode = (opts["mode"] || opts[:mode] || "cors").to_s
|
|
234
590
|
@cache = (opts["cache"] || opts[:cache] || "default").to_s
|
|
@@ -270,23 +626,62 @@ module Dommy
|
|
|
270
626
|
end
|
|
271
627
|
|
|
272
628
|
include Bridge::Methods
|
|
273
|
-
js_methods %w[clone]
|
|
629
|
+
js_methods %w[clone text json arrayBuffer blob bytes]
|
|
274
630
|
def __js_call__(method, _args)
|
|
275
631
|
case method
|
|
276
632
|
when "clone"
|
|
277
633
|
Request.new(
|
|
278
634
|
@url,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
635
|
+
{
|
|
636
|
+
"method" => @method,
|
|
637
|
+
"body" => @body,
|
|
638
|
+
"headers" => @headers.to_h,
|
|
639
|
+
"credentials" => @credentials,
|
|
640
|
+
"mode" => @mode,
|
|
641
|
+
"cache" => @cache,
|
|
642
|
+
"redirect" => @redirect,
|
|
643
|
+
"signal" => @signal
|
|
644
|
+
},
|
|
645
|
+
@window
|
|
287
646
|
)
|
|
647
|
+
when "text"
|
|
648
|
+
consume_body { immediate(Response.utf8_decode(@body_bytes)) }
|
|
649
|
+
when "json"
|
|
650
|
+
consume_body do
|
|
651
|
+
immediate(JSON.parse(Response.utf8_decode(@body_bytes)))
|
|
652
|
+
rescue JSON::ParserError => e
|
|
653
|
+
rejected(ErrorValue.new("JSON parse: #{e.message}"))
|
|
654
|
+
end
|
|
655
|
+
when "arrayBuffer"
|
|
656
|
+
consume_body { immediate(Bridge::ArrayBuffer.new(@body_bytes.bytes)) }
|
|
657
|
+
when "bytes"
|
|
658
|
+
consume_body { immediate(Bridge::Bytes.new(@body_bytes.bytes)) }
|
|
659
|
+
when "blob"
|
|
660
|
+
ct = @headers.__js_call__("get", ["content-type"]) || ""
|
|
661
|
+
consume_body { immediate(Blob.new([@body_bytes], {"type" => ct}, @window)) }
|
|
288
662
|
end
|
|
289
663
|
end
|
|
664
|
+
|
|
665
|
+
private
|
|
666
|
+
|
|
667
|
+
# WHATWG Body: the body can be consumed once. A second consume rejects rather
|
|
668
|
+
# than throwing synchronously.
|
|
669
|
+
def consume_body
|
|
670
|
+
if @body_used
|
|
671
|
+
return rejected(ErrorValue.new("Failed to read body: body stream already read", name: "TypeError"))
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
@body_used = true
|
|
675
|
+
yield
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def immediate(value)
|
|
679
|
+
@window ? PromiseValue.resolve(@window, value) : value
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def rejected(value)
|
|
683
|
+
@window ? PromiseValue.reject(@window, value) : value
|
|
684
|
+
end
|
|
290
685
|
end
|
|
291
686
|
|
|
292
687
|
# `Response` polyfill — just enough surface for Fetchy:
|
|
@@ -457,6 +852,17 @@ module Dommy
|
|
|
457
852
|
end
|
|
458
853
|
end
|
|
459
854
|
|
|
855
|
+
# WHATWG "UTF-8 decode" for a body's text(): interpret the raw bytes as
|
|
856
|
+
# UTF-8, replacing any ill-formed sequence with U+FFFD, and drop a single
|
|
857
|
+
# leading byte-order mark (U+FEFF). Well-formed UTF-8 (the common case) is
|
|
858
|
+
# returned unchanged. Used by both Request and Response text().
|
|
859
|
+
def self.utf8_decode(bytes)
|
|
860
|
+
s = bytes.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
861
|
+
s = s.scrub("\u{FFFD}") unless s.valid_encoding?
|
|
862
|
+
s = s[1..] if s.start_with?("\u{FEFF}")
|
|
863
|
+
s
|
|
864
|
+
end
|
|
865
|
+
|
|
460
866
|
# WHATWG "extract a body": map a body source to `[byte_string,
|
|
461
867
|
# default_content_type_or_nil]`. The default Content-Type is applied only
|
|
462
868
|
# when the caller supplied none.
|
|
@@ -544,7 +950,7 @@ module Dommy
|
|
|
544
950
|
def __js_call__(method, _args)
|
|
545
951
|
case method
|
|
546
952
|
when "text"
|
|
547
|
-
consume_body { immediate(@body) }
|
|
953
|
+
consume_body { immediate(Response.utf8_decode(@body)) }
|
|
548
954
|
when "json"
|
|
549
955
|
consume_body do
|
|
550
956
|
immediate(JSON.parse(scrub_lone_surrogates(@body)))
|
data/lib/dommy/history.rb
CHANGED
|
@@ -9,6 +9,10 @@ module Dommy
|
|
|
9
9
|
def initialize(window, location)
|
|
10
10
|
@window = window
|
|
11
11
|
@location = location
|
|
12
|
+
# Host (embedding session) seam: called with (:push | :replace |
|
|
13
|
+
# :traverse, url) after each history operation, so a session can keep
|
|
14
|
+
# its own navigation history and current URL in step with the page's
|
|
15
|
+
# same-document entries (Turbo Drive's pushState navigations).
|
|
12
16
|
# Each entry records the full href it navigated to, so back/forward can
|
|
13
17
|
# restore Location to it (and fire popstate) — a restoration that
|
|
14
18
|
# framework routers like Turbo's depend on to swap the cached snapshot.
|
|
@@ -46,6 +50,17 @@ module Dommy
|
|
|
46
50
|
|
|
47
51
|
include Bridge::Methods
|
|
48
52
|
js_methods %w[pushState replaceState back forward go]
|
|
53
|
+
attr_accessor :__internal_on_change__
|
|
54
|
+
|
|
55
|
+
# The current entry index / traversal API for the host session, used to
|
|
56
|
+
# mirror joint back/forward. go_to targets an absolute entry index (a
|
|
57
|
+
# previously observed __internal_index__); already there is a no-op, so
|
|
58
|
+
# no spurious popstate fires.
|
|
59
|
+
def __internal_index__ = @cursor
|
|
60
|
+
def __internal_go_to__(index)
|
|
61
|
+
go(index - @cursor) unless index == @cursor
|
|
62
|
+
end
|
|
63
|
+
|
|
49
64
|
def __js_call__(method, args)
|
|
50
65
|
case method
|
|
51
66
|
when "pushState"
|
|
@@ -66,18 +81,21 @@ module Dommy
|
|
|
66
81
|
def push(state, url)
|
|
67
82
|
resolved = resolve_url!(url)
|
|
68
83
|
@stack = @stack[0..@cursor]
|
|
69
|
-
|
|
84
|
+
# pushState never fires hashchange, even when only the fragment changes.
|
|
85
|
+
@location.__internal_set_url__(resolved, fire_hash: false) if resolved
|
|
70
86
|
# WHATWG: pushState serializes the state via structured-clone
|
|
71
87
|
# so subsequent caller-side mutation of the original cannot
|
|
72
88
|
# affect history.state.
|
|
73
89
|
@stack << {state: Dommy.structured_clone(state), url: @location.href}
|
|
74
90
|
@cursor = @stack.size - 1
|
|
91
|
+
__internal_on_change__&.call(:push, @location.href)
|
|
75
92
|
end
|
|
76
93
|
|
|
77
94
|
def replace(state, url)
|
|
78
95
|
resolved = resolve_url!(url)
|
|
79
|
-
@location.__internal_set_url__(resolved) if resolved
|
|
96
|
+
@location.__internal_set_url__(resolved, fire_hash: false) if resolved
|
|
80
97
|
@stack[@cursor] = {state: Dommy.structured_clone(state), url: @location.href}
|
|
98
|
+
__internal_on_change__&.call(:replace, @location.href)
|
|
81
99
|
end
|
|
82
100
|
|
|
83
101
|
# WHATWG "URL and history update steps": resolve the given URL against the
|
|
@@ -111,8 +129,11 @@ module Dommy
|
|
|
111
129
|
entry = @stack[@cursor]
|
|
112
130
|
# Restore Location to the target entry's URL BEFORE firing popstate, so a
|
|
113
131
|
# listener that reads `location` (Turbo's restoration visit, the WHATWG
|
|
114
|
-
# traversal steps) sees the destination, not the page we came from.
|
|
132
|
+
# traversal steps) sees the destination, not the page we came from. The
|
|
133
|
+
# host is notified before popstate too, so a handler's requests resolve
|
|
134
|
+
# against the destination URL.
|
|
115
135
|
@location.__internal_set_url__(entry[:url]) if entry[:url]
|
|
136
|
+
__internal_on_change__&.call(:traverse, entry[:url])
|
|
116
137
|
@window.fire_popstate(entry[:state])
|
|
117
138
|
end
|
|
118
139
|
end
|