dommy-rack 0.9.0 → 0.11.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: 242882a8db41a841906a2292592dcf047941a3578e043cf9dc872d03b6649c77
4
+ data.tar.gz: 62adc178e7d8bffd1e4d923a13f75d130de05d12b953c43377c0d7dc48ee72c2
5
5
  SHA512:
6
- metadata.gz: 6a342735719087256ae5ce571ec435b69b0a4e6ce264f400db0a6bc40baaa84cf36889769701ad32619d4694e0075e64e3dfe2bd3eaecffb84045bb66d85fd99
7
- data.tar.gz: 7ed6fa76e4123a4bb13bdce963b2d657e58f6639c19c980952d42081d191e19356c7cdb9e9ae9781443afc83a3545c13798db339ffe247624f2970692269e40e
6
+ metadata.gz: 41a58a2614a4608d75957cd777bf9e30d657cba682a90509c473627b50c5f88ad929c438afa10a5b54f641f26b7b4a8061280b567b84325ba23445543c895a32
7
+ data.tar.gz: 9e68474ae55c4051d0844da0ffea2c2df9e043a5e31cf86a6d52b805df78dd410110f3aab8bd006bc6690fc483474217154f1be3af037eab87c4e06c7e31dbf9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 — 2026-09-11
4
+
5
+ ### Added
6
+ - `Session#dialog_handler=` — answer the page's `alert` / `confirm` / `prompt`. The handler stays installed across navigations, so it also answers dialogs on pages loaded later.
7
+ - Traced requests now carry spans from inside the app (controller, SQL, render, and the jobs and mails a request triggers), flushed with the request they belong to. See dommy-rails for the Rails-side instrumentation.
8
+
9
+ ### Fixed
10
+ - A request whose app raises still closes its trace bracket, so the trace ends with the failure instead of an open request.
11
+ - A subresource fetch made while a request is in flight keeps its own spans rather than attaching them to the outer request.
12
+ - SQL bind values, when enabled, are masked through the trace's own sensitive-key filter — the same one that masks form params.
13
+ - A snapshot's content is stored before its artifact event is written, so a trace read back straight away is complete.
14
+
15
+ ## 0.10.0 — 2026-07-13
16
+
17
+ ### Added
18
+ - **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.
19
+ - **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.
20
+ - `Session#dispose` — full teardown (JS runtimes plus live WebSocket transports); `#dispose_js` remains JS-only.
21
+
22
+ ### Fixed
23
+ - Only `BUTTON` / `INPUT` elements are treated as submit buttons.
24
+
3
25
  ## 0.9.0 — 2026-06-22
4
26
 
5
27
  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
@@ -17,6 +17,13 @@ module Dommy
17
17
  # it as `last_request` and fires request listeners; an async path routes the
18
18
  # same notification through the scheduler inbox).
19
19
  # * `on_response` — observe the Response (response listeners).
20
+ # * `on_abort` — observe an env whose `@app.call` raised (no Response).
21
+ # * `on_app_start` / `on_app_finish` — bracket the app call ITSELF. Unlike
22
+ # the observation hooks above (which an async path routes through the
23
+ # scheduler inbox, i.e. LATER and on the page thread), these run inline,
24
+ # on whatever thread calls the app, so per-request state a hook installs
25
+ # for the app to find — the Trace's thread-local — is live exactly while
26
+ # the app runs. Anything they need to hand back travels on the env.
20
27
  #
21
28
  # Reload bookkeeping (`last_request_args`) and history/document application are
22
29
  # NOT here: they belong to the page thread and stay in Session / Navigation.
@@ -24,13 +31,17 @@ module Dommy
24
31
  # @param headers [HeaderStore, Hash] anything responding to `merge(overrides)
25
32
  # -> Hash`; the Session passes its live HeaderStore for the page path, a
26
33
  # plain snapshot Hash for a worker path.
27
- def initialize(app:, config:, cookie_jar:, headers:, on_request: nil, on_response: nil)
34
+ def initialize(app:, config:, cookie_jar:, headers:, on_request: nil, on_response: nil, on_abort: nil,
35
+ on_app_start: nil, on_app_finish: nil)
28
36
  @app = app
29
37
  @config = config
30
38
  @cookie_jar = cookie_jar
31
39
  @headers = headers
32
40
  @on_request = on_request
33
41
  @on_response = on_response
