@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.
Files changed (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -1,147 +1,680 @@
1
- import { type HAREntry } from '../har-manager.js';
1
+ import {
2
+ type BodyProvenance,
3
+ type HarAppendBatch,
4
+ type HarFile,
5
+ type HAREntry,
6
+ type HarRequest,
7
+ type HarResponse,
8
+ type Header,
9
+ type IncompleteLifecycle,
10
+ type WebSocketHAREntry,
11
+ type WebSocketMessage,
12
+ validateHarAppendBatch,
13
+ validateHarFile,
14
+ } from '../har-manager.js';
2
15
  import { type CDPClient } from './client.js';
3
16
 
4
- interface NetworkRequest {
17
+ const MAX_BODY_SIZE = 256 * 1024;
18
+
19
+ type Terminal =
20
+ | { kind: 'finished'; timestamp: number; encodedDataLength: number }
21
+ | { kind: 'redirect'; timestamp: number }
22
+ | { kind: 'failed'; timestamp: number; errorText: string; canceled: boolean; blockedReason: string | null; resourceType: string | null };
23
+ type ObservedResponse = { status: number; headers: Header[]; timestamp: number };
24
+ type RedirectResponse = { status: number; headers: Header[] };
25
+ type Generation = {
26
+ key: string;
27
+ order: number;
5
28
  requestId: string;
6
- url: string;
7
- method: string;
8
- headers: Record<string, string>;
9
- postData?: string;
10
- timestamp: number;
29
+ generation: number;
30
+ startedDateTime: string;
31
+ requestWallTime: number;
32
+ requestMonotonic: number;
33
+ request: HarRequest;
34
+ response?: ObservedResponse;
35
+ redirectResponse?: RedirectResponse;
36
+ terminal?: Terminal;
37
+ bodyPending: boolean;
38
+ };
39
+ type Materialized = { order: number; value: HAREntry | IncompleteLifecycle };
40
+
41
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
42
+ return value !== null && typeof value === 'object' && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
11
43
  }
12
44
 
13
- interface NetworkResponse {
45
+ // ── WebSocket capture (DevTools convention) ────────────────────────────────────
46
+
47
+ const MAX_WS_FRAMES = 200; // per socket; further frames are counted, not stored
48
+ const MAX_WS_FRAME_SIZE = 4 * 1024; // 4KB per frame payload in HAR
49
+
50
+ type WebSocketConnection = {
14
51
  requestId: string;
15
52
  url: string;
16
- status: number;
17
- headers: Record<string, string>;
18
- body?: string;
19
- timestamp: number;
53
+ wallTime?: number;
54
+ monotonicTime?: number;
55
+ requestHeaders: Header[];
56
+ status?: number;
57
+ responseHeaders: Header[];
58
+ messages: WebSocketMessage[];
59
+ droppedFrames: number;
60
+ };
61
+
62
+ function truncateWebSocketData(data: string): string {
63
+ const size = Buffer.byteLength(data, 'utf8');
64
+ if (size <= MAX_WS_FRAME_SIZE) return data;
65
+ let end = Math.min(data.length, MAX_WS_FRAME_SIZE);
66
+ while (end > 0 && Buffer.byteLength(data.slice(0, end), 'utf8') > MAX_WS_FRAME_SIZE) end--;
67
+ return data.slice(0, end) + `…[truncated: ${size} bytes]`;
68
+ }
69
+
70
+ function fail(message: string): never {
71
+ throw new Error(`Malformed owned Network event: ${message}`);
72
+ }
73
+
74
+ function nonempty(value: unknown, name: string): string {
75
+ if (typeof value !== 'string' || value.length === 0) fail(name);
76
+ return value;
77
+ }
78
+
79
+ function finite(value: unknown, name: string): number {
80
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) fail(name);
81
+ return value;
20
82
  }
21
83
 
22
- // HAREntry imported from ../har-manager.ts
84
+ function headers(value: unknown, name: string): Header[] {
85
+ if (!isPlainObject(value)) fail(name);
86
+ return Object.entries(value).map(([headerName, headerValue]) => {
87
+ if (typeof headerValue !== 'string') fail(`${name}.${headerName}`);
88
+ return { name: headerName, value: headerValue };
89
+ });
90
+ }
91
+
92
+ function wallTime(value: unknown): { seconds: number; iso: string } {
93
+ if (typeof value !== 'number' || !Number.isFinite(value)) fail('wallTime');
94
+ try {
95
+ return { seconds: value, iso: new Date(value * 1000).toISOString() };
96
+ } catch {
97
+ fail('wallTime is outside the ISO date range');
98
+ }
99
+ }
100
+
101
+ function captureVersion(): string {
102
+ const version = (globalThis as { __CAPTURE_VERSION__?: unknown }).__CAPTURE_VERSION__;
103
+ return typeof version === 'string' && version.length > 0 ? version : 'development';
104
+ }
23
105
 
24
- const SKIP_EXTENSIONS =
25
- /\.(png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|eot|css|mp4|webm|mp3)(\?|$)/i;
26
- const SKIP_DOMAINS =
27
- /(google-analytics|googletagmanager|doubleclick|facebook\.com\/tr|px\.ads|analytics|tracking|beacon|telemetry)/i;
28
- const MAX_BODY_SIZE = 256 * 1024; // 256KB per response body in HAR
106
+ function deepFreeze<T>(value: T): T {
107
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
108
+ Object.freeze(value);
109
+ for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
110
+ }
111
+ return value;
112
+ }
113
+
114
+ function same(a: unknown, b: unknown): boolean {
115
+ return JSON.stringify(a) === JSON.stringify(b);
116
+ }
117
+
118
+ function textPrefix(text: string): { text: string; bytes: number } {
119
+ let bytes = 0;
120
+ let end = 0;
121
+ for (const scalar of text) {
122
+ const size = Buffer.byteLength(scalar, 'utf8');
123
+ if (bytes + size > MAX_BODY_SIZE) break;
124
+ bytes += size;
125
+ end += scalar.length;
126
+ }
127
+ return { text: text.slice(0, end), bytes };
128
+ }
29
129
 
