dommy 0.7.0 → 0.8.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +9 -1
  3. data/lib/dommy/attr.rb +192 -39
  4. data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
  5. data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
  6. data/lib/dommy/backend.rb +46 -0
  7. data/lib/dommy/blob.rb +28 -9
  8. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  9. data/lib/dommy/bridge/methods.rb +57 -0
  10. data/lib/dommy/bridge.rb +97 -0
  11. data/lib/dommy/callable_invoker.rb +36 -0
  12. data/lib/dommy/cookie_store.rb +3 -1
  13. data/lib/dommy/crypto.rb +7 -1
  14. data/lib/dommy/css.rb +46 -0
  15. data/lib/dommy/custom_elements.rb +27 -3
  16. data/lib/dommy/data_transfer.rb +4 -0
  17. data/lib/dommy/document.rb +615 -48
  18. data/lib/dommy/dom_parser.rb +28 -15
  19. data/lib/dommy/element.rb +999 -471
  20. data/lib/dommy/event.rb +260 -96
  21. data/lib/dommy/event_source.rb +6 -2
  22. data/lib/dommy/fetch.rb +505 -43
  23. data/lib/dommy/file_reader.rb +11 -3
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +43 -8
  26. data/lib/dommy/html_collection.rb +55 -2
  27. data/lib/dommy/html_elements.rb +102 -1519
  28. data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
  29. data/lib/dommy/internal/global_functions.rb +26 -0
  30. data/lib/dommy/internal/idna.rb +16 -7
  31. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  32. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  33. data/lib/dommy/internal/namespaces.rb +70 -0
  34. data/lib/dommy/internal/node_equality.rb +86 -0
  35. data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
  36. data/lib/dommy/internal/observable_callback.rb +1 -5
  37. data/lib/dommy/internal/parent_node.rb +126 -0
  38. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  39. data/lib/dommy/internal/selector_parser.rb +664 -0
  40. data/lib/dommy/internal/url_parser.rb +677 -0
  41. data/lib/dommy/intersection_observer.rb +2 -0
  42. data/lib/dommy/location.rb +2 -0
  43. data/lib/dommy/media_query_list.rb +7 -1
  44. data/lib/dommy/message_channel.rb +32 -2
  45. data/lib/dommy/mutation_observer.rb +55 -12
  46. data/lib/dommy/navigator.rb +26 -12
  47. data/lib/dommy/node.rb +158 -28
  48. data/lib/dommy/notification.rb +3 -1
  49. data/lib/dommy/performance.rb +4 -0
  50. data/lib/dommy/performance_observer.rb +2 -0
  51. data/lib/dommy/promise.rb +14 -14
  52. data/lib/dommy/range.rb +74 -5
  53. data/lib/dommy/resize_observer.rb +2 -0
  54. data/lib/dommy/scheduler.rb +34 -13
  55. data/lib/dommy/shadow_root.rb +23 -54
  56. data/lib/dommy/storage.rb +2 -0
  57. data/lib/dommy/streams.rb +18 -27
  58. data/lib/dommy/svg_elements.rb +204 -3606
  59. data/lib/dommy/text_codec.rb +174 -21
  60. data/lib/dommy/tree_walker.rb +255 -66
  61. data/lib/dommy/url.rb +287 -449
  62. data/lib/dommy/url_pattern.rb +2 -0
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +37 -7
  65. data/lib/dommy/window.rb +202 -213
  66. data/lib/dommy/worker.rb +7 -7
  67. data/lib/dommy/xml_http_request.rb +15 -5
  68. data/lib/dommy.rb +7 -0
  69. metadata +12 -3
data/lib/dommy/fetch.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "securerandom"
4
5
 
5
6
  module Dommy
6
7
  # `fetch` polyfill. No real network — instead consults
@@ -46,7 +47,7 @@ module Dommy
46
47
  promise = PromiseValue.new(@window)
47
48
 
48
49
  if entry.nil?
49
- response = Response.new(@window, body: "not found", status: 404, status_text: "Not Found")
50
+ response = Response.new(@window, body: "not found", status: 404, status_text: "Not Found", type: "basic")
50
51
  promise.fulfill(response)
51
52
  return promise
52
53
  end
