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
|
@@ -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
|
+
}
|