Class: Dommy::Rack::WebSocketTransport

Inherits:
Object
  • Object
show all
Defined in:
lib/dommy/rack/web_socket_transport.rb

Overview

In-process WebSocket transport: connects a page's new WebSocket(url) to the Rack app ITSELF, the same way fetch/XHR resolve through the app. The server side receives a real RFC 6455 upgrade request and a real socket (one end of a socketpair, handed over via rack.hijack), so ActionCable's full stack runs unmodified — connection auth sees the session's cookies, the origin check sees the session's origin, and the cable event loop reads/writes actual WebSocket frames.

The client side of the protocol lives here: frame semantics over the WebSocketFrame codec (text/close/ping handling; ActionCable messages are single-frame text) plus a reader thread that parses server frames and marshals them onto the page thread via scheduler.post_external, where they fire the WebSocket's open/message/close events. settle / advance_time deliver them, like any other external completion.

Lifetime: a transport belongs to the page (realm) that opened it; the session closes all live transports on dispose. Note that ActionCable sends its JSON pings on REAL time (an every-3s event-loop timer), not the page's virtual clock — harmless for tests, which settle on the subscription/broadcast messages they wait for.

Defined Under Namespace

Classes: HandshakeFailed

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app:, ws:, scheduler:, url:, origin:, cookie_string: "") ⇒ WebSocketTransport

Returns a new instance of WebSocketTransport.



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/dommy/rack/web_socket_transport.rb', line 51

def initialize(app:, ws:, scheduler:, url:, origin:, cookie_string: "")
  @ws = ws
  @scheduler = scheduler
  @url = url
  @write_mutex = Mutex.new
  @sent_close = false
  @closed = false

  @client_io, server_io = ::Socket.pair(:UNIX, :STREAM)
  env = handshake_env(url, origin, cookie_string, server_io)
  status, _headers, _body = app.call(env)
  if env["rack.hijack_io"].nil? && status != -1
    # The app answered with a normal HTTP response (no cable mounted at
    # this path, or the upgrade was rejected): fail like a browser —
    # error then close, deferred so `onerror` handlers attach first.
    fail_connection
  else
    @reader = Thread.new { run_reader }
  end
end

Class Method Details

.rack_target(url, base:) ⇒ Object

Resolve url for the connector: an absolute ws(s) URL (http(s) is accepted and treated the same) that is same-origin with base. Returns the URI, or nil (the WebSocket then falls back to the in-memory stub).



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/dommy/rack/web_socket_transport.rb', line 35

def self.rack_target(url, base:)
  target = URI.join(base.to_s, url.to_s)
  scheme = {"ws" => "http", "wss" => "https"}[target.scheme] || target.scheme
  return nil unless %w[http https].include?(scheme)

  b = URI.parse(base.to_s)
  return nil unless b.host == target.host && b.port == target.port

  target.scheme = scheme
  # Re-parse so the return value is a URI::HTTP(S), not a URI::WS whose
  # scheme string was swapped (URI classes compare by class + value).
  URI.parse(target.to_s)
rescue URI::Error
  nil
end

Instance Method Details

#close(code = 1000, reason = "") ⇒ Object



78
79
80
# File 'lib/dommy/rack/web_socket_transport.rb', line 78

def close(code = 1000, reason = "")
  send_close_frame(code == 1005 ? 1000 : code, reason)
end

#disposeObject

Hard teardown (session dispose): drop the socket; the reader thread exits on EOF. Safe to call repeatedly.



84
85
86
87
88
89
90
# File 'lib/dommy/rack/web_socket_transport.rb', line 84

def dispose
  @closed = true
  @client_io&.close unless @client_io&.closed?
  @reader&.join(1)
rescue IOError
  nil
end

#send_text(data) ⇒ Object

--- API the WebSocket delegates to (page thread) ---



74
75
76
# File 'lib/dommy/rack/web_socket_transport.rb', line 74

def send_text(data)
  write_frame(WebSocketFrame::TEXT, data.b)
end