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 +4 -4
- data/CHANGELOG.md +22 -0
- data/lib/dommy/rack/history.rb +47 -10
- data/lib/dommy/rack/http_exchange.rb +26 -2
- data/lib/dommy/rack/navigation.rb +4 -2
- data/lib/dommy/rack/session.rb +282 -14
- data/lib/dommy/rack/session_runtime.rb +7 -0
- data/lib/dommy/rack/trace/formatter.rb +1 -0
- data/lib/dommy/rack/trace.rb +131 -11
- 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 +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 242882a8db41a841906a2292592dcf047941a3578e043cf9dc872d03b6649c77
|
|
4
|
+
data.tar.gz: 62adc178e7d8bffd1e4d923a13f75d130de05d12b953c43377c0d7dc48ee72c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
278
|
-
@navigation.revisit(url) if url
|
|
347
|
+
traverse_history(:back)
|
|
279
348
|
end
|
|
280
349
|
|
|
281
350
|
def forward
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
data/lib/dommy/rack/trace.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
186
|
-
#
|
|
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
|
-
|
|
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 ---
|
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.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.
|
|
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.
|
|
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:
|