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 +4 -4
- data/CHANGELOG.md +10 -0
- data/lib/dommy/rack/history.rb +47 -10
- data/lib/dommy/rack/navigation.rb +4 -2
- data/lib/dommy/rack/session.rb +218 -13
- data/lib/dommy/rack/session_runtime.rb +7 -0
- data/lib/dommy/rack/url.rb +12 -0
- data/lib/dommy/rack/version.rb +1 -1
- data/lib/dommy/rack/web_socket_frame.rb +82 -0
- data/lib/dommy/rack/web_socket_transport.rb +205 -0
- data/lib/dommy/rack.rb +2 -0
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3cc970f04543db2ddf8d988cceaeacfc644fa3b04773655767e477512ba85deb
|
|
4
|
+
data.tar.gz: 5dd3d1215a44fd431b40fc62705c1ed8ef3d438b9321895c90980aa9cec574af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/dommy/rack/history.rb
CHANGED
|
@@ -2,43 +2,80 @@
|
|
|
2
2
|
|
|
3
3
|
module Dommy
|
|
4
4
|
module Rack
|
|
5
|
-
# Browser-tab-style navigation history:
|
|
6
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
data/lib/dommy/rack/session.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
278
|
-
@navigation.revisit(url) if url
|
|
336
|
+
traverse_history(:back)
|
|
279
337
|
end
|
|
280
338
|
|
|
281
339
|
def forward
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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|
|
data/lib/dommy/rack/url.rb
CHANGED
|
@@ -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
|
data/lib/dommy/rack/version.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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:
|
|
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: []
|