Class: Dommy::WebSocket

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

Overview

WebSocket polyfill. Real implementations open a TCP-then-frame connection; dommy exposes an in-memory transport tests drive via the __* seams:

ws.__test_simulate_open__               — fires `open`
ws.__test_simulate_message__(data)      — fires `message`
ws.__test_simulate_close__(code, reason) — fires `close`
ws.__test_simulate_error__              — fires `error`
ws.__test_sent_messages__               — array of sent payloads

By default a new WebSocket(url) auto-opens via microtask so the common pattern (ws.onopen = ...; ws.send(...)) works without extra setup.

Spec: https://websockets.spec.whatwg.org/

Defined Under Namespace

Classes: Error

Constant Summary collapse

CONNECTING =
0
OPEN =
1
CLOSING =
2
CLOSED =
3
INLINE_HANDLERS =
%w[open message close error].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from Bridge::Methods

included

Methods included from EventTarget

#__dommy_dump_event_failure__, #__internal_deliver_event__, #__internal_process_event_handler_return__, #add_event_listener, capture_flag, #deliver_at, #dispatch_event, #event_name_from_on, #invoke_listener_isolated, js_truthy?, #on_handler, #remove_event_listener, #set_on_handler

Constructor Details

#initialize(window, url, protocols = nil) ⇒ WebSocket

Returns a new instance of WebSocket.



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/dommy/web_socket.rb', line 32

def initialize(window, url, protocols = nil)
  @window = window
  @url = url.to_s
  @ready_state = CONNECTING
  @buffered_amount = 0
  @extensions = ""
  @binary_type = "blob"
  # The subprotocol stays "" until the server selects one at the handshake;
  # remember what was requested and adopt the first on open.
  @requested_protocols = Array(protocols).flatten.map(&:to_s)
  @protocol = ""
  @sent_messages = []
  @inline_handlers = {}

  # A host-installed connector (Dommy::Rack wires real in-process
  # connections through it) owns the connection when it returns a
  # transport; otherwise fall back to the in-memory stub, which
  # auto-opens via microtask unless tests disable.
  connector = window.respond_to?(:websocket_connector) ? window.websocket_connector : nil
  @transport = connector&.call(self, @url, @requested_protocols)
  return if @transport

  auto_open = window.globals["__ws_auto_open__"]
  @window.scheduler.queue_microtask(proc { __test_simulate_open__ }) unless auto_open == false
end

Instance Attribute Details

#binary_typeObject

Returns the value of attribute binary_type.



30
31
32
# File 'lib/dommy/web_socket.rb', line 30

def binary_type
  @binary_type
end

#buffered_amountObject (readonly)

Returns the value of attribute buffered_amount.



29
30
31
# File 'lib/dommy/web_socket.rb', line 29

def buffered_amount
  @buffered_amount
end

#extensionsObject (readonly)

Returns the value of attribute extensions.



29
30
31
# File 'lib/dommy/web_socket.rb', line 29

def extensions
  @extensions
end

#protocolObject (readonly)

Returns the value of attribute protocol.



29
30
31
# File 'lib/dommy/web_socket.rb', line 29

def protocol
  @protocol
end

#ready_stateObject (readonly)

Returns the value of attribute ready_state.



29
30
31
# File 'lib/dommy/web_socket.rb', line 29

def ready_state
  @ready_state
end

#urlObject (readonly)

Returns the value of attribute url.



29
30
31
# File 'lib/dommy/web_socket.rb', line 29

def url
  @url
end

Instance Method Details

#__internal_event_parent__Object



227
228
229
# File 'lib/dommy/web_socket.rb', line 227

def __internal_event_parent__
  nil
end

#__js_call__(method, args) ⇒ Object



212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/dommy/web_socket.rb', line 212

def __js_call__(method, args)
  case method
  when "send"
    send(args[0])
  when "close"
    close(args[0] || 1000, args[1] || "")
  when "addEventListener"
    add_event_listener(args[0], args[1], args[2])
  when "removeEventListener"
    remove_event_listener(args[0], args[1], args[2])
  when "dispatchEvent"
    dispatch_event(args[0])
  end
end

#__js_get__(key) ⇒ Object

--- JS bridge -------------------------------------------------



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/dommy/web_socket.rb', line 171

def __js_get__(key)
  case key
  when "url"
    @url
  when "readyState"
    @ready_state
  when "bufferedAmount"
    @buffered_amount
  when "extensions"
    @extensions
  when "protocol"
    @protocol
  when "binaryType"
    @binary_type
  when "CONNECTING"
    CONNECTING
  when "OPEN"
    OPEN
  when "CLOSING"
    CLOSING
  when "CLOSED"
    CLOSED
  else
    @inline_handlers[inline_event_for(key)]
  end
end

#__js_set__(key, value) ⇒ Object