@@ -56,13 +57,20 @@ module Dommy
56
57
  status_text = entry["statusText"] || ""
57
58
  content_type = entry["contentType"] || "text/plain"
58
59
  headers = entry["headers"] || {"Content-Type" => content_type}
60
+ # Simulate a followed redirect: `[:url]` overrides the response URL (the
61
+ # final location) and `[:redirected]` flags it, so consumers that branch
62
+ # on `response.redirected` / `response.url` (e.g. Turbo updating history to
63
+ # the redirected location) see a realistic response.
64
+ response_url = entry["url"] || url
65
+ redirected = entry["redirected"] ? true : false
59
66
 
60
67
  delay = entry["delay"]
61
68
  if delay
62
69
  install_delayed_resolve(promise, body, status, status_text, headers, init, delay)
63
70
  else
64
71
  promise.fulfill(
65
- Response.new(@window, body: body, status: status, status_text: status_text, headers: headers, url: url)
72
+ Response.new(@window, body: body, status: status, status_text: status_text,
73
+ headers: headers, url: response_url, redirected: redirected, type: "basic")
66
74
  )
67
75
  end
68
76
 
@@ -99,7 +107,7 @@ module Dommy
99
107
  lambda do |*_args|
100
108
  next if cancelled[0]
101
109
 
102
- promise.fulfill(Response.new(@window, body: body, status: status, status_text: status_text, headers: headers))
110
+ promise.fulfill(Response.new(@window, body: body, status: status, status_text: status_text, headers: headers, type: "basic"))
103
111
  end,
104
112
  delay_ms.to_i
105
113
  )
@@ -163,6 +171,8 @@ module Dommy
163
171
  end
164
172
  end
165
173
 
174
+ include Bridge::Methods
175
+ js_methods %w[clone]
166
176
  def __js_call__(method, _args)
167
177
  case method
168
178
  when "clone"
@@ -185,13 +195,197 @@ module Dommy
185
195
  # `.entries()` / `.get(name)`) and `.text()` / `.json()` / `.body`
186
196
  # / `.arrayBuffer()` which all return Promise-like values.
187
197
  class Response
188
- def initialize(window, body:, status: 200, status_text: "", headers: nil, url: "")
198
+ # WHATWG null-body statuses: a Response with one of these may not carry a
199
+ # body (constructing one with a body is a TypeError). 101/103 are also
200
+ # null-body but fall outside the 200–599 range the constructor accepts.
201
+ NULL_BODY_STATUSES = [204, 205, 304].freeze
202
+ # Redirect statuses accepted by `Response.redirect(url, status)`.
203
+ REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
204
+
205
+ def initialize(window, body:, status: 200, status_text: "", headers: nil, url: "",
206
+ redirected: false, type: "default", has_body: true)
189
207
  @window = window
190
208
  @body = body.to_s
191
209
  @status = status
192
210
  @status_text = status_text.to_s
193
211
  @headers = Headers.new(headers || {})
194
212
  @url = url.to_s