42
+ @on_abort = on_abort
43
+ @on_app_start = on_app_start
44
+ @on_app_finish = on_app_finish
34
45
  end
35
46
 
36
47
  # Perform one request and return its Response. `headers` are per-request
@@ -45,7 +56,20 @@ module Dommy
45
56
  cookie_string: @cookie_jar.cookies_for(absolute_url)
46
57
  )
47
58
  @on_request&.call(env)
48
- status, response_headers, response_body = @app.call(env)
59
+ # on_app_start opened a request bracket (the Trace exposes itself
60
+ # thread-locally inside it); an app exception must still close it, or
61
+ # per-request state leaks past the failed request. Close it BEFORE
62
+ # on_abort, which reads what the bracket left on the env. The exception
63
+ # itself propagates unchanged.
64
+ @on_app_start&.call(env)
65
+ begin
66
+ status, response_headers, response_body = @app.call(env)
67
+ rescue ::Exception # rubocop:disable Lint/RescueException -- close the bracket for ANY abort
68
+ @on_app_finish&.call(env)
69
+ @on_abort&.call(env)
70
+ raise
71
+ end
72
+ @on_app_finish&.call(env)
49
73
  response = Response.new(status, response_headers, response_body, url: absolute_url)
50
74
  response.set_cookie_strings.each do |sc|
51
75
  @cookie_jar.store_from_header(sc, absolute_url)
@@ -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.
@@ -26,7 +49,7 @@ module Dommy
26
49
  keyword_init: true
27
50
  )
28
51
 
29
- attr_reader :last_request, :last_response, :history, :trace
52
+ attr_reader :last_request, :last_response, :history, :trace, :dialog_handler
30
53
 
31
54
  # A factory `->(session) { js_runtime_host }` that binds a JS runtime to a
32
55
  # session for `javascript: true`. dommy-js-quickjs installs one when its
@@ -106,6 +129,9 @@ module Dommy
106
129
  @scope_stack = []
107
130
  @request_listeners = []
108
131
  @response_listeners = []
132
+ @abort_listeners = []
133
+ @app_start_listeners = []
134
+ @app_finish_listeners = []
109
135
  @document_loaded_listeners = []
110
136
  @subresource_allowlist = [] # hosts allowed for cross-origin <script>/fetch/XHR
111
137
  @blocked_subresource_hosts = [] # cross-origin hosts declined since the last reset (awaiting a decision)
@@ -122,6 +148,14 @@ module Dommy
122
148
  # verbs drive JS handlers.
123
149
  def javascript? = !@js_runtime.nil?
124
150
 
151
+ # Supply native dialog answers to the current page and to every page
152
+ # subsequently installed while the handler is active. The Capybara driver
153
+ # uses this around accept_confirm / dismiss_confirm (and alert/prompt).
154
+ def dialog_handler=(handler)
155
+ @dialog_handler = handler
156
+ @current_window.dialog_handler = handler if @current_window
157
+ end
158
+
125
159
  # The bound JS runtime (a SessionRuntime), or nil when JS is disabled.
126
160
  # Exposed for the Trace to subscribe to the runtime's console / js_error /
127
161
  # script seams; not part of the everyday browsing API.
@@ -141,6 +175,7 @@ module Dommy
141
175
  # #advance_time.
142
176
  def settle
143
177
  require_js!.settle
178
+ __flush_page_navigation__
144
179
  self
145
180
  end
146
181
 
@@ -156,12 +191,41 @@ module Dommy
156
191
  def js_errors = @js_runtime ? @js_runtime.js_errors : []
157
192
  def console = @js_runtime ? @js_runtime.console : []
158
193
 
159
- # Dispose the JS runtime(s). Safe to call when JS is disabled.
194
+ # Full session teardown: the JS runtime(s) plus any live WebSocket
195
+ # transports. Safe to call when JS is disabled, and repeatedly.
196
+ def dispose
197
+ Array(@live_websocket_transports).each(&:dispose)
198
+ @live_websocket_transports = nil
199
+ dispose_js
200
+ end
201
+
202
+ # Dispose the JS runtime(s) only. Safe to call when JS is disabled.
160
203
  def dispose_js
161
204
  @js_runtime&.dispose
162
205
  @js_runtime = nil
163
206
  end
164
207
 
