@crouton-kit/capture 1.3.8 → 1.3.10
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.
- package/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
|
@@ -17,7 +17,7 @@ export interface BridgeRequest {
|
|
|
17
17
|
* domains (`Browser.*`, `Target.*`).
|
|
18
18
|
*/
|
|
19
19
|
targetId?: string;
|
|
20
|
-
/** CDP event name to
|
|
20
|
+
/** CDP event name to observe after this request is armed. Observation is future-only and scoped to this request's actual flattened target session (`undefined` without `targetId`); one event broadcasts to all already-armed same-key requests. */
|
|
21
21
|
waitEvent?: string;
|
|
22
22
|
timeoutMs?: number;
|
|
23
23
|
}
|
|
@@ -47,10 +47,26 @@ export type BridgeResponse = BridgeResponseOk | BridgeResponseErr;
|
|
|
47
47
|
// the plain bridge.
|
|
48
48
|
// ---------------------------------------------------------------------------
|
|
49
49
|
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* The three-way clock baseline the design's "Recorder timing model" requires to convert
|
|
52
|
+
* screencast-frame and Tracing timestamps into the authoritative `performance.now()` domain.
|
|
53
|
+
* `performanceNowMs`/`wallClockMs` are read synchronously when the recorder arms (`rec-start`);
|
|
54
|
+
* `firstScreencastTimestampSec`/`firstTraceEventTsUs` cannot be — no frame or trace batch has
|
|
55
|
+
* necessarily arrived yet at that instant — so they are filled in opportunistically as the
|
|
56
|
+
* recorder's own screencast/tracing handlers observe the first occurrence of each, and stay
|
|
57
|
+
* `null` (with `baselinesPending: true`) until then. `rec-stop`'s response carries the same
|
|
58
|
+
* object read again at stop time — the flush path a caller uses to get the completed baselines
|
|
59
|
+
* for `markers.json` even when `rec-start`'s own response returned it still pending.
|
|
60
|
+
*/
|
|
51
61
|
export interface RecorderClockBaselines {
|
|
52
62
|
performanceNowMs: number;
|
|
53
63
|
wallClockMs: number;
|
|
64
|
+
/** First `Page.screencastFrame`'s `metadata.timestamp` (wall-clock seconds), or `null` if none had arrived yet at the moment this snapshot was taken. */
|
|
65
|
+
firstScreencastTimestampSec: number | null;
|
|
66
|
+
/** First `Tracing.dataCollected` batch's earliest event `ts` (trace-clock microseconds), or `null` if none had arrived yet at the moment this snapshot was taken. */
|
|
67
|
+
firstTraceEventTsUs: number | null;
|
|
68
|
+
/** `true` while either timestamp above is still `null`. */
|
|
69
|
+
baselinesPending: boolean;
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
/**
|
|
@@ -58,10 +74,21 @@ export interface RecorderClockBaselines {
|
|
|
58
74
|
* domains, starts `Page.startScreencast` + `Tracing`, injects the
|
|
59
75
|
* Mutation/Resize/Performance observers, and captures the clock baseline.
|
|
60
76
|
* A recorder process only accepts one `rec-start` while `state==="idle"`.
|
|
77
|
+
*
|
|
78
|
+
* `nonce` (here and on every other recorder request shape) is the
|
|
79
|
+
* per-recording control-socket admission token: the recorder bridge process
|
|
80
|
+
* generates it at boot (64 lowercase hex chars, 256 bits) and hands it back
|
|
81
|
+
* to its starter over a private boot file; every request line must carry it
|
|
82
|
+
* verbatim, and the server constant-time-compares it BEFORE any handler
|
|
83
|
+
* runs. There is no unauthenticated request shape and no compatibility
|
|
84
|
+
* lane — a missing or mismatched nonce is answered `ok:false error:
|
|
85
|
+
* 'unauthorized'` with no side effects.
|
|
61
86
|
*/
|
|
62
87
|
export interface RecStartRequest {
|
|
63
88
|
reqId: number;
|
|
64
89
|
type: 'rec-start';
|
|
90
|
+
/** Per-recording control-socket admission token — see the doc comment above. */
|
|
91
|
+
nonce: string;
|
|
65
92
|
}
|
|
66
93
|
|
|
67
94
|
export interface RecStartResponseOk {
|
|
@@ -81,6 +108,8 @@ export interface RecStartResponseOk {
|
|
|
81
108
|
export interface RecStopRequest {
|
|
82
109
|
reqId: number;
|
|
83
110
|
type: 'rec-stop';
|
|
111
|
+
/** Per-recording control-socket admission token — see `RecStartRequest`. */
|
|
112
|
+
nonce: string;
|
|
84
113
|
}
|
|
85
114
|
|
|
86
115
|
export interface RecStopResponseOk {
|
|
@@ -90,6 +119,8 @@ export interface RecStopResponseOk {
|
|
|
90
119
|
frameCount: number;
|
|
91
120
|
eventCount: number;
|
|
92
121
|
durationMs: number;
|
|
122
|
+
/** The clock baselines re-read at stop time — the flush path for a `rec-start` response whose baselines were still pending. */
|
|
123
|
+
markers: RecorderClockBaselines;
|
|
93
124
|
}
|
|
94
125
|
|
|
95
126
|
/**
|
|
@@ -102,11 +133,15 @@ export interface RecStopResponseOk {
|
|
|
102
133
|
* timeline — this is the "marked CDP request message" the protocol
|
|
103
134
|
* supports. Omit `mark` for a plain passthrough call (still observed
|
|
104
135
|
* natively by the recorder's own event subscriptions, just not logged as a
|
|
105
|
-
* distinct input landmark).
|
|
136
|
+
* distinct input landmark). Omit `method` for a wait-event-only request;
|
|
137
|
+
* the bridge arms a future-only wait on the recorder tab websocket's
|
|
138
|
+
* unscoped event envelope and returns the matched event in one response.
|
|
106
139
|
*/
|
|
107
|
-
export interface
|
|
140
|
+
export interface RecCdpDispatchRequest {
|
|
108
141
|
reqId: number;
|
|
109
142
|
type: 'cdp';
|
|
143
|
+
/** Per-recording control-socket admission token — see `RecStartRequest`. */
|
|
144
|
+
nonce: string;
|
|
110
145
|
method: string;
|
|
111
146
|
params?: Record<string, unknown>;
|
|
112
147
|
mark?: string;
|
|
@@ -114,15 +149,66 @@ export interface RecCdpRequest {
|
|
|
114
149
|
timeoutMs?: number;
|
|
115
150
|
}
|
|
116
151
|
|
|
152
|
+
export interface RecCdpWaitEventRequest {
|
|
153
|
+
reqId: number;
|
|
154
|
+
type: 'cdp';
|
|
155
|
+
/** Per-recording control-socket admission token — see `RecStartRequest`. */
|
|
156
|
+
nonce: string;
|
|
157
|
+
method?: undefined;
|
|
158
|
+
waitEvent: string;
|
|
159
|
+
timeoutMs?: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type RecCdpRequest = RecCdpDispatchRequest | RecCdpWaitEventRequest;
|
|
163
|
+
|
|
117
164
|
export interface RecCdpResponseOk {
|
|
118
165
|
reqId: number;
|
|
119
166
|
ok: true;
|
|
120
167
|
type: 'cdp';
|
|
121
168
|
result?: unknown;
|
|
122
169
|
event?: unknown;
|
|
170
|
+
/**
|
|
171
|
+
* Present iff this request armed a `waitEvent` AND carried a `method`. It
|
|
172
|
+
* reports the load-wait outcome SEPARATELY from the method result: a method
|
|
173
|
+
* dispatch that succeeds is always `ok:true` with its `result` preserved,
|
|
174
|
+
* regardless of whether the paired wait observed its event
|
|
175
|
+
* (`'observed'`, with `event` set) or the wait's own bounded timer elapsed
|
|
176
|
+
* first (`'bounded-timeout'`, no `event`). A wait-event-ONLY request (no
|
|
177
|
+
* `method`) whose wait times out is still `ok:false` — that path has no
|
|
178
|
+
* method result to preserve, so its timeout is a genuine failure.
|
|
179
|
+
*/
|
|
180
|
+
waitOutcome?: 'observed' | 'bounded-timeout';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The action-return HAR flush/health barrier. A recorder-routed page action
|
|
185
|
+
* (`click`, `type`, `scroll`, `exec`, `navigate`) sends this after its settle
|
|
186
|
+
* window and before claiming success, so every HAR entry/body/append the
|
|
187
|
+
* recorder's streaming collector had already completed by then is durably in
|
|
188
|
+
* the owning session's live HAR first. Deliberately NON-attributing: the
|
|
189
|
+
* response reports store health only — `ok:true` means "all work completed at
|
|
190
|
+
* request time has appended and no fatal store error is latched"; it never
|
|
191
|
+
* says which entries belong to which action and carries no counts (the
|
|
192
|
+
* collector cannot truthfully attribute concurrent traffic to one action).
|
|
193
|
+
* Once the terminal `rec-stop` latch is claimed, HAR finalization belongs to
|
|
194
|
+
* that one owner and a later `har-flush` is answered with a deterministic
|
|
195
|
+
* `ok:false` rejection instead of sharing or re-driving the drain.
|
|
196
|
+
*/
|
|
197
|
+
export interface RecHarFlushRequest {
|
|
198
|
+
reqId: number;
|
|
199
|
+
type: 'har-flush';
|
|
200
|
+
/** Per-recording control-socket admission token — see `RecStartRequest`. */
|
|
201
|
+
nonce: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Health-only: success carries no result fields beyond the envelope — see `RecHarFlushRequest`. */
|
|
205
|
+
export interface RecHarFlushResponseOk {
|
|
206
|
+
reqId: number;
|
|
207
|
+
ok: true;
|
|
208
|
+
type: 'har-flush';
|
|
123
209
|
}
|
|
124
210
|
|
|
125
|
-
export type RecorderRequest = RecStartRequest | RecStopRequest | RecCdpRequest;
|
|
211
|
+
export type RecorderRequest = RecStartRequest | RecStopRequest | RecCdpRequest | RecHarFlushRequest;
|
|
126
212
|
|
|
127
213
|
export interface RecorderResponseErr {
|
|
128
214
|
reqId: number;
|
|
@@ -134,4 +220,5 @@ export interface RecorderResponseErr {
|
|
|
134
220
|
export type RecStartResponse = RecStartResponseOk | RecorderResponseErr;
|
|
135
221
|
export type RecStopResponse = RecStopResponseOk | RecorderResponseErr;
|
|
136
222
|
export type RecCdpResponse = RecCdpResponseOk | RecorderResponseErr;
|
|
137
|
-
export type
|
|
223
|
+
export type RecHarFlushResponse = RecHarFlushResponseOk | RecorderResponseErr;
|
|
224
|
+
export type RecorderResponse = RecStartResponse | RecStopResponse | RecCdpResponse | RecHarFlushResponse;
|
package/src/cdp/bridge/server.ts
CHANGED
|
@@ -19,20 +19,30 @@ import { type CDPClient } from '../client.js';
|
|
|
19
19
|
import { type BridgeRequest, type BridgeResponse } from './protocol.js';
|
|
20
20
|
|
|
21
21
|
interface EventWaiter {
|
|
22
|
-
resolve: (
|
|
22
|
+
resolve: (value: unknown) => void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface EventWait {
|
|
26
|
+
/** Returns the event or throws its timeout. The broker observes timeouts internally until this is called, so a slow triggering method cannot create an unhandled rejection. */
|
|
27
|
+
result(): Promise<unknown>;
|
|
28
|
+
/** Removes and settles this waiter without affecting any other waiter. */
|
|
29
|
+
cancel(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type EventWaitOutcome =
|
|
33
|
+
| { kind: 'event'; value: unknown }
|
|
34
|
+
| { kind: 'timeout'; error: Error }
|
|
35
|
+
| { kind: 'cancelled' };
|
|
36
|
+
|
|
25
37
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `waitEvent`-bearing requests.
|
|
38
|
+
* Observes future CDP events on one long-lived client. Waiters are scoped by
|
|
39
|
+
* the event envelope's actual flattened session id (including `undefined`
|
|
40
|
+
* for an unscoped event). An event is dropped when no exact-key waiter is
|
|
41
|
+
* armed, and otherwise broadcasts to every waiter already armed for that
|
|
42
|
+
* key. The broker never retains event history.
|
|
32
43
|
*/
|
|
33
44
|
export class EventBroker {
|
|
34
|
-
private
|
|
35
|
-
private waiters = new Map<string, EventWaiter[]>();
|
|
45
|
+
private waiters = new Map<string, Map<string | undefined, Set<EventWaiter>>>();
|
|
36
46
|
private listening = new Set<string>();
|
|
37
47
|
|
|
38
48
|
constructor(private client: Pick<CDPClient, 'on'>) {}
|
|
@@ -40,45 +50,109 @@ export class EventBroker {
|
|
|
40
50
|
private ensureListening(eventName: string): void {
|
|
41
51
|
if (this.listening.has(eventName)) return;
|
|
42
52
|
this.listening.add(eventName);
|
|
43
|
-
this.client.on(eventName, (params) => {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
53
|
+
this.client.on(eventName, (params, actualSessionId) => {
|
|
54
|
+
const bySession = this.waiters.get(eventName);
|
|
55
|
+
const matching = bySession?.get(actualSessionId);
|
|
56
|
+
if (!matching || matching.size === 0) return;
|
|
57
|
+
|
|
58
|
+
// Remove the exact bucket before resolving its snapshot. A waiter armed
|
|
59
|
+
// by a resolution continuation is therefore future-only and cannot
|
|
60
|
+
// observe the event currently being broadcast.
|
|
61
|
+
bySession!.delete(actualSessionId);
|
|
62
|
+
if (bySession!.size === 0) this.waiters.delete(eventName);
|
|
63
|
+
for (const waiter of matching) waiter.resolve(params);
|
|
54
64
|
});
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
wait(eventName: string, timeoutMs: number):
|
|
67
|
+
wait(eventName: string, sessionId: string | undefined, timeoutMs: number): EventWait {
|
|
58
68
|
this.ensureListening(eventName);
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
69
|
+
let bySession = this.waiters.get(eventName);
|
|
70
|
+
if (!bySession) {
|
|
71
|
+
bySession = new Map();
|
|
72
|
+
this.waiters.set(eventName, bySession);
|
|
62
73
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const current = this.waiters.get(eventName);
|
|
73
|
-
if (current) {
|
|
74
|
-
const idx = current.indexOf(entry);
|
|
75
|
-
if (idx >= 0) current.splice(idx, 1);
|
|
76
|
-
}
|
|
77
|
-
reject(new Error(`Timed out after ${timeoutMs}ms waiting for event "${eventName}"`));
|
|
78
|
-
}, timeoutMs);
|
|
79
|
-
list.push(entry);
|
|
80
|
-
this.waiters.set(eventName, list);
|
|
74
|
+
let matching = bySession.get(sessionId);
|
|
75
|
+
if (!matching) {
|
|
76
|
+
matching = new Set();
|
|
77
|
+
bySession.set(sessionId, matching);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let settleOutcome!: (outcome: EventWaitOutcome) => void;
|
|
81
|
+
const outcome = new Promise<EventWaitOutcome>((resolve) => {
|
|
82
|
+
settleOutcome = resolve;
|
|
81
83
|
});
|
|
84
|
+
let settled = false;
|
|
85
|
+
const removeOnlyThisWaiter = (): void => {
|
|
86
|
+
const currentBySession = this.waiters.get(eventName);
|
|
87
|
+
const current = currentBySession?.get(sessionId);
|
|
88
|
+
if (!current) return;
|
|
89
|
+
current.delete(waiter);
|
|
90
|
+
if (current.size === 0) currentBySession!.delete(sessionId);
|
|
91
|
+
if (currentBySession!.size === 0) this.waiters.delete(eventName);
|
|
92
|
+
};
|
|
93
|
+
const waiter: EventWaiter = {
|
|
94
|
+
resolve: (value) => {
|
|
95
|
+
if (settled) return;
|
|
96
|
+
settled = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
settleOutcome({ kind: 'event', value });
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
matching.add(waiter);
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
if (settled) return;
|
|
104
|
+
settled = true;
|
|
105
|
+
removeOnlyThisWaiter();
|
|
106
|
+
settleOutcome({
|
|
107
|
+
kind: 'timeout',
|
|
108
|
+
error: new Error(
|
|
109
|
+
`Timed out after ${timeoutMs}ms waiting for event "${eventName}"` +
|
|
110
|
+
(sessionId === undefined ? ' without a session' : ` in session "${sessionId}"`),
|
|
111
|
+
),
|
|
112
|
+
});
|
|
113
|
+
}, timeoutMs);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
result: async () => {
|
|
117
|
+
const settledOutcome = await outcome;
|
|
118
|
+
if (settledOutcome.kind === 'timeout') throw settledOutcome.error;
|
|
119
|
+
return settledOutcome.kind === 'event' ? settledOutcome.value : undefined;
|
|
120
|
+
},
|
|
121
|
+
cancel: () => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
settled = true;
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
removeOnlyThisWaiter();
|
|
126
|
+
settleOutcome({ kind: 'cancelled' });
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function handleBridgeRequest(
|
|
133
|
+
req: BridgeRequest,
|
|
134
|
+
client: Pick<CDPClient, 'send'>,
|
|
135
|
+
events: EventBroker,
|
|
136
|
+
attach: (targetId: string) => Promise<string>,
|
|
137
|
+
): Promise<BridgeResponse> {
|
|
138
|
+
let eventWait: EventWait | undefined;
|
|
139
|
+
try {
|
|
140
|
+
const sessionId = req.targetId ? await attach(req.targetId) : undefined;
|
|
141
|
+
eventWait = req.waitEvent
|
|
142
|
+
? events.wait(req.waitEvent, sessionId, req.timeoutMs ?? 10000)
|
|
143
|
+
: undefined;
|
|
144
|
+
const result = req.method
|
|
145
|
+
? await client.send(req.method, req.params ?? {}, 60000, sessionId)
|
|
146
|
+
: undefined;
|
|
147
|
+
const event = eventWait ? await eventWait.result() : undefined;
|
|
148
|
+
return { reqId: req.reqId, ok: true, result, event };
|
|
149
|
+
} catch (err) {
|
|
150
|
+
eventWait?.cancel();
|
|
151
|
+
return {
|
|
152
|
+
reqId: req.reqId,
|
|
153
|
+
ok: false,
|
|
154
|
+
error: err instanceof Error ? err.message : String(err),
|
|
155
|
+
};
|
|
82
156
|
}
|
|
83
157
|
}
|
|
84
158
|
|
|
@@ -99,7 +173,7 @@ export async function runBridgeServer(socketPath: string, port?: number): Promis
|
|
|
99
173
|
// requiring the full 32-char target id here.
|
|
100
174
|
const tab = await findTabById(resolvedPort, targetId);
|
|
101
175
|
if (!tab) {
|
|
102
|
-
throw new Error(`No target found for "${targetId}" on port ${resolvedPort}. Run "capture list" to see available tabs.`);
|
|
176
|
+
throw new Error(`No target found for "${targetId}" on port ${resolvedPort}. Run "capture tab list" to see available tabs.`);
|
|
103
177
|
}
|
|
104
178
|
const result = (await client.send('Target.attachToTarget', { targetId: tab.id, flatten: true })) as {
|
|
105
179
|
sessionId: string;
|
|
@@ -115,16 +189,7 @@ export async function runBridgeServer(socketPath: string, port?: number): Promis
|
|
|
115
189
|
} catch {
|
|
116
190
|
return;
|
|
117
191
|
}
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
const sessionId = req.targetId ? await attach(req.targetId) : undefined;
|
|
121
|
-
const eventPromise = req.waitEvent ? events.wait(req.waitEvent, req.timeoutMs ?? 10000) : undefined;
|
|
122
|
-
const result = req.method ? await client.send(req.method, req.params ?? {}, 60000, sessionId) : undefined;
|
|
123
|
-
const event = eventPromise ? await eventPromise : undefined;
|
|
124
|
-
resp = { reqId: req.reqId, ok: true, result, event };
|
|
125
|
-
} catch (err) {
|
|
126
|
-
resp = { reqId: req.reqId, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
127
|
-
}
|
|
192
|
+
const resp = await handleBridgeRequest(req, client, events, attach);
|
|
128
193
|
socket.write(JSON.stringify(resp) + '\n');
|
|
129
194
|
}
|
|
130
195
|
|
package/src/cdp/bridge/spawn.ts
CHANGED
|
@@ -5,16 +5,45 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { spawn } from 'child_process';
|
|
8
|
+
import * as crypto from 'crypto';
|
|
8
9
|
import * as fs from 'fs';
|
|
9
10
|
import * as path from 'path';
|
|
11
|
+
import { CAPTURE_ROOT, ensurePrivateDir } from '../../session/artifacts.js';
|
|
10
12
|
|
|
11
13
|
export function bridgeSocketPath(sessionDir: string): string {
|
|
12
14
|
return path.join(sessionDir, 'bridge.sock');
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Fixed, short, private (`0700`) directory every recorder socket lives in — flat, and
|
|
19
|
+
* deliberately NOT nested under a recording's (long) artifact dir. macOS caps `AF_UNIX`
|
|
20
|
+
* pathnames at ~104 bytes; a real recording dir (`{CAPTURE_ROOT}/{session}/motion/recs/{recId}`)
|
|
21
|
+
* combined with `os.tmpdir()`'s own (often long, per-user) prefix can already exceed that before
|
|
22
|
+
* adding a filename, so the socket can never live inside `recDir` — see `recorderSocketPath()`.
|
|
23
|
+
*/
|
|
24
|
+
function recorderSocketDir(): string {
|
|
25
|
+
return ensurePrivateDir(path.join(CAPTURE_ROOT, 'sock'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A short, fixed-length filename deterministically derived from `recDir` (not from `recDir`'s
|
|
30
|
+
* own length/depth), so the resulting socket path stays bounded regardless of how deep the
|
|
31
|
+
* recording's session/motion/recs nesting is.
|
|
32
|
+
*/
|
|
33
|
+
function shortSocketName(recDir: string): string {
|
|
34
|
+
return crypto.createHash('sha1').update(path.resolve(recDir)).digest('hex').slice(0, 16);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The recorder bridge's own socket path. Lives in the short, flat `recorderSocketDir()` — NOT
|
|
39
|
+
* inside `recDir` (the long, deep artifact directory `frames`/`events.jsonl`/etc. write into) —
|
|
40
|
+
* so binding it never risks exceeding the platform's `AF_UNIX` pathname limit. `recDir` is still
|
|
41
|
+
* required by every recorder-mode caller (spawn args, artifact writers); this function only
|
|
42
|
+
* derives the socket's own location from it, deterministically, without embedding `recDir` in
|
|
43
|
+
* the path itself.
|
|
44
|
+
*/
|
|
16
45
|
export function recorderSocketPath(recDir: string): string {
|
|
17
|
-
return path.join(
|
|
46
|
+
return path.join(recorderSocketDir(), `${shortSocketName(recDir)}.sock`);
|
|
18
47
|
}
|
|
19
48
|
|
|
20
49
|
export async function startBridge(
|
|
@@ -41,9 +70,13 @@ export async function startBridge(
|
|
|
41
70
|
const start = Date.now();
|
|
42
71
|
while (!fs.existsSync(socketPath)) {
|
|
43
72
|
if (Date.now() - start > timeoutMs) {
|
|
73
|
+
// Own the child we spawned: a bridge that never signalled readiness is
|
|
74
|
+
// ours to reap before we reject, so no orphaned process outlives the
|
|
75
|
+
// failed start. Mirrors startRecorderBridge()'s ownership discipline.
|
|
76
|
+
stopBridge(pid, socketPath);
|
|
44
77
|
throw new Error(
|
|
45
78
|
`CDP bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
|
|
46
|
-
`Check that a browser is reachable on port ${port} (capture
|
|
79
|
+
`Check that a browser is reachable on port ${port} (capture tab list --port ${port}).`,
|
|
47
80
|
);
|
|
48
81
|
}
|
|
49
82
|
await new Promise((r) => setTimeout(r, 50));
|
|
@@ -78,15 +111,27 @@ export function stopBridge(pid: number | null | undefined, socketPath: string |
|
|
|
78
111
|
* (U14's lifecycle routing) tears it down with the same `stopBridge()`
|
|
79
112
|
* used for the plain held bridge.
|
|
80
113
|
*
|
|
81
|
-
* Recorder mode is selected via a positional (`recorder <recDir>`)
|
|
82
|
-
* than a new flag — `capture`'s CLI arg parser (`src/cdp/args.ts`) is
|
|
83
|
-
* owned by this unit, but `positional` is already generic passthrough.
|
|
114
|
+
* Recorder mode is selected via a positional (`recorder <recDir> <harId>`)
|
|
115
|
+
* rather than a new flag — `capture`'s CLI arg parser (`src/cdp/args.ts`) is
|
|
116
|
+
* not owned by this unit, but `positional` is already generic passthrough.
|
|
117
|
+
*
|
|
118
|
+
* `harId` is the owning session's live HAR recording id (`.session.json`'s
|
|
119
|
+
* `harId`) — the recorder bridge installs a streaming `HARRecorder` on its
|
|
120
|
+
* held tab connection and appends network evidence to that store, so the id
|
|
121
|
+
* must travel to the spawned process. Required: a session-backed recorder
|
|
122
|
+
* always has one (the caller throws before spawning if it doesn't).
|
|
123
|
+
*
|
|
124
|
+
* The socket-file readiness poll below doubles as the nonce-boot-file
|
|
125
|
+
* guarantee: the recorder writes `{recDir}/recorder-nonce.json` BEFORE it
|
|
126
|
+
* binds the socket (see `../recorder-bridge.ts`), so once this resolves the
|
|
127
|
+
* caller can read the nonce without racing the child.
|
|
84
128
|
*/
|
|
85
129
|
export async function startRecorderBridge(
|
|
86
130
|
socketPath: string,
|
|
87
131
|
port: number,
|
|
88
132
|
targetId: string,
|
|
89
133
|
recDir: string,
|
|
134
|
+
harId: string,
|
|
90
135
|
timeoutMs = 5000,
|
|
91
136
|
): Promise<{ socketPath: string; pid: number }> {
|
|
92
137
|
const scriptPath = process.argv[1];
|
|
@@ -103,6 +148,7 @@ export async function startRecorderBridge(
|
|
|
103
148
|
targetId,
|
|
104
149
|
'recorder',
|
|
105
150
|
recDir,
|
|
151
|
+
harId,
|
|
106
152
|
],
|
|
107
153
|
{ detached: true, stdio: 'ignore' },
|
|
108
154
|
);
|
|
@@ -115,6 +161,7 @@ export async function startRecorderBridge(
|
|
|
115
161
|
const start = Date.now();
|
|
116
162
|
while (!fs.existsSync(socketPath)) {
|
|
117
163
|
if (Date.now() - start > timeoutMs) {
|
|
164
|
+
stopBridge(pid, socketPath);
|
|
118
165
|
throw new Error(
|
|
119
166
|
`Recorder bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
|
|
120
167
|
`Check that target "${targetId}" is reachable on port ${port}.`,
|
package/src/cdp/client.ts
CHANGED
|
@@ -8,7 +8,7 @@ export class CDPClient {
|
|
|
8
8
|
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
|
|
9
9
|
>();
|
|
10
10
|
private pendingTimeouts = new Map<number, NodeJS.Timeout>();
|
|
11
|
-
private eventHandlers = new Map<string, Array<(params: unknown) => void>>();
|
|
11
|
+
private eventHandlers = new Map<string, Array<(params: unknown, sessionId?: string) => void>>();
|
|
12
12
|
private ready: Promise<void>;
|
|
13
13
|
|
|
14
14
|
constructor(wsUrl: string, connectTimeout = 5000) {
|
|
@@ -47,7 +47,7 @@ export class CDPClient {
|
|
|
47
47
|
}
|
|
48
48
|
} else if (msg.method) {
|
|
49
49
|
const handlers = this.eventHandlers.get(msg.method);
|
|
50
|
-
handlers?.forEach((
|
|
50
|
+
handlers?.forEach((handler) => handler(msg.params, msg.sessionId));
|
|
51
51
|
}
|
|
52
52
|
});
|
|
53
53
|
}
|
|
@@ -80,13 +80,23 @@ export class CDPClient {
|
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
/** Registers an event callback. The second argument is the actual optional `sessionId` from a flattened CDP event envelope; it is never inferred from the method or params. */
|
|
84
|
+
on(event: string, handler: (params: unknown, sessionId?: string) => void): void {
|
|
84
85
|
if (!this.eventHandlers.has(event)) {
|
|
85
86
|
this.eventHandlers.set(event, []);
|
|
86
87
|
}
|
|
87
88
|
this.eventHandlers.get(event)!.push(handler);
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/** Unregisters a handler previously added with {@link on}. Required so one-shot event consumers (e.g. the `LayerTree.layerTreeDidChange` collector) can remove their listener on settle/timeout instead of leaking a retained closure for the connection's lifetime. */
|
|
92
|
+
off(event: string, handler: (params: unknown, sessionId?: string) => void): void {
|
|
93
|
+
const handlers = this.eventHandlers.get(event);
|
|
94
|
+
if (!handlers) return;
|
|
95
|
+
const index = handlers.indexOf(handler);
|
|
96
|
+
if (index !== -1) handlers.splice(index, 1);
|
|
97
|
+
if (handlers.length === 0) this.eventHandlers.delete(event);
|
|
98
|
+
}
|
|
99
|
+
|
|
90
100
|
/** Fires when the underlying websocket goes away (browser closed, crashed, CDP endpoint dropped). */
|
|
91
101
|
onDisconnect(handler: () => void): void {
|
|
92
102
|
this.ws.on('close', handler);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runBridgeServer } from '../bridge/server.js';
|
|
2
2
|
import { runRecorderBridge } from '../recorder-bridge.js';
|
|
3
|
+
import { invalidInput } from '../../errors.js';
|
|
3
4
|
import { type ParsedArgs } from '../types.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -8,31 +9,36 @@ import { type ParsedArgs } from '../types.js';
|
|
|
8
9
|
* --start` in recorder mode (`startRecorderBridge()`). Not documented in
|
|
9
10
|
* `capture --help` — nothing calls this directly.
|
|
10
11
|
*
|
|
11
|
-
* Recorder mode is selected by a `recorder <recDir>` positional
|
|
12
|
-
* than a new flag, since `src/cdp/args.ts`'s fixed flag set isn't
|
|
13
|
-
* this unit but `parsed.positional` is already generic passthrough:
|
|
14
|
-
* capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir>
|
|
12
|
+
* Recorder mode is selected by a `recorder <recDir> <harId>` positional
|
|
13
|
+
* rather than a new flag, since `src/cdp/args.ts`'s fixed flag set isn't
|
|
14
|
+
* owned by this unit but `parsed.positional` is already generic passthrough:
|
|
15
|
+
* capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir> <harId>
|
|
16
|
+
*
|
|
17
|
+
* Usage errors throw the repo's structured `CaptureError` — the root
|
|
18
|
+
* boundary in `src/capture.ts` renders it via `failureResult()` and exits
|
|
19
|
+
* nonzero, same as every other command leaf.
|
|
15
20
|
*/
|
|
16
21
|
export async function cmdBridgeServe(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
17
22
|
if (!parsed.socket) {
|
|
18
|
-
|
|
19
|
-
'Usage: capture __bridge-serve --socket <path> [--port <cdpPort>] [--target <tabId> recorder <recDir>]',
|
|
23
|
+
throw invalidInput(
|
|
24
|
+
'Usage: capture __bridge-serve --socket <path> [--port <cdpPort>] [--target <tabId> recorder <recDir> <harId>]',
|
|
25
|
+
'bridge_serve_usage',
|
|
20
26
|
);
|
|
21
|
-
process.exit(1);
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
if (parsed.positional[0] === 'recorder') {
|
|
25
30
|
const recDir = parsed.positional[1];
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
const harId = parsed.positional[2];
|
|
32
|
+
if (!parsed.target || !recDir || !harId) {
|
|
33
|
+
throw invalidInput(
|
|
34
|
+
'Usage: capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir> <harId>',
|
|
35
|
+
'bridge_serve_usage',
|
|
29
36
|
);
|
|
30
|
-
process.exit(1);
|
|
31
37
|
}
|
|
32
|
-
await runRecorderBridge({ socketPath: parsed.socket, port: parsed.port, targetId: parsed.target, recDir });
|
|
38
|
+
await runRecorderBridge({ socketPath: parsed.socket, port: parsed.port, targetId: parsed.target, recDir, harId });
|
|
33
39
|
// Deliberately does not exit: the open Unix socket server and the live
|
|
34
|
-
// tab websocket keep this detached process alive until
|
|
35
|
-
//
|
|
40
|
+
// tab websocket keep this detached process alive until an authenticated
|
|
41
|
+
// `rec-stop` completes (self-exit) or the caller escalates with SIGTERM.
|
|
36
42
|
return;
|
|
37
43
|
}
|
|
38
44
|
|