213
+ @redirected = redirected ? true : false
214
+ @type = type
215
+ @has_body = has_body ? true : false
216
+ @body_used = false
217
+ @body_stream = nil
218
+ end
219
+
220
+ # WHATWG `new Response(body, init)`. Validates the status (200–599, else a
221
+ # RangeError; a null-body status 204/205/304 with a body is a TypeError),
222
+ # defaults statusText to "" and status to 200, accepts `init.headers` as a
223
+ # plain object or a Headers instance, and — per the body-extraction step —
224
+ # defaults Content-Type to text/plain for a non-null body when none was
225
+ # supplied. A constructed response's url is "".
226
+ def self.__construct__(window, body, init)
227
+ opts = init.is_a?(Hash) ? init : {}
228
+ status = coerce_status(opts["status"] || opts[:status] || 200)
229
+ unless status.between?(200, 599)
230
+ raise Bridge::RangeError,
231
+ "Failed to construct 'Response': The status provided (#{status}) is outside the range [200, 599]."
232
+ end
233
+
234
+ has_body = !(body.nil? || (defined?(Bridge::UNDEFINED) && body.equal?(Bridge::UNDEFINED)))
235
+ if has_body && NULL_BODY_STATUSES.include?(status)
236
+ raise Bridge::TypeError,
237
+ "Failed to construct 'Response': Response with null body status (#{status}) cannot have body."
238
+ end
239
+
240
+ # Extract a body: derive its bytes and the Content-Type it implies (Blob →
241
+ # its MIME type, URLSearchParams → urlencoded, FormData → multipart, a
242
+ # string → text/plain). The implied type is only the *default* — an
243
+ # explicit init.headers Content-Type still wins.
244
+ body_bytes, default_ct = has_body ? extract_body(body) : ["", nil]
245
+
246
+ headers = coerce_headers(opts["headers"] || opts[:headers])
247
+ if default_ct && headers.keys.none? { |k| k.to_s.downcase == "content-type" }
248
+ headers = headers.merge("Content-Type" => default_ct)
249
+ end
250
+
251
+ new(window, body: body_bytes,
252
+ status: status,
253
+ status_text: validate_status_text!(opts["statusText"] || opts[:statusText] || ""),
254
+ headers: headers,
255
+ has_body: has_body)
256
+ end
257
+
258
+ # Static `Response.json(data, init)` — serialize `data` to JSON, defaulting
259
+ # Content-Type to application/json. (WHATWG Fetch §Response.json)
260
+ def self.__json__(window, data, init = nil)
261
+ # WHATWG: serialize `data` as JSON; if that yields `undefined` (the value
262
+ # is JS `undefined` — or absent — or otherwise non-serializable), throw a
263
+ # TypeError. JS `null` serializes to "null" and is allowed.
264
+ if defined?(Bridge::UNDEFINED) && data.equal?(Bridge::UNDEFINED)
265
+ raise Bridge::TypeError,
266
+ "Failed to execute 'json' on 'Response': The data is not JSON-serializable."
267
+ end
268
+
269
+ opts = init.is_a?(Hash) ? init : {}
270
+ status = coerce_status(opts["status"] || opts[:status] || 200)
271
+ unless status.between?(200, 599)
272
+ raise Bridge::RangeError,
273
+ "Failed to execute 'json' on 'Response': The status provided (#{status}) is outside the range [200, 599]."
274
+ end
275
+ if NULL_BODY_STATUSES.include?(status)
276
+ raise Bridge::TypeError,
277
+ "Failed to execute 'json' on 'Response': Response with null body status (#{status}) cannot have body."
278
+ end
279
+
280
+ headers = coerce_headers(opts["headers"] || opts[:headers])
281
+ unless headers.keys.any? { |k| k.to_s.downcase == "content-type" }
282
+ headers = headers.merge("Content-Type" => "application/json")
283
+ end
284
+
285
+ new(window, body: JSON.generate(data),
286
+ status: status,
287
+ status_text: validate_status_text!(opts["statusText"] || opts[:statusText] || ""),
288
+ headers: headers)
289
+ end
290
+
291
+ # Static `Response.redirect(url, status = 302)` — a redirect response whose
292
+ # `Location` header is the parsed-and-serialized `url`. Parsing failure is a
293
+ # TypeError; a non-redirect status is a RangeError. The url is resolved
294
+ # against the window's base URL so a relative target works. (WHATWG Fetch
295
+ # §Response.redirect)
296
+ def self.__redirect__(window, url, status = nil)
297
+ base = window.respond_to?(:location) && window.location.respond_to?(:href) ? window.location.href : nil
298
+ parsed = Dommy::URL.new(url.to_s, base) # raises Bridge::TypeError on failure
299
+
300
+ status = coerce_status(status.nil? || (defined?(Bridge::UNDEFINED) && status.equal?(Bridge::UNDEFINED)) ? 302 : status)
301
+ unless REDIRECT_STATUSES.include?(status)
302
+ raise Bridge::RangeError,
303
+ "Failed to execute 'redirect' on 'Response': Invalid status code #{status}."
304
+ end
305
+
306
+ resp = new(window, body: "", status: status, headers: {"Location" => parsed.href}, has_body: false)
307
+ # WHATWG: a redirect response's header guard is "immutable".
308
+ resp.__js_get__("headers").make_immutable!
309
+ resp
310
+ end
311
+
312
+ # Static `Response.error()` — a network-error response (status 0, not ok,
313
+ # type "error"). (WHATWG Fetch §Response.error)
314
+ def self.__error__(window)
315
+ resp = new(window, body: "", status: 0, type: "error", has_body: false)
316
+ # WHATWG: a network-error response's header guard is "immutable".
317
+ resp.__js_get__("headers").make_immutable!
318
+ resp
319
+ end
320
+
321
+ def self.coerce_status(value)
322
+ value.is_a?(Numeric) ? value.to_i : value.to_s.to_i
323
+ end
324
+
325
+ # WHATWG reason-phrase: HTAB / SP / VCHAR (0x21–0x7E) / obs-text (0x80–0xFF).
326
+ # Any other byte (NUL, CR, LF, other controls, DEL) makes statusText invalid
327
+ # → TypeError.
328
+ def self.validate_status_text!(text)
329
+ str = text.to_s
330
+ if str.each_byte.any? { |b| (b < 0x20 && b != 0x09) || b == 0x7f }
331
+ raise Bridge::TypeError, "Failed to construct 'Response': Invalid statusText."
332
+ end
333
+ str
334
+ end
335
+
336
+ def self.coerce_headers(raw)
337
+ case raw
338
+ when Headers then raw.to_h
339
+ when Hash then raw
340
+ else {}
341
+ end
342
+ end
343
+
344
+ # WHATWG "extract a body": map a body source to `[byte_string,
345
+ # default_content_type_or_nil]`. The default Content-Type is applied only
346
+ # when the caller supplied none.
347
+ def self.extract_body(body)
348
+ case body
349
+ when Blob # File < Blob
350
+ [body.__dommy_bytes__, (body.type.to_s.empty? ? nil : body.type)]
351
+ when URLSearchParams
352
+ [body.to_s, "application/x-www-form-urlencoded;charset=UTF-8"]
353
+ when FormData
354
+ multipart_body(body)
355
+ when Bridge::Bytes # an ArrayBuffer / TypedArray body
356
+ [body.pack_bytes, nil]
357
+ when String
358
+ [body, "text/plain;charset=UTF-8"]
359
+ else
360
+ if defined?(Bridge::UNDEFINED) && body.equal?(Bridge::UNDEFINED)
361
+ ["", nil]
362
+ else
363
+ [body.to_s, "text/plain;charset=UTF-8"]
364
+ end
365
+ end
366
+ end
367
+
368
+ # Serialize a FormData as a multipart/form-data body. Returns `[bytes,
369
+ # content_type]` where content_type carries the generated boundary.
370
+ def self.multipart_body(form_data)
371
+ boundary = "----DommyFormBoundary#{SecureRandom.hex(12)}"
372
+ crlf = "\r\n"
373
+ out = +""
374
+ form_data.entries.each do |name, value|
375
+ out << "--#{boundary}#{crlf}"
376
+ if value.is_a?(Blob)
377
+ filename = value.respond_to?(:name) ? value.name : "blob"
378
+ out << %(Content-Disposition: form-data; name="#{name}"; filename="#{filename}"#{crlf})
379
+ content_type = value.type.to_s.empty? ? "application/octet-stream" : value.type
380
+ out << "Content-Type: #{content_type}#{crlf}#{crlf}"
381
+ out << value.__dommy_bytes__ << crlf
382
+ else
383
+ out << %(Content-Disposition: form-data; name="#{name}"#{crlf}#{crlf})
384
+ out << value.to_s << crlf
385
+ end
386
+ end
387
+ out << "--#{boundary}--#{crlf}"
388
+ [out, "multipart/form-data; boundary=#{boundary}"]
195
389
  end
196
390
 
197
391
  def __js_get__(key)
@@ -204,47 +398,188 @@ module Dommy
204
398
  @status_text
205
399
  when "url"
206
400
  @url
401
+ when "redirected"
402
+ # Fetch API: true when the response is the result of a followed
403
+ # redirect (so `response.url` is the final, not requested, URL).
404
+ @redirected
405
+ when "type"
406
+ # WHATWG response type: "default" (constructed), "error" (Response.error),
407
+ # "basic" (a same-origin fetch), …
408
+ @type
207
409
  when "headers"
208
410
  @headers
209
411
  when "body"
210
- @body
412
+ # WHATWG: a ReadableStream of the body bytes, or null when there is no
413
+ # body. Merely reading `.body` does not consume it (identity preserved).
414
+ body_stream
415
+ when "bodyUsed"
416
+ body_used?
211
417
  end
212
418
  end
213
419
 
214
420
  def __js_set__(_key, _value)
215
- nil
421
+ Bridge::UNHANDLED
216
422
  end
217
423
 
424
+ include Bridge::Methods
425
+ js_methods %w[text json arrayBuffer blob formData clone]
218
426
  def __js_call__(method, _args)
219
427
  case method
220
428
  when "text"
221
- immediate(@body)
429
+ consume_body { immediate(@body) }
222
430
  when "json"
223
- begin
224
- immediate(JSON.parse(@body))
431
+ consume_body do
432
+ immediate(JSON.parse(scrub_lone_surrogates(@body)))
225
433
  rescue JSON::ParserError => e
226
- err = ErrorValue.new("JSON parse: #{e.message}")
227
- rejected(err)
434
+ rejected(ErrorValue.new("JSON parse: #{e.message}"))
228
435
  end
229
-
230
436
  when "arrayBuffer"
231
- immediate(@body.bytes)
437
+ # arrayBuffer()'s spec return type is ArrayBuffer — wrap so the host
438
+ # bridge decodes it to a bare JS ArrayBuffer (not a Uint8Array view).
439
+ consume_body { immediate(Bridge::ArrayBuffer.new(@body.bytes)) }
232
440
  when "blob"
233
- immediate(Blob.new([@body], "type" => @headers.__js_call__("get", ["content-type"]) || ""))
441
+ consume_body do
442
+ immediate(Blob.new([@body], {"type" => @headers.__js_call__("get", ["content-type"]) || ""}, @window))
443
+ end
444
+ when "formData"
445
+ consume_body { consume_form_data }
234
446
  when "clone"
235
- Response.new(
236
- @window,
237
- body: @body,
238
- status: @status,
239
- status_text: @status_text,
240
- headers: @headers.to_h,
241
- url: @url
242
- )
447
+ clone_response
243
448
  end
244
449
  end
245
450
 
246
451
  private
247
452
 
453
+ # WHATWG: the body can be consumed once. `bodyUsed` is true once a consume
454
+ # method ran or the body stream got locked by a reader.
455
+ def body_used?
456
+ @body_used || !!@body_stream&.locked
457
+ end
458
+
459
+ # Run a body-consume (text/json/arrayBuffer/blob), rejecting if the body was
460
+ # already used (spec returns a rejected promise, not a synchronous throw).
461
+ def consume_body
462
+ if body_used?
463
+ return rejected(ErrorValue.new("Failed to read body: body stream already read", name: "TypeError"))
464
+ end
465
+
466
+ @body_used = true
467
+ yield
468
+ end
469
+
470
+ # Lazily build + memoize the body ReadableStream (a single Uint8Array chunk
471
+ # then close), or nil when there is no body.
472
+ def body_stream
473
+ return nil unless @has_body
474
+
475
+ @body_stream ||= begin
476
+ stream = ReadableStream.new(@window)
477
+ stream.__internal_enqueue__(Bridge::Bytes.new(@body.bytes)) unless @body.empty?
478
+ stream.__internal_close__
479
+ stream
480
+ end
481
+ end
482
+
483
+ # WHATWG: parse the body as a FormData based on Content-Type —
484
+ # application/x-www-form-urlencoded or multipart/form-data. Any other type
485
+ # rejects with a TypeError.
486
+ def consume_form_data
487
+ content_type = (@headers.__js_call__("get", ["content-type"]) || "").to_s
488
+ if content_type.start_with?("application/x-www-form-urlencoded")
489
+ immediate(parse_urlencoded_form(@body))
490
+ elsif (match = content_type.match(/\bmultipart\/form-data\b.*?boundary=("?)([^";]+)\1/i))
491
+ immediate(parse_multipart_form(@body, match[2]))
492
+ else
493
+ rejected(ErrorValue.new("Failed to read body as FormData: unsupported Content-Type", name: "TypeError"))
494
+ end
495
+ end
496
+
497
+ def parse_urlencoded_form(body)
498
+ form = FormData.new
499
+ URLSearchParams.new(body).__js_call__("entries", []).each { |name, value| form.append(name, value) }
500
+ form
501
+ end
502
+
503
+ # Parse a multipart/form-data body (the inverse of Response.multipart_body):
504
+ # a part with a `filename` becomes a File entry, otherwise a string entry.
505
+ def parse_multipart_form(body, boundary)
506
+ form = FormData.new
507
+ body.split("--#{boundary}").each do |section|
508
+ next if section.empty? || section.start_with?("--") # preamble / closing
509
+
510
+ section = section.sub(/\A\r\n/, "")
511
+ head, content = section.split("\r\n\r\n", 2)
512
+ next unless content
513
+
514
+ content = content.sub(/\r\n\z/, "")
515
+ disposition = head[/Content-Disposition:\s*form-data;([^\r\n]*)/i, 1].to_s
516
+ name = disposition[/name="([^"]*)"/i, 1]
517
+ next unless name
518
+
519
+ filename = disposition[/filename="([^"]*)"/i, 1]
520
+ if filename
521
+ type = head[/Content-Type:\s*([^\r\n]+)/i, 1].to_s.strip
522
+ form.append(name, File.new([content], filename, {"type" => type}, @window))
523
+ else
524
+ form.append(name, content)
525
+ end
526
+ end
527
+ form
528
+ end
529
+
530
+ # WHATWG: clone throws if the body is already disturbed/locked; otherwise the
531
+ # clone is an independent Response over the same bytes.
532
+ def clone_response
533
+ if body_used?
534
+ raise Bridge::TypeError, "Failed to execute 'clone' on 'Response': Response body is already used."
535
+ end
536
+
537
+ Response.new(
538
+ @window,
539
+ body: @body,
540
+ status: @status,
541
+ status_text: @status_text,
542
+ headers: @headers.to_h,
543
+ url: @url,
544
+ redirected: @redirected,
545
+ type: @type,
546
+ has_body: @has_body
547
+ )
548
+ end
549
+
550
+ # A run of one or more adjacent `\uXXXX` JSON escapes.
551
+ SURROGATE_ESCAPE_RUN = /(?:\\u[0-9a-fA-F]{4})+/.freeze
552
+
553
+ # Ruby's `JSON.parse` rejects unpaired surrogate escapes (`\uD800` with no
554
+ # trailing low surrogate), and Ruby UTF-8 strings can't hold lone surrogates
555
+ # anyway. The Fetch/URL data corpus uses lone surrogates deliberately; the
556
+ # only meaningful thing to do with them is what the URL parser would do —
557
+ # replace each lone surrogate with U+FFFD. We do that at the escape level
558
+ # (rewriting lone `\uXXXX` surrogate escapes to `�`) so valid pairs are
559
+ # preserved exactly and the parse succeeds.
560
+ def scrub_lone_surrogates(text)
561
+ text.gsub(SURROGATE_ESCAPE_RUN) do |run|
562
+ units = run.scan(/\\u([0-9a-fA-F]{4})/).flatten.map { |h| h.to_i(16) }
563
+ out = +""
564
+ i = 0
565
+ while i < units.length
566
+ u = units[i]
567
+ nxt = units[i + 1]
568
+ if u.between?(0xD800, 0xDBFF) && nxt&.between?(0xDC00, 0xDFFF)
569
+ out << format("\\u%04x\\u%04x", u, nxt)
570
+ i += 2
571
+ elsif u.between?(0xD800, 0xDFFF)
572
+ out << "\\ufffd"
573
+ i += 1
574
+ else
575
+ out << format("\\u%04x", u)
576
+ i += 1
577
+ end
578
+ end
579
+ out
580
+ end
581
+ end
582
+
248
583
  def immediate(value)