208
+ # Factory for the window's websocket_connector seam (installed per
209
+ # realm by SessionRuntime): a same-origin `new WebSocket(url)` connects
210
+ # to the Rack app itself over rack.hijack (see WebSocketTransport), so
211
+ # ActionCable-backed features (Turbo Streams broadcasts, …) work
212
+ # in-process. A cross-origin URL returns nil, leaving the WebSocket on
213
+ # its in-memory stub.
214
+ def __internal_websocket_connector(window)
215
+ lambda do |ws, url, _protocols|
216
+ base = @current_url || default_host
217
+ target = WebSocketTransport.rack_target(url, base: base)
218
+ next nil unless target
219
+
220
+ transport = WebSocketTransport.new(
221
+ app: @app, ws: ws, scheduler: window.scheduler, url: target,
222
+ origin: Url.origin(target), cookie_string: @cookie_jar.cookies_for(target.to_s)
223
+ )
224
+ (@live_websocket_transports ||= []) << transport
225
+ transport
226
+ end
227
+ end
228
+
165
229
  # --- Config readers used by collaborators ---
166
230
 
167
231
  def default_host = @config.default_host
@@ -261,8 +325,8 @@ module Dommy
261
325
  result
262
326
  end
263
327
 
264
- def navigate(method: "GET", url:, params: nil, body: nil, headers: {})
265
- @navigation.navigate(method: method, url: url, params: params, body: body, headers: headers)
328
+ def navigate(method: "GET", url:, params: nil, body: nil, headers: {}, replace: false)
329
+ @navigation.navigate(method: method, url: url, params: params, body: body, headers: headers, replace: replace)
266
330
  end
267
331
 
268
332
  def reload
@@ -273,14 +337,63 @@ module Dommy
273
337
  response
274
338
  end
275
339
 
340
+ # Traverse the joint history like a browser's back button: a
341
+ # same-document target within the LIVE page (a Turbo Drive pushState
342
+ # entry) moves window.history and fires popstate — Turbo's restoration
343
+ # visit runs, no full request; a target across a document boundary
344
+ # re-requests the URL. Returns the destination URL, or nil at the edge.
345
+ # A JS session may need `settle` afterwards for the restoration fetch.
276
346
  def back
277
- url = @history.back
278
- @navigation.revisit(url) if url
347
+ traverse_history(:back)
279
348
  end
280
349
 
281
350
  def forward
282
- url = @history.forward
283
- @navigation.revisit(url) if url
351
+ traverse_history(:forward)
352
+ end
353
+
354
+ # --- NavigationDelegate port (see Dommy::Navigation) ---
355
+
356
+ # The delegate attached to each page's window (in SessionRuntime). A
357
+ # page-initiated navigation — a JS `location.href=` / `form.submit()`, a
358
+ # submitted form, an activated `<a>` — routes here. Navigation is a task:
359
+ # performing it synchronously could dispose the JS realm still on the
360
+ # stack, so it is recorded and performed at the next drain (#after_interaction
361
+ # / #settle), exactly like the standalone Browser.
362
+ def __navigation_delegate_for__(window)
363
+ PageNavigationDelegate.new(self, window)
364
+ end
365
+
366
+ def __enqueue_page_navigation__(window, nav)
367
+ # A retained handle to a navigated-away page must not steer the session.
368
+ return unless window.equal?(@current_window)
369
+
370
+ @pending_navigation = nav
371
+ end
372
+
373
+ def __enqueue_page_traverse__(window, delta)
374
+ return unless window.equal?(@current_window)
375
+
376
+ @pending_navigation = {traverse: delta}
377
+ end
378
+
379
+ # Perform a recorded page navigation, if any. Called after the JS runtime
380
+ # drains so the document/realm swap never runs with the outgoing realm's
381
+ # JS on the stack.
382
+ def __flush_page_navigation__
383
+ nav = @pending_navigation
384
+ return unless nav
385
+
386
+ @pending_navigation = nil
387
+ if nav.key?(:traverse)
388
+ traverse_history(nav[:traverse].negative? ? :back : :forward)
389
+ else
390
+ perform_page_navigation(nav)
391
+ end
392
+ rescue CrossOriginError, UnsupportedURLError, InvalidFormError, TooManyRedirectsError
393
+ # A page-initiated navigation that is blocked, unsupported, or loops on
394
+ # redirects is dropped (a browser blocks/abandons it); it must not crash
395
+ # the drain the way a Ruby-driven `visit` (which re-raises) does.
396
+ nil
284
397
  end
