@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
package/src/cdp/har-recorder.ts
CHANGED
|
@@ -1,147 +1,680 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
38
|
-
private
|
|
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(
|
|
181
|
+
constructor(
|
|
182
|
+
private readonly client: CDPClient,
|
|
183
|
+
private readonly sink?: HarSink,
|
|
184
|
+
) {}
|
|
41
185
|
|
|
42
186
|
get responseCount(): number {
|
|
43
|
-
return this.
|
|
187
|
+
return this.entryCount;
|
|
44
188
|
}
|
|
45
189
|
|
|
46
190
|
async start(): Promise<void> {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
}
|