30
- function shouldRecordRequest(url: string): boolean {
31
- if (SKIP_EXTENSIONS.test(url)) return false;
32
- if (SKIP_DOMAINS.test(url)) return false;
33
- return true;
130
+ function strictBase64(text: string): Buffer {
131
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text)) {
132
+ throw new Error('Network.getResponseBody returned malformed base64');
133
+ }
134
+ return Buffer.from(text, 'base64');
34
135
  }
35
136
 
137
+ /**
138
+ * A streaming recorder's append authority: receives each materialized value
139
+ * exactly once as a single-value validated batch. The recorder serializes
140
+ * calls (never two in flight) and latches the first rejection as its fatal
141
+ * store error — it never retries or re-emits a batch.
142
+ */
143
+ export type HarSink = (batch: HarAppendBatch) => Promise<void>;
144
+
145
+ /**
146
+ * Assembles validated Network events into HAR evidence. It owns no file or
147
+ * bridge lifecycle, and admits every request — there is no URL, extension, or
148
+ * domain filtering.
149
+ *
150
+ * Two exclusive modes, chosen at construction:
151
+ * - **Snapshot** (no sink): values accumulate internally; `finish()` /
152
+ * `finishPartial()` return one immutable validated `HarFile`.
153
+ * - **Streaming** (sink provided): each completed entry or incomplete
154
+ * lifecycle is emitted exactly once, in completion order, through the sink;
155
+ * nothing accumulates. `flush()` is the health/completion barrier and
156
+ * `drain()` is the finalizer whose first synchronous line cuts admission.
157
+ */
36
158
  export class HARRecorder {
37
- private requests = new Map<string, NetworkRequest>();
38
- private responses = new Map<string, NetworkResponse>();
159
+ private phase: 'new' | 'starting' | 'recording' | 'finalizing' | 'finalized' | 'failed' = 'new';
160
+ private admissionOpen = false;
161
+ private readonly nextGenerationByRequestId = new Map<string, number>();
162
+ private readonly activeByRequestId = new Map<string, Generation>();
163
+ /** Exactly-once guard for both modes: a key is recorded at most once. */
164
+ private readonly recordedKeys = new Set<string>();
165
+ /** Snapshot mode only — streaming mode retains no materialized values. */
166
+ private readonly materializedByKey = new Map<string, Materialized>();
167
+ /** Last terminal remains comparable until this CDP id opens its next generation. */
168
+ private readonly closedTerminals = new Map<string, Terminal>();
169
+ /** Sockets opened while this recorder is attached — CDP does not replay creation events for pre-existing sockets. */
170
+ private readonly webSockets = new Map<string, WebSocketConnection>();
171
+ private readonly bodyTasks = new Set<Promise<void>>();
172
+ private nextOrder = 0;
173
+ private entryCount = 0;
174
+ private fatalError: Error | null = null;
175
+ private finalResult: HarFile | null = null;
176
+ private finalizing: Promise<HarFile> | null = null;
177
+ /** Streaming mode: serialized sink appends, in emission order. */
178
+ private sinkChain: Promise<void> = Promise.resolve();
179
+ private draining: Promise<void> | null = null;
39
180
 
40
- constructor(private client: CDPClient) {}
181
+ constructor(
182
+ private readonly client: CDPClient,
183
+ private readonly sink?: HarSink,
184
+ ) {}
41
185
 
42
186
  get responseCount(): number {
43
- return this.responses.size;
187
+ return this.entryCount;
44
188
  }
45
189
 
46
190
  async start(): Promise<void> {
47
- await this.client.send('Network.enable');
48
-
49
- this.client.on('Network.requestWillBeSent', (params: unknown) => {
50
- const p = params as {
51
- requestId: string;
52
- request: {
53
- url: string;
54
- method: string;
55
- headers: Record<string, string>;
56
- postData?: string;
57
- };
58
- timestamp: number;
59
- };
60
- if (!shouldRecordRequest(p.request.url)) return;
61
- this.requests.set(p.requestId, {
62
- requestId: p.requestId,
63
- url: p.request.url,
64
- method: p.request.method,
65
- headers: p.request.headers,
66
- postData: p.request.postData,
67
- timestamp: p.timestamp,
68
- });
191
+ if (this.phase !== 'new') throw new Error('HAR recorder has already been started');
192
+ this.phase = 'starting';
193
+ try {
194
+ await this.client.send('Network.enable');
195
+ if (this.phase !== 'starting') return;
196
+ this.client.on('Network.requestWillBeSent', (params) => this.admit(() => this.onRequest(params)));
197
+ this.client.on('Network.responseReceived', (params) => this.admit(() => this.onResponse(params)));
198
+ this.client.on('Network.loadingFinished', (params) => this.admit(() => this.onFinished(params)));
199
+ this.client.on('Network.loadingFailed', (params) => this.admit(() => this.onFailed(params)));
200
+ this.client.on('Network.webSocketCreated', (params) => this.admit(() => this.onWebSocketCreated(params)));
201
+ this.client.on('Network.webSocketWillSendHandshakeRequest', (params) => this.admit(() => this.onWebSocketHandshakeRequest(params)));
202
+ this.client.on('Network.webSocketHandshakeResponseReceived', (params) => this.admit(() => this.onWebSocketHandshakeResponse(params)));
203
+ this.client.on('Network.webSocketFrameSent', (params) => this.admit(() => this.onWebSocketFrame('send', params)));
204
+ this.client.on('Network.webSocketFrameReceived', (params) => this.admit(() => this.onWebSocketFrame('receive', params)));
205
+ this.phase = 'recording';
206
+ this.admissionOpen = true;
207
+ } catch (error) {
208
+ if (this.phase === 'starting') throw this.latchFatal(error);
209
+ }
210
+ }
211
+
212
+ private admit(fn: () => void): void {
213
+ if (!this.admissionOpen || this.fatalError) return;
214
+ try {
215
+ fn();
216
+ } catch (error) {
217
+ this.latchFatal(error);
218
+ }
219
+ }
220
+
221
+ private latchFatal(error: unknown): Error {
222
+ if (!this.fatalError) this.fatalError = error instanceof Error ? error : new Error(String(error));
223
+ this.admissionOpen = false;
224
+ this.phase = 'failed';
225
+ return this.fatalError;
226
+ }
227
+
228
+ private onRequest(params: unknown): void {
229
+ if (!isPlainObject(params) || !isPlainObject(params.request)) fail('requestWillBeSent');
230
+ const requestId = nonempty(params.requestId, 'requestId');
231
+ const requestPayload = params.request;
232
+ const request: HarRequest = {
233
+ method: nonempty(requestPayload.method, 'request.method'),
234
+ url: nonempty(requestPayload.url, 'request.url'),
235
+ headers: headers(requestPayload.headers, 'request.headers'),
236
+ };
237
+ if ('postData' in requestPayload) {
238
+ if (typeof requestPayload.postData !== 'string') fail('request.postData');
239
+ const contentType = request.headers.find((header) => header.name.toLowerCase() === 'content-type');
240
+ request.postData = { mimeType: contentType?.value ?? '', text: requestPayload.postData };
241
+ }
242
+ const monotonic = finite(params.timestamp, 'timestamp');
243
+ const wall = wallTime(params.wallTime);
244
+ const previous = this.activeByRequestId.get(requestId);
245
+ const hasRedirect = 'redirectResponse' in params && params.redirectResponse !== undefined;
246
+ if (previous && !hasRedirect) fail('duplicate active requestId without redirectResponse');
247
+ if (hasRedirect && previous) {
248
+ const redirect = this.parseResponse(params.redirectResponse, 'redirectResponse');
249
+ if (redirect.status < 300 || redirect.status > 399) fail('redirectResponse.status');
250
+ this.assignTerminal(previous, { kind: 'redirect', timestamp: monotonic }, redirect);
251
+ }
252
+ this.closedTerminals.delete(requestId);
253
+ const generation = this.nextGenerationByRequestId.get(requestId) ?? 1;
254
+ this.nextGenerationByRequestId.set(requestId, generation + 1);
255
+ this.activeByRequestId.set(requestId, {
256
+ key: `${requestId}:${generation}`,
257
+ order: this.nextOrder++,
258
+ requestId,
259
+ generation,
260
+ startedDateTime: wall.iso,
261
+ requestWallTime: wall.seconds,
262
+ requestMonotonic: monotonic,
263
+ request,
264
+ bodyPending: false,
69
265
  });
266
+ }
267
+
268
+ // WebSocket lifecycle — ported local-main capture, admitted like every other
269
+ // owned Network event. Only sockets OPENED while this recorder is attached
270
+ // are visible (CDP does not replay creation events for pre-existing
271
+ // sockets), so events for unknown sockets are ignored rather than fatal.
272
+ private onWebSocketCreated(params: unknown): void {
273
+ if (!isPlainObject(params)) fail('webSocketCreated');
274
+ const requestId = nonempty(params.requestId, 'requestId');
275
+ const url = nonempty(params.url, 'url');
276
+ this.webSockets.set(requestId, { requestId, url, requestHeaders: [], responseHeaders: [], messages: [], droppedFrames: 0 });
277
+ }
278
+
279
+ private onWebSocketHandshakeRequest(params: unknown): void {
280
+ if (!isPlainObject(params)) fail('webSocketWillSendHandshakeRequest');
281
+ const ws = this.webSockets.get(nonempty(params.requestId, 'requestId'));
282
+ if (!ws) return;
283
+ if (!isPlainObject(params.request)) fail('webSocketWillSendHandshakeRequest.request');
284
+ ws.wallTime = finite(params.wallTime, 'wallTime');
285
+ ws.monotonicTime = finite(params.timestamp, 'timestamp');
286
+ ws.requestHeaders = headers(params.request.headers, 'request.headers');
287
+ }
288
+
289
+ private onWebSocketHandshakeResponse(params: unknown): void {
290
+ if (!isPlainObject(params)) fail('webSocketHandshakeResponseReceived');
291
+ const ws = this.webSockets.get(nonempty(params.requestId, 'requestId'));
292
+ if (!ws) return;
293
+ if (!isPlainObject(params.response)) fail('webSocketHandshakeResponseReceived.response');
294
+ const status = finite(params.response.status, 'response.status');
295
+ if (!Number.isInteger(status) || status < 100 || status > 599) fail('response.status');
296
+ ws.status = status;
297
+ ws.responseHeaders = headers(params.response.headers, 'response.headers');
298
+ }
299
+
300
+ private onWebSocketFrame(type: 'send' | 'receive', params: unknown): void {
301
+ if (!isPlainObject(params)) fail('webSocketFrame');
302
+ const ws = this.webSockets.get(nonempty(params.requestId, 'requestId'));
303
+ if (!ws) return;
304
+ if (ws.messages.length >= MAX_WS_FRAMES) {
305
+ ws.droppedFrames++;
306
+ return;
307
+ }
308
+ if (!isPlainObject(params.response)) fail('webSocketFrame.response');
309
+ const timestamp = finite(params.timestamp, 'timestamp');
310
+ const opcode = finite(params.response.opcode, 'response.opcode');
311
+ if (!Number.isInteger(opcode)) fail('response.opcode');
312
+ const data = typeof params.response.payloadData === 'string' ? params.response.payloadData : fail('response.payloadData');
313
+ const time = ws.wallTime !== undefined && ws.monotonicTime !== undefined
314
+ ? ws.wallTime + (timestamp - ws.monotonicTime)
315
+ : timestamp;
316
+ ws.messages.push({ type, time, opcode, data: truncateWebSocketData(data) });
317
+ }
70
318
 
71
- this.client.on('Network.responseReceived', (params: unknown) => {
72
- const p = params as {
73
- requestId: string;
74
- response: {
75
- url: string;
76
- status: number;
77
- headers: Record<string, string>;
78
- };
79
- timestamp: number;
319
+ /** WebSocket entries materialize only at snapshot time — a socket has no
320
+ * request/terminal lifecycle, so it is never part of the generation maps. */
321
+ private buildWebSocketEntries(): WebSocketHAREntry[] {
322
+ return [...this.webSockets.values()].map((ws) => {
323
+ const messages = ws.droppedFrames > 0
324
+ ? [...ws.messages, {
325
+ type: 'receive' as const,
326
+ time: 0,
327
+ opcode: 1,
328
+ data: `[${ws.droppedFrames} further frames not recorded — per-socket cap of ${MAX_WS_FRAMES}]`,
329
+ }]
330
+ : ws.messages;
331
+ return {
332
+ startedDateTime: new Date(ws.wallTime !== undefined ? ws.wallTime * 1000 : Date.now()).toISOString(),
333
+ request: { method: 'GET', url: ws.url, headers: ws.requestHeaders },
334
+ response: { status: ws.status ?? 0, headers: ws.responseHeaders, content: {} },
335
+ _resourceType: 'websocket' as const,
336
+ _webSocketMessages: messages,
80
337
  };
81
- if (!this.requests.has(p.requestId)) return;
82
- this.responses.set(p.requestId, {
83
- requestId: p.requestId,
84
- url: p.response.url,
85
- status: p.response.status,
86
- headers: p.response.headers,
87
- timestamp: p.timestamp,
88
- });
89
338
  });
90
339
  }
91
340
 
92
- private buildHar(): { log: { entries: HAREntry[] } } {
341
+ private parseResponse(value: unknown, name: string): RedirectResponse {
342
+ if (!isPlainObject(value)) fail(name);
343
+ nonempty(value.url, `${name}.url`);
344
+ const status = finite(value.status, `${name}.status`);
345
+ if (!Number.isInteger(status) || status < 100 || status > 599) fail(`${name}.status`);
346
+ return { status, headers: headers(value.headers, `${name}.headers`) };
347
+ }
348
+
349
+ private eventRequestId(params: unknown): string | null {
350
+ return isPlainObject(params) && typeof params.requestId === 'string' && params.requestId.length > 0 ? params.requestId : null;
351
+ }
352
+
353
+ private onResponse(params: unknown): void {
354
+ const requestId = this.eventRequestId(params);
355
+ if (!requestId) return;
356
+ const current = this.activeByRequestId.get(requestId);
357
+ if (!current) return;
358
+ const event = params as Record<string, unknown>;
359
+ const parsed = this.parseResponse(event.response, 'response');
360
+ const next: ObservedResponse = { ...parsed, timestamp: finite(event.timestamp, 'timestamp') };
361
+ if (current.response && !same(current.response, next)) fail('conflicting responseReceived');
362
+ current.response ??= next;
363
+ }
364
+
365
+ private onFinished(params: unknown): void {
366
+ const requestId = this.eventRequestId(params);
367
+ if (!requestId) return;
368
+ const current = this.activeByRequestId.get(requestId);
369
+ const closed = this.closedTerminals.has(requestId);
370
+ if (!current && !closed) return;
371
+ const event = params as Record<string, unknown>;
372
+ const terminal: Terminal = { kind: 'finished', timestamp: finite(event.timestamp, 'timestamp'), encodedDataLength: finite(event.encodedDataLength, 'encodedDataLength') };
373
+ if (!current) return this.compareClosedTerminal(requestId, terminal);
374
+ if (!current.response) fail('loadingFinished without responseReceived');
375
+ this.assignTerminal(current, terminal);
376
+ }
377
+
378
+ private onFailed(params: unknown): void {
379
+ const requestId = this.eventRequestId(params);
380
+ if (!requestId) return;
381
+ const current = this.activeByRequestId.get(requestId);
382
+ const closed = this.closedTerminals.has(requestId);
383
+ if (!current && !closed) return;
384
+ const event = params as Record<string, unknown>;
385
+ const terminal: Terminal = {
386
+ kind: 'failed',
387
+ timestamp: finite(event.timestamp, 'timestamp'),
388
+ errorText: typeof event.errorText === 'string' ? event.errorText : fail('errorText'),
389
+ canceled: event.canceled === undefined ? false : typeof event.canceled === 'boolean' ? event.canceled : fail('canceled'),
390
+ blockedReason: event.blockedReason === undefined ? null : typeof event.blockedReason === 'string' ? event.blockedReason : fail('blockedReason'),
391
+ resourceType: event.type === undefined ? null : typeof event.type === 'string' ? event.type : fail('type'),
392
+ };
393
+ if (!current) return this.compareClosedTerminal(requestId, terminal);
394
+ this.assignTerminal(current, terminal);
395
+ }
396
+
397
+ private compareClosedTerminal(requestId: string, terminal: Terminal): void {
398
+ const previous = this.closedTerminals.get(requestId);
399
+ if (previous && !same(previous, terminal)) fail('conflicting terminal event');
400
+ }
401
+
402
+ private assignTerminal(current: Generation, terminal: Terminal, redirectResponse?: RedirectResponse): void {
403
+ if (current.terminal) {
404
+ if (!same(current.terminal, terminal)) fail('conflicting terminal event');
405
+ return;
406
+ }
407
+ current.terminal = terminal;
408
+ this.closedTerminals.set(current.requestId, terminal);
409
+ if (redirectResponse) {
410
+ current.redirectResponse = redirectResponse;
411
+ this.materialize(current, { state: 'not_applicable', reason: 'redirect' });
412
+ } else if (terminal.kind === 'finished') {
413
+ current.bodyPending = true;
414
+ const task = this.fetchBody(current);
415
+ this.bodyTasks.add(task);
416
+ void task.finally(() => this.bodyTasks.delete(task));
417
+ } else {
418
+ this.materialize(current, current.response ? { state: 'not_applicable', reason: 'network_failed' } : { state: 'not_applicable', reason: 'no_response' });
419
+ }
420
+ }
421
+
422
+ private async fetchBody(current: Generation): Promise<void> {
423
+ let body: BodyProvenance;
424
+ let content: HarResponse['content'] = {};
425
+ try {
426
+ const result = await this.client.send('Network.getResponseBody', { requestId: current.requestId }, 5000);
427
+ if (!isPlainObject(result) || typeof result.body !== 'string' || typeof result.base64Encoded !== 'boolean') throw new Error('Network.getResponseBody returned malformed payload');
428
+ if (result.base64Encoded) {
429
+ const decoded = strictBase64(result.body);
430
+ const prefix = decoded.subarray(0, MAX_BODY_SIZE);
431
+ content = { text: prefix.toString('base64'), encoding: 'base64' };
432
+ body = { state: 'captured', sourceEncoding: 'base64', decodedByteLength: decoded.length, capturedByteLength: prefix.length, truncated: prefix.length < decoded.length };
433
+ } else {
434
+ const decodedByteLength = Buffer.byteLength(result.body, 'utf8');
435
+ const prefix = textPrefix(result.body);
436
+ content = { text: prefix.text };
437
+ body = { state: 'captured', sourceEncoding: 'text', decodedByteLength, capturedByteLength: prefix.bytes, truncated: prefix.bytes < decodedByteLength };
438
+ }
439
+ } catch (error) {
440
+ body = { state: 'fetch_failed', error: error instanceof Error && error.message ? error.message : String(error) };
441
+ }
442
+ if (this.finalResult) return;
443
+ current.bodyPending = false;
444
+ this.materialize(current, body, content);
445
+ }
446
+
447
+ private deleteActive(current: Generation): void {
448
+ if (this.activeByRequestId.get(current.requestId) === current) this.activeByRequestId.delete(current.requestId);
449
+ }
450
+
451
+ private install(current: Generation, value: HAREntry | IncompleteLifecycle): void {
452
+ if (!this.recordedKeys.has(current.key)) {
453
+ this.recordedKeys.add(current.key);
454
+ if (!('kind' in value)) this.entryCount++;
455
+ if (this.sink) this.emit(value);
456
+ else this.materializedByKey.set(current.key, { order: current.order, value });
457
+ }
458
+ this.deleteActive(current);
459
+ }
460
+
461
+ /**
462
+ * Streaming mode: queue one exactly-once single-value batch onto the
463
+ * serialized sink chain. The batch is validated and frozen before the sink
464
+ * sees it; a validation or sink failure latches as the fatal store error and
465
+ * stops all later sink calls — the store is never reset or retried.
466
+ */
467
+ private emit(value: HAREntry | IncompleteLifecycle): void {
468
+ let batch: HarAppendBatch;
469
+ try {
470
+ batch = deepFreeze(
471
+ validateHarAppendBatch(
472
+ 'kind' in value
473
+ ? { entries: [], incompleteLifecycles: [value] }
474
+ : { entries: [value], incompleteLifecycles: [] },
475
+ 'HARRecorder stream batch',
476
+ ),
477
+ );
478
+ } catch (error) {
479
+ this.latchFatal(error);
480
+ return;
481
+ }
482
+ const sink = this.sink!;
483
+ this.sinkChain = this.sinkChain
484
+ .then(async () => {
485
+ if (this.fatalError) return;
486
+ await sink(batch);
487
+ })
488
+ .catch((error) => {
489
+ this.latchFatal(error);
490
+ });
491
+ }
492
+
493
+ private materialize(current: Generation, body: BodyProvenance, content: HarResponse['content'] = {}): void {
494
+ if (!current.terminal || this.recordedKeys.has(current.key)) return;
495
+ const response = current.redirectResponse ?? current.response;
496
+ const invalid = this.clockViolation(current, current.redirectResponse ? null : current.response?.timestamp ?? null);
497
+ if (invalid) this.install(current, this.invalidClock(current, invalid));
498
+ else {
499
+ const terminal = current.terminal;
500
+ const received = response !== undefined;
501
+ this.install(current, {
502
+ startedDateTime: current.startedDateTime,
503
+ time: (terminal.timestamp - current.requestMonotonic) * 1000,
504
+ request: current.request,
505
+ response: received ? { status: response.status, headers: response.headers, content } : { status: 0, headers: [], content: {} },
506
+ _capture: {
507
+ schemaVersion: 1,
508
+ requestId: current.requestId,
509
+ generation: current.generation,
510
+ clocks: { requestWallTime: current.requestWallTime, requestMonotonic: current.requestMonotonic, responseMonotonic: current.redirectResponse ? null : current.response?.timestamp ?? null, terminalMonotonic: terminal.timestamp },
511
+ terminal: terminal.kind === 'finished' ? { kind: 'finished', encodedDataLength: terminal.encodedDataLength } : terminal.kind === 'redirect' ? { kind: 'redirect' } : { kind: 'failed', errorText: terminal.errorText, canceled: terminal.canceled, blockedReason: terminal.blockedReason, resourceType: terminal.resourceType },
512
+ response: received ? { state: 'received' } : { state: 'unavailable' },
513
+ body,
514
+ },
515
+ });
516
+ }
517
+ }
518
+
519
+ private clockViolation(current: Generation, responseTimestamp: number | null): Extract<IncompleteLifecycle, { kind: 'invalid_clock_order' }>['violation'] | null {
520
+ const terminal = current.terminal!;
521
+ if (responseTimestamp !== null && responseTimestamp < current.requestMonotonic) return 'response_before_request';
522
+ if (terminal.timestamp < current.requestMonotonic) return 'terminal_before_request';
523
+ if (responseTimestamp !== null && terminal.timestamp < responseTimestamp) return 'terminal_before_response';
524
+ return null;
525
+ }
526
+
527
+ private invalidClock(current: Generation, violation: Extract<IncompleteLifecycle, { kind: 'invalid_clock_order' }>['violation']): IncompleteLifecycle {
528
+ const terminal = current.terminal!;
529
+ const observedResponse = current.redirectResponse ?? current.response;
530
+ return {
531
+ kind: 'invalid_clock_order',
532
+ requestId: current.requestId,
533
+ generation: current.generation,
534
+ startedDateTime: current.startedDateTime,
535
+ request: current.request,
536
+ response: observedResponse ? { status: observedResponse.status, headers: observedResponse.headers, responseMonotonic: current.redirectResponse ? null : current.response!.timestamp } : null,
537
+ terminal: terminal.kind === 'finished' ? { kind: 'finished', terminalMonotonic: terminal.timestamp, encodedDataLength: terminal.encodedDataLength } : terminal.kind === 'redirect' ? { kind: 'redirect', terminalMonotonic: terminal.timestamp } : { kind: 'failed', terminalMonotonic: terminal.timestamp, errorText: terminal.errorText, canceled: terminal.canceled, blockedReason: terminal.blockedReason, resourceType: terminal.resourceType },
538
+ _capture: { schemaVersion: 1, requestWallTime: current.requestWallTime, requestMonotonic: current.requestMonotonic },
539
+ violation,
540
+ };
541
+ }
542
+
543
+ private stopped(current: Generation): IncompleteLifecycle {
544
+ return {
545
+ kind: 'stopped_before_terminal', requestId: current.requestId, generation: current.generation, startedDateTime: current.startedDateTime, request: current.request,
546
+ _capture: { schemaVersion: 1, requestWallTime: current.requestWallTime, requestMonotonic: current.requestMonotonic, response: current.response ? { status: current.response.status, headers: current.response.headers, responseMonotonic: current.response.timestamp } : null },
547
+ };
548
+ }
549
+
550
+ private stoppedDuringBody(current: Generation): IncompleteLifecycle {
551
+ // Sole call site guards `terminal.kind === 'finished'` — only a finished
552
+ // terminal carries a pending body fetch.
553
+ const terminal = current.terminal as Extract<Terminal, { kind: 'finished' }>;
554
+ const response = current.response!;
93
555
  return {
94
- log: {
95
- entries: Array.from(this.responses.values()).map((resp) => {
96
- const req = this.requests.get(resp.requestId);
97
- return {
98
- startedDateTime: new Date(resp.timestamp * 1000).toISOString(),
99
- request: {
100
- method: req?.method ?? 'GET',
101
- url: resp.url,
102
- headers: Object.entries(req?.headers ?? {}).map(
103
- ([name, value]) => ({ name, value }),
104
- ),
105
- postData: req?.postData ? { text: req.postData } : undefined,
106
- },
107
- response: {
108
- status: resp.status,
109
- headers: Object.entries(resp.headers).map(([name, value]) => ({
110
- name,
111
- value,
112
- })),
113
- content: { text: resp.body },
114
- },
115
- };
116
- }),
117
- },
556
+ kind: 'stopped_during_body', requestId: current.requestId, generation: current.generation, startedDateTime: current.startedDateTime, request: current.request,
557
+ response: { status: response.status, headers: response.headers },
558
+ _capture: { schemaVersion: 1, requestWallTime: current.requestWallTime, requestMonotonic: current.requestMonotonic, responseMonotonic: response.timestamp, terminalMonotonic: terminal.timestamp, encodedDataLength: terminal.encodedDataLength },
118
559
  };
119
560
  }
120
561
 
121
- async finish(): Promise<{ log: { entries: HAREntry[] } }> {
122
- // Fetch response bodies in parallel — sequential fetches with 5s timeouts
123
- // were causing ~50s hangs when many responses had evicted bodies
124
- await Promise.allSettled(
125
- Array.from(this.responses.entries()).map(async ([reqId, resp]) => {
126
- const bodyResult = (await this.client.send('Network.getResponseBody', {
127
- requestId: reqId,
128
- }, 5000)) as {
129
- body: string;
130
- base64Encoded: boolean;
131
- };
132
- const decoded = bodyResult.base64Encoded
133
- ? Buffer.from(bodyResult.body, 'base64').toString()
134
- : bodyResult.body;
135
- resp.body = decoded.length > MAX_BODY_SIZE
136
- ? decoded.slice(0, MAX_BODY_SIZE) + `\n[truncated: ${decoded.length} bytes]`
137
- : decoded;
138
- }),
139
- );
140
-
141
- return this.buildHar();
142
- }
143
-
144
- finishPartial(): { log: { entries: HAREntry[] } } {
145
- return this.buildHar();
562
+ private snapshot(partial: boolean): HarFile {
563
+ const ordered = [...this.materializedByKey.values()];
564
+ for (const current of this.activeByRequestId.values()) {
565
+ const invalid = current.terminal ? this.clockViolation(current, current.response?.timestamp ?? null) : null;
566
+ const value = partial && current.bodyPending && current.terminal?.kind === 'finished' && current.response && !invalid
567
+ ? this.stoppedDuringBody(current)
568
+ : current.terminal && invalid
569
+ ? this.invalidClock(current, invalid)
570
+ : this.stopped(current);
571
+ ordered.push({ order: current.order, value });
572
+ }
573
+ ordered.sort((a, b) => a.order - b.order);
574
+ const entries: HAREntry[] = [];
575
+ const incompleteLifecycles: IncompleteLifecycle[] = [];
576
+ for (const { value } of ordered) ('kind' in value ? incompleteLifecycles : entries).push(value as never);
577
+ entries.push(...this.buildWebSocketEntries());
578
+ return { log: { version: '1.2', creator: { name: 'capture', version: captureVersion() }, entries }, incompleteLifecycles };
579
+ }
580
+
581
+ private freezeValidated(snapshot: HarFile): HarFile {
582
+ return deepFreeze(validateHarFile(snapshot, 'HARRecorder output'));
583
+ }
584
+
585
+ private throwFatal(): void {
586
+ if (this.fatalError) throw this.fatalError;
587
+ }
588
+
589
+ /**
590
+ * Streaming health/completion barrier: waits for every body fetch in flight
591
+ * at call time, then for every sink append queued through that point, and
592
+ * throws the latched fatal store error if any work has failed. Work admitted
593
+ * after the call is deliberately not awaited, so the barrier terminates
594
+ * under continuous traffic.
595
+ */
596
+ async flush(): Promise<void> {
597
+ if (!this.sink) throw new Error('flush() requires a streaming HAR recorder (constructed with a sink)');
598
+ this.throwFatal();
599
+ const pending = [...this.bodyTasks];
600
+ if (pending.length > 0) await Promise.all(pending);
601
+ await this.sinkChain;
602
+ this.throwFatal();
603
+ }
604
+
605
+ /**
606
+ * Streaming finalizer. Its first synchronous line closes Network-event
607
+ * admission — events arriving after the call cannot allocate, mutate, or
608
+ * append. It then drains pre-cut body work, finalizes the frozen active map
609
+ * exactly once as incomplete lifecycle evidence through the sink, drains
610
+ * those appends, and rejects with the fatal store error if any append or
611
+ * assembly failed. Idempotent: repeat calls settle with the first outcome.
612
+ */
613
+ async drain(): Promise<void> {
614
+ if (!this.sink) throw new Error('drain() requires a streaming HAR recorder (constructed with a sink)');
615
+ this.admissionOpen = false;
616
+ if (this.draining) return this.draining;
617
+ this.throwFatal();
618
+ this.phase = 'finalizing';
619
+ this.draining = (async () => {
620
+ try {
621
+ while (this.bodyTasks.size > 0) await Promise.all([...this.bodyTasks]);
622
+ this.throwFatal();
623
+ for (const current of [...this.activeByRequestId.values()]) {
624
+ if (!current.terminal) this.install(current, this.stopped(current));
625
+ else if (!this.recordedKeys.has(current.key)) this.install(current, this.invalidClock(current, this.clockViolation(current, current.response?.timestamp ?? null)!));
626
+ }
627
+ // WebSocket evidence is finalized exactly once, here — snapshot()
628
+ // covers the snapshot-mode finalizers, so the streaming finalizer
629
+ // must emit each socket's entry through the sink itself.
630
+ for (const entry of this.buildWebSocketEntries()) this.emit(entry);
631
+ await this.sinkChain;
632
+ this.throwFatal();
633
+ this.phase = 'finalized';
634
+ } catch (error) {
635
+ throw this.latchFatal(error);
636
+ }
637
+ })();
638
+ return this.draining;
639
+ }
640
+
641
+ async finish(): Promise<HarFile> {
642
+ if (this.sink) throw new Error('finish() is snapshot-only — a streaming HAR recorder finalizes with drain()');
643
+ if (this.finalResult) return this.finalResult;
644
+ this.admissionOpen = false;
645
+ this.throwFatal();
646
+ if (this.finalizing) return this.finalizing;
647
+ this.phase = 'finalizing';
648
+ this.finalizing = (async () => {
649
+ try {
650
+ while (this.bodyTasks.size > 0) await Promise.all([...this.bodyTasks]);
651
+ this.throwFatal();
652
+ for (const current of [...this.activeByRequestId.values()]) {
653
+ if (!current.terminal) this.install(current, this.stopped(current));
654
+ else if (!this.recordedKeys.has(current.key)) this.install(current, this.invalidClock(current, this.clockViolation(current, current.response?.timestamp ?? null)!));
655
+ }
656
+ this.finalResult = this.freezeValidated(this.snapshot(false));
657
+ this.phase = 'finalized';
658
+ return this.finalResult;
659
+ } catch (error) {
660
+ throw this.latchFatal(error);
661
+ }
662
+ })();
663
+ return this.finalizing;
664
+ }
665
+
666
+ finishPartial(): HarFile {
667
+ if (this.sink) throw new Error('finishPartial() is snapshot-only — a streaming HAR recorder finalizes with drain()');
668
+ if (this.finalResult) return this.finalResult;
669
+ this.admissionOpen = false;
670
+ this.throwFatal();
671
+ if (this.finalizing) throw new Error('HAR recorder is finalizing with finish()');
672
+ try {
673
+ this.finalResult = this.freezeValidated(this.snapshot(true));
674
+ this.phase = 'finalized';
675
+ return this.finalResult;
676
+ } catch (error) {
677
+ throw this.latchFatal(error);
678
+ }
146
679
  }
147
680
  }