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.
@@ -0,0 +1,704 @@
1
+ import {
2
+ MAX_REACT_FLIGHT_JSON_NODES,
3
+ MAX_REACT_FLIGHT_ROWS,
4
+ boundedJsonNodeCount,
5
+ } from './react-flight-limits.ts';
6
+
7
+ const DEFAULT_PER_RESPONSE_BYTES = 2 * 1024 * 1024;
8
+ const DEFAULT_ACTIVE_BYTES = 8 * 1024 * 1024;
9
+ const DEFAULT_RECOVERED_BYTES = 4 * 1024 * 1024;
10
+ const STREAM_SLAB_BYTES = 64 * 1024;
11
+ export const RESPONSE_BODY_STREAM_ALLOCATION_HEADROOM_BYTES = STREAM_SLAB_BYTES;
12
+ export const RSC_USER_INTENT_WINDOW_MS = 10_000;
13
+
14
+ export const STREAM_TRUNCATION_MARKER = '\n[…truncated…]';
15
+
16
+ interface ResponseBodyStreamCandidate {
17
+ method: string;
18
+ resourceType: string;
19
+ status: number;
20
+ mimeType?: string;
21
+ contentLength?: number;
22
+ url: string;
23
+ requestHeaders: Record<string, string>;
24
+ recentUserIntent?: boolean;
25
+ recentUserActivation?: boolean;
26
+ intendedUrl?: string;
27
+ }
28
+
29
+ export type ResponseBodyCompletion =
30
+ | { kind: 'failed'; errorText: string; canceled?: boolean; blockedReason?: string }
31
+ | { kind: 'finished' }
32
+ | { kind: 'timeout' };
33
+
34
+ interface StreamedBodyRecoveryContext {
35
+ completion: ResponseBodyCompletion;
36
+ method?: string;
37
+ mimeType?: string;
38
+ url: string;
39
+ requestHeaders: Record<string, string>;
40
+ body: string;
41
+ }
42
+
43
+ interface ResponseBodyResolution {
44
+ body: string | null;
45
+ source: 'normal' | 'stream' | null;
46
+ normalError?: unknown;
47
+ }
48
+
49
+ export interface ResponseBodyStreamCapture {
50
+ readonly requestId: string;
51
+ readonly seq: number;
52
+ readonly prefixSlabs: ResponseBodyStreamSlab[];
53
+ readonly dataSlabs: ResponseBodyStreamSlab[];
54
+ bytes: number;
55
+ allocatedBytes: number;
56
+ truncated: boolean;
57
+ abandoned: boolean;
58
+ released: boolean;
59
+ }
60
+
61
+ interface ResponseBodyStreamSlab {
62
+ readonly buffer: Buffer;
63
+ used: number;
64
+ }
65
+
66
+ interface ResponseBodyStreamStats {
67
+ attempted: number;
68
+ started: number;
69
+ discarded: number;
70
+ abandoned: number;
71
+ observedBytes: number;
72
+ recovered: number;
73
+ recoveredBytes: number;
74
+ activeBytes: number;
75
+ peakActiveBytes: number;
76
+ }
77
+
78
+ interface ResponseBodyStreamLimits {
79
+ perResponseBytes?: number;
80
+ activeBytes?: number;
81
+ recoveredBytes?: number;
82
+ }
83
+
84
+ export interface ResponseBodyStreamLease {
85
+ readonly requestId: string;
86
+ readonly seq: number;
87
+ priority: boolean;
88
+ commandSettled: boolean;
89
+ requestCompleted: boolean;
90
+ }
91
+
92
+ /**
93
+ * Bounds issued CDP streams, including commands that have not settled and
94
+ * browser-side streams whose retained bytes were already abandoned. A lease is
95
+ * released only after both the CDP command and the request have completed.
96
+ */
97
+ export class ResponseBodyStreamLeaseTracker {
98
+ readonly #limit: number;
99
+ readonly #priorityReserve: number;
100
+ readonly #leases = new Set<ResponseBodyStreamLease>();
101
+
102
+ constructor(limit = 16, priorityReserve = 0) {
103
+ this.#limit = limit;
104
+ this.#priorityReserve = Math.max(0, Math.min(priorityReserve, limit));
105
+ }
106
+
107
+ begin(requestId: string, seq: number, priority = false): ResponseBodyStreamLease | null {
108
+ if (this.#leases.size >= this.#limit) return null;
109
+ if (!priority && this.#leases.size >= this.#limit - this.#priorityReserve) {
110
+ return null;
111
+ }
112
+ const lease = { requestId, seq, priority, commandSettled: false, requestCompleted: false };
113
+ this.#leases.add(lease);
114
+ return lease;
115
+ }
116
+
117
+ markCommandSettled(lease: ResponseBodyStreamLease): void {
118
+ if (!this.#leases.has(lease)) return;
119
+ lease.commandSettled = true;
120
+ this.#releaseIfDone(lease);
121
+ }
122
+
123
+ promote(lease: ResponseBodyStreamLease): void {
124
+ if (this.#leases.has(lease)) lease.priority = true;
125
+ }
126
+
127
+ markRequestCompleted(requestId: string, seq: number | undefined): void {
128
+ if (seq === undefined) return;
129
+ for (const lease of this.#leases) {
130
+ if (lease.requestId !== requestId || lease.seq !== seq) continue;
131
+ lease.requestCompleted = true;
132
+ this.#releaseIfDone(lease);
133
+ }
134
+ }
135
+
136
+ clear(): void {
137
+ this.#leases.clear();
138
+ }
139
+
140
+ get size(): number {
141
+ return this.#leases.size;
142
+ }
143
+
144
+ #releaseIfDone(lease: ResponseBodyStreamLease): void {
145
+ if (lease.commandSettled && lease.requestCompleted) this.#leases.delete(lease);
146
+ }
147
+ }
148
+
149
+ const BODYLESS_STATUSES = new Set([101, 204, 205, 304]);
150
+
151
+ function requestHeader(headers: Record<string, string>, name: string): string | undefined {
152
+ const entry = Object.entries(headers).find(([candidate]) => candidate.toLowerCase() === name);
153
+ return entry?.[1];
154
+ }
155
+
156
+ export function matchesIntendedRoute(requestUrl: string, intendedUrl: string | undefined): boolean {
157
+ if (!intendedUrl) return false;
158
+ try {
159
+ const request = new URL(requestUrl);
160
+ const intended = new URL(intendedUrl);
161
+ request.searchParams.delete('_rsc');
162
+ intended.searchParams.delete('_rsc');
163
+ const normalizePath = (path: string): string => (path === '/' ? path : path.replace(/\/$/, ''));
164
+ const normalizeSearch = (url: URL): string =>
165
+ JSON.stringify(
166
+ [...url.searchParams.entries()].sort(([firstKey, firstValue], [secondKey, secondValue]) =>
167
+ firstKey === secondKey
168
+ ? firstValue.localeCompare(secondValue)
169
+ : firstKey.localeCompare(secondKey),
170
+ ),
171
+ );
172
+ return (
173
+ request.origin === intended.origin &&
174
+ normalizePath(request.pathname) === normalizePath(intended.pathname) &&
175
+ normalizeSearch(request) === normalizeSearch(intended)
176
+ );
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ function isRscGetRequest(url: string, headers: Record<string, string>): boolean {
183
+ const rsc = requestHeader(headers, 'rsc')?.trim();
184
+ const accept = requestHeader(headers, 'accept')?.toLowerCase() ?? '';
185
+ const hasRouterState = Boolean(requestHeader(headers, 'next-router-state-tree')?.trim());
186
+ let hasRscQuery = false;
187
+ try {
188
+ hasRscQuery = new URL(url).searchParams.has('_rsc');
189
+ } catch {
190
+ // Headers remain sufficient when a captured URL is malformed.
191
+ }
192
+ return rsc === '1' || hasRscQuery || accept.includes('text/x-component') || hasRouterState;
193
+ }
194
+
195
+ function isRscPrefetchRequest(headers: Record<string, string>): boolean {
196
+ return (
197
+ requestHeader(headers, 'next-router-prefetch')?.trim() === '1' ||
198
+ Boolean(requestHeader(headers, 'next-router-segment-prefetch')?.trim())
199
+ );
200
+ }
201
+
202
+ export function needsLaterIntent(candidate: ResponseBodyStreamCandidate): boolean {
203
+ return (
204
+ isRscPrefetchRequest(candidate.requestHeaders) &&
205
+ (!candidate.recentUserActivation || !matchesIntendedRoute(candidate.url, candidate.intendedUrl))
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Keep speculative streaming limited to React Flight responses that Chrome can
211
+ * evict from getResponseBody. Production App Router traffic has two families:
212
+ * GET navigation/prefetch requests and POST Server Actions. Both are Fetch
213
+ * requests returning text/x-component, but Server Actions use Next-Action and
214
+ * have neither `_rsc` nor `RSC: 1`. Ordinary JSON/XHR bodies continue through
215
+ * the normal recorder path and incur no streaming traffic.
216
+ */
217
+ export function shouldStreamResponseBody(candidate: ResponseBodyStreamCandidate): boolean {
218
+ if (candidate.resourceType !== 'Fetch') return false;
219
+ if (
220
+ candidate.status < 200 ||
221
+ candidate.status >= 300 ||
222
+ BODYLESS_STATUSES.has(candidate.status)
223
+ ) {
224
+ return false;
225
+ }
226
+ if (
227
+ candidate.contentLength !== undefined &&
228
+ candidate.contentLength > DEFAULT_PER_RESPONSE_BYTES
229
+ ) {
230
+ return false;
231
+ }
232
+
233
+ const mime = (candidate.mimeType ?? '').split(';', 1)[0]?.trim().toLowerCase() ?? '';
234
+ if (mime !== 'text/x-component') return false;
235
+
236
+ const method = candidate.method.toUpperCase();
237
+ if (method === 'POST') {
238
+ return Boolean(requestHeader(candidate.requestHeaders, 'next-action')?.trim());
239
+ }
240
+ if (method !== 'GET') return false;
241
+
242
+ return isRscGetRequest(candidate.url, candidate.requestHeaders);
243
+ }
244
+
245
+ function flightJsonPayload(payload: string): string {
246
+ // Flight row tags precede a JSON payload. React 18/19 uses single-letter
247
+ // tags such as I while Next's resource hints use combined tags such as HL.
248
+ return /^[A-Z]+(?=[\[{\"\d\-ntf])(.*)$/.exec(payload)?.[1] ?? payload;
249
+ }
250
+
251
+ function parseFlightJsonPayload(payload: string, budget: { remaining: number }): unknown {
252
+ const candidate = flightJsonPayload(payload);
253
+ const nodes = boundedJsonNodeCount(candidate, budget.remaining);
254
+ if (nodes === null) throw new Error('React Flight JSON width exceeds budget');
255
+ budget.remaining -= nodes;
256
+ return JSON.parse(candidate);
257
+ }
258
+
259
+ function isAsciiHex(byte: number | undefined): boolean {
260
+ return (
261
+ byte !== undefined &&
262
+ ((byte >= 0x30 && byte <= 0x39) ||
263
+ (byte >= 0x41 && byte <= 0x46) ||
264
+ (byte >= 0x61 && byte <= 0x66))
265
+ );
266
+ }
267
+
268
+ function collectFlightChunkReferences(node: unknown, references: string[]): boolean {
269
+ const stack: Array<{ node: unknown; depth: number }> = [{ node, depth: 0 }];
270
+ let visited = 0;
271
+ while (stack.length > 0) {
272
+ const current = stack.pop();
273
+ if (!current || current.depth > 256 || ++visited > MAX_REACT_FLIGHT_JSON_NODES) return false;
274
+ if (typeof current.node === 'string') {
275
+ // React's model reviver only interprets strings whose first byte is `$`;
276
+ // `$$` is an escaped literal dollar. Do not scan arbitrary user text.
277
+ if (!current.node.startsWith('$') || current.node.startsWith('$$')) continue;
278
+ const match = /^\$(?:L|@|F|Q|W|B|K|Z|i|h|P|Y@|Y)?([0-9a-fA-F]+)(?::|$)/.exec(current.node);
279
+ if (match?.[1]) references.push(match[1].toLowerCase());
280
+ continue;
281
+ }
282
+ if (!current.node || typeof current.node !== 'object') continue;
283
+ const values = Array.isArray(current.node)
284
+ ? current.node
285
+ : Object.values(current.node as Record<string, unknown>);
286
+ for (const value of values) stack.push({ node: value, depth: current.depth + 1 });
287
+ }
288
+ return true;
289
+ }
290
+
291
+ interface ParsedFlightBody {
292
+ defined: Set<string>;
293
+ root: unknown;
294
+ referenceCount: number;
295
+ }
296
+
297
+ function parseCompleteFlightBody(body: string): ParsedFlightBody | null {
298
+ if (!body.endsWith('\n') || body.includes(STREAM_TRUNCATION_MARKER)) return null;
299
+
300
+ const bytes = Buffer.from(body, 'utf8');
301
+ const defined = new Set<string>();
302
+ const parsedPayloads: unknown[] = [];
303
+ const openStreams = new Set<string>();
304
+ const closedStreams = new Set<string>();
305
+ let root: unknown;
306
+ let offset = 0;
307
+ let rowCount = 0;
308
+ const jsonBudget = { remaining: MAX_REACT_FLIGHT_JSON_NODES };
309
+
310
+ while (offset < bytes.length) {
311
+ if (++rowCount > MAX_REACT_FLIGHT_ROWS) return null;
312
+ const lineEnd = bytes.indexOf(0x0a, offset);
313
+ if (lineEnd < 0 || lineEnd === offset) return null;
314
+
315
+ if (bytes[offset] === 0x3a) {
316
+ const line = bytes.toString('utf8', offset, lineEnd);
317
+ const hintPayload = /^:[A-Z]+(.+)$/.exec(line)?.[1];
318
+ if (!hintPayload) return null;
319
+ try {
320
+ parseFlightJsonPayload(hintPayload, jsonBudget);
321
+ } catch {
322
+ return null;
323
+ }
324
+ offset = lineEnd + 1;
325
+ continue;
326
+ }
327
+
328
+ // React 19 may prefix its first timing row with `#`. It remains an
329
+ // ordinary chunk definition for completeness/reference accounting.
330
+ let idStart = offset;
331
+ if (bytes[idStart] === 0x23) idStart++;
332
+ let colon = idStart;
333
+ while (colon < bytes.length) {
334
+ if (!isAsciiHex(bytes[colon])) break;
335
+ colon++;
336
+ }
337
+ if (colon === idStart || bytes[colon] !== 0x3a) return null;
338
+ const chunkId = bytes.toString('ascii', idStart, colon).toLowerCase();
339
+ if (closedStreams.has(chunkId)) return null;
340
+ const payloadStart = colon + 1;
341
+ if (payloadStart >= bytes.length) return null;
342
+
343
+ // T rows are byte-length-framed rather than newline-framed. Their text
344
+ // may contain newlines and the next row may begin immediately afterward.
345
+ if (bytes[payloadStart] === 0x54) {
346
+ if (defined.has(chunkId) || openStreams.has(chunkId)) return null;
347
+ let comma = payloadStart + 1;
348
+ while (comma < bytes.length) {
349
+ if (!isAsciiHex(bytes[comma])) break;
350
+ comma++;
351
+ }
352
+ if (comma === payloadStart + 1 || bytes[comma] !== 0x2c) return null;
353
+ const length = Number.parseInt(bytes.toString('ascii', payloadStart + 1, comma), 16);
354
+ if (!Number.isSafeInteger(length)) return null;
355
+ const textEnd = comma + 1 + length;
356
+ if (textEnd > bytes.length) return null;
357
+ defined.add(chunkId);
358
+ offset = textEnd;
359
+ continue;
360
+ }
361
+
362
+ const payload = bytes.toString('utf8', payloadStart, lineEnd);
363
+ if (!payload) return null;
364
+ let parsed: unknown;
365
+ if (payload === 'R' || payload === 'r' || payload === 'X' || payload === 'x') {
366
+ if (defined.has(chunkId) || openStreams.has(chunkId)) return null;
367
+ openStreams.add(chunkId);
368
+ parsed = null;
369
+ } else if (payload.startsWith('C')) {
370
+ if (!openStreams.delete(chunkId)) return null;
371
+ closedStreams.add(chunkId);
372
+ const finalValue = payload.slice(1);
373
+ if (finalValue) {
374
+ try {
375
+ parsed = parseFlightJsonPayload(finalValue, jsonBudget);
376
+ } catch {
377
+ return null;
378
+ }
379
+ parsedPayloads.push(parsed);
380
+ } else {
381
+ parsed = null;
382
+ }
383
+ } else {
384
+ if (defined.has(chunkId) || openStreams.has(chunkId)) return null;
385
+ try {
386
+ parsed = parseFlightJsonPayload(payload, jsonBudget);
387
+ } catch {
388
+ // Reject binary/text Flight rows for failed loads until we have an
389
+ // observed, independently verifiable completeness rule for them.
390
+ return null;
391
+ }
392
+ parsedPayloads.push(parsed);
393
+ }
394
+ defined.add(chunkId);
395
+ if (chunkId === '0') root = parsed;
396
+ offset = lineEnd + 1;
397
+ }
398
+
399
+ if (openStreams.size > 0) return null;
400
+
401
+ let referenceCount = 0;
402
+ const references: string[] = [];
403
+ for (const payload of parsedPayloads) {
404
+ if (!collectFlightChunkReferences(payload, references)) return null;
405
+ }
406
+ for (const chunkId of references) {
407
+ if (!defined.has(chunkId)) return null;
408
+ referenceCount++;
409
+ }
410
+ return { defined, root, referenceCount };
411
+ }
412
+
413
+ /**
414
+ * React Flight has no explicit EOF marker. A safely reusable canceled payload
415
+ * must nevertheless be line-complete, contain the root chunk, and define every
416
+ * chunk referenced by that payload. This deliberately favors false negatives:
417
+ * an unfamiliar but valid Flight encoding falls back to a bodyless request.
418
+ */
419
+ export function isStructurallyCompleteReactFlight(body: string): boolean {
420
+ const parsed = parseCompleteFlightBody(body);
421
+ if (!parsed) return false;
422
+ const { defined, root, referenceCount } = parsed;
423
+ if (!root || typeof root !== 'object' || Array.isArray(root)) return false;
424
+ const rootRecord = root as Record<string, unknown>;
425
+ const isLegacyNavigation =
426
+ typeof rootRecord.b === 'string' &&
427
+ Array.isArray(rootRecord.f) &&
428
+ typeof rootRecord.q === 'string';
429
+ const isModernNavigation =
430
+ Array.isArray(rootRecord.c) &&
431
+ Array.isArray(rootRecord.f) &&
432
+ typeof rootRecord.q === 'string' &&
433
+ typeof rootRecord.i === 'boolean';
434
+ const isTreePrefetch =
435
+ rootRecord.tree !== null &&
436
+ typeof rootRecord.tree === 'object' &&
437
+ typeof rootRecord.staleTime === 'number' &&
438
+ Number.isFinite(rootRecord.staleTime);
439
+ const isDataPrefetch = typeof rootRecord.buildId === 'string' && Array.isArray(rootRecord.data);
440
+
441
+ if (isTreePrefetch) return defined.size === 1 && referenceCount === 0;
442
+ if (!isLegacyNavigation && !isModernNavigation && !isDataPrefetch) return false;
443
+ return defined.size > 1 && referenceCount > 0;
444
+ }
445
+
446
+ /**
447
+ * Server Action Flight roots differ by Next.js generation: older responses
448
+ * use an array root, while newer responses use { a, f, b }. In both cases we
449
+ * require a line-complete JSON-tagged stream, root chunk zero, more than one
450
+ * defined chunk, and no dangling chunk references.
451
+ */
452
+ export function isStructurallyCompleteServerActionFlight(body: string): boolean {
453
+ const parsed = parseCompleteFlightBody(body);
454
+ if (!parsed || parsed.defined.size <= 1 || parsed.referenceCount === 0) return false;
455
+ let actionReference: string | undefined;
456
+ if (Array.isArray(parsed.root)) {
457
+ if (!Array.isArray(parsed.root[1])) return false;
458
+ actionReference = typeof parsed.root[0] === 'string' ? parsed.root[0] : undefined;
459
+ }
460
+ if (!parsed.root || typeof parsed.root !== 'object') return false;
461
+ if (!Array.isArray(parsed.root)) {
462
+ const root = parsed.root as Record<string, unknown>;
463
+ if (!('f' in root) || typeof root.b !== 'string') return false;
464
+ actionReference = typeof root.a === 'string' ? root.a : undefined;
465
+ }
466
+ const referencedChunk = /^\$@([0-9a-fA-F]+)$/.exec(actionReference ?? '')?.[1]?.toLowerCase();
467
+ return Boolean(referencedChunk && parsed.defined.has(referencedChunk));
468
+ }
469
+
470
+ /**
471
+ * A completed load is safe to recover. Failed loads are normally partial and
472
+ * remain bodyless; the sole exception is the verified Next.js RSC navigation
473
+ * pattern where Chrome reports ERR_ABORTED after delivering the complete
474
+ * text/x-component payload to the renderer.
475
+ */
476
+ export function shouldRecoverStreamedBody(context: StreamedBodyRecoveryContext): boolean {
477
+ if (context.body.includes(STREAM_TRUNCATION_MARKER)) return false;
478
+ const mime = (context.mimeType ?? '').split(';', 1)[0]?.trim().toLowerCase();
479
+ if (mime !== 'text/x-component') return false;
480
+ if (context.completion.kind === 'finished') return true;
481
+ if (
482
+ context.completion.kind !== 'failed' ||
483
+ context.completion.errorText !== 'net::ERR_ABORTED' ||
484
+ context.completion.canceled !== true ||
485
+ context.completion.blockedReason !== undefined
486
+ ) {
487
+ return false;
488
+ }
489
+
490
+ if (
491
+ context.method?.toUpperCase() === 'POST' &&
492
+ requestHeader(context.requestHeaders, 'next-action')?.trim()
493
+ ) {
494
+ return isStructurallyCompleteServerActionFlight(context.body);
495
+ }
496
+
497
+ if (context.method && context.method.toUpperCase() !== 'GET') return false;
498
+ return (
499
+ isRscGetRequest(context.url, context.requestHeaders) &&
500
+ isStructurallyCompleteReactFlight(context.body)
501
+ );
502
+ }
503
+
504
+ /** Normal CDP body capture always wins; the speculative stream is consulted only on failure. */
505
+ export async function resolveResponseBodyWithFallback(input: {
506
+ readNormal: () => Promise<string>;
507
+ readStream?: () => Promise<string | null>;
508
+ }): Promise<ResponseBodyResolution> {
509
+ try {
510
+ return { body: await input.readNormal(), source: 'normal' };
511
+ } catch (normalError) {
512
+ if (!input.readStream) return { body: null, source: null, normalError };
513
+ const body = await input.readStream();
514
+ return body === null
515
+ ? { body: null, source: null, normalError }
516
+ : { body, source: 'stream', normalError };
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Bounded in-memory storage for speculative CDP response streams. Nothing is
522
+ * persisted unless the recorder later calls recover() after getResponseBody
523
+ * failed. Aggregate pressure abandons a capture instead of returning a random
524
+ * partial suffix/prefix as if it were trustworthy.
525
+ */
526
+ export class ResponseBodyStreamStore {
527
+ readonly #perResponseBytes: number;
528
+ readonly #activeBytesLimit: number;
529
+ readonly #recoveredBytesLimit: number;
530
+ readonly #stats: ResponseBodyStreamStats = {
531
+ attempted: 0,
532
+ started: 0,
533
+ discarded: 0,
534
+ abandoned: 0,
535
+ observedBytes: 0,
536
+ recovered: 0,
537
+ recoveredBytes: 0,
538
+ activeBytes: 0,
539
+ peakActiveBytes: 0,
540
+ };
541
+
542
+ constructor(limits: ResponseBodyStreamLimits = {}) {
543
+ this.#perResponseBytes = limits.perResponseBytes ?? DEFAULT_PER_RESPONSE_BYTES;
544
+ this.#activeBytesLimit = limits.activeBytes ?? DEFAULT_ACTIVE_BYTES;
545
+ this.#recoveredBytesLimit = limits.recoveredBytes ?? DEFAULT_RECOVERED_BYTES;
546
+ }
547
+
548
+ begin(requestId: string, seq: number): ResponseBodyStreamCapture {
549
+ this.#stats.attempted++;
550
+ return {
551
+ requestId,
552
+ seq,
553
+ prefixSlabs: [],
554
+ dataSlabs: [],
555
+ bytes: 0,
556
+ allocatedBytes: 0,
557
+ truncated: false,
558
+ abandoned: false,
559
+ released: false,
560
+ };
561
+ }
562
+
563
+ markStarted(capture: ResponseBodyStreamCapture): void {
564
+ if (!capture.released && !capture.abandoned) this.#stats.started++;
565
+ }
566
+
567
+ appendBufferedData(capture: ResponseBodyStreamCapture, base64: string): void {
568
+ this.#append(capture, base64, capture.prefixSlabs);
569
+ }
570
+
571
+ appendData(capture: ResponseBodyStreamCapture, base64: string): void {
572
+ this.#append(capture, base64, capture.dataSlabs);
573
+ }
574
+
575
+ discard(capture: ResponseBodyStreamCapture): void {
576
+ if (capture.released) return;
577
+ this.#release(capture);
578
+ this.#stats.discarded++;
579
+ }
580
+
581
+ abandon(capture: ResponseBodyStreamCapture): void {
582
+ if (capture.released) return;
583
+ capture.abandoned = true;
584
+ this.#release(capture);
585
+ this.#stats.abandoned++;
586
+ }
587
+
588
+ recover(
589
+ capture: ResponseBodyStreamCapture,
590
+ accept: (body: string) => boolean = () => true,
591
+ ): string | null {
592
+ if (capture.released || capture.abandoned || capture.bytes === 0) {
593
+ this.discard(capture);
594
+ return null;
595
+ }
596
+
597
+ const remainingRecoveryBudget = this.#recoveredBytesLimit - this.#stats.recoveredBytes;
598
+ if (remainingRecoveryBudget <= 0 || capture.bytes > remainingRecoveryBudget) {
599
+ this.abandon(capture);
600
+ return null;
601
+ }
602
+
603
+ const chunks = [...capture.prefixSlabs, ...capture.dataSlabs].map((slab) =>
604
+ slab.buffer.subarray(0, slab.used),
605
+ );
606
+ const complete = Buffer.concat(chunks, capture.bytes);
607
+ const body = `${complete.toString('utf8')}${capture.truncated ? STREAM_TRUNCATION_MARKER : ''}`;
608
+
609
+ let accepted = false;
610
+ try {
611
+ accepted = accept(body);
612
+ } catch {
613
+ this.discard(capture);
614
+ return null;
615
+ }
616
+ if (!accepted) {
617
+ this.discard(capture);
618
+ return null;
619
+ }
620
+
621
+ this.#stats.recovered++;
622
+ this.#stats.recoveredBytes += complete.length;
623
+ this.#release(capture);
624
+ return body;
625
+ }
626
+
627
+ get stats(): ResponseBodyStreamStats {
628
+ return { ...this.#stats };
629
+ }
630
+
631
+ #append(
632
+ capture: ResponseBodyStreamCapture,
633
+ base64: string,
634
+ target: ResponseBodyStreamSlab[],
635
+ ): void {
636
+ if (capture.released || capture.abandoned || base64.length === 0) return;
637
+
638
+ const remainingForResponse = this.#perResponseBytes - capture.bytes;
639
+ if (remainingForResponse <= 0) {
640
+ capture.truncated = true;
641
+ return;
642
+ }
643
+
644
+ // CDP chunks are independently padded base64 strings. Decode each chunk
645
+ // independently, but retain bytes until finalization so split UTF-8 code
646
+ // points are decoded only after concatenation.
647
+ const maxBase64Chars = Math.ceil(remainingForResponse / 3) * 4;
648
+ const encodedPrefix = base64.slice(0, maxBase64Chars);
649
+ const decoded = Buffer.from(encodedPrefix, 'base64');
650
+ const accepted = decoded.subarray(0, remainingForResponse);
651
+ const wasTruncated = encodedPrefix.length < base64.length || decoded.length > accepted.length;
652
+
653
+ if (accepted.length > 0) {
654
+ let sourceOffset = 0;
655
+ while (sourceOffset < accepted.length) {
656
+ let slab = target.at(-1);
657
+ if (!slab || slab.used === slab.buffer.length) {
658
+ const remainingAllocation = this.#activeBytesLimit - this.#stats.activeBytes;
659
+ if (remainingAllocation <= 0) {
660
+ this.abandon(capture);
661
+ return;
662
+ }
663
+ const capacity = Math.min(
664
+ STREAM_SLAB_BYTES,
665
+ this.#perResponseBytes - capture.bytes - sourceOffset,
666
+ remainingAllocation,
667
+ );
668
+ if (capacity <= 0) {
669
+ this.abandon(capture);
670
+ return;
671
+ }
672
+ slab = { buffer: Buffer.allocUnsafe(capacity), used: 0 };
673
+ target.push(slab);
674
+ capture.allocatedBytes += capacity;
675
+ this.#stats.activeBytes += capacity;
676
+ this.#stats.peakActiveBytes = Math.max(
677
+ this.#stats.peakActiveBytes,
678
+ this.#stats.activeBytes,
679
+ );
680
+ }
681
+ const copied = accepted.copy(
682
+ slab.buffer,
683
+ slab.used,
684
+ sourceOffset,
685
+ sourceOffset + Math.min(accepted.length - sourceOffset, slab.buffer.length - slab.used),
686
+ );
687
+ slab.used += copied;
688
+ sourceOffset += copied;
689
+ }
690
+ capture.bytes += accepted.length;
691
+ this.#stats.observedBytes += accepted.length;
692
+ }
693
+ if (wasTruncated) capture.truncated = true;
694
+ }
695
+
696
+ #release(capture: ResponseBodyStreamCapture): void {
697
+ if (capture.released) return;
698
+ this.#stats.activeBytes = Math.max(0, this.#stats.activeBytes - capture.allocatedBytes);
699
+ capture.prefixSlabs.length = 0;
700
+ capture.dataSlabs.length = 0;
701
+ capture.allocatedBytes = 0;
702
+ capture.released = true;
703
+ }
704
+ }