285
398
 
286
399
  # --- Basic request API (navigates, updating page state) ---
@@ -409,6 +522,35 @@ module Dommy
409
522
  self
410
523
  end
411
524
 
525
+ # Register a callback invoked with the Rack env when a request ABORTS
526
+ # (the app raised, so no Response exists). Every on_request has a
527
+ # matching on_response OR on_abort — per-request state can bracket on
528
+ # the pair.
529
+ def on_abort(&block)
530
+ @abort_listeners << block
531
+ self
532
+ end
533
+
534
+ # Register callbacks that BRACKET the Rack app call itself, with the env.
535
+ # Unlike on_request / on_response (which the async-network path routes
536
+ # through the scheduler inbox, so they run after the fact on the page
537
+ # thread), these fire inline on whatever thread calls the app — which is
538
+ # the only place a hook can install per-request state the app itself will
539
+ # find, such as the Trace's thread-local. State they need to hand to the
540
+ # later on_response travels on the env, not on the listener's owner.
541
+ def on_app_start(&block)
542
+ @app_start_listeners << block
543
+ self
544
+ end
545
+
546
+ # The matching close. Runs for a successful AND a raising app call, and
547
+ # before on_abort so the aborted request's observer sees what the bracket
548
+ # left on the env.
549
+ def on_app_finish(&block)
550
+ @app_finish_listeners << block
551
+ self
552
+ end
553
+
412
554
  # Register a callback invoked with the new Window each time a navigation
413
555
  # installs an HTML document (visit, redirects, link clicks, form submits,
414
556
  # back/forward, reload, meta refresh). This is the page-load lifecycle
@@ -578,10 +720,40 @@ module Dommy
578
720
  },
579
721
  on_response: lambda { |response|
580
722
  @response_listeners.each { |cb| cb.call(response) }
723
+ },
724
+ on_abort: lambda { |env|
725
+ @abort_listeners.each { |cb| cb.call(env) }
726
+ },
727
+ on_app_start: lambda { |env|
728
+ @app_start_listeners.each { |cb| cb.call(env) }
729
+ },
730
+ on_app_finish: lambda { |env|
731
+ @app_finish_listeners.each { |cb| cb.call(env) }
581
732
  }
582
733
  )
583
734
  end
584
735
 
736
+ # One step of the joint back/forward traversal (see #back).
737
+ def traverse_history(direction)
738
+ target = direction == :back ? @history.back : @history.forward
739
+ return nil unless target
740
+
741
+ if target.window&.equal?(@current_window) && target.windex
742
+ begin
743
+ @history_traversing = true
744
+ @current_window.history.__internal_go_to__(target.windex)
745
+ ensure
746
+ @history_traversing = false
747
+ end
748
+ @current_url = target.url
749
+ @js_runtime&.drain
750
+ else
751
+ @navigation.revisit(target.url)
752
+ end
753
+ target.url
754
+ end
755
+ private :traverse_history
756
+
585
757
  # Build a worker-safe thunk that fetches `target` (already absolute) and
586
758
  # returns the Response, for the async-network path. Called on the page
587
759
  # thread: it enforces same-origin and captures a header snapshot here, then
@@ -600,11 +772,12 @@ module Dommy
600
772
 
601
773
  # Apply a final navigation response: update last_response, current_url,
602
774
  # the document (HTML only), and the history stack.
603
- def apply_navigation_response(response, final_url, push_history: true)
775
+ def apply_navigation_response(response, final_url, push_history: true, replace: false)
604
776
  @last_response = response
605
777
  @current_url = final_url
606
778
  if response.html?
607
779
  @current_window = response.window
780
+ @current_window.dialog_handler = @dialog_handler
608
781
  # Set the geometry mode before scripts boot so the very first
609
782
  # getBoundingClientRect a framework calls already sees it.
610
783
  @current_window.approximate_layout = @approximate_layout if @approximate_layout
@@ -612,9 +785,53 @@ module Dommy
612
785
  # DOMContentLoaded) run, so CSS-driven computed styles and :visible
613
786
  # are correct from the first observation.
614
787
  install_stylesheet_loading(@current_window) if load_stylesheets?
