@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/har-manager.ts
CHANGED
|
@@ -1,69 +1,1020 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HAR
|
|
2
|
+
* HAR schema authority — the sole owner of the HAR TypeScript types and their
|
|
3
|
+
* strict runtime validators.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
+
* `validateHarFile` and `validateHarAppendBatch` are the only sanctioned way to
|
|
6
|
+
* turn parsed JSON into a `HarFile`/`HarAppendBatch`; every consumer (append
|
|
7
|
+
* input, live reads, final `har.json` reads, recorder output, and the
|
|
8
|
+
* pre-commit snapshot at session stop) runs the same validators and never casts
|
|
9
|
+
* parsed JSON to these types. The validators enforce closed schemas: they
|
|
10
|
+
* reject unknown keys, non-plain objects, sparse arrays, non-finite numbers,
|
|
11
|
+
* unknown discriminants, and any value outside the declared domains — no field
|
|
12
|
+
* is ever defaulted or coerced.
|
|
13
|
+
*
|
|
14
|
+
* The `HAR_DIR`/`createHarRecording`/`readHarRecording`/`appendToHarRecording`/
|
|
15
|
+
* `deleteHarRecording` store functions below are the schema-conformant
|
|
16
|
+
* private-artifact implementation for active session and command-owned HAR
|
|
17
|
+
* files. The live store is an append-only NDJSON log below the private session
|
|
18
|
+
* tree: one header record identifying the store format and creator, then one
|
|
19
|
+
* validated `HarAppendBatch` record per append. Each append is a single
|
|
20
|
+
* `O_APPEND` no-follow write of one newline-terminated record — there is no
|
|
21
|
+
* lock and no read-modify-write, so concurrent writers (a command-side append
|
|
22
|
+
* and the recorder's streaming path) interleave whole records instead of
|
|
23
|
+
* contending for a lock. Reads parse the whole log fail-closed through the
|
|
24
|
+
* same validators: an empty store, an unterminated trailing record, a corrupt
|
|
25
|
+
* line, or an unknown shape throws and is never rewritten or recreated.
|
|
5
26
|
*/
|
|
6
27
|
|
|
7
|
-
import
|
|
28
|
+
import crypto from 'node:crypto';
|
|
8
29
|
import * as path from 'path';
|
|
9
30
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
31
|
+
import {
|
|
32
|
+
appendPrivateFile,
|
|
33
|
+
assertUnderCaptureRoot,
|
|
34
|
+
CAPTURE_ROOT,
|
|
35
|
+
createPrivateFile,
|
|
36
|
+
ensurePrivateDir,
|
|
37
|
+
readPrivateFile,
|
|
38
|
+
unlinkPrivateFile,
|
|
39
|
+
} from './session/artifacts.js';
|
|
40
|
+
|
|
41
|
+
// ── Exported TypeScript schema ───────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface Header {
|
|
44
|
+
name: string;
|
|
45
|
+
value: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PostData {
|
|
49
|
+
mimeType: string;
|
|
50
|
+
text: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface HarRequest {
|
|
54
|
+
method: string;
|
|
55
|
+
url: string;
|
|
56
|
+
headers: Header[];
|
|
57
|
+
postData?: PostData;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface HarResponseContent {
|
|
61
|
+
text?: string;
|
|
62
|
+
encoding?: 'base64';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface HarResponse {
|
|
66
|
+
status: number;
|
|
67
|
+
headers: Header[];
|
|
68
|
+
content: HarResponseContent;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type CaptureTerminal =
|
|
72
|
+
| { kind: 'finished'; encodedDataLength: number }
|
|
73
|
+
| { kind: 'redirect' }
|
|
74
|
+
| {
|
|
75
|
+
kind: 'failed';
|
|
76
|
+
errorText: string;
|
|
77
|
+
canceled: boolean;
|
|
78
|
+
blockedReason: string | null;
|
|
79
|
+
resourceType: string | null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export interface CaptureClocks {
|
|
83
|
+
requestWallTime: number;
|
|
84
|
+
requestMonotonic: number;
|
|
85
|
+
responseMonotonic: number | null;
|
|
86
|
+
terminalMonotonic: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type CaptureResponseState = { state: 'received' } | { state: 'unavailable' };
|
|
90
|
+
|
|
91
|
+
export type BodyProvenance =
|
|
92
|
+
| {
|
|
93
|
+
state: 'captured';
|
|
94
|
+
sourceEncoding: 'text';
|
|
95
|
+
decodedByteLength: number;
|
|
96
|
+
capturedByteLength: number;
|
|
97
|
+
truncated: boolean;
|
|
98
|
+
}
|
|
99
|
+
| {
|
|
100
|
+
state: 'captured';
|
|
101
|
+
sourceEncoding: 'base64';
|
|
102
|
+
decodedByteLength: number;
|
|
103
|
+
capturedByteLength: number;
|
|
104
|
+
truncated: boolean;
|
|
105
|
+
}
|
|
106
|
+
| { state: 'fetch_failed'; error: string }
|
|
107
|
+
| { state: 'not_applicable'; reason: 'redirect' | 'no_response' | 'network_failed' };
|
|
108
|
+
|
|
109
|
+
export interface CaptureMeta {
|
|
110
|
+
schemaVersion: 1;
|
|
111
|
+
requestId: string;
|
|
112
|
+
generation: number;
|
|
113
|
+
clocks: CaptureClocks;
|
|
114
|
+
terminal: CaptureTerminal;
|
|
115
|
+
response: CaptureResponseState;
|
|
116
|
+
body: BodyProvenance;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface HttpHAREntry {
|
|
120
|
+
startedDateTime: string;
|
|
121
|
+
time: number;
|
|
122
|
+
request: HarRequest;
|
|
123
|
+
response: HarResponse;
|
|
124
|
+
_capture: CaptureMeta;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One WebSocket frame (Chrome DevTools HAR convention): send/receive + payload. */
|
|
128
|
+
export interface WebSocketMessage {
|
|
129
|
+
type: 'send' | 'receive';
|
|
130
|
+
time: number;
|
|
131
|
+
opcode: number;
|
|
132
|
+
data: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A WebSocket connection captured as a HAR entry (Chrome DevTools convention):
|
|
137
|
+
* the handshake as request/response (status 0 when the handshake response was
|
|
138
|
+
* never observed) and the frames in `_webSocketMessages`. WebSocket entries
|
|
139
|
+
* carry no HTTP timing and no `_capture` provenance — their lifecycle is the
|
|
140
|
+
* socket's, not a request/terminal pair.
|
|
141
|
+
*/
|
|
142
|
+
export interface WebSocketHAREntry {
|
|
143
|
+
startedDateTime: string;
|
|
144
|
+
request: HarRequest;
|
|
145
|
+
response: HarResponse;
|
|
146
|
+
_resourceType: 'websocket';
|
|
147
|
+
_webSocketMessages: WebSocketMessage[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type HAREntry = HttpHAREntry | WebSocketHAREntry;
|
|
151
|
+
|
|
152
|
+
export type IncompleteLifecycle =
|
|
153
|
+
| {
|
|
154
|
+
kind: 'stopped_before_terminal';
|
|
155
|
+
requestId: string;
|
|
156
|
+
generation: number;
|
|
157
|
+
startedDateTime: string;
|
|
158
|
+
request: HarRequest;
|
|
159
|
+
_capture: {
|
|
160
|
+
schemaVersion: 1;
|
|
161
|
+
requestWallTime: number;
|
|
162
|
+
requestMonotonic: number;
|
|
163
|
+
response: null | { status: number; headers: Header[]; responseMonotonic: number };
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
| {
|
|
167
|
+
kind: 'invalid_clock_order';
|
|
168
|
+
requestId: string;
|
|
169
|
+
generation: number;
|
|
170
|
+
startedDateTime: string;
|
|
171
|
+
request: HarRequest;
|
|
172
|
+
response: null | { status: number; headers: Header[]; responseMonotonic: number | null };
|
|
173
|
+
terminal:
|
|
174
|
+
| { kind: 'finished'; terminalMonotonic: number; encodedDataLength: number }
|
|
175
|
+
| { kind: 'redirect'; terminalMonotonic: number }
|
|
176
|
+
| {
|
|
177
|
+
kind: 'failed';
|
|
178
|
+
terminalMonotonic: number;
|
|
179
|
+
errorText: string;
|
|
180
|
+
canceled: boolean;
|
|
181
|
+
blockedReason: string | null;
|
|
182
|
+
resourceType: string | null;
|
|
183
|
+
};
|
|
184
|
+
_capture: { schemaVersion: 1; requestWallTime: number; requestMonotonic: number };
|
|
185
|
+
violation: 'response_before_request' | 'terminal_before_request' | 'terminal_before_response';
|
|
186
|
+
}
|
|
187
|
+
| {
|
|
188
|
+
kind: 'stopped_during_body';
|
|
189
|
+
requestId: string;
|
|
190
|
+
generation: number;
|
|
191
|
+
startedDateTime: string;
|
|
192
|
+
request: HarRequest;
|
|
193
|
+
response: { status: number; headers: Header[] };
|
|
194
|
+
_capture: {
|
|
195
|
+
schemaVersion: 1;
|
|
196
|
+
requestWallTime: number;
|
|
197
|
+
requestMonotonic: number;
|
|
198
|
+
responseMonotonic: number;
|
|
199
|
+
terminalMonotonic: number;
|
|
200
|
+
encodedDataLength: number;
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export interface HarFile {
|
|
205
|
+
log: {
|
|
206
|
+
version: '1.2';
|
|
207
|
+
creator: { name: 'capture'; version: string };
|
|
208
|
+
entries: HAREntry[];
|
|
209
|
+
};
|
|
210
|
+
incompleteLifecycles: IncompleteLifecycle[];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface HarAppendBatch {
|
|
214
|
+
entries: HAREntry[];
|
|
215
|
+
incompleteLifecycles: IncompleteLifecycle[];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const MAX_BODY_BYTES = 262144;
|
|
219
|
+
|
|
220
|
+
// ── Validation primitives ────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function fail(source: string, path: string, message: string): never {
|
|
223
|
+
throw new Error(`HAR validation failed [${source}] at ${path}: ${message}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function hasOwn(o: Record<string, unknown>, key: string): boolean {
|
|
227
|
+
return Object.prototype.hasOwnProperty.call(o, key);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
231
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) return false;
|
|
232
|
+
const proto = Object.getPrototypeOf(v);
|
|
233
|
+
return proto === Object.prototype || proto === null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function vObject(v: unknown, source: string, path: string): Record<string, unknown> {
|
|
237
|
+
if (!isPlainObject(v)) fail(source, path, 'must be a plain object');
|
|
238
|
+
return v;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function exactKeys(
|
|
242
|
+
o: Record<string, unknown>,
|
|
243
|
+
allowed: readonly string[],
|
|
244
|
+
source: string,
|
|
245
|
+
path: string,
|
|
246
|
+
): void {
|
|
247
|
+
for (const k of Object.keys(o)) {
|
|
248
|
+
if (!allowed.includes(k)) fail(source, `${path}.${k}`, 'is an unknown key');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function vArray(v: unknown, source: string, path: string): unknown[] {
|
|
253
|
+
if (!Array.isArray(v)) fail(source, path, 'must be an array');
|
|
254
|
+
for (let i = 0; i < v.length; i++) {
|
|
255
|
+
if (!Object.prototype.hasOwnProperty.call(v, i)) {
|
|
256
|
+
fail(source, `${path}[${i}]`, 'is a sparse-array hole');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return v;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function vString(v: unknown, source: string, path: string): string {
|
|
263
|
+
if (typeof v !== 'string') fail(source, path, 'must be a string');
|
|
264
|
+
return v;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function vNonEmptyString(v: unknown, source: string, path: string): string {
|
|
268
|
+
const s = vString(v, source, path);
|
|
269
|
+
if (s.length === 0) fail(source, path, 'must be a nonempty string');
|
|
270
|
+
return s;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function vNullableString(v: unknown, source: string, path: string): string | null {
|
|
274
|
+
if (v === null) return null;
|
|
275
|
+
return vString(v, source, path);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function vBoolean(v: unknown, source: string, path: string): boolean {
|
|
279
|
+
if (typeof v !== 'boolean') fail(source, path, 'must be a boolean');
|
|
280
|
+
return v;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function vLiteral<T extends string | number>(
|
|
284
|
+
v: unknown,
|
|
285
|
+
expected: T,
|
|
286
|
+
source: string,
|
|
287
|
+
path: string,
|
|
288
|
+
): T {
|
|
289
|
+
if (v !== expected) fail(source, path, `must equal ${JSON.stringify(expected)}`);
|
|
290
|
+
return expected;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function vFinite(v: unknown, source: string, path: string): number {
|
|
294
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) fail(source, path, 'must be a finite number');
|
|
295
|
+
return v;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function vFiniteMin(v: unknown, min: number, source: string, path: string): number {
|
|
299
|
+
const n = vFinite(v, source, path);
|
|
300
|
+
if (n < min) fail(source, path, `must be >= ${min}`);
|
|
301
|
+
return n;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function vIntInRange(v: unknown, lo: number, hi: number, source: string, path: string): number {
|
|
305
|
+
if (typeof v !== 'number' || !Number.isInteger(v)) fail(source, path, 'must be an integer');
|
|
306
|
+
if (v < lo || v > hi) fail(source, path, `must be an integer in ${lo}..${hi}`);
|
|
307
|
+
return v;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function vSafeIntMin(v: unknown, min: number, source: string, path: string): number {
|
|
311
|
+
if (typeof v !== 'number' || !Number.isSafeInteger(v)) fail(source, path, 'must be a safe integer');
|
|
312
|
+
if (v < min) fail(source, path, `must be a safe integer >= ${min}`);
|
|
313
|
+
return v;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function vSafeIntInRange(v: unknown, lo: number, hi: number, source: string, path: string): number {
|
|
317
|
+
if (typeof v !== 'number' || !Number.isSafeInteger(v)) fail(source, path, 'must be a safe integer');
|
|
318
|
+
if (v < lo || v > hi) fail(source, path, `must be a safe integer in ${lo}..${hi}`);
|
|
319
|
+
return v;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Canonical UTC ISO instant equal to `new Date(requestWallTime * 1000).toISOString()`. */
|
|
323
|
+
function assertStartedDateTime(
|
|
324
|
+
startedDateTime: string,
|
|
325
|
+
requestWallTime: number,
|
|
326
|
+
source: string,
|
|
327
|
+
wallPath: string,
|
|
328
|
+
startedPath: string,
|
|
329
|
+
): void {
|
|
330
|
+
const d = new Date(requestWallTime * 1000);
|
|
331
|
+
if (Number.isNaN(d.getTime())) {
|
|
332
|
+
fail(source, wallPath, 'is outside the representable ISO date range');
|
|
333
|
+
}
|
|
334
|
+
if (startedDateTime !== d.toISOString()) {
|
|
335
|
+
fail(source, startedPath, 'must equal new Date(requestWallTime * 1000).toISOString()');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Shared shape validators ──────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
function vHeader(v: unknown, source: string, path: string): Header {
|
|
342
|
+
const o = vObject(v, source, path);
|
|
343
|
+
exactKeys(o, ['name', 'value'], source, path);
|
|
344
|
+
return {
|
|
345
|
+
name: vString(o.name, source, `${path}.name`),
|
|
346
|
+
value: vString(o.value, source, `${path}.value`),
|
|
16
347
|
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function vHeaders(v: unknown, source: string, path: string): Header[] {
|
|
351
|
+
return vArray(v, source, path).map((h, i) => vHeader(h, source, `${path}[${i}]`));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function vPostData(v: unknown, source: string, path: string): PostData {
|
|
355
|
+
const o = vObject(v, source, path);
|
|
356
|
+
exactKeys(o, ['mimeType', 'text'], source, path);
|
|
357
|
+
return {
|
|
358
|
+
mimeType: vString(o.mimeType, source, `${path}.mimeType`),
|
|
359
|
+
text: vString(o.text, source, `${path}.text`),
|
|
21
360
|
};
|
|
22
|
-
startedDateTime: string;
|
|
23
361
|
}
|
|
24
362
|
|
|
25
|
-
|
|
363
|
+
function vRequest(v: unknown, source: string, path: string): HarRequest {
|
|
364
|
+
const o = vObject(v, source, path);
|
|
365
|
+
const withPost = hasOwn(o, 'postData');
|
|
366
|
+
exactKeys(o, withPost ? ['method', 'url', 'headers', 'postData'] : ['method', 'url', 'headers'], source, path);
|
|
367
|
+
const req: HarRequest = {
|
|
368
|
+
method: vNonEmptyString(o.method, source, `${path}.method`),
|
|
369
|
+
url: vNonEmptyString(o.url, source, `${path}.url`),
|
|
370
|
+
headers: vHeaders(o.headers, source, `${path}.headers`),
|
|
371
|
+
};
|
|
372
|
+
if (withPost) {
|
|
373
|
+
req.postData = vPostData(o.postData, source, `${path}.postData`);
|
|
374
|
+
}
|
|
375
|
+
return req;
|
|
376
|
+
}
|
|
26
377
|
|
|
27
|
-
|
|
378
|
+
function vClocks(v: unknown, source: string, path: string): CaptureClocks {
|
|
379
|
+
const o = vObject(v, source, path);
|
|
380
|
+
exactKeys(o, ['requestWallTime', 'requestMonotonic', 'responseMonotonic', 'terminalMonotonic'], source, path);
|
|
381
|
+
const requestMonotonic = vFiniteMin(o.requestMonotonic, 0, source, `${path}.requestMonotonic`);
|
|
382
|
+
const terminalMonotonic = vFiniteMin(o.terminalMonotonic, 0, source, `${path}.terminalMonotonic`);
|
|
383
|
+
const responseMonotonic =
|
|
384
|
+
o.responseMonotonic === null
|
|
385
|
+
? null
|
|
386
|
+
: vFiniteMin(o.responseMonotonic, 0, source, `${path}.responseMonotonic`);
|
|
387
|
+
if (terminalMonotonic < requestMonotonic) {
|
|
388
|
+
fail(source, `${path}.terminalMonotonic`, 'must be >= requestMonotonic');
|
|
389
|
+
}
|
|
390
|
+
if (responseMonotonic !== null) {
|
|
391
|
+
if (responseMonotonic < requestMonotonic) {
|
|
392
|
+
fail(source, `${path}.responseMonotonic`, 'must be >= requestMonotonic');
|
|
393
|
+
}
|
|
394
|
+
if (responseMonotonic > terminalMonotonic) {
|
|
395
|
+
fail(source, `${path}.responseMonotonic`, 'must be <= terminalMonotonic');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
requestWallTime: vFinite(o.requestWallTime, source, `${path}.requestWallTime`),
|
|
400
|
+
requestMonotonic,
|
|
401
|
+
responseMonotonic,
|
|
402
|
+
terminalMonotonic,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
28
405
|
|
|
29
|
-
|
|
30
|
-
|
|
406
|
+
function vEntryTerminal(v: unknown, source: string, path: string): CaptureTerminal {
|
|
407
|
+
const o = vObject(v, source, path);
|
|
408
|
+
if (o.kind === 'finished') {
|
|
409
|
+
exactKeys(o, ['kind', 'encodedDataLength'], source, path);
|
|
410
|
+
return { kind: 'finished', encodedDataLength: vFiniteMin(o.encodedDataLength, 0, source, `${path}.encodedDataLength`) };
|
|
411
|
+
}
|
|
412
|
+
if (o.kind === 'redirect') {
|
|
413
|
+
exactKeys(o, ['kind'], source, path);
|
|
414
|
+
return { kind: 'redirect' };
|
|
415
|
+
}
|
|
416
|
+
if (o.kind === 'failed') {
|
|
417
|
+
exactKeys(o, ['kind', 'errorText', 'canceled', 'blockedReason', 'resourceType'], source, path);
|
|
418
|
+
return {
|
|
419
|
+
kind: 'failed',
|
|
420
|
+
errorText: vString(o.errorText, source, `${path}.errorText`),
|
|
421
|
+
canceled: vBoolean(o.canceled, source, `${path}.canceled`),
|
|
422
|
+
blockedReason: vNullableString(o.blockedReason, source, `${path}.blockedReason`),
|
|
423
|
+
resourceType: vNullableString(o.resourceType, source, `${path}.resourceType`),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return fail(source, `${path}.kind`, 'must be "finished", "redirect", or "failed"');
|
|
31
427
|
}
|
|
32
428
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return
|
|
429
|
+
function vCaptureResponseState(v: unknown, source: string, path: string): CaptureResponseState {
|
|
430
|
+
const o = vObject(v, source, path);
|
|
431
|
+
exactKeys(o, ['state'], source, path);
|
|
432
|
+
if (o.state === 'received') return { state: 'received' };
|
|
433
|
+
if (o.state === 'unavailable') return { state: 'unavailable' };
|
|
434
|
+
return fail(source, `${path}.state`, 'must be "received" or "unavailable"');
|
|
39
435
|
}
|
|
40
436
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
437
|
+
function vBodyProvenance(v: unknown, source: string, path: string): BodyProvenance {
|
|
438
|
+
const o = vObject(v, source, path);
|
|
439
|
+
if (o.state === 'captured') {
|
|
440
|
+
if (o.sourceEncoding !== 'text' && o.sourceEncoding !== 'base64') {
|
|
441
|
+
fail(source, `${path}.sourceEncoding`, 'must be "text" or "base64"');
|
|
442
|
+
}
|
|
443
|
+
exactKeys(o, ['state', 'sourceEncoding', 'decodedByteLength', 'capturedByteLength', 'truncated'], source, path);
|
|
444
|
+
const decodedByteLength = vSafeIntMin(o.decodedByteLength, 0, source, `${path}.decodedByteLength`);
|
|
445
|
+
const capturedByteLength = vSafeIntInRange(o.capturedByteLength, 0, MAX_BODY_BYTES, source, `${path}.capturedByteLength`);
|
|
446
|
+
const truncated = vBoolean(o.truncated, source, `${path}.truncated`);
|
|
447
|
+
if (capturedByteLength > decodedByteLength) {
|
|
448
|
+
fail(source, `${path}.capturedByteLength`, 'must not exceed decodedByteLength');
|
|
449
|
+
}
|
|
450
|
+
if (truncated !== capturedByteLength < decodedByteLength) {
|
|
451
|
+
fail(source, `${path}.truncated`, 'must equal (capturedByteLength < decodedByteLength)');
|
|
452
|
+
}
|
|
453
|
+
return { state: 'captured', sourceEncoding: o.sourceEncoding, decodedByteLength, capturedByteLength, truncated };
|
|
46
454
|
}
|
|
455
|
+
if (o.state === 'fetch_failed') {
|
|
456
|
+
exactKeys(o, ['state', 'error'], source, path);
|
|
457
|
+
return { state: 'fetch_failed', error: vNonEmptyString(o.error, source, `${path}.error`) };
|
|
458
|
+
}
|
|
459
|
+
if (o.state === 'not_applicable') {
|
|
460
|
+
exactKeys(o, ['state', 'reason'], source, path);
|
|
461
|
+
if (o.reason !== 'redirect' && o.reason !== 'no_response' && o.reason !== 'network_failed') {
|
|
462
|
+
fail(source, `${path}.reason`, 'must be "redirect", "no_response", or "network_failed"');
|
|
463
|
+
}
|
|
464
|
+
return { state: 'not_applicable', reason: o.reason };
|
|
465
|
+
}
|
|
466
|
+
return fail(source, `${path}.state`, 'must be "captured", "fetch_failed", or "not_applicable"');
|
|
47
467
|
}
|
|
48
468
|
|
|
49
|
-
|
|
50
|
-
const harPath = harFilePath(id);
|
|
51
|
-
let har: HarFile;
|
|
469
|
+
// ── WebSocket entries (DevTools convention) ──────────────────────────────────
|
|
52
470
|
|
|
471
|
+
function vWebSocketMessage(v: unknown, source: string, path: string): WebSocketMessage {
|
|
472
|
+
const o = vObject(v, source, path);
|
|
473
|
+
exactKeys(o, ['type', 'time', 'opcode', 'data'], source, path);
|
|
474
|
+
if (o.type !== 'send' && o.type !== 'receive') {
|
|
475
|
+
fail(source, `${path}.type`, 'must be "send" or "receive"');
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
type: o.type,
|
|
479
|
+
time: vFinite(o.time, source, `${path}.time`),
|
|
480
|
+
opcode: vIntInRange(o.opcode, 0, 15, source, `${path}.opcode`),
|
|
481
|
+
data: vString(o.data, source, `${path}.data`),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function vWebSocketEntry(o: Record<string, unknown>, source: string, path: string): WebSocketHAREntry {
|
|
486
|
+
exactKeys(o, ['startedDateTime', 'request', 'response', '_resourceType', '_webSocketMessages'], source, path);
|
|
487
|
+
vLiteral(o._resourceType, 'websocket', source, `${path}._resourceType`);
|
|
488
|
+
const startedDateTime = vNonEmptyString(o.startedDateTime, source, `${path}.startedDateTime`);
|
|
489
|
+
const parsed = new Date(startedDateTime);
|
|
490
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== startedDateTime) {
|
|
491
|
+
fail(source, `${path}.startedDateTime`, 'must be a canonical UTC ISO instant');
|
|
492
|
+
}
|
|
493
|
+
const request = vRequest(o.request, source, `${path}.request`);
|
|
494
|
+
const respObj = vObject(o.response, source, `${path}.response`);
|
|
495
|
+
exactKeys(respObj, ['status', 'headers', 'content'], source, `${path}.response`);
|
|
496
|
+
const status = vIntInRange(respObj.status, 0, 599, source, `${path}.response.status`);
|
|
497
|
+
if (status !== 0 && status < 100) {
|
|
498
|
+
fail(source, `${path}.response.status`, 'must be 0 (handshake response never observed) or 100..599');
|
|
499
|
+
}
|
|
500
|
+
const respHeaders = vHeaders(respObj.headers, source, `${path}.response.headers`);
|
|
501
|
+
const contentObj = vObject(respObj.content, source, `${path}.response.content`);
|
|
502
|
+
exactKeys(contentObj, [], source, `${path}.response.content`);
|
|
503
|
+
const messages = vArray(o._webSocketMessages, source, `${path}._webSocketMessages`).map((m, i) =>
|
|
504
|
+
vWebSocketMessage(m, source, `${path}._webSocketMessages[${i}]`),
|
|
505
|
+
);
|
|
506
|
+
return {
|
|
507
|
+
startedDateTime,
|
|
508
|
+
request,
|
|
509
|
+
response: { status, headers: respHeaders, content: {} },
|
|
510
|
+
_resourceType: 'websocket',
|
|
511
|
+
_webSocketMessages: messages,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── HAREntry ─────────────────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
function vEntry(v: unknown, source: string, path: string): HAREntry {
|
|
518
|
+
const o = vObject(v, source, path);
|
|
519
|
+
if (hasOwn(o, '_resourceType')) return vWebSocketEntry(o, source, path);
|
|
520
|
+
exactKeys(o, ['startedDateTime', 'time', 'request', 'response', '_capture'], source, path);
|
|
521
|
+
|
|
522
|
+
const startedDateTime = vString(o.startedDateTime, source, `${path}.startedDateTime`);
|
|
523
|
+
const time = vFiniteMin(o.time, 0, source, `${path}.time`);
|
|
524
|
+
const request = vRequest(o.request, source, `${path}.request`);
|
|
525
|
+
|
|
526
|
+
// Response — structure only; content keys/values are coupled to body below.
|
|
527
|
+
const respObj = vObject(o.response, source, `${path}.response`);
|
|
528
|
+
exactKeys(respObj, ['status', 'headers', 'content'], source, `${path}.response`);
|
|
529
|
+
const status = vIntInRange(respObj.status, 0, 599, source, `${path}.response.status`);
|
|
530
|
+
const respHeaders = vHeaders(respObj.headers, source, `${path}.response.headers`);
|
|
531
|
+
const contentObj = vObject(respObj.content, source, `${path}.response.content`);
|
|
532
|
+
|
|
533
|
+
// _capture
|
|
534
|
+
const capObj = vObject(o._capture, source, `${path}._capture`);
|
|
535
|
+
exactKeys(capObj, ['schemaVersion', 'requestId', 'generation', 'clocks', 'terminal', 'response', 'body'], source, `${path}._capture`);
|
|
536
|
+
vLiteral(capObj.schemaVersion, 1, source, `${path}._capture.schemaVersion`);
|
|
537
|
+
const requestId = vNonEmptyString(capObj.requestId, source, `${path}._capture.requestId`);
|
|
538
|
+
const generation = vSafeIntMin(capObj.generation, 1, source, `${path}._capture.generation`);
|
|
539
|
+
const clocks = vClocks(capObj.clocks, source, `${path}._capture.clocks`);
|
|
540
|
+
const terminal = vEntryTerminal(capObj.terminal, source, `${path}._capture.terminal`);
|
|
541
|
+
const capResponse = vCaptureResponseState(capObj.response, source, `${path}._capture.response`);
|
|
542
|
+
const body = vBodyProvenance(capObj.body, source, `${path}._capture.body`);
|
|
543
|
+
|
|
544
|
+
// startedDateTime is derived only from request wall time.
|
|
545
|
+
assertStartedDateTime(startedDateTime, clocks.requestWallTime, source, `${path}._capture.clocks.requestWallTime`, `${path}.startedDateTime`);
|
|
546
|
+
|
|
547
|
+
// time is derived only from request→terminal monotonic clocks.
|
|
548
|
+
const expectedTime = (clocks.terminalMonotonic - clocks.requestMonotonic) * 1000;
|
|
549
|
+
if (time !== expectedTime) {
|
|
550
|
+
fail(source, `${path}.time`, 'must equal (terminalMonotonic - requestMonotonic) * 1000');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Response-state ⇄ status coupling.
|
|
554
|
+
if (capResponse.state === 'received') {
|
|
555
|
+
if (status < 100 || status > 599) {
|
|
556
|
+
fail(source, `${path}.response.status`, 'a received response requires status 100..599');
|
|
557
|
+
}
|
|
558
|
+
} else {
|
|
559
|
+
// unavailable
|
|
560
|
+
if (terminal.kind !== 'failed') {
|
|
561
|
+
fail(source, `${path}._capture.response.state`, '"unavailable" is permitted only for terminal kind "failed"');
|
|
562
|
+
}
|
|
563
|
+
if (status !== 0) fail(source, `${path}.response.status`, 'an unavailable response requires status 0');
|
|
564
|
+
if (respHeaders.length !== 0) fail(source, `${path}.response.headers`, 'an unavailable response requires empty headers');
|
|
565
|
+
if (clocks.responseMonotonic !== null) {
|
|
566
|
+
fail(source, `${path}._capture.clocks.responseMonotonic`, 'must be null when no response was received');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (status === 0 && capResponse.state !== 'unavailable') {
|
|
570
|
+
fail(source, `${path}.response.status`, 'status 0 is permitted only when _capture.response.state is "unavailable"');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Terminal ⇄ response/status/body coupling.
|
|
574
|
+
if (terminal.kind === 'finished') {
|
|
575
|
+
if (capResponse.state !== 'received') {
|
|
576
|
+
fail(source, `${path}._capture.response.state`, 'a finished terminal requires a received response');
|
|
577
|
+
}
|
|
578
|
+
if (body.state !== 'captured' && body.state !== 'fetch_failed') {
|
|
579
|
+
fail(source, `${path}._capture.body.state`, 'a finished terminal requires body "captured" or "fetch_failed"');
|
|
580
|
+
}
|
|
581
|
+
} else if (terminal.kind === 'redirect') {
|
|
582
|
+
if (capResponse.state !== 'received') {
|
|
583
|
+
fail(source, `${path}._capture.response.state`, 'a redirect requires a received response');
|
|
584
|
+
}
|
|
585
|
+
if (status < 300 || status > 399) {
|
|
586
|
+
fail(source, `${path}.response.status`, 'a redirect requires status 300..399');
|
|
587
|
+
}
|
|
588
|
+
if (body.state !== 'not_applicable' || body.reason !== 'redirect') {
|
|
589
|
+
fail(source, `${path}._capture.body`, 'a redirect requires body not_applicable/redirect');
|
|
590
|
+
}
|
|
591
|
+
if (clocks.responseMonotonic !== null) {
|
|
592
|
+
fail(source, `${path}._capture.clocks.responseMonotonic`, 'must be null for a redirect entry');
|
|
593
|
+
}
|
|
594
|
+
} else {
|
|
595
|
+
// failed
|
|
596
|
+
if (body.state !== 'not_applicable' || (body.reason !== 'no_response' && body.reason !== 'network_failed')) {
|
|
597
|
+
fail(source, `${path}._capture.body`, 'a failed terminal requires body not_applicable/no_response or not_applicable/network_failed');
|
|
598
|
+
}
|
|
599
|
+
if (capResponse.state === 'unavailable' && body.reason !== 'no_response') {
|
|
600
|
+
fail(source, `${path}._capture.body.reason`, 'a failed terminal without a response requires reason "no_response"');
|
|
601
|
+
}
|
|
602
|
+
if (capResponse.state === 'received' && body.reason !== 'network_failed') {
|
|
603
|
+
fail(source, `${path}._capture.body.reason`, 'a failed terminal with a received response requires reason "network_failed"');
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Content ⇄ body coupling (content.text/encoding are controlled solely by body.state).
|
|
608
|
+
const content: HarResponseContent = {};
|
|
609
|
+
if (body.state === 'captured' && body.sourceEncoding === 'text') {
|
|
610
|
+
exactKeys(contentObj, ['text'], source, `${path}.response.content`);
|
|
611
|
+
const text = vString(contentObj.text, source, `${path}.response.content.text`);
|
|
612
|
+
if (Buffer.byteLength(text, 'utf8') !== body.capturedByteLength) {
|
|
613
|
+
fail(source, `${path}.response.content.text`, 'UTF-8 byte length must equal body.capturedByteLength');
|
|
614
|
+
}
|
|
615
|
+
content.text = text;
|
|
616
|
+
} else if (body.state === 'captured' && body.sourceEncoding === 'base64') {
|
|
617
|
+
exactKeys(contentObj, ['text', 'encoding'], source, `${path}.response.content`);
|
|
618
|
+
const text = vString(contentObj.text, source, `${path}.response.content.text`);
|
|
619
|
+
vLiteral(contentObj.encoding, 'base64', source, `${path}.response.content.encoding`);
|
|
620
|
+
const decoded = Buffer.from(text, 'base64');
|
|
621
|
+
if (decoded.toString('base64') !== text) {
|
|
622
|
+
fail(source, `${path}.response.content.text`, 'must be canonical base64');
|
|
623
|
+
}
|
|
624
|
+
if (decoded.length !== body.capturedByteLength) {
|
|
625
|
+
fail(source, `${path}.response.content.text`, 'decoded byte length must equal body.capturedByteLength');
|
|
626
|
+
}
|
|
627
|
+
if (body.capturedByteLength !== Math.min(body.decodedByteLength, MAX_BODY_BYTES)) {
|
|
628
|
+
fail(source, `${path}._capture.body.capturedByteLength`, 'must equal min(decodedByteLength, 262144) for base64 bodies');
|
|
629
|
+
}
|
|
630
|
+
content.text = text;
|
|
631
|
+
content.encoding = 'base64';
|
|
632
|
+
} else {
|
|
633
|
+
// fetch_failed or not_applicable: both content.text and content.encoding absent.
|
|
634
|
+
exactKeys(contentObj, [], source, `${path}.response.content`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
startedDateTime,
|
|
639
|
+
time,
|
|
640
|
+
request,
|
|
641
|
+
response: { status, headers: respHeaders, content },
|
|
642
|
+
_capture: { schemaVersion: 1, requestId, generation, clocks, terminal, response: capResponse, body },
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ── IncompleteLifecycle ──────────────────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
type IncompleteResponse = { status: number; headers: Header[]; responseMonotonic: number | null };
|
|
649
|
+
|
|
650
|
+
function vIncompleteResponse(
|
|
651
|
+
v: unknown,
|
|
652
|
+
source: string,
|
|
653
|
+
path: string,
|
|
654
|
+
minResponseMonotonic: number,
|
|
655
|
+
): { status: number; headers: Header[]; responseMonotonic: number };
|
|
656
|
+
function vIncompleteResponse(
|
|
657
|
+
v: unknown,
|
|
658
|
+
source: string,
|
|
659
|
+
path: string,
|
|
660
|
+
minResponseMonotonic: number,
|
|
661
|
+
allowUnknownClock: true,
|
|
662
|
+
): IncompleteResponse;
|
|
663
|
+
function vIncompleteResponse(
|
|
664
|
+
v: unknown,
|
|
665
|
+
source: string,
|
|
666
|
+
path: string,
|
|
667
|
+
minResponseMonotonic: number,
|
|
668
|
+
allowUnknownClock = false,
|
|
669
|
+
): IncompleteResponse {
|
|
670
|
+
const o = vObject(v, source, path);
|
|
671
|
+
exactKeys(o, ['status', 'headers', 'responseMonotonic'], source, path);
|
|
672
|
+
const responseMonotonic = o.responseMonotonic === null
|
|
673
|
+
? allowUnknownClock ? null : fail(source, `${path}.responseMonotonic`, 'must be a finite number')
|
|
674
|
+
: vFiniteMin(o.responseMonotonic, minResponseMonotonic, source, `${path}.responseMonotonic`);
|
|
675
|
+
return {
|
|
676
|
+
status: vIntInRange(o.status, 100, 599, source, `${path}.status`),
|
|
677
|
+
headers: vHeaders(o.headers, source, `${path}.headers`),
|
|
678
|
+
responseMonotonic,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function vStoppedBeforeTerminal(
|
|
683
|
+
o: Record<string, unknown>,
|
|
684
|
+
source: string,
|
|
685
|
+
path: string,
|
|
686
|
+
): Extract<IncompleteLifecycle, { kind: 'stopped_before_terminal' }> {
|
|
687
|
+
exactKeys(o, ['kind', 'requestId', 'generation', 'startedDateTime', 'request', '_capture'], source, path);
|
|
688
|
+
const requestId = vNonEmptyString(o.requestId, source, `${path}.requestId`);
|
|
689
|
+
const generation = vSafeIntMin(o.generation, 1, source, `${path}.generation`);
|
|
690
|
+
const startedDateTime = vString(o.startedDateTime, source, `${path}.startedDateTime`);
|
|
691
|
+
const request = vRequest(o.request, source, `${path}.request`);
|
|
692
|
+
|
|
693
|
+
const cap = vObject(o._capture, source, `${path}._capture`);
|
|
694
|
+
exactKeys(cap, ['schemaVersion', 'requestWallTime', 'requestMonotonic', 'response'], source, `${path}._capture`);
|
|
695
|
+
vLiteral(cap.schemaVersion, 1, source, `${path}._capture.schemaVersion`);
|
|
696
|
+
const requestWallTime = vFinite(cap.requestWallTime, source, `${path}._capture.requestWallTime`);
|
|
697
|
+
const requestMonotonic = vFiniteMin(cap.requestMonotonic, 0, source, `${path}._capture.requestMonotonic`);
|
|
698
|
+
const response =
|
|
699
|
+
cap.response === null
|
|
700
|
+
? null
|
|
701
|
+
: vIncompleteResponse(cap.response, source, `${path}._capture.response`, requestMonotonic);
|
|
702
|
+
|
|
703
|
+
assertStartedDateTime(startedDateTime, requestWallTime, source, `${path}._capture.requestWallTime`, `${path}.startedDateTime`);
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
kind: 'stopped_before_terminal',
|
|
707
|
+
requestId,
|
|
708
|
+
generation,
|
|
709
|
+
startedDateTime,
|
|
710
|
+
request,
|
|
711
|
+
_capture: { schemaVersion: 1, requestWallTime, requestMonotonic, response },
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function vInvalidClockTerminal(
|
|
716
|
+
v: unknown,
|
|
717
|
+
source: string,
|
|
718
|
+
path: string,
|
|
719
|
+
): Extract<IncompleteLifecycle, { kind: 'invalid_clock_order' }>['terminal'] {
|
|
720
|
+
const o = vObject(v, source, path);
|
|
721
|
+
if (o.kind === 'finished') {
|
|
722
|
+
exactKeys(o, ['kind', 'terminalMonotonic', 'encodedDataLength'], source, path);
|
|
723
|
+
return {
|
|
724
|
+
kind: 'finished',
|
|
725
|
+
terminalMonotonic: vFiniteMin(o.terminalMonotonic, 0, source, `${path}.terminalMonotonic`),
|
|
726
|
+
encodedDataLength: vFiniteMin(o.encodedDataLength, 0, source, `${path}.encodedDataLength`),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
if (o.kind === 'redirect') {
|
|
730
|
+
exactKeys(o, ['kind', 'terminalMonotonic'], source, path);
|
|
731
|
+
return { kind: 'redirect', terminalMonotonic: vFiniteMin(o.terminalMonotonic, 0, source, `${path}.terminalMonotonic`) };
|
|
732
|
+
}
|
|
733
|
+
if (o.kind === 'failed') {
|
|
734
|
+
exactKeys(o, ['kind', 'terminalMonotonic', 'errorText', 'canceled', 'blockedReason', 'resourceType'], source, path);
|
|
735
|
+
return {
|
|
736
|
+
kind: 'failed',
|
|
737
|
+
terminalMonotonic: vFiniteMin(o.terminalMonotonic, 0, source, `${path}.terminalMonotonic`),
|
|
738
|
+
errorText: vString(o.errorText, source, `${path}.errorText`),
|
|
739
|
+
canceled: vBoolean(o.canceled, source, `${path}.canceled`),
|
|
740
|
+
blockedReason: vNullableString(o.blockedReason, source, `${path}.blockedReason`),
|
|
741
|
+
resourceType: vNullableString(o.resourceType, source, `${path}.resourceType`),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
return fail(source, `${path}.kind`, 'must be "finished", "redirect", or "failed"');
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function vInvalidClockOrder(
|
|
748
|
+
o: Record<string, unknown>,
|
|
749
|
+
source: string,
|
|
750
|
+
path: string,
|
|
751
|
+
): Extract<IncompleteLifecycle, { kind: 'invalid_clock_order' }> {
|
|
752
|
+
exactKeys(o, ['kind', 'requestId', 'generation', 'startedDateTime', 'request', 'response', 'terminal', '_capture', 'violation'], source, path);
|
|
753
|
+
const requestId = vNonEmptyString(o.requestId, source, `${path}.requestId`);
|
|
754
|
+
const generation = vSafeIntMin(o.generation, 1, source, `${path}.generation`);
|
|
755
|
+
const startedDateTime = vString(o.startedDateTime, source, `${path}.startedDateTime`);
|
|
756
|
+
const request = vRequest(o.request, source, `${path}.request`);
|
|
757
|
+
const response =
|
|
758
|
+
o.response === null ? null : vIncompleteResponse(o.response, source, `${path}.response`, 0, true);
|
|
759
|
+
const terminal = vInvalidClockTerminal(o.terminal, source, `${path}.terminal`);
|
|
760
|
+
|
|
761
|
+
const cap = vObject(o._capture, source, `${path}._capture`);
|
|
762
|
+
exactKeys(cap, ['schemaVersion', 'requestWallTime', 'requestMonotonic'], source, `${path}._capture`);
|
|
763
|
+
vLiteral(cap.schemaVersion, 1, source, `${path}._capture.schemaVersion`);
|
|
764
|
+
const requestWallTime = vFinite(cap.requestWallTime, source, `${path}._capture.requestWallTime`);
|
|
765
|
+
const requestMonotonic = vFiniteMin(cap.requestMonotonic, 0, source, `${path}._capture.requestMonotonic`);
|
|
766
|
+
|
|
767
|
+
let violation: Extract<IncompleteLifecycle, { kind: 'invalid_clock_order' }>['violation'];
|
|
768
|
+
if (o.violation === 'response_before_request') violation = 'response_before_request';
|
|
769
|
+
else if (o.violation === 'terminal_before_request') violation = 'terminal_before_request';
|
|
770
|
+
else if (o.violation === 'terminal_before_response') violation = 'terminal_before_response';
|
|
771
|
+
else return fail(source, `${path}.violation`, 'must be "response_before_request", "terminal_before_request", or "terminal_before_response"');
|
|
772
|
+
|
|
773
|
+
assertStartedDateTime(startedDateTime, requestWallTime, source, `${path}._capture.requestWallTime`, `${path}.startedDateTime`);
|
|
774
|
+
|
|
775
|
+
// CDP redirectResponse preserves redirect facts but has no independently observed response clock.
|
|
776
|
+
if (terminal.kind === 'redirect') {
|
|
777
|
+
if (response === null) fail(source, `${path}.response`, 'a redirect terminal requires an observed response');
|
|
778
|
+
if (response.status < 300 || response.status > 399) {
|
|
779
|
+
fail(source, `${path}.response.status`, 'a redirect terminal requires status 300..399');
|
|
780
|
+
}
|
|
781
|
+
if (response.responseMonotonic !== null) {
|
|
782
|
+
fail(source, `${path}.response.responseMonotonic`, 'must be null for a redirect terminal');
|
|
783
|
+
}
|
|
784
|
+
} else if (response?.responseMonotonic === null) {
|
|
785
|
+
fail(source, `${path}.response.responseMonotonic`, 'may be null only for a redirect terminal');
|
|
786
|
+
}
|
|
787
|
+
if (violation === 'response_before_request') {
|
|
788
|
+
if (response === null || response.responseMonotonic === null) fail(source, `${path}.violation`, '"response_before_request" requires an observed response clock');
|
|
789
|
+
else if (!(response.responseMonotonic < requestMonotonic)) {
|
|
790
|
+
fail(source, `${path}.violation`, '"response_before_request" requires response.responseMonotonic < requestMonotonic');
|
|
791
|
+
}
|
|
792
|
+
} else if (violation === 'terminal_before_request') {
|
|
793
|
+
if (!(terminal.terminalMonotonic < requestMonotonic)) {
|
|
794
|
+
fail(source, `${path}.violation`, '"terminal_before_request" requires terminal.terminalMonotonic < requestMonotonic');
|
|
795
|
+
}
|
|
796
|
+
} else {
|
|
797
|
+
if (response === null || response.responseMonotonic === null) fail(source, `${path}.violation`, '"terminal_before_response" requires an observed response clock');
|
|
798
|
+
else if (!(terminal.terminalMonotonic < response.responseMonotonic)) {
|
|
799
|
+
fail(source, `${path}.violation`, '"terminal_before_response" requires terminal.terminalMonotonic < response.responseMonotonic');
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return { kind: 'invalid_clock_order', requestId, generation, startedDateTime, request, response, terminal, _capture: { schemaVersion: 1, requestWallTime, requestMonotonic }, violation };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function vStoppedDuringBody(
|
|
807
|
+
o: Record<string, unknown>,
|
|
808
|
+
source: string,
|
|
809
|
+
path: string,
|
|
810
|
+
): Extract<IncompleteLifecycle, { kind: 'stopped_during_body' }> {
|
|
811
|
+
exactKeys(o, ['kind', 'requestId', 'generation', 'startedDateTime', 'request', 'response', '_capture'], source, path);
|
|
812
|
+
const requestId = vNonEmptyString(o.requestId, source, `${path}.requestId`);
|
|
813
|
+
const generation = vSafeIntMin(o.generation, 1, source, `${path}.generation`);
|
|
814
|
+
const startedDateTime = vString(o.startedDateTime, source, `${path}.startedDateTime`);
|
|
815
|
+
const request = vRequest(o.request, source, `${path}.request`);
|
|
816
|
+
|
|
817
|
+
const respObj = vObject(o.response, source, `${path}.response`);
|
|
818
|
+
exactKeys(respObj, ['status', 'headers'], source, `${path}.response`);
|
|
819
|
+
const response = {
|
|
820
|
+
status: vIntInRange(respObj.status, 100, 599, source, `${path}.response.status`),
|
|
821
|
+
headers: vHeaders(respObj.headers, source, `${path}.response.headers`),
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const cap = vObject(o._capture, source, `${path}._capture`);
|
|
825
|
+
exactKeys(cap, ['schemaVersion', 'requestWallTime', 'requestMonotonic', 'responseMonotonic', 'terminalMonotonic', 'encodedDataLength'], source, `${path}._capture`);
|
|
826
|
+
vLiteral(cap.schemaVersion, 1, source, `${path}._capture.schemaVersion`);
|
|
827
|
+
const requestWallTime = vFinite(cap.requestWallTime, source, `${path}._capture.requestWallTime`);
|
|
828
|
+
const requestMonotonic = vFiniteMin(cap.requestMonotonic, 0, source, `${path}._capture.requestMonotonic`);
|
|
829
|
+
const responseMonotonic = vFiniteMin(cap.responseMonotonic, requestMonotonic, source, `${path}._capture.responseMonotonic`);
|
|
830
|
+
const terminalMonotonic = vFiniteMin(cap.terminalMonotonic, responseMonotonic, source, `${path}._capture.terminalMonotonic`);
|
|
831
|
+
const encodedDataLength = vFiniteMin(cap.encodedDataLength, 0, source, `${path}._capture.encodedDataLength`);
|
|
832
|
+
|
|
833
|
+
assertStartedDateTime(startedDateTime, requestWallTime, source, `${path}._capture.requestWallTime`, `${path}.startedDateTime`);
|
|
834
|
+
|
|
835
|
+
return {
|
|
836
|
+
kind: 'stopped_during_body',
|
|
837
|
+
requestId,
|
|
838
|
+
generation,
|
|
839
|
+
startedDateTime,
|
|
840
|
+
request,
|
|
841
|
+
response,
|
|
842
|
+
_capture: { schemaVersion: 1, requestWallTime, requestMonotonic, responseMonotonic, terminalMonotonic, encodedDataLength },
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function vIncomplete(v: unknown, source: string, path: string): IncompleteLifecycle {
|
|
847
|
+
const o = vObject(v, source, path);
|
|
848
|
+
if (o.kind === 'stopped_before_terminal') return vStoppedBeforeTerminal(o, source, path);
|
|
849
|
+
if (o.kind === 'invalid_clock_order') return vInvalidClockOrder(o, source, path);
|
|
850
|
+
if (o.kind === 'stopped_during_body') return vStoppedDuringBody(o, source, path);
|
|
851
|
+
return fail(source, `${path}.kind`, 'must be "stopped_before_terminal", "invalid_clock_order", or "stopped_during_body"');
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ── Top-level validators ─────────────────────────────────────────────────────
|
|
855
|
+
|
|
856
|
+
export function validateHarFile(value: unknown, source: string): HarFile {
|
|
857
|
+
const o = vObject(value, source, 'harFile');
|
|
858
|
+
exactKeys(o, ['log', 'incompleteLifecycles'], source, 'harFile');
|
|
859
|
+
|
|
860
|
+
const log = vObject(o.log, source, 'harFile.log');
|
|
861
|
+
exactKeys(log, ['version', 'creator', 'entries'], source, 'harFile.log');
|
|
862
|
+
vLiteral(log.version, '1.2', source, 'harFile.log.version');
|
|
863
|
+
|
|
864
|
+
const creator = vObject(log.creator, source, 'harFile.log.creator');
|
|
865
|
+
exactKeys(creator, ['name', 'version'], source, 'harFile.log.creator');
|
|
866
|
+
vLiteral(creator.name, 'capture', source, 'harFile.log.creator.name');
|
|
867
|
+
const creatorVersion = vNonEmptyString(creator.version, source, 'harFile.log.creator.version');
|
|
868
|
+
|
|
869
|
+
const entries = vArray(log.entries, source, 'harFile.log.entries').map((e, i) => vEntry(e, source, `harFile.log.entries[${i}]`));
|
|
870
|
+
const incompleteLifecycles = vArray(o.incompleteLifecycles, source, 'harFile.incompleteLifecycles').map((c, i) =>
|
|
871
|
+
vIncomplete(c, source, `harFile.incompleteLifecycles[${i}]`),
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
log: { version: '1.2', creator: { name: 'capture', version: creatorVersion }, entries },
|
|
876
|
+
incompleteLifecycles,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
export function validateHarAppendBatch(value: unknown, source: string): HarAppendBatch {
|
|
881
|
+
const o = vObject(value, source, 'harAppendBatch');
|
|
882
|
+
exactKeys(o, ['entries', 'incompleteLifecycles'], source, 'harAppendBatch');
|
|
883
|
+
const entries = vArray(o.entries, source, 'harAppendBatch.entries').map((e, i) => vEntry(e, source, `harAppendBatch.entries[${i}]`));
|
|
884
|
+
const incompleteLifecycles = vArray(o.incompleteLifecycles, source, 'harAppendBatch.incompleteLifecycles').map((c, i) =>
|
|
885
|
+
vIncomplete(c, source, `harAppendBatch.incompleteLifecycles[${i}]`),
|
|
886
|
+
);
|
|
887
|
+
return { entries, incompleteLifecycles };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ── HAR recording file API (session-owned, private, append-only NDJSON) ─────
|
|
891
|
+
|
|
892
|
+
export const HAR_DIR = path.join(CAPTURE_ROOT, '.har');
|
|
893
|
+
|
|
894
|
+
function captureVersion(): string {
|
|
895
|
+
const version = (globalThis as { __CAPTURE_VERSION__?: unknown }).__CAPTURE_VERSION__;
|
|
896
|
+
return typeof version === 'string' && version.length > 0 ? version : 'development';
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function token(): string {
|
|
900
|
+
return crypto.randomBytes(6).toString('hex');
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** The store's first NDJSON record — written once at creation; every read
|
|
904
|
+
* requires it, so any file that is not a capture live-HAR append log (including
|
|
905
|
+
* an empty file) fails closed at the header. */
|
|
906
|
+
interface HarStoreHeader {
|
|
907
|
+
format: 'capture-live-har-ndjson';
|
|
908
|
+
version: 1;
|
|
909
|
+
creator: { name: 'capture'; version: string };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function vStoreHeader(value: unknown, source: string): HarStoreHeader {
|
|
913
|
+
const o = vObject(value, source, 'harStoreHeader');
|
|
914
|
+
exactKeys(o, ['format', 'version', 'creator'], source, 'harStoreHeader');
|
|
915
|
+
vLiteral(o.format, 'capture-live-har-ndjson', source, 'harStoreHeader.format');
|
|
916
|
+
vLiteral(o.version, 1, source, 'harStoreHeader.version');
|
|
917
|
+
const creator = vObject(o.creator, source, 'harStoreHeader.creator');
|
|
918
|
+
exactKeys(creator, ['name', 'version'], source, 'harStoreHeader.creator');
|
|
919
|
+
vLiteral(creator.name, 'capture', source, 'harStoreHeader.creator.name');
|
|
920
|
+
const version = vNonEmptyString(creator.version, source, 'harStoreHeader.creator.version');
|
|
921
|
+
return { format: 'capture-live-har-ndjson', version: 1, creator: { name: 'capture', version } };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Resolves an identifier or absolute path to the canonical HAR JSON path.
|
|
926
|
+
* Identifiers without separators are also treated as absolute paths for legacy
|
|
927
|
+
* callers that only persist a token.
|
|
928
|
+
*/
|
|
929
|
+
export function harFilePath(id: string): string {
|
|
930
|
+
if (path.isAbsolute(id)) return assertUnderCaptureRoot(id);
|
|
931
|
+
if (path.basename(id) === id) return assertUnderCaptureRoot(path.resolve(CAPTURE_ROOT, id));
|
|
932
|
+
if (id.endsWith('.json') && id.includes(path.sep)) return assertUnderCaptureRoot(id);
|
|
933
|
+
return assertUnderCaptureRoot(path.resolve(id));
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export async function createHarRecording(sessionDir: string = CAPTURE_ROOT): Promise<{ id: string; path: string }> {
|
|
937
|
+
const sessionAbsolute = path.resolve(sessionDir);
|
|
938
|
+
assertUnderCaptureRoot(sessionAbsolute);
|
|
939
|
+
const harDir = path.join(sessionAbsolute, '.har');
|
|
940
|
+
ensurePrivateDir(harDir);
|
|
941
|
+
|
|
942
|
+
const header: HarStoreHeader = {
|
|
943
|
+
format: 'capture-live-har-ndjson',
|
|
944
|
+
version: 1,
|
|
945
|
+
creator: { name: 'capture', version: captureVersion() },
|
|
946
|
+
};
|
|
947
|
+
const headerLine = `${JSON.stringify(header)}\n`;
|
|
948
|
+
for (;;) {
|
|
949
|
+
const candidate = path.join(harDir, `${token()}.json`);
|
|
950
|
+
// O_EXCL creation: a token collision loses to the existing store instead of
|
|
951
|
+
// replacing it.
|
|
952
|
+
try {
|
|
953
|
+
createPrivateFile(candidate, headerLine);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') continue;
|
|
956
|
+
throw error;
|
|
957
|
+
}
|
|
958
|
+
return { id: candidate, path: candidate };
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export async function readHarRecording(id: string): Promise<HarFile> {
|
|
963
|
+
const target = harFilePath(id);
|
|
964
|
+
const source = `live HAR recording ${target}`;
|
|
965
|
+
let raw: string;
|
|
53
966
|
try {
|
|
54
|
-
|
|
55
|
-
} catch {
|
|
56
|
-
|
|
967
|
+
raw = readPrivateFile(target).toString('utf8');
|
|
968
|
+
} catch (error) {
|
|
969
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
970
|
+
throw new Error(`live HAR recording is missing — cannot read: ${target}`, { cause: error });
|
|
971
|
+
}
|
|
972
|
+
throw error;
|
|
57
973
|
}
|
|
974
|
+
if (raw.length === 0) fail(source, 'store', 'is empty — a live HAR store always begins with its header record');
|
|
975
|
+
if (!raw.endsWith('\n')) fail(source, 'store', 'ends with an unterminated record — the store is corrupt');
|
|
976
|
+
const lines = raw.slice(0, -1).split('\n');
|
|
977
|
+
const parseLine = (line: string, index: number): unknown => {
|
|
978
|
+
if (line.length === 0) fail(source, `store line ${index + 1}`, 'is empty');
|
|
979
|
+
try {
|
|
980
|
+
return JSON.parse(line);
|
|
981
|
+
} catch {
|
|
982
|
+
return fail(source, `store line ${index + 1}`, 'is not valid JSON');
|
|
983
|
+
}
|
|
984
|
+
};
|
|
58
985
|
|
|
59
|
-
|
|
60
|
-
|
|
986
|
+
const header = vStoreHeader(parseLine(lines[0], 0), source);
|
|
987
|
+
const entries: HAREntry[] = [];
|
|
988
|
+
const incompleteLifecycles: IncompleteLifecycle[] = [];
|
|
989
|
+
for (let i = 1; i < lines.length; i++) {
|
|
990
|
+
const batch = validateHarAppendBatch(parseLine(lines[i], i), `${source} line ${i + 1}`);
|
|
991
|
+
entries.push(...batch.entries);
|
|
992
|
+
incompleteLifecycles.push(...batch.incompleteLifecycles);
|
|
993
|
+
}
|
|
994
|
+
return {
|
|
995
|
+
log: { version: '1.2', creator: header.creator, entries },
|
|
996
|
+
incompleteLifecycles,
|
|
997
|
+
};
|
|
61
998
|
}
|
|
62
999
|
|
|
63
|
-
export function
|
|
1000
|
+
export async function appendToHarRecording(id: string, batch: HarAppendBatch): Promise<void> {
|
|
1001
|
+
if (batch.entries.length === 0 && batch.incompleteLifecycles.length === 0) return;
|
|
1002
|
+
|
|
1003
|
+
const safeBatch = validateHarAppendBatch(batch, `har append batch ${id}`);
|
|
1004
|
+
const target = harFilePath(id);
|
|
64
1005
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
1006
|
+
// One O_APPEND no-follow write of one newline-terminated record. The file
|
|
1007
|
+
// is opened without O_CREAT: appending to a missing (deleted) store fails
|
|
1008
|
+
// explicitly and never recreates it.
|
|
1009
|
+
appendPrivateFile(target, `${JSON.stringify(safeBatch)}\n`);
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
1012
|
+
throw new Error(`live HAR recording is missing — cannot append: ${target}`, { cause: error });
|
|
1013
|
+
}
|
|
1014
|
+
throw error;
|
|
68
1015
|
}
|
|
69
1016
|
}
|
|
1017
|
+
|
|
1018
|
+
export async function deleteHarRecording(id: string): Promise<void> {
|
|
1019
|
+
unlinkPrivateFile(harFilePath(id));
|
|
1020
|
+
}
|