Class: Dommy::Response

Inherits:
Object
  • Object
show all
Includes:
Bridge::Methods
Defined in:
lib/dommy/fetch.rb

Overview

Response polyfill — just enough surface for Fetchy: [:status] / [:ok] / [:url] / [:headers] (with .entries() / .get(name)) and .text() / .json() / .body / .arrayBuffer() which all return Promise-like values.

Constant Summary collapse

NULL_BODY_STATUSES =

WHATWG null-body statuses: a Response with one of these may not carry a body (constructing one with a body is a TypeError). 101/103 are also null-body but fall outside the 200–599 range the constructor accepts.

[204, 205, 304].freeze
REDIRECT_STATUSES =

Redirect statuses accepted by Response.redirect(url, status).

[301, 302, 303, 307, 308].freeze
FORBIDDEN_RESPONSE_HEADERS =

Forbidden response-header names (WHATWG Fetch): never exposed on a Response's Headers. Set-Cookie is handled by the network layer's cookie jar, not JS — and a real server often sends MULTIPLE Set-Cookie headers folded into one newline-joined value, which is an invalid Headers value and used to crash Response construction (e.g. doubleclick's IDE+test_cookie).

%w[set-cookie set-cookie2].freeze

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Bridge::Methods

included

Constructor Details

#initialize(window, body:, status: 200, status_text: "", headers: nil, url: "", redirected: false, type: "default", has_body: true) ⇒ Response

Returns a new instance of Response.



706
707
708
709
710
711
712
713
714
715
716
717
718
719
# File 'lib/dommy/fetch.rb', line 706

def initialize(window, body:, status: 200, status_text: "", headers: nil, url: "",
               redirected: false, type: "default", has_body: true)
  @window = window
  @body = body.to_s
  @status = status
  @status_text = status_text.to_s
  @headers = Headers.new(strip_forbidden_headers(headers))
  @url = url.to_s
  @redirected = redirected ? true : false
  @type = type
  @has_body = has_body ? true : false
  @body_used = false
  @body_stream = nil
end

Class Method Details

.__construct__(window, body, init) ⇒ Object

WHATWG new Response(body, init). Validates the status (200–599, else a RangeError; a null-body status 204/205/304 with a body is a TypeError), defaults statusText to "" and status to 200, accepts init.headers as a plain object or a Headers instance, and — per the body-extraction step — defaults Content-Type to text/plain for a non-null body when none was supplied. A constructed response's url is "".



737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
# File 'lib/dommy/fetch.rb', line 737

def self.__construct__(window, body, init)
  opts = init.is_a?(Hash) ? init : {}
  status = coerce_status(opts["status"] || opts[:status] || 200)
  unless status.between?(200, 599)
    raise Bridge::RangeError,
      "Failed to construct 'Response': The status provided (#{status}) is outside the range [200, 599]."
  end

  has_body = !(body.nil? || (defined?(Bridge::UNDEFINED) && body.equal?(Bridge::UNDEFINED)))
  if has_body && NULL_BODY_STATUSES.include?(status)
    raise Bridge::TypeError,
      "Failed to construct 'Response': Response with null body status (#{status}) cannot have body."
  end

  # Extract a body: derive its bytes and the Content-Type it implies (Blob →
  # its MIME type, URLSearchParams → urlencoded, FormData → multipart, a
  # string → text/plain). The implied type is only the *default* — an
  # explicit init.headers Content-Type still wins.
  body_bytes, default_ct = has_body ? extract_body(body) : ["", nil]

  headers = coerce_headers(opts["headers"] || opts[:headers])
  if default_ct && headers.keys.none? { |k| k.to_s.downcase == "content-type" }
    headers = headers.merge("Content-Type" => default_ct)
  end

  new(window, body: body_bytes,
              status: status,
              status_text: validate_status_text!(opts["statusText"] || opts[:statusText] || ""),
              headers: headers,
              has_body: has_body)
end

.__error__(window) ⇒ Object

Static Response.error() — a network-error response (status 0, not ok, type "error"). (WHATWG Fetch §Response.error)



825
826
827
828
829
830
# File 'lib/dommy/fetch.rb', line 825

def self.__error__(window)
  resp = new(window, body: "", status: 0, type: "error", has_body: false)
  # WHATWG: a network-error response's header guard is "immutable".
  resp.__js_get__("headers").make_immutable!
  resp
end

.__json__(window, data, init = nil) ⇒ Object

Static Response.json(data, init) — serialize data to JSON, defaulting Content-Type to application/json. (WHATWG Fetch §Response.json)



771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
# File 'lib/dommy/fetch.rb', line 771

def self.__json__(window, data, init = nil)
  # WHATWG: serialize `data` as JSON; if that yields `undefined` (the value
  # is JS `undefined` — or absent — or otherwise non-serializable), throw a
  # TypeError. JS `null` serializes to "null" and is allowed.
  if defined?(Bridge::UNDEFINED) && data.equal?(Bridge::UNDEFINED)
    raise Bridge::TypeError,
      "Failed to execute 'json' on 'Response': The data is not JSON-serializable."
  end

  opts = init.is_a?(Hash) ? init : {}
  status = coerce_status(opts["status"] || opts[:status] || 200)
  unless status.between?(200, 599)
    raise Bridge::RangeError,
      "Failed to execute 'json' on 'Response': The status provided (#{status}) is outside the range [200, 599]."
  end
  if NULL_BODY_STATUSES.include?(status)
    raise Bridge::TypeError,
      "Failed to execute 'json' on 'Response': Response with null body status (#{status}) cannot have body."
  end

  headers = coerce_headers(opts["headers"] || opts[:headers])
  unless headers.keys.any? { |k| k.to_s.downcase == "content-type" }
    headers = headers.merge("Content-Type" => "application/json")
  end

  new(window, body: JSON.generate(data),
              status: status,
              status_text: validate_status_text!(opts["statusText"] || opts[:statusText] || ""),
              headers: headers)
end

.__redirect__(window, url, status = nil) ⇒ Object

Static Response.redirect(url, status = 302) — a redirect response whose Location header is the parsed-and-serialized url. Parsing failure is a TypeError; a non-redirect status is a RangeError. The url is resolved against the window's base URL so a relative target works. (WHATWG Fetch §Response.redirect)



807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
# File 'lib/dommy/fetch.rb', line 807

def self.__redirect__(window, url, status = nil)
  base = window.respond_to?(:location) && window.location.respond_to?(:href) ? window.location.href : nil
  parsed = Dommy::URL.new(url.to_s, base) # raises Bridge::TypeError on failure

  status = coerce_status(status.nil? || (defined?(Bridge::UNDEFINED) && status.equal?(Bridge::UNDEFINED)) ? 302 : status)
  unless REDIRECT_STATUSES.include?(status)
    raise Bridge::RangeError,
      "Failed to execute 'redirect' on 'Response': Invalid status code #{status}."
  end

  resp = new(window, body: "", status: status, headers: {"Location" => parsed.href}, has_body: false)
  # WHATWG: a redirect response's header guard is "immutable".
  resp.__js_get__("headers").make_immutable!
  resp
end

.coerce_headers(raw) ⇒ Object



847
848
849
850
851
852
853
# File 'lib/dommy/fetch.rb', line 847

def self.coerce_headers(raw)
  case raw
  when Headers then raw.to_h
  when Hash then raw
  else {}
  end
end

.coerce_status(value) ⇒ Object



832
833
834
# File 'lib/dommy/fetch.rb', line 832

def self.coerce_status(value)
  value.is_a?(Numeric) ? value.to_i : value.to_s.to_i
end

.extract_body(body) ⇒ Object

WHATWG "extract a body": map a body source to [byte_string, default_content_type_or_nil]. The default Content-Type is applied only when the caller supplied none.



869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
# File 'lib/dommy/fetch.rb', line 869

def self.extract_body(body)
  case body
  when Blob # File < Blob
    [body.__dommy_bytes__, (body.type.to_s.empty? ? nil : body.type)]
  when URLSearchParams
    [body.to_s, "application/x-www-form-urlencoded;charset=UTF-8"]
  when FormData
    multipart_body(body)
  when Bridge::Bytes # an ArrayBuffer / TypedArray body
    [body.pack_bytes, nil]
  when String
    [body, "text/plain;charset=UTF-8"]
  else
    if defined?(Bridge::UNDEFINED) && body.equal?(Bridge::UNDEFINED)
      ["", nil]
    else
      [body.to_s, "text/plain;charset=UTF-8"]
    end
  end
end

.multipart_body(form_data) ⇒ Object

Serialize a FormData as a multipart/form-data body. Returns [bytes, content_type] where content_type carries the generated boundary.



892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
# File 'lib/dommy/fetch.rb', line 892

def self.multipart_body(form_data)
  boundary = "----DommyFormBoundary#{SecureRandom.hex(12)}"
  crlf = "\r\n"
  out = +""
  form_data.entries.each do |name, value|
    out << "--#{boundary}#{crlf}"
    if value.is_a?(Blob)
      filename = value.respond_to?(:name) ? value.name : "blob"
      out << %(Content-Disposition: form-data; name="#{name}"; filename="#{filename}"#{crlf})
      content_type = value.type.to_s.empty? ? "application/octet-stream" : value.type
      out << "Content-Type: #{content_type}#{crlf}#{crlf}"
      out << value.__dommy_bytes__ << crlf
    else
      out << %(Content-Disposition: form-data; name="#{name}"#{crlf}#{crlf})
      out << value.to_s << crlf
    end
  end
  out << "--#{boundary}--#{crlf}"
  [out, "multipart/form-data; boundary=#{boundary}"]
end

.utf8_decode(bytes) ⇒ Object

WHATWG "UTF-8 decode" for a body's text(): interpret the raw bytes as UTF-8, replacing any ill-formed sequence with U+FFFD, and drop a single leading byte-order mark (U+FEFF). Well-formed UTF-8 (the common case) is returned unchanged. Used by both Request and Response text().



859
860
861
862
863
864
# File 'lib/dommy/fetch.rb', line 859

def self.utf8_decode(bytes)
  s = bytes.to_s.dup.force_encoding(Encoding::UTF_8)
  s = s.scrub("\u{FFFD}") unless s.valid_encoding?
  s = s[1..] if s.start_with?("\u{FEFF}")
  s
end

.validate_status_text!(text) ⇒ Object

WHATWG reason-phrase: HTAB / SP / VCHAR (0x21–0x7E) / obs-text (0x80–0xFF). Any other byte (NUL, CR, LF, other controls, DEL) makes statusText invalid → TypeError.



839
840
841
842
843
844
845
# File 'lib/dommy/fetch.rb', line 839

def self.validate_status_text!(text)
  str = text.to_s
  if str.each_byte.any? { |b| (b < 0x20 && b != 0x09) || b == 0x7f }
    raise Bridge::TypeError, "Failed to construct 'Response': Invalid statusText."
  end
  str
end

Instance Method Details

#__js_call__(method, _args) ⇒ Object



950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
# File 'lib/dommy/fetch.rb', line 950

def __js_call__(method, _args)
  case method
  when "text"
    consume_body { immediate(Response.utf8_decode(@body)) }
  when "json"
    consume_body do
      immediate(JSON.parse(scrub_lone_surrogates(@body)))
    rescue JSON::ParserError => e
      rejected(ErrorValue.new("JSON parse: #{e.message}"))
    end
  when "arrayBuffer"
    # arrayBuffer()'s spec return type is ArrayBuffer — wrap so the host
    # bridge decodes it to a bare JS ArrayBuffer (not a Uint8Array view).
    consume_body { immediate(Bridge::ArrayBuffer.new(@body.bytes)) }
  when "blob"
    consume_body do
      immediate(Blob.new([@body], {"type" => @headers.__js_call__("get", ["content-type"]) || ""}, @window))
    end
  when "formData"
    consume_body { consume_form_data }
  when "clone"
    clone_response
  end
end

#__js_get__(key) ⇒ Object



913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
# File 'lib/dommy/fetch.rb', line 913

def __js_get__(key)
  case key
  when "status"
    @status
  when "ok"
    @status >= 200 && @status < 300
  when "statusText"
    @status_text
  when "url"
    @url
  when "redirected"
    # Fetch API: true when the response is the result of a followed
    # redirect (so `response.url` is the final, not requested, URL).
    @redirected
  when "type"
    # WHATWG response type: "default" (constructed), "error" (Response.error),
    # "basic" (a same-origin fetch), …
    @type
  when "headers"
    @headers
  when "body"
    # WHATWG: a ReadableStream of the body bytes, or null when there is no
    # body. Merely reading `.body` does not consume it (identity preserved).
    body_stream
  when "bodyUsed"
    body_used?
  else
    Bridge::ABSENT
  end
end

#__js_set__(_key, _value) ⇒ Object



944
945
946
# File 'lib/dommy/fetch.rb', line 944

def __js_set__(_key, _value)
  Bridge::UNHANDLED
end

#strip_forbidden_headers(headers) ⇒ Object

Drop forbidden response headers (Set-Cookie/Set-Cookie2) before they reach the Headers object. Only a Hash (the network path) carries them; a Headers or nil passes through unchanged.



724
725
726
727
728
729
# File 'lib/dommy/fetch.rb', line 724

def strip_forbidden_headers(headers)
  return {} if headers.nil?
  return headers unless headers.is_a?(Hash)

  headers.reject { |name, _| FORBIDDEN_RESPONSE_HEADERS.include?(name.to_s.downcase) }
end