249
584
  PromiseValue.resolve(@window, value)
250
585
  end
@@ -254,16 +589,67 @@ module Dommy
254
589
  end
255
590
  end
256
591
 
257
- # Minimal `Headers` proxy. Consumer code typically calls
258
- # `headers.call(:entries)` and iterates via `Array.from(...)`, so
259
- # we just need `entries` and `get`.
592
+ # WHATWG `Headers`. Names are stored lowercased and compared
593
+ # case-insensitively; iteration (`keys`/`values`/`entries`/`forEach`) is
594
+ # sorted by name with duplicate values combined by ", " (the spec's "sort
595
+ # and combine" output). `new Headers(init)` fills from a record (Hash → set
596
+ # per key), a sequence (Array of `[name, value]` pairs → append), or another
597
+ # Headers instance.
260
598
  class Headers
261
- def initialize(hash)
262
- @hash = hash.is_a?(Hash) ? hash.transform_keys(&:to_s) : {}
599
+ # RFC 7230 token — a valid header name (one or more of these bytes).
600
+ HEADER_NAME = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze
601
+ # Leading/trailing HTTP whitespace (tab or space) trimmed from a value.
602
+ HTTP_WHITESPACE = /\A[\t ]+|[\t ]+\z/.freeze
603
+
604
+ def initialize(init = nil)
605
+ @list = [] # ordered [lowercased name, value] pairs (duplicates allowed)
606
+ @guard = :none # :none (mutable) or :immutable (Response.error/redirect)
607
+ fill(init)
608
+ end
609
+
610
+ # WHATWG: mark these headers immutable — the guard `Response.error()` /
611
+ # `Response.redirect()` give their headers. Any later set/append/delete then
612
+ # raises a TypeError. (Other guards — request/request-no-cors/response
613
+ # forbidden-name filtering — are out of scope: fetch here is stubbed.)
614
+ def make_immutable!
615
+ @guard = :immutable
616
+ self
617
+ end
618
+
619
+ # WHATWG "fill": a record (Hash) sets each key; a sequence (Array) appends
620
+ # each [name, value] pair (a non-2-element member is a TypeError); another
621
+ # Headers is copied pair-for-pair.
622
+ def fill(init)
623
+ case init
624
+ when Headers
625
+ init.__raw_pairs__.each { |name, value| append_value(name, value) }
626
+ when Array
627
+ init.each do |pair|
628
+ unless pair.is_a?(Array) && pair.length == 2
629
+ raise Bridge::TypeError,
630
+ "Failed to construct 'Headers': The provided value cannot be converted to a sequence of [name, value] pairs."
631
+ end
632
+ append_value(pair[0], pair[1])
633
+ end
634
+ when Hash
635
+ init.each { |name, value| set_value(name, value) }
636
+ end
637
+ nil
638
+ end
639
+
640
+ # Internal: a copy of the raw [name, value] pairs — lets one Headers be
641
+ # filled from another without losing duplicates or split Set-Cookie values.
642
+ def __raw_pairs__
643
+ @list.map(&:dup)
263
644
  end