788
+ # The history entry exists and the window-history sync is live
789
+ # BEFORE scripts boot: Turbo's replaceState-on-start then lands on
790
+ # THIS entry, and its pushState navigations append after it.
791
+ windex = @current_window.history.__internal_index__
792
+ if replace
793
+ # location.replace() / reload() / a redirect: overwrite the current
794
+ # entry's URL and rebind it to the new document (no new entry).
795
+ @history.replace_current_url(final_url)
796
+ @history.rebind_current(window: @current_window, windex: windex)
797
+ elsif push_history
798
+ @history.push(final_url, window: @current_window, windex: windex)
799
+ else
800
+ # A revisit re-loaded this URL into a fresh document: re-bind the
801
+ # existing entry so traversal sync matches the live window.
802
+ @history.rebind_current(window: @current_window, windex: windex)
803
+ end
804
+ install_history_sync(@current_window)
615
805
  @document_loaded_listeners.each { |cb| cb.call(@current_window) }
806
+ elsif replace
807
+ @history.replace_current_url(final_url)
808
+ elsif push_history
809
+ @history.push(final_url)
810
+ end
811
+ end
812
+
813
+ # Mirror the page's same-document history operations (Turbo Drive's
814
+ # pushState navigations, JS history.back()) into the session: the joint
815
+ # history gains/updates entries and current_url follows, so
816
+ # `browser.current_path` and `browser.back` see what a browser's URL
817
+ # bar and back button would. Guarded against echo while the session
818
+ # itself drives a traversal, and against a stale window — a retained
819
+ # handle to a navigated-away page must not touch the session's state.
820
+ def install_history_sync(window)
821
+ window.history.__internal_on_change__ = lambda do |kind, url|
822
+ next if @history_traversing
823
+ next unless window.equal?(@current_window)
824
+
825
+ @current_url = url
826
+ case kind
827
+ when :push
828
+ @history.push(url, window: window, windex: window.history.__internal_index__)
829
+ when :replace
830
+ @history.replace_current_url(url)
831
+ when :traverse
832
+ @history.sync_to(window, window.history.__internal_index__)
833
+ end
616
834
  end
617
- @history.push(final_url) if push_history
618
835
  end
619
836
 
620
837
  # Wire same-origin CSS loading for a freshly installed document: fill
@@ -659,6 +876,7 @@ module Dommy
659
876
  # before the next line. A no-op when JS is disabled (the mixin default).
660
877
  def after_interaction
661
878
  @js_runtime&.drain
879
+ __flush_page_navigation__
662
880
  end
663
881
 
664
882
  private
@@ -680,6 +898,19 @@ module Dommy
680
898
  },
681
899
  on_response: sched && lambda { |response|
682
900
  sched.post_external { @response_listeners.each { |cb| cb.call(response) } }
901
+ },
902
+ on_abort: sched && lambda { |env|
903
+ sched.post_external { @abort_listeners.each { |cb| cb.call(env) } }
904
+ },
905
+ # The app-call bracket is NOT posted to the inbox: it has to be open
906
+ # while the worker thread is inside the app, which a page-thread
907
+ # callback scheduled for later can never be. Its listeners touch only
908
+ # thread-locals and the env, so running them off-thread is safe.
909
+ on_app_start: lambda { |env|
910
+ @app_start_listeners.each { |cb| cb.call(env) }
911
+ },
912
+ on_app_finish: lambda { |env|
913
+ @app_finish_listeners.each { |cb| cb.call(env) }
683
914
  }
684
915
  )
685
916
  end
@@ -721,6 +952,43 @@ module Dommy
721
952
  @current_url ? {"Referer" => @current_url} : {}
722
953
  end
723
954
 
