imprint-mcp 0.6.2 → 0.6.4
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/README.md +8 -1
- package/package.json +1 -1
- package/prompts/audit-agent.md +6 -5
- package/prompts/build-planning.md +2 -2
- package/prompts/compile-agent.md +6 -4
- package/prompts/live-verifier-agent.md +24 -11
- package/src/cli.ts +41 -6
- package/src/imprint/agent.ts +7 -2
- package/src/imprint/audit.ts +255 -42
- package/src/imprint/auth-compile-tools.ts +64 -13
- package/src/imprint/claude-cli-compile.ts +43 -7
- package/src/imprint/codex-cli-compile.ts +205 -129
- package/src/imprint/compile-agent-types.ts +25 -2
- package/src/imprint/compile-agent.ts +197 -40
- package/src/imprint/compile-tools.ts +164 -22
- package/src/imprint/inject-listener.ts +31 -3
- package/src/imprint/live-verifier.ts +167 -6
- package/src/imprint/llm.ts +7 -3
- package/src/imprint/mcp-compile-server.ts +188 -83
- package/src/imprint/mcp-server.ts +16 -1
- package/src/imprint/probe-backends.ts +60 -25
- package/src/imprint/react-flight-limits.ts +31 -0
- package/src/imprint/record.ts +657 -46
- package/src/imprint/redact.ts +240 -30
- package/src/imprint/response-body-intent.ts +73 -0
- package/src/imprint/response-body-lifecycle.ts +32 -0
- package/src/imprint/response-body-stream.ts +704 -0
- package/src/imprint/teach-state.ts +52 -2
- package/src/imprint/teach.ts +94 -69
- package/src/imprint/tracing.ts +60 -238
package/src/imprint/record.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* on clean shutdown (Ctrl+C, /done, or external AbortSignal).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
7
8
|
import { mkdirSync } from 'node:fs';
|
|
8
9
|
import { join as pathJoin, resolve as pathResolve } from 'node:path';
|
|
9
10
|
import { createInterface } from 'node:readline';
|
|
@@ -11,10 +12,26 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|
|
11
12
|
import CDP from 'chrome-remote-interface';
|
|
12
13
|
import envPaths from 'env-paths';
|
|
13
14
|
import { launchChromium } from './chromium.ts';
|
|
14
|
-
import { IMPRINT_SENTINEL,
|
|
15
|
+
import { IMPRINT_SENTINEL, createInjectedListenerSource } from './inject-listener.ts';
|
|
15
16
|
import { isDebug } from './log.ts';
|
|
16
17
|
import { defaultSessionJsonlPath } from './paths.ts';
|
|
18
|
+
import { type ResponseBodyIntentKind, ResponseBodyIntentTracker } from './response-body-intent.ts';
|
|
19
|
+
import { RequestBodyLifecycleTracker } from './response-body-lifecycle.ts';
|
|
20
|
+
import {
|
|
21
|
+
RESPONSE_BODY_STREAM_ALLOCATION_HEADROOM_BYTES,
|
|
22
|
+
type ResponseBodyCompletion,
|
|
23
|
+
type ResponseBodyStreamCapture,
|
|
24
|
+
type ResponseBodyStreamLease,
|
|
25
|
+
ResponseBodyStreamLeaseTracker,
|
|
26
|
+
ResponseBodyStreamStore,
|
|
27
|
+
matchesIntendedRoute,
|
|
28
|
+
needsLaterIntent,
|
|
29
|
+
resolveResponseBodyWithFallback,
|
|
30
|
+
shouldRecoverStreamedBody,
|
|
31
|
+
shouldStreamResponseBody,
|
|
32
|
+
} from './response-body-stream.ts';
|
|
17
33
|
import { createSessionWriter } from './session-writer.ts';
|
|
34
|
+
import { isTelemetryRequest } from './telemetry.ts';
|
|
18
35
|
import type { CapturedEvent, CapturedRequest, CookieSnapshot, StorageSnapshot } from './types.ts';
|
|
19
36
|
import { VERSION } from './version.ts';
|
|
20
37
|
|
|
@@ -34,6 +51,8 @@ interface RecordOptions {
|
|
|
34
51
|
signal?: AbortSignal;
|
|
35
52
|
/** Skip the interactive stdin narration loop (tests). */
|
|
36
53
|
noNarration?: boolean;
|
|
54
|
+
/** Test/embedding hook for attaching a second CDP driver. */
|
|
55
|
+
onBrowserReady?: (port: number, closeBrowser: () => Promise<void>) => void;
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
interface RecordResult {
|
|
@@ -51,6 +70,89 @@ interface PendingRequest {
|
|
|
51
70
|
headers: Record<string, string>;
|
|
52
71
|
body?: string;
|
|
53
72
|
resourceType: string;
|
|
73
|
+
requestAtMs: number;
|
|
74
|
+
recentUserIntent?: boolean;
|
|
75
|
+
recentUserActivation?: boolean;
|
|
76
|
+
intendedUrl?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface StreamingNetworkDomain {
|
|
80
|
+
streamResourceContent?: (params: { requestId: string }) => Promise<{ bufferedData: string }>;
|
|
81
|
+
dataReceived: (callback: (params: { requestId: string; data?: string }) => void) => unknown;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ActiveResponseStream {
|
|
85
|
+
seq: number;
|
|
86
|
+
url: string;
|
|
87
|
+
capture: ResponseBodyStreamCapture;
|
|
88
|
+
started: Promise<boolean>;
|
|
89
|
+
lease: ResponseBodyStreamLease;
|
|
90
|
+
priority: boolean;
|
|
91
|
+
requiresIntent: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface DormantResponseStream {
|
|
95
|
+
active: ActiveResponseStream;
|
|
96
|
+
completion: ResponseBodyCompletion;
|
|
97
|
+
method: string;
|
|
98
|
+
mimeType?: string;
|
|
99
|
+
url: string;
|
|
100
|
+
requestHeaders: Record<string, string>;
|
|
101
|
+
retainedAtMs: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const MAX_RESPONSE_BODY_BYTES = 2 * 1024 * 1024;
|
|
105
|
+
const STREAM_START_TIMEOUT_MS = 2_000;
|
|
106
|
+
const MAX_ISSUED_RESPONSE_STREAMS = 16;
|
|
107
|
+
const MAX_STREAM_START_FAILURES = 8;
|
|
108
|
+
const MAX_DORMANT_RESPONSE_STREAMS = 8;
|
|
109
|
+
const DORMANT_RESPONSE_STREAM_TTL_MS = 30_000;
|
|
110
|
+
const MAX_ACTIVE_STREAM_BYTES = 8 * 1024 * 1024;
|
|
111
|
+
const MAX_PASSIVE_STREAM_BYTES =
|
|
112
|
+
MAX_ACTIVE_STREAM_BYTES -
|
|
113
|
+
MAX_RESPONSE_BODY_BYTES -
|
|
114
|
+
RESPONSE_BODY_STREAM_ALLOCATION_HEADROOM_BYTES;
|
|
115
|
+
|
|
116
|
+
function capResponseBody(body: string): string {
|
|
117
|
+
const encoded = Buffer.from(body, 'utf8');
|
|
118
|
+
if (encoded.length <= MAX_RESPONSE_BODY_BYTES) return body;
|
|
119
|
+
let end = MAX_RESPONSE_BODY_BYTES;
|
|
120
|
+
// Do not split a multi-byte UTF-8 code point at the storage boundary.
|
|
121
|
+
while (end > 0 && (encoded[end] ?? 0) >> 6 === 0b10) end--;
|
|
122
|
+
return `${encoded.subarray(0, end).toString('utf8')}\n[…truncated…]`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isUnsupportedStreamMethodError(error: unknown): boolean {
|
|
126
|
+
const message = String(error).toLowerCase();
|
|
127
|
+
const responseCode =
|
|
128
|
+
typeof error === 'object' && error !== null && 'response' in error
|
|
129
|
+
? (error as { response?: { code?: unknown } }).response?.code
|
|
130
|
+
: undefined;
|
|
131
|
+
const code =
|
|
132
|
+
typeof error === 'object' && error !== null && 'code' in error
|
|
133
|
+
? (error as { code?: unknown }).code
|
|
134
|
+
: responseCode;
|
|
135
|
+
return (
|
|
136
|
+
code === -32601 ||
|
|
137
|
+
message.includes('-32601') ||
|
|
138
|
+
message.includes('method not found') ||
|
|
139
|
+
message.includes("method wasn't found")
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function responseContentLength(headers: Record<string, unknown>): number | undefined {
|
|
144
|
+
const entry = Object.entries(headers).find(([name]) => name.toLowerCase() === 'content-length');
|
|
145
|
+
if (!entry) return undefined;
|
|
146
|
+
const value = Number(entry[1]);
|
|
147
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hasSameOrigin(first: string, second: string): boolean {
|
|
151
|
+
try {
|
|
152
|
+
return new URL(first).origin === new URL(second).origin;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
54
156
|
}
|
|
55
157
|
|
|
56
158
|
export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
@@ -101,12 +203,16 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
|
101
203
|
},
|
|
102
204
|
});
|
|
103
205
|
const { Network, Page, Runtime } = client;
|
|
206
|
+
const streamingNetwork = Network as typeof Network & StreamingNetworkDomain;
|
|
104
207
|
|
|
105
208
|
await Promise.all([Network.enable(), Page.enable(), Runtime.enable()]);
|
|
106
209
|
|
|
107
210
|
// Passive DOM listener emits sentinel-prefixed console.log lines we parse
|
|
108
211
|
// via Runtime.consoleAPICalled below.
|
|
109
|
-
|
|
212
|
+
const eventToken = randomUUID();
|
|
213
|
+
await Page.addScriptToEvaluateOnNewDocument({
|
|
214
|
+
source: createInjectedListenerSource(eventToken),
|
|
215
|
+
});
|
|
110
216
|
|
|
111
217
|
const writer = createSessionWriter(outPath, {
|
|
112
218
|
site: opts.site,
|
|
@@ -128,31 +234,205 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
|
128
234
|
// sleep(100) heuristic dropped flight-search payloads silently.
|
|
129
235
|
const pending = new Map<string, PendingRequest>();
|
|
130
236
|
const inflight = new Set<Promise<void>>();
|
|
131
|
-
const bodyReady = new Map<
|
|
237
|
+
const bodyReady = new Map<
|
|
238
|
+
string,
|
|
239
|
+
ReturnType<typeof Promise.withResolvers<Exclude<ResponseBodyCompletion, { kind: 'timeout' }>>>
|
|
240
|
+
>();
|
|
241
|
+
const requestGenerations = new Map<string, number>();
|
|
242
|
+
const bodyLifecycles = new RequestBodyLifecycleTracker();
|
|
243
|
+
const bodyIntents = new ResponseBodyIntentTracker();
|
|
244
|
+
const intentRequestUrls = new Map<string, string>();
|
|
245
|
+
const intentRequestActivations = new Map<string, boolean>();
|
|
246
|
+
const streamStore = new ResponseBodyStreamStore();
|
|
247
|
+
const streamLeases = new ResponseBodyStreamLeaseTracker(MAX_ISSUED_RESPONSE_STREAMS, 4);
|
|
248
|
+
const activeStreams = new Map<string, ActiveResponseStream>();
|
|
249
|
+
const dormantStreams = new Map<number, DormantResponseStream>();
|
|
250
|
+
let streamMethodSupported = typeof streamingNetwork.streamResourceContent === 'function';
|
|
251
|
+
let streamStartFailures = 0;
|
|
252
|
+
let normalBodySuccesses = 0;
|
|
253
|
+
let normalBodyFailures = 0;
|
|
254
|
+
let networkCaptureClosing = false;
|
|
255
|
+
let responseAdmissionClosed = false;
|
|
256
|
+
let browserExited = false;
|
|
257
|
+
|
|
258
|
+
const discardDormantStream = (captureSeq: number): void => {
|
|
259
|
+
const dormant = dormantStreams.get(captureSeq);
|
|
260
|
+
if (!dormant) return;
|
|
261
|
+
dormantStreams.delete(captureSeq);
|
|
262
|
+
streamStore.discard(dormant.active.capture);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const pruneDormantStreams = (nowMs = Date.now()): void => {
|
|
266
|
+
for (const [captureSeq, dormant] of dormantStreams) {
|
|
267
|
+
if (nowMs - dormant.retainedAtMs <= DORMANT_RESPONSE_STREAM_TTL_MS) continue;
|
|
268
|
+
discardDormantStream(captureSeq);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const makeRoomForPriorityStream = (): void => {
|
|
273
|
+
pruneDormantStreams();
|
|
274
|
+
while (dormantStreams.size > 0 && streamStore.stats.activeBytes > MAX_PASSIVE_STREAM_BYTES) {
|
|
275
|
+
const oldest = dormantStreams.keys().next().value as number | undefined;
|
|
276
|
+
if (oldest === undefined) break;
|
|
277
|
+
discardDormantStream(oldest);
|
|
278
|
+
}
|
|
279
|
+
if (streamStore.stats.activeBytes <= MAX_PASSIVE_STREAM_BYTES) return;
|
|
280
|
+
for (const [requestId, active] of activeStreams) {
|
|
281
|
+
if (active.priority) continue;
|
|
282
|
+
streamStore.abandon(active.capture);
|
|
283
|
+
if (activeStreams.get(requestId) === active) activeStreams.delete(requestId);
|
|
284
|
+
if (streamStore.stats.activeBytes <= MAX_PASSIVE_STREAM_BYTES) break;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const enforcePassiveStreamBudget = (requestId: string, active: ActiveResponseStream): void => {
|
|
289
|
+
if (active.priority) return;
|
|
290
|
+
pruneDormantStreams();
|
|
291
|
+
while (dormantStreams.size > 0 && streamStore.stats.activeBytes > MAX_PASSIVE_STREAM_BYTES) {
|
|
292
|
+
const oldest = dormantStreams.keys().next().value as number | undefined;
|
|
293
|
+
if (oldest === undefined) break;
|
|
294
|
+
discardDormantStream(oldest);
|
|
295
|
+
}
|
|
296
|
+
if (streamStore.stats.activeBytes <= MAX_PASSIVE_STREAM_BYTES) return;
|
|
297
|
+
streamStore.abandon(active.capture);
|
|
298
|
+
if (activeStreams.get(requestId) === active) activeStreams.delete(requestId);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const promoteDormantStreams = (
|
|
302
|
+
intendedUrl: string,
|
|
303
|
+
intentAt: number,
|
|
304
|
+
intentKind: ResponseBodyIntentKind,
|
|
305
|
+
): void => {
|
|
306
|
+
if (intentKind !== 'pointerdown' && intentKind !== 'click') return;
|
|
307
|
+
pruneDormantStreams(intentAt);
|
|
308
|
+
let promotedActive = false;
|
|
309
|
+
for (const active of activeStreams.values()) {
|
|
310
|
+
if (!active.requiresIntent || !matchesIntendedRoute(active.url, intendedUrl)) continue;
|
|
311
|
+
active.requiresIntent = false;
|
|
312
|
+
active.priority = true;
|
|
313
|
+
streamLeases.promote(active.lease);
|
|
314
|
+
promotedActive = true;
|
|
315
|
+
}
|
|
316
|
+
if (promotedActive) makeRoomForPriorityStream();
|
|
317
|
+
for (const [captureSeq, dormant] of dormantStreams) {
|
|
318
|
+
if (!matchesIntendedRoute(dormant.url, intendedUrl)) continue;
|
|
319
|
+
dormantStreams.delete(captureSeq);
|
|
320
|
+
const body = streamStore.recover(dormant.active.capture, (candidate) =>
|
|
321
|
+
shouldRecoverStreamedBody({
|
|
322
|
+
completion: dormant.completion,
|
|
323
|
+
method: dormant.method,
|
|
324
|
+
mimeType: dormant.mimeType,
|
|
325
|
+
url: dormant.url,
|
|
326
|
+
requestHeaders: dormant.requestHeaders,
|
|
327
|
+
body: candidate,
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
if (body !== null) writer.requestBody(captureSeq, capResponseBody(body));
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
(streamingNetwork.dataReceived as StreamingNetworkDomain['dataReceived'])((params) => {
|
|
335
|
+
if (!params.data) return;
|
|
336
|
+
const active = activeStreams.get(params.requestId);
|
|
337
|
+
if (!active) return;
|
|
338
|
+
streamStore.appendData(active.capture, params.data);
|
|
339
|
+
enforcePassiveStreamBudget(params.requestId, active);
|
|
340
|
+
if (active.capture.truncated || active.capture.abandoned) {
|
|
341
|
+
streamStore.abandon(active.capture);
|
|
342
|
+
if (activeStreams.get(params.requestId) === active) {
|
|
343
|
+
activeStreams.delete(params.requestId);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
});
|
|
132
347
|
|
|
133
348
|
Network.requestWillBeSent((params) => {
|
|
134
349
|
const { request, requestId, type } = params;
|
|
350
|
+
if (responseAdmissionClosed || (networkCaptureClosing && !pending.has(requestId))) return;
|
|
135
351
|
if (isDebug()) {
|
|
136
352
|
console.error(`[debug] requestWillBeSent ${requestId} ${request.method} ${request.url}`);
|
|
137
353
|
}
|
|
354
|
+
const previousGeneration = requestGenerations.get(requestId);
|
|
355
|
+
streamLeases.markRequestCompleted(requestId, previousGeneration);
|
|
356
|
+
const previousReady = bodyReady.get(requestId);
|
|
357
|
+
previousReady?.resolve({ kind: 'failed', errorText: 'requestId reused' });
|
|
358
|
+
const previousStream = activeStreams.get(requestId);
|
|
359
|
+
if (previousStream) {
|
|
360
|
+
streamStore.abandon(previousStream.capture);
|
|
361
|
+
activeStreams.delete(requestId);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const requestAtMs =
|
|
365
|
+
typeof params.wallTime === 'number' && Number.isFinite(params.wallTime)
|
|
366
|
+
? params.wallTime * 1_000
|
|
367
|
+
: Date.now();
|
|
368
|
+
const matchedIntentUrl = bodyIntents.match(request.url, requestAtMs);
|
|
369
|
+
const matchedActivationUrl = bodyIntents.matchActivation(request.url, requestAtMs);
|
|
370
|
+
const directIntentMatch = Boolean(matchedIntentUrl);
|
|
371
|
+
const previousIntentUrl = intentRequestUrls.get(requestId);
|
|
372
|
+
const redirectedIntentMatch = Boolean(
|
|
373
|
+
params.redirectResponse &&
|
|
374
|
+
previousIntentUrl &&
|
|
375
|
+
hasSameOrigin(request.url, previousIntentUrl) &&
|
|
376
|
+
matchesIntendedRoute(request.url, previousIntentUrl),
|
|
377
|
+
);
|
|
378
|
+
const intentMatched = directIntentMatch || redirectedIntentMatch;
|
|
379
|
+
const intendedUrl = directIntentMatch
|
|
380
|
+
? matchedIntentUrl
|
|
381
|
+
: redirectedIntentMatch
|
|
382
|
+
? previousIntentUrl
|
|
383
|
+
: undefined;
|
|
384
|
+
const recentUserActivation = Boolean(
|
|
385
|
+
matchedActivationUrl ||
|
|
386
|
+
(redirectedIntentMatch && intentRequestActivations.get(requestId) === true),
|
|
387
|
+
);
|
|
388
|
+
if (intendedUrl) intentRequestUrls.set(requestId, intendedUrl);
|
|
389
|
+
else intentRequestUrls.delete(requestId);
|
|
390
|
+
if (intendedUrl) intentRequestActivations.set(requestId, recentUserActivation);
|
|
391
|
+
else intentRequestActivations.delete(requestId);
|
|
392
|
+
|
|
393
|
+
const requestSeq = nextSeq();
|
|
394
|
+
requestGenerations.set(requestId, requestSeq);
|
|
138
395
|
pending.set(requestId, {
|
|
139
|
-
seq:
|
|
396
|
+
seq: requestSeq,
|
|
140
397
|
timestamp: elapsed(),
|
|
141
398
|
method: request.method,
|
|
142
399
|
url: request.url,
|
|
143
400
|
headers: request.headers as Record<string, string>,
|
|
144
401
|
body: typeof request.postData === 'string' ? request.postData : undefined,
|
|
145
402
|
resourceType: type ?? 'Other',
|
|
403
|
+
requestAtMs,
|
|
404
|
+
recentUserIntent: intentMatched,
|
|
405
|
+
recentUserActivation,
|
|
406
|
+
intendedUrl,
|
|
146
407
|
});
|
|
147
|
-
bodyReady.set(
|
|
408
|
+
bodyReady.set(
|
|
409
|
+
requestId,
|
|
410
|
+
Promise.withResolvers<Exclude<ResponseBodyCompletion, { kind: 'timeout' }>>(),
|
|
411
|
+
);
|
|
148
412
|
});
|
|
149
413
|
|
|
150
414
|
Network.responseReceived((params) => {
|
|
415
|
+
if (responseAdmissionClosed) return;
|
|
151
416
|
const { requestId, response } = params;
|
|
152
417
|
const reqInfo = pending.get(requestId);
|
|
153
418
|
if (!reqInfo) return;
|
|
154
419
|
pending.delete(requestId);
|
|
155
420
|
|
|
421
|
+
// Runtime and Network events originate in separate CDP domains. Re-check
|
|
422
|
+
// at response time so a browser-timestamped intent that arrived after
|
|
423
|
+
// requestWillBeSent still qualifies its request deterministically.
|
|
424
|
+
const lateIntentUrl = bodyIntents.match(reqInfo.url, reqInfo.requestAtMs);
|
|
425
|
+
const lateActivationUrl = bodyIntents.matchActivation(reqInfo.url, reqInfo.requestAtMs);
|
|
426
|
+
if (lateIntentUrl) {
|
|
427
|
+
reqInfo.recentUserIntent = true;
|
|
428
|
+
reqInfo.intendedUrl = lateIntentUrl;
|
|
429
|
+
intentRequestUrls.set(requestId, lateIntentUrl);
|
|
430
|
+
}
|
|
431
|
+
if (lateActivationUrl) {
|
|
432
|
+
reqInfo.recentUserActivation = true;
|
|
433
|
+
intentRequestActivations.set(requestId, true);
|
|
434
|
+
}
|
|
435
|
+
|
|
156
436
|
if (isDebug()) {
|
|
157
437
|
console.error(
|
|
158
438
|
`[debug] responseReceived ${requestId} status=${response.status} ${reqInfo.url}`,
|
|
@@ -176,17 +456,194 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
|
176
456
|
};
|
|
177
457
|
writer.request(captured);
|
|
178
458
|
|
|
459
|
+
const streamResourceContent = streamingNetwork.streamResourceContent;
|
|
460
|
+
if (
|
|
461
|
+
streamMethodSupported &&
|
|
462
|
+
streamStartFailures < MAX_STREAM_START_FAILURES &&
|
|
463
|
+
streamResourceContent &&
|
|
464
|
+
!isTelemetryRequest(captured) &&
|
|
465
|
+
shouldStreamResponseBody({
|
|
466
|
+
method: reqInfo.method,
|
|
467
|
+
resourceType: reqInfo.resourceType,
|
|
468
|
+
status: response.status,
|
|
469
|
+
mimeType: response.mimeType,
|
|
470
|
+
contentLength: responseContentLength(response.headers),
|
|
471
|
+
url: reqInfo.url,
|
|
472
|
+
requestHeaders: reqInfo.headers,
|
|
473
|
+
recentUserIntent: reqInfo.recentUserIntent,
|
|
474
|
+
recentUserActivation: reqInfo.recentUserActivation,
|
|
475
|
+
intendedUrl: reqInfo.intendedUrl,
|
|
476
|
+
})
|
|
477
|
+
) {
|
|
478
|
+
const streamCandidate = {
|
|
479
|
+
method: reqInfo.method,
|
|
480
|
+
resourceType: reqInfo.resourceType,
|
|
481
|
+
status: response.status,
|
|
482
|
+
mimeType: response.mimeType,
|
|
483
|
+
contentLength: responseContentLength(response.headers),
|
|
484
|
+
url: reqInfo.url,
|
|
485
|
+
requestHeaders: reqInfo.headers,
|
|
486
|
+
recentUserIntent: reqInfo.recentUserIntent,
|
|
487
|
+
recentUserActivation: reqInfo.recentUserActivation,
|
|
488
|
+
intendedUrl: reqInfo.intendedUrl,
|
|
489
|
+
};
|
|
490
|
+
const priority = reqInfo.method === 'POST' || reqInfo.recentUserActivation === true;
|
|
491
|
+
const lease = streamLeases.begin(requestId, captured.seq, priority);
|
|
492
|
+
if (lease) {
|
|
493
|
+
if (priority) makeRoomForPriorityStream();
|
|
494
|
+
const capture = streamStore.begin(requestId, captured.seq);
|
|
495
|
+
const active: ActiveResponseStream = {
|
|
496
|
+
seq: captured.seq,
|
|
497
|
+
url: reqInfo.url,
|
|
498
|
+
capture,
|
|
499
|
+
started: Promise.resolve(false),
|
|
500
|
+
lease,
|
|
501
|
+
priority,
|
|
502
|
+
requiresIntent: needsLaterIntent(streamCandidate),
|
|
503
|
+
};
|
|
504
|
+
activeStreams.set(requestId, active);
|
|
505
|
+
active.started = streamResourceContent({ requestId })
|
|
506
|
+
.then(({ bufferedData }) => {
|
|
507
|
+
if (activeStreams.get(requestId) !== active) {
|
|
508
|
+
streamStore.discard(capture);
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
streamStore.markStarted(capture);
|
|
512
|
+
if (bufferedData) {
|
|
513
|
+
streamStore.appendBufferedData(capture, bufferedData);
|
|
514
|
+
enforcePassiveStreamBudget(requestId, active);
|
|
515
|
+
if (capture.truncated || capture.abandoned) {
|
|
516
|
+
streamStore.abandon(capture);
|
|
517
|
+
if (activeStreams.get(requestId) === active) activeStreams.delete(requestId);
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return true;
|
|
522
|
+
})
|
|
523
|
+
.catch((error) => {
|
|
524
|
+
streamStartFailures++;
|
|
525
|
+
if (
|
|
526
|
+
isUnsupportedStreamMethodError(error) ||
|
|
527
|
+
streamStartFailures >= MAX_STREAM_START_FAILURES
|
|
528
|
+
) {
|
|
529
|
+
streamMethodSupported = false;
|
|
530
|
+
}
|
|
531
|
+
if (activeStreams.get(requestId) === active) activeStreams.delete(requestId);
|
|
532
|
+
streamStore.discard(capture);
|
|
533
|
+
if (isDebug()) {
|
|
534
|
+
console.error(
|
|
535
|
+
`[debug] response stream unavailable seq=${captured.seq} ${reqInfo.url}: ${String(error)}`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
return false;
|
|
539
|
+
})
|
|
540
|
+
.finally(() => streamLeases.markCommandSettled(lease));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
bodyLifecycles.begin(requestId, captured.seq);
|
|
179
545
|
const bodyWork = (async () => {
|
|
180
546
|
const ready = bodyReady.get(requestId);
|
|
181
|
-
|
|
182
|
-
await Promise.race([
|
|
183
|
-
|
|
184
|
-
|
|
547
|
+
const completion: ResponseBodyCompletion = ready
|
|
548
|
+
? await Promise.race([
|
|
549
|
+
ready.promise,
|
|
550
|
+
sleep(30_000).then((): ResponseBodyCompletion => ({ kind: 'timeout' })),
|
|
551
|
+
])
|
|
552
|
+
: { kind: 'timeout' };
|
|
553
|
+
if (bodyReady.get(requestId) === ready) bodyReady.delete(requestId);
|
|
554
|
+
|
|
555
|
+
const activeCandidate = activeStreams.get(requestId);
|
|
556
|
+
const active = activeCandidate?.seq === captured.seq ? activeCandidate : undefined;
|
|
557
|
+
const stoppedAtShutdownCutoff =
|
|
558
|
+
completion.kind === 'failed' && completion.errorText === 'shutdown cutoff';
|
|
185
559
|
try {
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
560
|
+
const resolved = await resolveResponseBodyWithFallback({
|
|
561
|
+
readNormal: async () => {
|
|
562
|
+
if (browserExited || stoppedAtShutdownCutoff) {
|
|
563
|
+
throw new Error('response body read unavailable during shutdown');
|
|
564
|
+
}
|
|
565
|
+
if (requestGenerations.get(requestId) !== captured.seq) {
|
|
566
|
+
throw new Error('request generation changed');
|
|
567
|
+
}
|
|
568
|
+
const bodyResp = await Network.getResponseBody({ requestId });
|
|
569
|
+
if (requestGenerations.get(requestId) !== captured.seq) {
|
|
570
|
+
throw new Error('request generation changed');
|
|
571
|
+
}
|
|
572
|
+
normalBodySuccesses++;
|
|
573
|
+
return bodyResp.base64Encoded
|
|
574
|
+
? Buffer.from(bodyResp.body, 'base64').toString('utf8')
|
|
575
|
+
: bodyResp.body;
|
|
576
|
+
},
|
|
577
|
+
readStream: active
|
|
578
|
+
? async () => {
|
|
579
|
+
normalBodyFailures++;
|
|
580
|
+
const started = await Promise.race([
|
|
581
|
+
active.started,
|
|
582
|
+
sleep(STREAM_START_TIMEOUT_MS).then(() => false),
|
|
583
|
+
]);
|
|
584
|
+
if (
|
|
585
|
+
!started ||
|
|
586
|
+
activeStreams.get(requestId) !== active ||
|
|
587
|
+
requestGenerations.get(requestId) !== captured.seq
|
|
588
|
+
) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
if (
|
|
592
|
+
active.requiresIntent &&
|
|
593
|
+
completion.kind !== 'timeout' &&
|
|
594
|
+
!bodyIntents.hasMatchingActivationSince(reqInfo.url, reqInfo.requestAtMs)
|
|
595
|
+
) {
|
|
596
|
+
pruneDormantStreams();
|
|
597
|
+
if (active.capture.bytes === 0) return null;
|
|
598
|
+
while (dormantStreams.size >= MAX_DORMANT_RESPONSE_STREAMS) {
|
|
599
|
+
const oldest = dormantStreams.keys().next().value as number | undefined;
|
|
600
|
+
if (oldest === undefined) break;
|
|
601
|
+
discardDormantStream(oldest);
|
|
602
|
+
}
|
|
603
|
+
dormantStreams.set(captured.seq, {
|
|
604
|
+
active,
|
|
605
|
+
completion,
|
|
606
|
+
method: reqInfo.method,
|
|
607
|
+
mimeType: response.mimeType,
|
|
608
|
+
url: reqInfo.url,
|
|
609
|
+
requestHeaders: reqInfo.headers,
|
|
610
|
+
retainedAtMs: Date.now(),
|
|
611
|
+
});
|
|
612
|
+
while (
|
|
613
|
+
dormantStreams.size > 0 &&
|
|
614
|
+
streamStore.stats.activeBytes > MAX_PASSIVE_STREAM_BYTES
|
|
615
|
+
) {
|
|
616
|
+
const oldest = dormantStreams.keys().next().value as number | undefined;
|
|
617
|
+
if (oldest === undefined) break;
|
|
618
|
+
discardDormantStream(oldest);
|
|
619
|
+
}
|
|
620
|
+
if (activeStreams.get(requestId) === active) activeStreams.delete(requestId);
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
return streamStore.recover(active.capture, (body) =>
|
|
624
|
+
shouldRecoverStreamedBody({
|
|
625
|
+
completion,
|
|
626
|
+
method: reqInfo.method,
|
|
627
|
+
mimeType: response.mimeType,
|
|
628
|
+
url: reqInfo.url,
|
|
629
|
+
requestHeaders: reqInfo.headers,
|
|
630
|
+
body,
|
|
631
|
+
}),
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
: undefined,
|
|
635
|
+
});
|
|
636
|
+
if (resolved.source !== 'normal' && resolved.normalError !== undefined && !active) {
|
|
637
|
+
normalBodyFailures++;
|
|
638
|
+
}
|
|
639
|
+
if (resolved.body === null || requestGenerations.get(requestId) !== captured.seq) {
|
|
640
|
+
if (isDebug() && resolved.normalError !== undefined) {
|
|
641
|
+
console.error(
|
|
642
|
+
`[debug] body unavailable seq=${captured.seq} ${reqInfo.url}: ${String(resolved.normalError)}`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
190
647
|
// Body cap for the on-disk session. Server-rendered HTML pages on
|
|
191
648
|
// travel/booking sites routinely run 250-500KB (Costco's rental-car
|
|
192
649
|
// results page is ~262KB). The previous 256KB cap silently chopped
|
|
@@ -196,12 +653,23 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
|
196
653
|
// ~99th percentile of full-page renders without bloating most
|
|
197
654
|
// sessions — `Network.getResponseBody` still streams to memory,
|
|
198
655
|
// so very large bodies remain capped to protect process memory.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
656
|
+
writer.requestBody(captured.seq, capResponseBody(resolved.body));
|
|
657
|
+
if (resolved.source === 'stream' && isDebug()) {
|
|
658
|
+
console.error(
|
|
659
|
+
`[debug] recovered streamed response body seq=${captured.seq} bytes=${Buffer.byteLength(resolved.body)} ${reqInfo.url}`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
} finally {
|
|
663
|
+
if (active && !dormantStreams.has(captured.seq)) {
|
|
664
|
+
streamStore.discard(active.capture);
|
|
665
|
+
if (activeStreams.get(requestId) === active) activeStreams.delete(requestId);
|
|
666
|
+
}
|
|
667
|
+
const terminalWhileActive = bodyLifecycles.finish(requestId, captured.seq);
|
|
668
|
+
if (
|
|
669
|
+
(completion.kind !== 'timeout' || terminalWhileActive) &&
|
|
670
|
+
requestGenerations.get(requestId) === captured.seq
|
|
671
|
+
) {
|
|
672
|
+
requestGenerations.delete(requestId);
|
|
205
673
|
}
|
|
206
674
|
}
|
|
207
675
|
})();
|
|
@@ -210,28 +678,42 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
|
210
678
|
});
|
|
211
679
|
|
|
212
680
|
Network.loadingFinished((params) => {
|
|
213
|
-
|
|
681
|
+
const generation = requestGenerations.get(params.requestId);
|
|
682
|
+
const ready = bodyReady.get(params.requestId);
|
|
683
|
+
streamLeases.markRequestCompleted(params.requestId, generation);
|
|
684
|
+
ready?.resolve({ kind: 'finished' });
|
|
685
|
+
const canDeleteGeneration =
|
|
686
|
+
generation === undefined || bodyLifecycles.markTerminal(params.requestId, generation);
|
|
687
|
+
if (!ready && canDeleteGeneration && requestGenerations.get(params.requestId) === generation) {
|
|
688
|
+
requestGenerations.delete(params.requestId);
|
|
689
|
+
}
|
|
690
|
+
intentRequestUrls.delete(params.requestId);
|
|
691
|
+
intentRequestActivations.delete(params.requestId);
|
|
214
692
|
});
|
|
215
693
|
|
|
216
694
|
Network.loadingFailed((params) => {
|
|
695
|
+
const generation = requestGenerations.get(params.requestId);
|
|
696
|
+
const ready = bodyReady.get(params.requestId);
|
|
697
|
+
streamLeases.markRequestCompleted(params.requestId, generation);
|
|
217
698
|
if (isDebug()) {
|
|
218
699
|
console.error(`[debug] loadingFailed ${params.requestId} ${params.errorText}`);
|
|
219
700
|
}
|
|
220
|
-
|
|
701
|
+
ready?.resolve({
|
|
702
|
+
kind: 'failed',
|
|
703
|
+
errorText: params.errorText,
|
|
704
|
+
canceled: params.canceled,
|
|
705
|
+
blockedReason: params.blockedReason,
|
|
706
|
+
});
|
|
221
707
|
bodyReady.delete(params.requestId);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
const navResult = await Page.navigate({ url: opts.url });
|
|
231
|
-
if (isDebug()) {
|
|
232
|
-
console.error(`[debug] navigate returned: ${JSON.stringify(navResult)}`);
|
|
708
|
+
intentRequestUrls.delete(params.requestId);
|
|
709
|
+
intentRequestActivations.delete(params.requestId);
|
|
710
|
+
if (pending.delete(params.requestId)) requestGenerations.delete(params.requestId);
|
|
711
|
+
const canDeleteGeneration =
|
|
712
|
+
generation === undefined || bodyLifecycles.markTerminal(params.requestId, generation);
|
|
713
|
+
if (!ready && canDeleteGeneration && requestGenerations.get(params.requestId) === generation) {
|
|
714
|
+
requestGenerations.delete(params.requestId);
|
|
233
715
|
}
|
|
234
|
-
}
|
|
716
|
+
});
|
|
235
717
|
|
|
236
718
|
// ── Page navigation events ────────────────────────────────────────────────
|
|
237
719
|
Page.frameNavigated((params) => {
|
|
@@ -249,14 +731,47 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
|
249
731
|
// The injector posts lines like: [IMPRINT] click {"tag":"button","id":...,"selector":...}
|
|
250
732
|
Runtime.consoleAPICalled((params) => {
|
|
251
733
|
try {
|
|
252
|
-
if (params.type !== 'log' || !params.args || params.args.length <
|
|
734
|
+
if (params.type !== 'log' || !params.args || params.args.length < 4) return;
|
|
253
735
|
const first = params.args[0];
|
|
254
736
|
if (!first || first.type !== 'string' || first.value !== IMPRINT_SENTINEL) return;
|
|
255
737
|
const second = params.args[1];
|
|
256
738
|
const third = params.args[2];
|
|
257
|
-
const
|
|
258
|
-
|
|
739
|
+
const fourth = params.args[3];
|
|
740
|
+
if (!second || second.type !== 'string' || second.value !== eventToken) return;
|
|
741
|
+
const eventType = third?.type === 'string' ? third.value : null;
|
|
742
|
+
const payload = fourth?.type === 'string' ? fourth.value : null;
|
|
259
743
|
if (!eventType || !payload) return;
|
|
744
|
+
if (eventType === 'intent') {
|
|
745
|
+
try {
|
|
746
|
+
const detail = JSON.parse(payload) as {
|
|
747
|
+
absoluteHref?: unknown;
|
|
748
|
+
intentAt?: unknown;
|
|
749
|
+
intentKind?: unknown;
|
|
750
|
+
};
|
|
751
|
+
const intentAt =
|
|
752
|
+
typeof params.timestamp === 'number' && Number.isFinite(params.timestamp)
|
|
753
|
+
? params.timestamp
|
|
754
|
+
: Date.now();
|
|
755
|
+
const intentKind =
|
|
756
|
+
detail.intentKind === 'pointerover' ||
|
|
757
|
+
detail.intentKind === 'pointerdown' ||
|
|
758
|
+
detail.intentKind === 'focusin' ||
|
|
759
|
+
detail.intentKind === 'click'
|
|
760
|
+
? detail.intentKind
|
|
761
|
+
: undefined;
|
|
762
|
+
bodyIntents.record(
|
|
763
|
+
intentAt,
|
|
764
|
+
typeof detail.absoluteHref === 'string' ? detail.absoluteHref : undefined,
|
|
765
|
+
intentKind,
|
|
766
|
+
);
|
|
767
|
+
if (typeof detail.absoluteHref === 'string' && intentKind) {
|
|
768
|
+
promoteDormantStreams(detail.absoluteHref, intentAt, intentKind);
|
|
769
|
+
}
|
|
770
|
+
} catch {
|
|
771
|
+
// Ignore malformed intent records.
|
|
772
|
+
}
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
260
775
|
// Map injector's event names to our CapturedEvent type union.
|
|
261
776
|
const allowed: CapturedEvent['type'][] = ['click', 'input', 'change', 'submit'];
|
|
262
777
|
if (!allowed.includes(eventType as CapturedEvent['type'])) return;
|
|
@@ -271,6 +786,19 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
|
271
786
|
}
|
|
272
787
|
});
|
|
273
788
|
|
|
789
|
+
// Event listeners are wired — safe to drive Chromium to the target URL.
|
|
790
|
+
// Registering them first ensures early hydration actions and user intent are
|
|
791
|
+
// observable instead of racing the initial navigation.
|
|
792
|
+
if (opts.url && opts.url !== 'about:blank') {
|
|
793
|
+
if (isDebug()) {
|
|
794
|
+
console.error(`[debug] navigating to ${opts.url}`);
|
|
795
|
+
}
|
|
796
|
+
const navResult = await Page.navigate({ url: opts.url });
|
|
797
|
+
if (isDebug()) {
|
|
798
|
+
console.error(`[debug] navigate returned: ${JSON.stringify(navResult)}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
274
802
|
// ── WebSocket frames (sent + received, payload truncated to 1KB) ─────────
|
|
275
803
|
const wsUrls = new Map<string, string>();
|
|
276
804
|
Network.webSocketCreated((params) => {
|
|
@@ -393,7 +921,19 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
|
393
921
|
};
|
|
394
922
|
|
|
395
923
|
const narrationLoop: Promise<void> = (async () => {
|
|
396
|
-
if (opts.noNarration)
|
|
924
|
+
if (opts.noNarration) {
|
|
925
|
+
if (opts.signal && !opts.signal.aborted) {
|
|
926
|
+
await Promise.race([
|
|
927
|
+
new Promise<void>((resolve) =>
|
|
928
|
+
opts.signal?.addEventListener('abort', () => resolve(), { once: true }),
|
|
929
|
+
),
|
|
930
|
+
chromium.process.exitCode === null
|
|
931
|
+
? new Promise<void>((resolve) => chromium.process.once('exit', () => resolve()))
|
|
932
|
+
: Promise.resolve(),
|
|
933
|
+
]);
|
|
934
|
+
}
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
397
937
|
rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
398
938
|
console.log('');
|
|
399
939
|
console.log('[imprint] recording. drive the browser, narrate as you go.');
|
|
@@ -432,11 +972,57 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
|
432
972
|
return { jsonlPath: jp, sessionPath: sp, count: seq };
|
|
433
973
|
}
|
|
434
974
|
shuttingDown = true;
|
|
975
|
+
networkCaptureClosing = true;
|
|
435
976
|
narrationOpen = false;
|
|
436
977
|
rl?.close();
|
|
437
978
|
|
|
438
|
-
//
|
|
439
|
-
//
|
|
979
|
+
// Stop admitting new generations, but give requests that already emitted
|
|
980
|
+
// requestWillBeSent a short window to receive their response headers and
|
|
981
|
+
// join the normal inflight body drain.
|
|
982
|
+
const pendingDeadline = Date.now() + 2_000;
|
|
983
|
+
while (!browserExited && Date.now() < pendingDeadline) {
|
|
984
|
+
if (pending.size > 0) {
|
|
985
|
+
await sleep(25);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
// Redirects reuse a request id and briefly leave `pending` empty between
|
|
989
|
+
// responseReceived for the redirect and requestWillBeSent for its target.
|
|
990
|
+
await sleep(25);
|
|
991
|
+
if (pending.size === 0) break;
|
|
992
|
+
}
|
|
993
|
+
responseAdmissionClosed = true;
|
|
994
|
+
for (const requestId of pending.keys()) {
|
|
995
|
+
pending.delete(requestId);
|
|
996
|
+
bodyReady.get(requestId)?.resolve({ kind: 'failed', errorText: 'shutdown cutoff' });
|
|
997
|
+
bodyReady.delete(requestId);
|
|
998
|
+
requestGenerations.delete(requestId);
|
|
999
|
+
intentRequestUrls.delete(requestId);
|
|
1000
|
+
intentRequestActivations.delete(requestId);
|
|
1001
|
+
}
|
|
1002
|
+
while (!browserExited && bodyReady.size > 0 && Date.now() < pendingDeadline) await sleep(25);
|
|
1003
|
+
// Requests that already received response headers no longer appear in
|
|
1004
|
+
// `pending`, but their body workers can still be waiting for a terminal
|
|
1005
|
+
// Network event. Once admission closes there is nothing useful left to
|
|
1006
|
+
// wait for: resolve every remaining waiter so shutdown cannot inherit the
|
|
1007
|
+
// normal 30-second body timeout (especially after Chromium exits).
|
|
1008
|
+
for (const [requestId, ready] of bodyReady) {
|
|
1009
|
+
ready.resolve({ kind: 'failed', errorText: 'shutdown cutoff' });
|
|
1010
|
+
bodyReady.delete(requestId);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// A dead browser can leave an already-issued CDP command unresolved. Tear
|
|
1014
|
+
// down the transport before awaiting body workers so those promises reject
|
|
1015
|
+
// instead of holding shutdown open indefinitely.
|
|
1016
|
+
if (browserExited) {
|
|
1017
|
+
try {
|
|
1018
|
+
await Promise.race([client.close(), sleep(1_000)]);
|
|
1019
|
+
} catch {
|
|
1020
|
+
// The browser may have already closed the transport.
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Drain in-flight body fetches before closing the JSONL stream — CDP body
|
|
1025
|
+
// fetch is async, and late arrivals would otherwise be silently dropped.
|
|
440
1026
|
if (inflight.size > 0) {
|
|
441
1027
|
if (isDebug()) {
|
|
442
1028
|
console.error(`[debug] draining ${inflight.size} inflight handlers`);
|
|
@@ -446,13 +1032,34 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
|
446
1032
|
|
|
447
1033
|
// Final cookie snapshot before CDP teardown — confirmation pages
|
|
448
1034
|
// sometimes set fresh session cookies the replay needs.
|
|
449
|
-
|
|
450
|
-
|
|
1035
|
+
if (!browserExited) {
|
|
1036
|
+
await snapshotCookies('end');
|
|
1037
|
+
await snapshotStorage('end');
|
|
1038
|
+
}
|
|
451
1039
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
1040
|
+
if (!browserExited) {
|
|
1041
|
+
try {
|
|
1042
|
+
await client.close();
|
|
1043
|
+
} catch {
|
|
1044
|
+
// ignore
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const outstandingIssuedStreams = streamLeases.size;
|
|
1048
|
+
for (const active of activeStreams.values()) streamStore.discard(active.capture);
|
|
1049
|
+
activeStreams.clear();
|
|
1050
|
+
for (const dormant of dormantStreams.values()) streamStore.discard(dormant.active.capture);
|
|
1051
|
+
dormantStreams.clear();
|
|
1052
|
+
streamLeases.clear();
|
|
1053
|
+
if (isDebug() && streamStore.stats.attempted > 0) {
|
|
1054
|
+
console.error(
|
|
1055
|
+
`[debug] response stream stats ${JSON.stringify({
|
|
1056
|
+
...streamStore.stats,
|
|
1057
|
+
streamStartFailures,
|
|
1058
|
+
outstandingIssuedStreams,
|
|
1059
|
+
normalBodySuccesses,
|
|
1060
|
+
normalBodyFailures,
|
|
1061
|
+
})}`,
|
|
1062
|
+
);
|
|
456
1063
|
}
|
|
457
1064
|
await chromium.close();
|
|
458
1065
|
const { jsonlPath, sessionPath } = await writer.close();
|
|
@@ -472,7 +1079,11 @@ export async function record(opts: RecordOptions): Promise<RecordResult> {
|
|
|
472
1079
|
opts.signal.addEventListener('abort', () => void shutdown());
|
|
473
1080
|
}
|
|
474
1081
|
// If the user closes the window, wind down.
|
|
475
|
-
chromium.process.once('exit', () =>
|
|
1082
|
+
chromium.process.once('exit', () => {
|
|
1083
|
+
browserExited = true;
|
|
1084
|
+
void shutdown();
|
|
1085
|
+
});
|
|
1086
|
+
opts.onBrowserReady?.(chromium.port, chromium.close);
|
|
476
1087
|
|
|
477
1088
|
await narrationLoop;
|
|
478
1089
|
return shutdown();
|