198
199
200
201
202
203
204
205
206
207
208
# File 'lib/dommy/web_socket.rb', line 198

def __js_set__(key, value)
  case key
  when "binaryType"
    self.binary_type = value
  else
    event = inline_event_for(key)
    set_inline_handler(event, value) if event
  end

  nil
end

#__test_sent_messages__Object

--- Test seams ------------------------------------------------



134
135
136
# File 'lib/dommy/web_socket.rb', line 134

def __test_sent_messages__
  @sent_messages.dup
end

#__test_simulate_close__(code = 1000, reason = "", was_clean: true) ⇒ Object



153
154
155
156
157
158
159
160
161
162
163
# File 'lib/dommy/web_socket.rb', line 153

def __test_simulate_close__(code = 1000, reason = "", was_clean: true)
  @ready_state = CLOSED
  dispatch_event(
    CloseEvent.new(
      "close",
      "code" => code,
      "reason" => reason,
      "wasClean" => was_clean
    )
  )
end

#__test_simulate_error__Object



165
166
167
# File 'lib/dommy/web_socket.rb', line 165

def __test_simulate_error__
  dispatch_event(Event.new("error"))
end

#__test_simulate_message__(data) ⇒ Object



147
148
149
150
151
# File 'lib/dommy/web_socket.rb', line 147

def __test_simulate_message__(data)
  return if @ready_state != OPEN

  dispatch_event(MessageEvent.new("message", "data" => data))
end

#__test_simulate_open__Object



138
139
140
141
142
143
144
145
# File 'lib/dommy/web_socket.rb', line 138

def __test_simulate_open__
  return if @ready_state != CONNECTING

  @ready_state = OPEN
  # The handshake "selects" the first requested subprotocol.
  @protocol = @requested_protocols.first || ""
  dispatch_event(Event.new("open"))
end

#__transport_closed__(code = 1000, reason = "", was_clean: true) ⇒ Object



122
123
124
125
126
# File 'lib/dommy/web_socket.rb', line 122

def __transport_closed__(code = 1000, reason = "", was_clean: true)
  return if @ready_state == CLOSED

  __test_simulate_close__(code, reason, was_clean: was_clean)
end

#__transport_error__Object



128
129
130
# File 'lib/dommy/web_socket.rb', line 128

def __transport_error__
  __test_simulate_error__
end

#__transport_message__(data) ⇒ Object



118
119
120
# File 'lib/dommy/web_socket.rb', line 118

def __transport_message__(data)
  __test_simulate_message__(data)
end

#__transport_open__(protocol = nil) ⇒ Object

--- Transport callbacks --------------------------------------- A connector-provided transport reports the connection lifecycle through these, ON THE PAGE THREAD (a threaded transport marshals via scheduler.post_external). They share the state machine with the test seams so stub-driven and transport-driven sockets behave identically.



109
110
111
112
113
114
115
116
# File 'lib/dommy/web_socket.rb', line 109

def __transport_open__(protocol = nil)
  @protocol = protocol.to_s unless protocol.nil?
  return if @ready_state != CONNECTING

  @ready_state = OPEN
  @protocol = @requested_protocols.first || "" if @protocol.empty? && protocol.nil?
  dispatch_event(Event.new("open"))
end

#close(code = nil, reason = nil) ⇒ Object

close([code[, reason]]): code must be 1000 or in 3000–4999, and the UTF-8 reason must be ≤ 123 bytes, else throw — matching the WebSocket spec.



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/dommy/web_socket.rb', line 78

def close(code = nil, reason = nil)
  unless code.nil?
    c = code.to_i
    unless c == 1000 || c.between?(3000, 4999)
      raise DOMException::InvalidAccessError, "The close code must be 1000 or in 3000-4999, got #{c}."
    end
  end
  if reason && reason.to_s.bytesize > 123
    raise DOMException::SyntaxError, "The close reason must not exceed 123 UTF-8 bytes."
  end
  return if @ready_state == CLOSED || @ready_state == CLOSING

  @ready_state = CLOSING
  final_code = code.nil? ? 1005 : code.to_i
  final_reason = reason.to_s
  if @transport
    # The transport completes the closing handshake and reports back via
    # __transport_closed__ (which fires the close event).
    @transport.close(final_code, final_reason)
  else
    @window.scheduler.queue_microtask(proc { __test_simulate_close__(final_code, final_reason) })
  end
  nil
end

#send(data) ⇒ Object



65
66
67
68
69
70
71
72
73
74
# File 'lib/dommy/web_socket.rb', line 65

def send(data)
  # send() before the connection opens is an InvalidStateError (a
  # DOMException), not a bare Ruby error.
  raise DOMException::InvalidStateError, "WebSocket is not open" if @ready_state == CONNECTING
  return if @ready_state != OPEN # CLOSING/CLOSED silently discard (buffered)

  @sent_messages << data
  @transport&.send_text(data.to_s)
  nil
end