264
645
 
646
+ # A plain Hash of name => combined value (duplicate names combined by ", ";
647
+ # Set-Cookie collapses to its combined value — use getSetCookie for the
648
+ # split list). For callers that want a simple record.
265
649
  def to_h
266
- @hash.dup
650
+ sort_and_combine.each_with_object({}) do |(name, value), out|
651
+ out[name] = out.key?(name) ? "#{out[name]}, #{value}" : value
652
+ end
267
653
  end
268
654
 
269
655
  def __js_get__(_key)
@@ -271,41 +657,117 @@ module Dommy
271
657
  end
272
658
 
273
659
  def __js_set__(_key, _value)
274
- nil
660
+ Bridge::UNHANDLED
275
661
  end
276
662
 
663
+ include Bridge::Methods
664
+ js_methods %w[set append delete keys values get entries has forEach getSetCookie]
277
665
  def __js_call__(method, args)
278
666
  case method
667
+ when "set"
668
+ set_value(args[0], args[1])
669
+ nil
670
+ when "append"
671
+ append_value(args[0], args[1])
672
+ nil
673
+ when "delete"
674
+ ensure_mutable!
675
+ name = validate_name!(args[0])
676
+ @list.reject! { |n, _| n == name }
677
+ nil
279
678
  when "get"