955
+ # Carry out a page-initiated navigation recorded by the delegate: resolve
956
+ # the target, skip non-http(s) schemes (javascript:/mailto:/data:) and a
957
+ # bare same-page fragment link, then navigate — folding form params into
958
+ # the query (GET) or body (POST) as usual.
959
+ def perform_page_navigation(nav)
960
+ target = resolve_document_url(nav[:url])
961
+ return unless %w[http https].include?(uri_scheme(target))
962
+ return if nav[:params].nil? && same_page_fragment?(target)
963
+
964
+ method = (nav[:method] || "GET").to_s.upcase
965
+ params = nav[:params]
966
+ method, params = apply_delegate_method_override(method, params) if params
967
+ navigate(method: method, url: target, params: params, body: nav[:body],
968
+ headers: referer_headers, replace: nav[:replace])
969
+ end
970
+
971
+ # The delegate path serializes forms through core FormSubmission, which
972
+ # doesn't know the session's method-override policy; apply it here so a
973
+ # Rails `_method` hidden field turns a POST into PATCH/PUT/DELETE even for
974
+ # an app without Rack::MethodOverride — matching the non-delegate submit.
975
+ def apply_delegate_method_override(method, params)
976
+ return [method, params] unless method == "POST" && @config.respect_method_override
977
+
978
+ pairs = params.dup
979
+ i = pairs.index { |name, _| name == @config.method_override_param }
980
+ return [method, params] unless i
981
+
982
+ override = pairs.delete_at(i)[1].to_s.upcase
983
+ %w[PATCH PUT DELETE].include?(override) ? [override, pairs] : [method, params]
984
+ end
985
+
986
+ def uri_scheme(url)
987
+ URI.parse(url).scheme.to_s.downcase
988
+ rescue URI::InvalidURIError
989
+ ""
990
+ end
991
+
724
992
  # A link to the current page that differs only by fragment does not
725
993
  # issue a request (browser behavior).
726
994
  def same_page_fragment?(target)
@@ -763,10 +1031,10 @@ module Dommy
763
1031
  # A <button> defaults to type=submit; an <input> submits only for
764
1032
  # type=submit or type=image.
765
1033
  def submit_button?(button)
766
- if button.tag_name == "BUTTON"
767
- button.type == "submit"
768
- else
769
- %w[submit image].include?(button.type)
1034
+ case button.tag_name
1035
+ when "BUTTON" then button.type == "submit"
1036
+ when "INPUT" then %w[submit image].include?(button.type)
1037
+ else false
770
1038
  end
771
1039
  end
772
1040
 
@@ -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|
@@ -35,6 +35,7 @@ module Dommy
35
35
  when :console then "CONSOLE [#{data[:level]}] #{data[:text]}"
36
36
  when :js_error then "JS_ERROR #{data[:message]}"
37
37
  when :dom then dom(data)
38
+ when :span then "SPAN [#{data[:kind]}] #{data[:label]} (#{data[:duration_ms]}ms)"
38
39
  else "#{event.type.to_s.upcase} #{compact(data)}"
39
40
  end
40
41
  end
@@ -36,6 +36,16 @@ module Dommy
36
36
  LEVELS = %i[off actions verbose].freeze
37
37
  REALM_TYPES = %i[script console js_error].freeze
38
38
 
39
+ # The app-call bracket's channels. The thread-locals are how in-app
40
+ # instrumentation (dommy-rails' TraceInstrumentation) finds the trace
41
+ # whose request is running on THIS thread; the env keys carry that
42
+ # request's spans — and the bracket's saved outer values — back to the
43
+ # response listener, which may run later and on another thread.
44
+ TRACE_THREAD_KEY = :__dommy_active_trace__
45
+ SPANS_THREAD_KEY = :__dommy_trace_spans__
46
+ SPANS_ENV_KEY = "dommy.trace.spans"
47
+ BRACKET_ENV_KEY = "dommy.trace.outer_bracket"
48
+
39
49
  # Build a Trace, wire it to the session's (and its runtime's) seams, and
40
50
  # return it. A `:off` trace wires nothing.
41
51
  def self.attach(session, level: :verbose, dom: false, filter: ParamFilter::DEFAULT, snapshots: false)
@@ -68,6 +78,11 @@ module Dommy
68
78
 
69
79
  @session.on_request { |env| __internal_on_request(env) }
70
80
  @session.on_response { |response| __internal_on_response(response) }
81
+ @session.on_abort { |env| __internal_on_abort(env) } if @session.respond_to?(:on_abort)
82
+ if @session.respond_to?(:on_app_start)
83
+ @session.on_app_start { |env| __internal_open_app_bracket(env) }
84
+ @session.on_app_finish { |env| __internal_close_app_bracket(env) }
85
+ end
71
86
 
72
87
  runtime = @session.respond_to?(:__internal_js_runtime) ? @session.__internal_js_runtime : nil
73
88
  if runtime
@@ -90,8 +105,9 @@ module Dommy
90
105
 
91
106
  @seq += 1
92
107
  @action_seq = @seq
