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.
- checksums.yaml +4 -4
- data/lib/dommy/animation.rb +9 -1
- data/lib/dommy/attr.rb +192 -39
- data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
- data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
- data/lib/dommy/backend.rb +46 -0
- data/lib/dommy/blob.rb +28 -9
- data/lib/dommy/bridge/constructor_registry.rb +28 -0
- data/lib/dommy/bridge/methods.rb +57 -0
- data/lib/dommy/bridge.rb +97 -0
- data/lib/dommy/callable_invoker.rb +36 -0
- data/lib/dommy/cookie_store.rb +3 -1
- data/lib/dommy/crypto.rb +7 -1
- data/lib/dommy/css.rb +46 -0
- data/lib/dommy/custom_elements.rb +27 -3
- data/lib/dommy/data_transfer.rb +4 -0
- data/lib/dommy/document.rb +615 -48
- data/lib/dommy/dom_parser.rb +28 -15
- data/lib/dommy/element.rb +999 -471
- data/lib/dommy/event.rb +260 -96
- data/lib/dommy/event_source.rb +6 -2
- data/lib/dommy/fetch.rb +505 -43
- data/lib/dommy/file_reader.rb +11 -3
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +43 -8
- data/lib/dommy/html_collection.rb +55 -2
- data/lib/dommy/html_elements.rb +102 -1519
- data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +26 -0
- data/lib/dommy/internal/idna.rb +16 -7
- data/lib/dommy/internal/ipv4_parser.rb +22 -7
- data/lib/dommy/internal/mutation_coordinator.rb +11 -2
- data/lib/dommy/internal/namespaces.rb +70 -0
- data/lib/dommy/internal/node_equality.rb +86 -0
- data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
- data/lib/dommy/internal/observable_callback.rb +1 -5
- data/lib/dommy/internal/parent_node.rb +126 -0
- data/lib/dommy/internal/reflected_attributes.rb +103 -13
- data/lib/dommy/internal/selector_parser.rb +664 -0
- data/lib/dommy/internal/url_parser.rb +677 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +7 -1
- data/lib/dommy/message_channel.rb +32 -2
- data/lib/dommy/mutation_observer.rb +55 -12
- data/lib/dommy/navigator.rb +26 -12
- data/lib/dommy/node.rb +158 -28
- data/lib/dommy/notification.rb +3 -1
- data/lib/dommy/performance.rb +4 -0
- data/lib/dommy/performance_observer.rb +2 -0
- data/lib/dommy/promise.rb +14 -14
- data/lib/dommy/range.rb +74 -5
- data/lib/dommy/resize_observer.rb +2 -0
- data/lib/dommy/scheduler.rb +34 -13
- data/lib/dommy/shadow_root.rb +23 -54
- data/lib/dommy/storage.rb +2 -0
- data/lib/dommy/streams.rb +18 -27
- data/lib/dommy/svg_elements.rb +204 -3606
- data/lib/dommy/text_codec.rb +174 -21
- data/lib/dommy/tree_walker.rb +255 -66
- data/lib/dommy/url.rb +287 -449
- data/lib/dommy/url_pattern.rb +2 -0
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +37 -7
- data/lib/dommy/window.rb +202 -213
- data/lib/dommy/worker.rb +7 -7
- data/lib/dommy/xml_http_request.rb +15 -5
- data/lib/dommy.rb +7 -0
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
immediate(JSON.parse(@body))
|
|
431
|
+
consume_body do
|
|
432
|
+
immediate(JSON.parse(scrub_lone_surrogates(@body)))
|
|
225
433
|
rescue JSON::ParserError => e
|
|
226
|
-
|
|
227
|
-
rejected(err)
|
|
434
|
+
rejected(ErrorValue.new("JSON parse: #{e.message}"))
|
|
228
435
|
end
|
|
229
|
-
|
|
230
436
|
when "arrayBuffer"
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
258
|
-
# `
|
|
259
|
-
#
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
#
|
|
293
|
-
# `(_, _, h) => h.get(
|
|
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
|
-
|
|
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
|