280
- name = args[0].to_s
281
- @hash[name] || @hash[Headers.canonical(name)]
282
- when "entries"
283
- @hash.to_a
679
+ get_combined(validate_name!(args[0]))
284
680
  when "has"
285
- # Match `get`'s case-insensitive lookup: try the raw name
286
- # first, then the Title-Case canonical form. WHATWG defines
287
- # header names as case-insensitive throughout the Headers API.
288
- name = args[0].to_s
289
- @hash.key?(name) || @hash.key?(Headers.canonical(name))
681
+ name = validate_name!(args[0])
682
+ @list.any? { |n, _| n == name }
683
+ when "keys"
684
+ sort_and_combine.map(&:first)
685
+ when "values"
686
+ sort_and_combine.map(&:last)
687
+ when "entries"
688
+ sort_and_combine
689
+ when "getSetCookie"
690
+ # WHATWG: Set-Cookie's individual values, in insertion order (never
691
+ # combined, unlike every other header).
692
+ @list.select { |n, _| n == "set-cookie" }.map(&:last)
290
693
  when "forEach"
291
- # WHATWG: forEach(callback) — callback(value, key, headers).
292
- # Pass `self` as the third argument so consumers that read
293
- # `(_, _, h) => h.get("Foo")` work the same as in a browser.
694
+ # WHATWG: forEach(callback) — callback(value, key, headers), in the
695
+ # sort-and-combine order. `self` is the third argument so
696
+ # `(_, _, h) => h.get(...)` works the same as in a browser.
294
697
  cb = args[0]
295
- @hash.each do |k, v|
698
+ sort_and_combine.each do |k, v|
296
699
  if cb.respond_to?(:__js_call__)
297
700
  cb.__js_call__("call", [v, k, self])
298
701
  elsif cb.respond_to?(:call)
299
702
  cb.call(v, k, self)
300
703
  end
301
704
  end
302
-
303
705
  nil
304
706
  end
305
707
  end
306
708
 
709
+ # RFC 7230 Title-Case form of a header name. Retained as a public helper;
710
+ # the Headers store itself is lowercased per the WHATWG spec.
307
711
  def self.canonical(name)
308
712
  name.split("-").map(&:capitalize).join("-")
309
713
  end
714
+
715
+ private
716
+
717
+ def set_value(name, value)
718
+ ensure_mutable!
719
+ key = validate_name!(name)
720
+ val = validate_value!(value)
721
+ @list.reject! { |n, _| n == key }
722
+ @list << [key, val]
723
+ end
724
+
725
+ def append_value(name, value)
726
+ ensure_mutable!
727
+ @list << [validate_name!(name), validate_value!(value)]
728
+ end
729
+
730
+ def ensure_mutable!
731
+ return unless @guard == :immutable
732
+
733
+ raise Bridge::TypeError, "Failed to execute on 'Headers': These headers are immutable."
734
+ end
735
+
736
+ # WHATWG: a header name must be a token, else a TypeError. Returns it
737
+ # lowercased (the form names are stored and compared in).
738
+ def validate_name!(name)
739
+ str = name.to_s
740
+ unless str.match?(HEADER_NAME)
741
+ raise Bridge::TypeError, "Failed to execute on 'Headers': '#{str}' is an invalid header name."
742
+ end
743
+ str.downcase
744
+ end
745
+
746
+ # WHATWG: trim leading/trailing HTTP whitespace, then reject a value
747
+ # containing NUL/CR/LF with a TypeError.
748
+ def validate_value!(value)
749
+ val = value.to_s.gsub(HTTP_WHITESPACE, "")
750
+ if val.match?(/[\x00\r\n]/)
751
+ raise Bridge::TypeError, "Failed to execute on 'Headers': '#{val}' is an invalid header value."
752
+ end
753
+ val
754
+ end
755
+
756
+ def get_combined(name)
757
+ values = @list.select { |n, _| n == name }.map(&:last)
758
+ values.empty? ? nil : values.join(", ")
759
+ end
760
+
761
+ # WHATWG "sort and combine": unique names sorted ascending; each name's
762
+ # values combined by ", ", except Set-Cookie whose values stay separate.
763
+ def sort_and_combine
764
+ @list.map(&:first).uniq.sort.flat_map do |name|
765
+ if name == "set-cookie"
766
+ @list.select { |n, _| n == "set-cookie" }.map { |n, v| [n, v] }
767
+ else
768
+ [[name, get_combined(name)]]
769
+ end
770
+ end
771
+ end
310
772
  end
311
773
  end