dommy-rack 0.9.0 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c964baa96ffd06e89b381a195f92728d59425d855c9eed0f2f60a901b405b82
4
- data.tar.gz: 3883f5b58d4bc7817443486a46f597f4aa8204b4159f46e632fc998f084585d6
3
+ metadata.gz: 3cc970f04543db2ddf8d988cceaeacfc644fa3b04773655767e477512ba85deb
4
+ data.tar.gz: 5dd3d1215a44fd431b40fc62705c1ed8ef3d438b9321895c90980aa9cec574af
5
5
  SHA512:
6
- metadata.gz: 6a342735719087256ae5ce571ec435b69b0a4e6ce264f400db0a6bc40baaa84cf36889769701ad32619d4694e0075e64e3dfe2bd3eaecffb84045bb66d85fd99
7
- data.tar.gz: 7ed6fa76e4123a4bb13bdce963b2d657e58f6639c19c980952d42081d191e19356c7cdb9e9ae9781443afc83a3545c13798db339ffe247624f2970692269e40e
6
+ metadata.gz: 7628031ea4b93483bfedb41fb5f716bf7ab80e2155d9505250b618fb20175952035d4c7570d1403895be39c69064213d27507f3d845cd0a2f18486b587b9726d
7
+ data.tar.gz: '0852d2605cb0919350c97a93e2d63992e856b3440831249632c66071787b9349def147cf08d3f862664f2fe8d1371807a14249a9637fc522f9ca5307a14e4e01'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 — 2026-07-13
4
+
5
+ ### Added
6
+ - **In-process WebSockets:** a same-origin `new WebSocket(url)` on a page connects to the Rack app itself over a real RFC 6455 handshake, so ActionCable's full stack (cookie auth, origin check, cable event loop) runs unmodified and Turbo Streams broadcasts work in tests. Cross-origin URLs keep the in-memory stub.
7
+ - **Joint session/window history:** same-document (`pushState`) navigations appear in the session history and `current_url`, and `Session#back` / `#forward` traverse them on the live page (Turbo Drive's restoration path) while document boundaries still re-request — matching a browser tab's single history list. JS-initiated traversal (`history.back()`) stays in sync.
8
+ - `Session#dispose` — full teardown (JS runtimes plus live WebSocket transports); `#dispose_js` remains JS-only.
9
+
10
+ ### Fixed
11
+ - Only `BUTTON` / `INPUT` elements are treated as submit buttons.
12
+
3
13
  ## 0.9.0 — 2026-06-22
4
14
 
5
15
  Versioned in lockstep with [`dommy`](https://github.com/takahashim/dommy) 0.9.0.
@@ -2,43 +2,80 @@
2
2
 
3
3
  module Dommy
4
4
  module Rack
5
- # Browser-tab-style navigation history: an ordered stack of visited URLs
6
- # with a cursor. Visiting a new URL truncates any forward entries.
5
+ # Browser-tab-style navigation history: ONE ordered stack covering both
6
+ # full-document navigations and same-document (pushState) entries, like a
7
+ # real tab's joint history. Each entry remembers which window it belongs
8
+ # to and that window's own history index, so Session#back / #forward can
9
+ # decide between a popstate traversal (same live document — Turbo Drive's
10
+ # restoration path) and a full re-request (document boundary).
7
11
  class History
12
+ # `window` / `windex` tie the entry to a page and its window.history
13
+ # cursor position; Session#back / #forward traverse in-page (popstate)
14
+ # exactly when the target entry's window IS the live current window.
15
+ Entry = Struct.new(:url, :window, :windex)
16
+
8
17
  def initialize
9
18
  @stack = []
10
19
  @index = -1
11
20
  end
12
21
 
13
- def push(url)
22
+ def push(url, window: nil, windex: nil)
14
23
  kept = @index >= 0 ? @stack[0..@index] : []
15
- @stack = kept + [url]
24
+ @stack = kept + [Entry.new(url, window, windex)]
16
25
  @index = @stack.size - 1
17
26
  url
18
27
  end
19
28
 
20
- # Move the cursor back one entry and return that URL, or nil at the start.
29
+ # Move the cursor back/forward one entry and return that Entry, or nil
30
+ # at the edge (Session picks the traversal mechanism from it).
21
31
  def back
22
32
  return nil if @index <= 0
23
33
 
24
34
  @index -= 1
25
- current
35
+ current_entry
26
36
  end
27
37
 
28
- # Move the cursor forward one entry and return that URL, or nil at the end.
29
38
  def forward
30
39
  return nil if @index >= @stack.size - 1
31
40
 
32
41
  @index += 1
33
- current
42
+ current_entry
43
+ end
44
+
45
+ # replaceState: the current entry's URL changes in place.
46
+ def replace_current_url(url)
47
+ current_entry&.url = url
48
+ url
49
+ end
50
+
51
+ # Re-bind the current entry to a fresh window (a revisit re-loaded the
52
+ # URL into a new document), so later same-document sync matches it.
53
+ def rebind_current(window:, windex:)
54
+ entry = current_entry
55
+ return unless entry
56
+
57
+ entry.window = window
58
+ entry.windex = windex
34
59
  end
35
60
 
36
- def current
61
+ # Mirror a traversal the page itself performed (JS history.back()):
62
+ # move the cursor to the entry recorded for (window, windex). No-op
63
+ # when unknown (e.g. an entry created before sync was installed).
64
+ def sync_to(window, windex)
65
+ i = @stack.index { |e| e.window.equal?(window) && e.windex == windex }
66
+ @index = i if i
67
+ current_entry
68
+ end
69
+
70
+ def current_entry
37
71
  @stack[@index] if @index >= 0
38
72
  end
39
73
 
74
+ # URL-shaped views, kept for compatibility with existing callers.
75
+ def current = current_entry&.url
76
+
40
77
  def entries
41
- @stack.dup
78
+ @stack.map(&:url)
42
79
  end
43
80
  end
44
81
  end
@@ -50,7 +50,7 @@ module Dommy
50
50
 
51
51
  # Perform a navigation, following redirects per session policy, then
52
52
  # apply the final response to the session (updating document + history).
53
- def navigate(method:, url:, params: nil, body: nil, headers: {})
53
+ def navigate(method:, url:, params: nil, body: nil, headers: {}, replace: false)
54
54
  return navigate_about(url.to_s) if url.to_s.start_with?("about:")
55
55
 
56
56
  verb = method.to_s.upcase
@@ -67,7 +67,9 @@ module Dommy
67
67
  check_same_origin!(target)
68
68
 
69
69
  response, final_url = run(method: verb, url: target, params: params, body: body, headers: headers)
70
- @session.apply_navigation_response(response, final_url)
70
+ # replace: a location.replace() / reload() / redirect updates the current
71
+ # history entry in place rather than pushing a new one.
72
+ @session.apply_navigation_response(response, final_url, replace: replace)
71
73
  maybe_follow_meta_refresh(response) || response
72
74
  end
73
75
 
@@ -6,6 +6,29 @@ require "tmpdir"
6
6
 
7
7
  module Dommy
8
8
  module Rack
9
+ # The NavigationDelegate (Dommy::Navigation port) attached to each page's
10
+ # window: it forwards a page-initiated navigation/traversal intent to the
11
+ # owning Session (which defers and performs it at the next drain). Bound to
12
+ # the specific window so a stale, navigated-away page cannot steer the
13
+ # session — the Session checks window identity before recording.
14
+ class PageNavigationDelegate
15
+ def initialize(session, window)
16
+ @session = session
17
+ @window = window
18
+ end
19
+
20
+ def navigate(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, headers: {}, replace: false)
21
+ @session.__enqueue_page_navigation__(@window, {
22
+ url: url, method: method, body: body, params: params, enctype: enctype,
23
+ headers: headers, replace: replace, source: source
24
+ })
25
+ end
26
+
27
+ def traverse(delta)
28
+ @session.__enqueue_page_traverse__(@window, delta)
29
+ end
30
+ end
31
+
9
32
  # A single browser-like session over a Rack application. Owns the current
10
33
  # URL, document, cookie jar, persistent header store, and history; delegates
11
34
  # URL/redirect logic to Navigation and form data collection to FormSubmission.
@@ -141,6 +164,7 @@ module Dommy
141
164
  # #advance_time.
142
165
  def settle
143
166
  require_js!.settle
167
+ __flush_page_navigation__
144
168
  self
145
169
  end
146
170
 
@@ -156,12 +180,41 @@ module Dommy
156
180
  def js_errors = @js_runtime ? @js_runtime.js_errors : []
157
181
  def console = @js_runtime ? @js_runtime.console : []
158
182
 
159
- # Dispose the JS runtime(s). Safe to call when JS is disabled.
183
+ # Full session teardown: the JS runtime(s) plus any live WebSocket
184
+ # transports. Safe to call when JS is disabled, and repeatedly.
185
+ def dispose
186
+ Array(@live_websocket_transports).each(&:dispose)
187
+ @live_websocket_transports = nil
188
+ dispose_js
189
+ end
190
+
191
+ # Dispose the JS runtime(s) only. Safe to call when JS is disabled.
160
192
  def dispose_js
161
193
  @js_runtime&.dispose
162
194
  @js_runtime = nil
163
195
  end
164
196
 
197
+ # Factory for the window's websocket_connector seam (installed per
198
+ # realm by SessionRuntime): a same-origin `new WebSocket(url)` connects
199
+ # to the Rack app itself over rack.hijack (see WebSocketTransport), so
200
+ # ActionCable-backed features (Turbo Streams broadcasts, …) work
201
+ # in-process. A cross-origin URL returns nil, leaving the WebSocket on
202
+ # its in-memory stub.
203
+ def __internal_websocket_connector(window)
204
+ lambda do |ws, url, _protocols|
205
+ base = @current_url || default_host
206
+ target = WebSocketTransport.rack_target(url, base: base)
207
+ next nil unless target
208
+
209
+ transport = WebSocketTransport.new(
210
+ app: @app, ws: ws, scheduler: window.scheduler, url: target,
211
+ origin: Url.origin(target), cookie_string: @cookie_jar.cookies_for(target.to_s)
212
+ )
213
+ (@live_websocket_transports ||= []) << transport
214
+ transport
215
+ end
216
+ end
217
+
165
218
  # --- Config readers used by collaborators ---
166
219
 
167
220
  def default_host = @config.default_host
@@ -261,8 +314,8 @@ module Dommy
261
314
  result
262
315
  end
263
316
 
264
- def navigate(method: "GET", url:, params: nil, body: nil, headers: {})
265
- @navigation.navigate(method: method, url: url, params: params, body: body, headers: headers)
317
+ def navigate(method: "GET", url:, params: nil, body: nil, headers: {}, replace: false)
318
+ @navigation.navigate(method: method, url: url, params: params, body: body, headers: headers, replace: replace)
266
319
  end
267
320
 
268
321
  def reload
@@ -273,14 +326,63 @@ module Dommy
273
326
  response
274
327
  end
275
328
 
329
+ # Traverse the joint history like a browser's back button: a
330
+ # same-document target within the LIVE page (a Turbo Drive pushState
331
+ # entry) moves window.history and fires popstate — Turbo's restoration
332
+ # visit runs, no full request; a target across a document boundary
333
+ # re-requests the URL. Returns the destination URL, or nil at the edge.
334
+ # A JS session may need `settle` afterwards for the restoration fetch.
276
335
  def back
277
- url = @history.back
278
- @navigation.revisit(url) if url
336
+ traverse_history(:back)
279
337
  end
280
338
 
281
339
  def forward
282
- url = @history.forward
283
- @navigation.revisit(url) if url
340
+ traverse_history(:forward)
341
+ end
342
+
343
+ # --- NavigationDelegate port (see Dommy::Navigation) ---
344
+
345
+ # The delegate attached to each page's window (in SessionRuntime). A
346
+ # page-initiated navigation — a JS `location.href=` / `form.submit()`, a
347
+ # submitted form, an activated `<a>` — routes here. Navigation is a task:
348
+ # performing it synchronously could dispose the JS realm still on the
349
+ # stack, so it is recorded and performed at the next drain (#after_interaction
350
+ # / #settle), exactly like the standalone Browser.
351
+ def __navigation_delegate_for__(window)
352
+ PageNavigationDelegate.new(self, window)
353
+ end
354
+
355
+ def __enqueue_page_navigation__(window, nav)
356
+ # A retained handle to a navigated-away page must not steer the session.
357
+ return unless window.equal?(@current_window)
358
+
359
+ @pending_navigation = nav
360
+ end
361
+
362
+ def __enqueue_page_traverse__(window, delta)
363
+ return unless window.equal?(@current_window)
364
+
365
+ @pending_navigation = {traverse: delta}
366
+ end
367
+
368
+ # Perform a recorded page navigation, if any. Called after the JS runtime
369
+ # drains so the document/realm swap never runs with the outgoing realm's
370
+ # JS on the stack.
371
+ def __flush_page_navigation__
372
+ nav = @pending_navigation
373
+ return unless nav
374
+
375
+ @pending_navigation = nil
376
+ if nav.key?(:traverse)
377
+ traverse_history(nav[:traverse].negative? ? :back : :forward)
378
+ else
379
+ perform_page_navigation(nav)
380
+ end
381
+ rescue CrossOriginError, UnsupportedURLError, InvalidFormError, TooManyRedirectsError
382
+ # A page-initiated navigation that is blocked, unsupported, or loops on
383
+ # redirects is dropped (a browser blocks/abandons it); it must not crash
384
+ # the drain the way a Ruby-driven `visit` (which re-raises) does.
385
+ nil
284
386
  end
285
387
 
286
388
  # --- Basic request API (navigates, updating page state) ---
@@ -582,6 +684,27 @@ module Dommy
582
684
  )
583
685
  end
584
686
 
687
+ # One step of the joint back/forward traversal (see #back).
688
+ def traverse_history(direction)
689
+ target = direction == :back ? @history.back : @history.forward
690
+ return nil unless target
691
+
692
+ if target.window&.equal?(@current_window) && target.windex
693
+ begin
694
+ @history_traversing = true
695
+ @current_window.history.__internal_go_to__(target.windex)
696
+ ensure
697
+ @history_traversing = false
698
+ end
699
+ @current_url = target.url
700
+ @js_runtime&.drain
701
+ else
702
+ @navigation.revisit(target.url)
703
+ end
704
+ target.url
705
+ end
706
+ private :traverse_history
707
+
585
708
  # Build a worker-safe thunk that fetches `target` (already absolute) and
586
709
  # returns the Response, for the async-network path. Called on the page
587
710
  # thread: it enforces same-origin and captures a header snapshot here, then
@@ -600,7 +723,7 @@ module Dommy
600
723
 
601
724
  # Apply a final navigation response: update last_response, current_url,
602
725
  # the document (HTML only), and the history stack.
603
- def apply_navigation_response(response, final_url, push_history: true)
726
+ def apply_navigation_response(response, final_url, push_history: true, replace: false)
604
727
  @last_response = response
605
728
  @current_url = final_url
606
729
  if response.html?
@@ -612,9 +735,53 @@ module Dommy
612
735
  # DOMContentLoaded) run, so CSS-driven computed styles and :visible
613
736
  # are correct from the first observation.
614
737
  install_stylesheet_loading(@current_window) if load_stylesheets?
738
+ # The history entry exists and the window-history sync is live
739
+ # BEFORE scripts boot: Turbo's replaceState-on-start then lands on
740
+ # THIS entry, and its pushState navigations append after it.
741
+ windex = @current_window.history.__internal_index__
742
+ if replace
743
+ # location.replace() / reload() / a redirect: overwrite the current
744
+ # entry's URL and rebind it to the new document (no new entry).
745
+ @history.replace_current_url(final_url)
746
+ @history.rebind_current(window: @current_window, windex: windex)
747
+ elsif push_history
748
+ @history.push(final_url, window: @current_window, windex: windex)
749
+ else
750
+ # A revisit re-loaded this URL into a fresh document: re-bind the
751
+ # existing entry so traversal sync matches the live window.
752
+ @history.rebind_current(window: @current_window, windex: windex)
753
+ end
754
+ install_history_sync(@current_window)
615
755
  @document_loaded_listeners.each { |cb| cb.call(@current_window) }
756
+ elsif replace
757
+ @history.replace_current_url(final_url)
758
+ elsif push_history
759
+ @history.push(final_url)
760
+ end
761
+ end
762
+
763
+ # Mirror the page's same-document history operations (Turbo Drive's
764
+ # pushState navigations, JS history.back()) into the session: the joint
765
+ # history gains/updates entries and current_url follows, so
766
+ # `browser.current_path` and `browser.back` see what a browser's URL
767
+ # bar and back button would. Guarded against echo while the session
768
+ # itself drives a traversal, and against a stale window — a retained
769
+ # handle to a navigated-away page must not touch the session's state.
770
+ def install_history_sync(window)
771
+ window.history.__internal_on_change__ = lambda do |kind, url|
772
+ next if @history_traversing
773
+ next unless window.equal?(@current_window)
774
+
775
+ @current_url = url
776
+ case kind
777
+ when :push
778
+ @history.push(url, window: window, windex: window.history.__internal_index__)
779
+ when :replace
780
+ @history.replace_current_url(url)
781
+ when :traverse
782
+ @history.sync_to(window, window.history.__internal_index__)
783
+ end
616
784
  end
617
- @history.push(final_url) if push_history
618
785
  end
619
786
 
620
787
  # Wire same-origin CSS loading for a freshly installed document: fill
@@ -659,6 +826,7 @@ module Dommy
659
826
  # before the next line. A no-op when JS is disabled (the mixin default).
660
827
  def after_interaction
661
828
  @js_runtime&.drain
829
+ __flush_page_navigation__
662
830
  end
663
831
 
664
832
  private
@@ -721,6 +889,43 @@ module Dommy
721
889
  @current_url ? {"Referer" => @current_url} : {}
722
890
  end
723
891
 
892
+ # Carry out a page-initiated navigation recorded by the delegate: resolve
893
+ # the target, skip non-http(s) schemes (javascript:/mailto:/data:) and a
894
+ # bare same-page fragment link, then navigate — folding form params into
895
+ # the query (GET) or body (POST) as usual.
896
+ def perform_page_navigation(nav)
897
+ target = resolve_document_url(nav[:url])
898
+ return unless %w[http https].include?(uri_scheme(target))
899
+ return if nav[:params].nil? && same_page_fragment?(target)
900
+
901
+ method = (nav[:method] || "GET").to_s.upcase
902
+ params = nav[:params]
903
+ method, params = apply_delegate_method_override(method, params) if params
904
+ navigate(method: method, url: target, params: params, body: nav[:body],
905
+ headers: referer_headers, replace: nav[:replace])
906
+ end
907
+
908
+ # The delegate path serializes forms through core FormSubmission, which
909
+ # doesn't know the session's method-override policy; apply it here so a
910
+ # Rails `_method` hidden field turns a POST into PATCH/PUT/DELETE even for
911
+ # an app without Rack::MethodOverride — matching the non-delegate submit.
912
+ def apply_delegate_method_override(method, params)
913
+ return [method, params] unless method == "POST" && @config.respect_method_override
914
+
915
+ pairs = params.dup
916
+ i = pairs.index { |name, _| name == @config.method_override_param }
917
+ return [method, params] unless i
918
+
919
+ override = pairs.delete_at(i)[1].to_s.upcase
920
+ %w[PATCH PUT DELETE].include?(override) ? [override, pairs] : [method, params]
921
+ end
922
+
923
+ def uri_scheme(url)
924
+ URI.parse(url).scheme.to_s.downcase
925
+ rescue URI::InvalidURIError
926
+ ""
927
+ end
928
+
724
929
  # A link to the current page that differs only by fragment does not
725
930
  # issue a request (browser behavior).
726
931
  def same_page_fragment?(target)
@@ -763,10 +968,10 @@ module Dommy
763
968
  # A <button> defaults to type=submit; an <input> submits only for
764
969
  # type=submit or type=image.
765
970
  def submit_button?(button)
766
- if button.tag_name == "BUTTON"
767
- button.type == "submit"
768
- else
769
- %w[submit image].include?(button.type)
971
+ case button.tag_name
972
+ when "BUTTON" then button.type == "submit"
973
+ when "INPUT" then %w[submit image].include?(button.type)
974
+ else false
770
975
  end
771
976
  end
772
977
 
@@ -133,6 +133,10 @@ module Dommy
133
133
  if (window = doc&.default_view)
134
134
  rt.install_window(window)
135
135
  rt.install_browser_globals
136
+ # Page-initiated navigations (JS location.href=, form submit, activated
137
+ # <a>) route through the core NavigationDelegate port to the session,
138
+ # which defers and performs them at the next drain.
139
+ window.navigation_delegate = @session.__navigation_delegate_for__(window)
136
140
  resources = ::Dommy::Rack::Resources.new(@session)
137
141
  # Off-thread network is opt-in: with a session executor, fetch / XHR
138
142
  # resolve through a DeferredResponse on this window's scheduler;
@@ -140,6 +144,9 @@ module Dommy
140
144
  window.globals["__fetch_handler__"] = ::Dommy::Resources::FetchHandler.new(
141
145
  resources, executor: @session.network_executor, scheduler: window.scheduler
142
146
  )
147
+ # Same-origin WebSockets connect to the Rack app itself (ActionCable
148
+ # et al.); cross-origin ones keep the in-memory stub.
149
+ window.websocket_connector = @session.__internal_websocket_connector(window)
143
150
  # Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
144
151
  # fetch + run through the same resources adapter, after boot.
145
152
  doc.external_script_runner = lambda do |element, src|
@@ -43,6 +43,18 @@ module Dommy
43
43
  def escape_non_ascii(str)
44
44
  str.b.gsub(/[^\x00-\x7F]/n) { |byte| format("%%%02X", byte.unpack1("C")) }
45
45
  end
46
+
47
+ # `host` or `host:port`, omitting a default port (the `Host` header /
48
+ # tuple-origin serialization rule shared by HTTP and WebSocket).
49
+ def http_host(uri)
50
+ uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
51
+ end
52
+
53
+ # `scheme://host[:port]`, the tuple origin for `uri` (used for the
54
+ # `Origin` header a same-origin WebSocket connection presents).
55
+ def origin(uri)
56
+ "#{uri.scheme}://#{http_host(uri)}"
57
+ end
46
58
  end
47
59
  end
48
60
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Dommy
4
4
  module Rack
5
- VERSION = "0.9.0"
5
+ VERSION = "0.10.0"
6
6
  end
7
7
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Dommy
6
+ module Rack
7
+ # RFC 6455 frame codec — the byte layout only, no I/O lifecycle. Encodes
8
+ # client frames (masked, per §5.3) and decodes server frames from a
9
+ # blocking IO. WebSocketTransport owns the socket, threads, and what each
10
+ # opcode means; this module owns how a frame is laid out on the wire.
11
+ module WebSocketFrame
12
+ module_function
13
+
14
+ TEXT = 0x1
15
+ BINARY = 0x2
16
+ CLOSE = 0x8
17
+ PING = 0x9
18
+ PONG = 0xA
19
+
20
+ # Read one frame from `io`. Returns [opcode, payload] with the payload
21
+ # unmasked. Raises EOFError when the stream ends mid-frame.
22
+ def read(io)
23
+ b1, b2 = read_exact(io, 2).unpack("C2")
24
+ opcode = b1 & 0x0f
25
+ length = b2 & 0x7f
26
+ length = read_exact(io, 2).unpack1("n") if length == 126
27
+ length = read_exact(io, 8).unpack1("Q>") if length == 127
28
+ mask = (b2 & 0x80).positive? ? read_exact(io, 4) : nil
29
+ payload = length.zero? ? +"" : read_exact(io, length)
30
+ payload = xor_mask(payload, mask) if mask
31
+ [opcode, payload]
32
+ end
33
+
34
+ # A single client frame: FIN + opcode, then the length with the mask
35
+ # bit set (clients MUST mask; RFC 6455 §5.3), the 4-byte key, and the
36
+ # masked payload.
37
+ def client_frame(opcode, payload)
38
+ header = [0x80 | opcode].pack("C")
39
+ length = payload.bytesize
40
+ header <<
41
+ if length < 126 then [0x80 | length].pack("C")
42
+ elsif length < 65_536 then [0x80 | 126, length].pack("Cn")
43
+ else [0x80 | 127, length].pack("CQ>")
44
+ end
45
+ key = SecureRandom.bytes(4)
46
+ header + key + xor_mask(payload, key)
47
+ end
48
+
49
+ # The body of a close frame: 2-byte status code + UTF-8 reason.
50
+ def close_payload(code, reason)
51
+ [code].pack("n") + reason.to_s.b
52
+ end
53
+
54
+ # [code, reason] from a close frame's payload; an empty payload means
55
+ # "no status received" (1005).
56
+ def parse_close(payload)
57
+ code = payload.bytesize >= 2 ? payload[0, 2].unpack1("n") : 1005
58
+ reason = payload.byteslice(2..).to_s.force_encoding(Encoding::UTF_8)
59
+ [code, reason]
60
+ end
61
+
62
+ def read_exact(io, n)
63
+ data = +""
64
+ while data.bytesize < n
65
+ chunk = io.read(n - data.bytesize)
66
+ raise EOFError, "connection closed" if chunk.nil?
67
+
68
+ data << chunk
69
+ end
70
+ data
71
+ end
72
+
73
+ # XOR (un)masking — its own inverse, so one helper serves both sides.
74
+ def xor_mask(payload, key)
75
+ bytes = payload.bytes
76
+ key_bytes = key.bytes
77
+ bytes.each_index { |i| bytes[i] ^= key_bytes[i % 4] }
78
+ bytes.pack("C*")
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "securerandom"
5
+ require "stringio"
6
+ require "uri"
7
+
8
+ module Dommy
9
+ module Rack
10
+ # In-process WebSocket transport: connects a page's `new WebSocket(url)`
11
+ # to the Rack app ITSELF, the same way fetch/XHR resolve through the app.
12
+ # The server side receives a real RFC 6455 upgrade request and a real
13
+ # socket (one end of a socketpair, handed over via `rack.hijack`), so
14
+ # ActionCable's full stack runs unmodified — connection auth sees the
15
+ # session's cookies, the origin check sees the session's origin, and the
16
+ # cable event loop reads/writes actual WebSocket frames.
17
+ #
18
+ # The client side of the protocol lives here: frame semantics over the
19
+ # WebSocketFrame codec (text/close/ping handling; ActionCable messages are
20
+ # single-frame text) plus a reader thread that parses server frames and
21
+ # marshals them onto the page thread via `scheduler.post_external`, where
22
+ # they fire the WebSocket's open/message/close events. `settle` /
23
+ # `advance_time` deliver them, like any other external completion.
24
+ #
25
+ # Lifetime: a transport belongs to the page (realm) that opened it; the
26
+ # session closes all live transports on dispose. Note that ActionCable
27
+ # sends its JSON pings on REAL time (an every-3s event-loop timer), not
28
+ # the page's virtual clock — harmless for tests, which settle on the
29
+ # subscription/broadcast messages they wait for.
30
+ class WebSocketTransport
31
+ # Resolve `url` for the connector: an absolute ws(s) URL (http(s) is
32
+ # accepted and treated the same) that is same-origin with `base`.
33
+ # Returns the URI, or nil (the WebSocket then falls back to the
34
+ # in-memory stub).
35
+ def self.rack_target(url, base:)
36
+ target = URI.join(base.to_s, url.to_s)
37
+ scheme = {"ws" => "http", "wss" => "https"}[target.scheme] || target.scheme
38
+ return nil unless %w[http https].include?(scheme)
39
+
40
+ b = URI.parse(base.to_s)
41
+ return nil unless b.host == target.host && b.port == target.port
42
+
43
+ target.scheme = scheme
44
+ # Re-parse so the return value is a URI::HTTP(S), not a URI::WS whose
45
+ # scheme string was swapped (URI classes compare by class + value).
46
+ URI.parse(target.to_s)
47
+ rescue URI::Error
48
+ nil
49
+ end
50
+
51
+ def initialize(app:, ws:, scheduler:, url:, origin:, cookie_string: "")
52
+ @ws = ws
53
+ @scheduler = scheduler
54
+ @url = url
55
+ @write_mutex = Mutex.new
56
+ @sent_close = false
57
+ @closed = false
58
+
59
+ @client_io, server_io = ::Socket.pair(:UNIX, :STREAM)
60
+ env = handshake_env(url, origin, cookie_string, server_io)
61
+ status, _headers, _body = app.call(env)
62
+ if env["rack.hijack_io"].nil? && status != -1
63
+ # The app answered with a normal HTTP response (no cable mounted at
64
+ # this path, or the upgrade was rejected): fail like a browser —
65
+ # error then close, deferred so `onerror` handlers attach first.
66
+ fail_connection
67
+ else
68
+ @reader = Thread.new { run_reader }
69
+ end
70
+ end
71
+
72
+ # --- API the WebSocket delegates to (page thread) ---
73
+
74
+ def send_text(data)
75
+ write_frame(WebSocketFrame::TEXT, data.b)
76
+ end
77
+
78
+ def close(code = 1000, reason = "")
79
+ send_close_frame(code == 1005 ? 1000 : code, reason)
80
+ end
81
+
82
+ # Hard teardown (session dispose): drop the socket; the reader thread
83
+ # exits on EOF. Safe to call repeatedly.
84
+ def dispose
85
+ @closed = true
86
+ @client_io&.close unless @client_io&.closed?
87
+ @reader&.join(1)
88
+ rescue IOError
89
+ nil
90
+ end
91
+
92
+ private
93
+
94
+ def handshake_env(url, origin, cookie_string, server_io)
95
+ env = {
96
+ "REQUEST_METHOD" => "GET",
97
+ "SCRIPT_NAME" => "",
98
+ "PATH_INFO" => url.path.empty? ? "/" : url.path,
99
+ "QUERY_STRING" => url.query.to_s,
100
+ "SERVER_NAME" => url.host,
101
+ "SERVER_PORT" => url.port.to_s,
102
+ "HTTP_HOST" => Url.http_host(url),
103
+ "HTTP_UPGRADE" => "websocket",
104
+ "HTTP_CONNECTION" => "Upgrade",
105
+ "HTTP_SEC_WEBSOCKET_KEY" => SecureRandom.base64(16),
106
+ "HTTP_SEC_WEBSOCKET_VERSION" => "13",
107
+ "HTTP_ORIGIN" => origin,
108
+ "REMOTE_ADDR" => "127.0.0.1",
109
+ "rack.url_scheme" => url.scheme,
110
+ "rack.input" => StringIO.new(""),
111
+ "rack.errors" => $stderr,
112
+ "rack.multithread" => true,
113
+ "rack.multiprocess" => false,
114
+ "rack.run_once" => false,
115
+ "rack.hijack?" => true,
116
+ }
117
+ env["HTTP_COOKIE"] = cookie_string unless cookie_string.to_s.empty?
118
+ env["rack.hijack"] = proc { env["rack.hijack_io"] = server_io }
119
+ env
120
+ end
121
+
122
+ def fail_connection
123
+ @closed = true
124
+ @client_io.close
125
+ @scheduler.queue_microtask(proc do
126
+ @ws.__transport_error__
127
+ @ws.__transport_closed__(1006, "", was_clean: false)
128
+ end)
129
+ end
130
+
131
+ # --- Reader thread ---
132
+
133
+ def run_reader
134
+ protocol = read_handshake_response!
135
+ post { @ws.__transport_open__(protocol) }
136
+ read_frames
137
+ rescue HandshakeFailed
138
+ post { @ws.__transport_error__ }
139
+ post { @ws.__transport_closed__(1006, "", was_clean: false) }
140
+ rescue IOError, EOFError, Errno::ECONNRESET, Errno::EPIPE
141
+ post { @ws.__transport_closed__(1006, "", was_clean: false) } unless @closed
142
+ ensure
143
+ @client_io.close rescue nil
144
+ end
145
+
146
+ class HandshakeFailed < StandardError; end
147
+
148
+ # Read the server's HTTP response head; only `101 Switching Protocols`
149
+ # continues (the handshake accept hash is not re-verified — the server
150
+ # is the app under test, not an untrusted peer). Returns the selected
151
+ # subprotocol, if any.
152
+ def read_handshake_response!
153
+ head = +""
154
+ head << WebSocketFrame.read_exact(@client_io, 1) until head.end_with?("\r\n\r\n")
155
+ raise HandshakeFailed unless head.start_with?("HTTP/1.1 101")
156
+
157
+ head[/^sec-websocket-protocol:\s*(\S+)/i, 1]
158
+ end
159
+
160
+ def read_frames
161
+ loop do
162
+ opcode, payload = WebSocketFrame.read(@client_io)
163
+
164
+ case opcode
165
+ when WebSocketFrame::TEXT, WebSocketFrame::BINARY # continuation frames unsupported: cable messages are single-frame
166
+ data = opcode == WebSocketFrame::TEXT ? payload.force_encoding(Encoding::UTF_8) : payload
167
+ post { @ws.__transport_message__(data) }
168
+ when WebSocketFrame::CLOSE # complete the handshake, then report
169
+ code, reason = WebSocketFrame.parse_close(payload)
170
+ send_close_frame(code == 1005 ? 1000 : code, "")
171
+ @closed = true
172
+ post { @ws.__transport_closed__(code, reason, was_clean: true) }
173
+ break
174
+ when WebSocketFrame::PING
175
+ write_frame(WebSocketFrame::PONG, payload)
176
+ end
177
+ end
178
+ end
179
+
180
+ # --- Frame writing (page thread and reader thread; mutex-guarded) ---
181
+
182
+ def send_close_frame(code, reason)
183
+ return if @sent_close
184
+
185
+ @sent_close = true
186
+ write_frame(WebSocketFrame::CLOSE, WebSocketFrame.close_payload(code, reason))
187
+ end
188
+
189
+ def write_frame(opcode, payload)
190
+ frame = WebSocketFrame.client_frame(opcode, payload)
191
+ @write_mutex.synchronize do
192
+ return if @client_io.closed?
193
+
194
+ @client_io.write(frame)
195
+ end
196
+ rescue IOError, Errno::EPIPE
197
+ nil
198
+ end
199
+
200
+ def post(&block)
201
+ @scheduler.post_external(&block)
202
+ end
203
+ end
204
+ end
205
+ end
data/lib/dommy/rack.rb CHANGED
@@ -22,6 +22,8 @@ require_relative "rack/resources"
22
22
  require_relative "rack/network_bridge"
23
23
  require_relative "rack/session_runtime"
24
24
  require_relative "rack/trace"
25
+ require_relative "rack/web_socket_frame"
26
+ require_relative "rack/web_socket_transport"
25
27
  require_relative "rack/session"
26
28
 
27
29
  module Dommy
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dommy-rack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.9.0
18
+ version: 0.10.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 0.9.0
25
+ version: 0.10.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rack
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -78,6 +78,8 @@ files:
78
78
  - lib/dommy/rack/url.rb
79
79
  - lib/dommy/rack/version.rb
80
80
  - lib/dommy/rack/visibility.rb
81
+ - lib/dommy/rack/web_socket_frame.rb
82
+ - lib/dommy/rack/web_socket_transport.rb
81
83
  - sig/dommy/rack.rbs
82
84
  homepage: https://github.com/takahashim/dommy
83
85
  licenses:
@@ -99,7 +101,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
99
101
  - !ruby/object:Gem::Version
100
102
  version: '0'
101
103
  requirements: []
102
- rubygems_version: 3.6.9
104
+ rubygems_version: 4.0.10
103
105
  specification_version: 4
104
106
  summary: Rack-backed browser session layer for Dommy
105
107
  test_files: []