93
- @events << Event.new(seq: @seq, t: now_ms, wall_ms: monotonic_ms, type: :action, name: verb,
108
+ event = Event.new(seq: @seq, t: now_ms, wall_ms: monotonic_ms, type: :action, name: verb,
94
109
  action_seq: nil, data: {verb: verb, label: label, source: __internal_caller_source})
110
+ @events << event
95
111
  nil
96
112
  end
97
113
 
@@ -117,6 +133,10 @@ module Dommy
117
133
  nil
118
134
  end
119
135
 
136
+ # The configured sensitive-key filter — instrumentation layers mask
137
+ # values they record (SQL binds) with the SAME rules as form params.
138
+ def __internal_param_filter__ = @param_filter
139
+
120
140
  # --- Queries (read-only views over the event stream) ---
121
141
 
122
142
  def http = events_of(:http)
@@ -139,7 +159,7 @@ module Dommy
139
159
  def to_s = to_text
140
160
 
141
161
  def to_ndjson(status: "ok", wall_time: nil, metadata: nil)
142
- inline = @artifacts.transform_values { |content| {content: content, encoding: "utf-8"} }
162
+ inline = InlineArtifacts.new(@artifacts)
143
163
  Ndjson.new(@events, level: @level, wall_time: wall_time, metadata: metadata,
144
164
  artifacts: inline, end_wall_ms: monotonic_ms).document(status: status)
145
165
  end
@@ -166,38 +186,109 @@ module Dommy
166
186
  dir
167
187
  end
168
188
 
189
+ # Lazily shapes an artifact's inline emission fields ({content:,
190
+ # encoding:}) by seq — the single owner of the inline wire shape that
191
+ # to_ndjson hands the serializer.
192
+ class InlineArtifacts
193
+ def initialize(artifacts)
194
+ @artifacts = artifacts
195
+ end
196
+
197
+ def [](seq)
198
+ content = @artifacts[seq]
199
+ content ? {content: content, encoding: "utf-8"} : nil
200
+ end
201
+ end
202
+
203
+ # Instrumentation entry point (reached via the thread-local): buffer an
204
+ # inside-the-request span (controller/db/render — anything an
205
+ # instrumentation layer measured). Emitted as completed span events,
206
+ # parented to the enclosing :http event, when the response arrives —
207
+ # spans finish before the response exists, so they can't reference it
208
+ # any earlier. Public; the surrounding request seams stay private.
209
+ #
210
+ # The buffer is the one the app-call bracket published thread-locally,
211
+ # not an ivar: a subresource fetch (JS `fetch`/XHR) runs the app on a
212
+ # network worker thread, so a per-Trace buffer would be a race AND would
213
+ # mis-attribute those spans to whatever the page thread was doing.
214
+ def __internal_record_span__(kind:, label:, duration_ms:, data: nil)
215
+ buffer = Thread.current[SPANS_THREAD_KEY]
216
+ return if @level == :off || buffer.nil?
217
+
218
+ buffer << {kind: kind.to_s, label: label.to_s,
219
+ duration_ms: duration_ms.to_f.round(2), data: data}
220
+ nil
221
+ end
222
+
169
223
  private
170
224
 
171
225
  # Emit one event, gated by the recording level, and return it (or nil if
172
226
  # gated out). `seq` is the canonical order; `t` is the virtual clock if a
173
227
  # window exists.
174
- def __internal_emit(type, data, name: nil, window: nil)
228
+ # `artifact:` carries the event's captured content (a DOM snapshot),
229
+ # stored under the event's seq alongside it.
230
+ def __internal_emit(type, data, name: nil, window: nil, artifact: nil)
175
231
  return if @level == :off
176
232
  return if REALM_TYPES.include?(type) && @level != :verbose
177
233
 
178
234
  @seq += 1
179
235
  event = Event.new(seq: @seq, t: window&.scheduler&.now_ms, wall_ms: monotonic_ms, type: type,
180
236
  name: name, action_seq: @action_seq, data: data)
237
+ @artifacts[event.seq] = artifact if artifact
181
238
  @events << event
182
239
  event
183
240
  end
184
241
 
185
- # on_request fires before its on_response (single-threaded, per redirect
186
- # hop), so stash the method/path here and emit the `:http` event when the
187
- # response arrives with its status.
242
+ # on_request fires before its on_response (per redirect hop, in order),
243
+ # so stash the method/path here and emit the `:http` event when the
244
+ # response arrives with its status. The env is stashed too: the app-call
245
+ # bracket leaves this request's spans on it, and on the async-network
246
+ # path this listener runs after the app already finished.
188
247
  def __internal_on_request(env)
189
248
  @pending_request = {
190
249
  method: env["REQUEST_METHOD"],
191
250
  path: env["PATH_INFO"],
192
- query: presence(env["QUERY_STRING"])
251
+ query: presence(env["QUERY_STRING"]),
252
+ env: env
193
253
  }
194
254
  nil
195
255
  end
196
256
 
257
+ # --- the app-call bracket (runs on the thread that calls the app) ---
258
+
259
+ # Expose this trace to in-app instrumentation (dommy-rails subscribes to
260
+ # ActiveSupport::Notifications and records spans through the thread-local)
261
+ # for exactly the duration of the app call, and give it a buffer to fill.
262
+ # The buffer rides on the env, so the response listener finds this
263
+ # request's spans even when the app ran on another thread.
264
+ #
265
+ # The previous values are saved rather than assumed nil: a nested request
266
+ # (an app that drives a Dommy session of its own) must restore its
267
+ # caller's bracket instead of tearing it down.
268
+ def __internal_open_app_bracket(env)
269
+ return if @level == :off
270
+
271
+ spans = []
272
+ env[SPANS_ENV_KEY] = spans
273
+ env[BRACKET_ENV_KEY] = [Thread.current[TRACE_THREAD_KEY], Thread.current[SPANS_THREAD_KEY]]
274
+ Thread.current[TRACE_THREAD_KEY] = self
275
+ Thread.current[SPANS_THREAD_KEY] = spans
276
+ nil
277
+ end
278
+
279
+ def __internal_close_app_bracket(env)
280
+ saved = env.delete(BRACKET_ENV_KEY)
281
+ return if saved.nil?
282
+
283
+ Thread.current[TRACE_THREAD_KEY] = saved[0]
284
+ Thread.current[SPANS_THREAD_KEY] = saved[1]
285
+ nil
286
+ end
287
+
197
288
  def __internal_on_response(response)
198
289
  request = @pending_request || {}
199
290
  @pending_request = nil
200
- __internal_emit(:http, {
291
+ http = __internal_emit(:http, {
201
292
  method: request[:method],
202
293
  path: request[:path] || path_of(response.url),
203
294
  query: request[:query],
@@ -206,6 +297,36 @@ module Dommy
206
297
  location: response.location_header,
207
298
  set_cookie: response.set_cookie_strings.map { |raw| cookie_name(raw) }
208
299
  })
300
+ __internal_flush_spans(http, request[:env])
301
+ end
302
+
303
+ # The request never produced a Response (the app raised): record the
304
+ # aborted request itself, with the spans the closed bracket left on the
305
+ # env, so the trace shows what ran before the exception.
306
+ def __internal_on_abort(env)
307
+ request = @pending_request || {}
308
+ @pending_request = nil
309
+ http = __internal_emit(:http, {
310
+ method: request[:method] || env["REQUEST_METHOD"],
311
+ path: request[:path] || env["PATH_INFO"],
312
+ query: request[:query],
313
+ status: nil,
314
+ aborted: true
315
+ })
316
+ __internal_flush_spans(http, request[:env] || env)
317
+ end
318
+
319
+ def __internal_flush_spans(http_event, env)
320
+ spans = env && env.delete(SPANS_ENV_KEY)
321
+ return if spans.nil? || spans.empty? || http_event.nil?
322
+
323
+ spans.each do |span|
324
+ payload = {kind: span[:kind], label: span[:label],
325
+ duration_ms: span[:duration_ms], parent: http_event.seq}
326
+ payload.merge!(span[:data]) if span[:data]
327
+ __internal_emit(:span, payload)
328
+ end
329
+ nil
209
330
  end
210
331
 
211
332
  def __internal_on_document(window)
@@ -219,10 +340,9 @@ module Dommy
219
340
  html = @session.document&.to_html
220
341
  return unless html
221
342
 
222
- event = __internal_emit(:artifact,
343
+ __internal_emit(:artifact,
223
344
  {kind: "dom_snapshot", label: "DOM #{@session.current_url}", content_type: "text/html"},
224
- window: window)
225
- @artifacts[event.seq] = html if event
345
+ window: window, artifact: html)
226
346
  end
227
347
 
228
348
  # --- DOM observation ---
@@ -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.11.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.11.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.11.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.11.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: