imprint-mcp 0.6.3 → 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.
@@ -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, INJECTED_LISTENER_SOURCE } from './inject-listener.ts';
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
- await Page.addScriptToEvaluateOnNewDocument({ source: INJECTED_LISTENER_SOURCE });
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<string, ReturnType<typeof Promise.withResolvers<void>>>();
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: nextSeq(),
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(requestId, Promise.withResolvers<void>());
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
- if (ready) {
182
- await Promise.race([ready.promise, sleep(30_000)]);
183
- }
184
- bodyReady.delete(requestId);
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 bodyResp = await Network.getResponseBody({ requestId });
187
- const body = bodyResp.base64Encoded
188
- ? Buffer.from(bodyResp.body, 'base64').toString('utf8')
189
- : bodyResp.body;
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
- const MAX = 2 * 1024 * 1024;
200
- const truncated = body.length > MAX ? `${body.slice(0, MAX)}\n[…truncated…]` : body;
201
- writer.requestBody(captured.seq, truncated);
202
- } catch (err) {
203
- if (isDebug()) {
204
- console.error(`[debug] body unavailable seq=${captured.seq} ${reqInfo.url}: ${err}`);
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
- bodyReady.get(params.requestId)?.resolve();
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
- bodyReady.get(params.requestId)?.resolve();
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
- pending.delete(params.requestId);
223
- });
224
-
225
- // Network is wired — safe to drive Chromium to the target URL.
226
- if (opts.url && opts.url !== 'about:blank') {
227
- if (isDebug()) {
228
- console.error(`[debug] navigating to ${opts.url}`);
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 < 2) return;
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 eventType = second?.type === 'string' ? second.value : null;
258
- const payload = third?.type === 'string' ? third.value : null;
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) return;
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
- // Drain in-flight body fetches before closing the JSONL stream — CDP
439
- // body fetch is async, late arrivals would be silently dropped.
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
- await snapshotCookies('end');
450
- await snapshotStorage('end');
1035
+ if (!browserExited) {
1036
+ await snapshotCookies('end');
1037
+ await snapshotStorage('end');
1038
+ }
451
1039
 
452
- try {
453
- await client.close();
454
- } catch {
455
- // ignore
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', () => void shutdown());
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();