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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +46 -8
  3. data/lib/dommy/backend/makiri_adapter.rb +34 -0
  4. data/lib/dommy/backend.rb +29 -0
  5. data/lib/dommy/blob.rb +48 -6
  6. data/lib/dommy/browser.rb +239 -36
  7. data/lib/dommy/callable_invoker.rb +6 -2
  8. data/lib/dommy/document.rb +525 -57
  9. data/lib/dommy/element.rb +479 -239
  10. data/lib/dommy/event.rb +296 -15
  11. data/lib/dommy/fetch.rb +431 -25
  12. data/lib/dommy/history.rb +24 -3
  13. data/lib/dommy/html_collection.rb +145 -21
  14. data/lib/dommy/html_elements.rb +1675 -266
  15. data/lib/dommy/interaction/driver.rb +155 -14
  16. data/lib/dommy/interaction/event_synthesis.rb +115 -7
  17. data/lib/dommy/interaction/field_interactor.rb +60 -0
  18. data/lib/dommy/internal/child_node.rb +199 -0
  19. data/lib/dommy/internal/css/cascade.rb +44 -2
  20. data/lib/dommy/internal/global_functions.rb +23 -10
  21. data/lib/dommy/internal/mutation_coordinator.rb +27 -7
  22. data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
  23. data/lib/dommy/internal/observer_manager.rb +6 -0
  24. data/lib/dommy/internal/observer_matcher.rb +6 -8
  25. data/lib/dommy/internal/parent_node.rb +37 -73
  26. data/lib/dommy/internal/selector_matcher.rb +89 -0
  27. data/lib/dommy/internal/selector_parser.rb +44 -7
  28. data/lib/dommy/js/custom_element_bridge.rb +13 -2
  29. data/lib/dommy/js/dom_interfaces.rb +19 -4
  30. data/lib/dommy/js/host_bridge.rb +21 -0
  31. data/lib/dommy/js/host_runtime.js +926 -64
  32. data/lib/dommy/js/script_boot.rb +80 -0
  33. data/lib/dommy/location.rb +76 -13
  34. data/lib/dommy/mutation_observer.rb +3 -4
  35. data/lib/dommy/navigation.rb +263 -0
  36. data/lib/dommy/node.rb +180 -27
  37. data/lib/dommy/range.rb +15 -3
  38. data/lib/dommy/scheduler.rb +16 -0
  39. data/lib/dommy/shadow_root.rb +33 -0
  40. data/lib/dommy/storage.rb +61 -10
  41. data/lib/dommy/tree_walker.rb +18 -36
  42. data/lib/dommy/url.rb +4 -2
  43. data/lib/dommy/version.rb +1 -1
  44. data/lib/dommy/web_socket.rb +45 -2
  45. data/lib/dommy/window.rb +126 -7
  46. data/lib/dommy/xml_http_request.rb +30 -4
  47. data/lib/dommy.rb +2 -0
  48. 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
- fulfill_from_entry(promise, result, url, init)
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
- stub_map[url] || stub_map[@window.__internal_url_path__(url)]
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
- return init unless init.is_a?(Hash)
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
- h = init.transform_keys(&:to_s)
150
- body = h["body"]
151
- return h unless body.is_a?(Blob)
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
- headers = (h["headers"] || {}).dup
154
- content_type_set = headers.any? { |k, _| k.to_s.downcase == "content-type" }
155
- headers["Content-Type"] = body.type if !content_type_set && !body.type.empty?
156
- h["headers"] = headers
157
- h
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
- "method" => @method,
280
- "body" => @body,
281
- "headers" => @headers.to_h,
282
- "credentials" => @credentials,
283
- "mode" => @mode,
284
- "cache" => @cache,
285
- "redirect" => @redirect,
286
- "signal" => @signal
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
- @location.__internal_set_url__(resolved) if resolved
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