imprint-mcp 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/package.json +1 -1
- package/prompts/audit-agent.md +6 -5
- package/prompts/build-planning.md +2 -2
- package/prompts/compile-agent.md +6 -4
- package/prompts/live-verifier-agent.md +24 -11
- package/src/cli.ts +41 -6
- package/src/imprint/agent.ts +7 -2
- package/src/imprint/audit.ts +255 -42
- package/src/imprint/auth-compile-tools.ts +64 -13
- package/src/imprint/claude-cli-compile.ts +43 -7
- package/src/imprint/codex-cli-compile.ts +205 -129
- package/src/imprint/compile-agent-types.ts +25 -2
- package/src/imprint/compile-agent.ts +197 -40
- package/src/imprint/compile-tools.ts +164 -22
- package/src/imprint/inject-listener.ts +31 -3
- package/src/imprint/live-verifier.ts +167 -6
- package/src/imprint/llm.ts +7 -3
- package/src/imprint/mcp-compile-server.ts +188 -83
- package/src/imprint/mcp-server.ts +16 -1
- package/src/imprint/probe-backends.ts +60 -25
- package/src/imprint/react-flight-limits.ts +31 -0
- package/src/imprint/record.ts +657 -46
- package/src/imprint/redact.ts +240 -30
- package/src/imprint/response-body-intent.ts +73 -0
- package/src/imprint/response-body-lifecycle.ts +32 -0
- package/src/imprint/response-body-stream.ts +704 -0
- package/src/imprint/teach-state.ts +52 -2
- package/src/imprint/teach.ts +94 -69
- package/src/imprint/tracing.ts +60 -238
package/src/imprint/redact.ts
CHANGED
|
@@ -15,11 +15,44 @@
|
|
|
15
15
|
import { splitSetCookieHeader } from './cookie-jar.ts';
|
|
16
16
|
import type { Replacement } from './credential-extract.ts';
|
|
17
17
|
import { hasFreeformRedactionHint, redactFreeformText } from './freeform-redact.ts';
|
|
18
|
+
import {
|
|
19
|
+
MAX_REACT_FLIGHT_JSON_NODES,
|
|
20
|
+
MAX_REACT_FLIGHT_ROWS,
|
|
21
|
+
boundedJsonNodeCount,
|
|
22
|
+
} from './react-flight-limits.ts';
|
|
18
23
|
import { isAlwaysSecretHeader, isSensitiveHeader, isSensitiveKey } from './sensitive-keys.ts';
|
|
19
24
|
import type { CapturedRequest, Session } from './types.ts';
|
|
20
25
|
|
|
21
26
|
const USER_INTERACTION_TYPES = new Set(['click', 'input', 'change', 'submit']);
|
|
22
27
|
const MULTI_VALUE_HEADERS = new Set(['cookie', 'set-cookie']);
|
|
28
|
+
const MAX_FLIGHT_JSON_NESTING_DEPTH = 256;
|
|
29
|
+
|
|
30
|
+
/** Cheap, string-aware guard against making JSON.parse plus recursive
|
|
31
|
+
* redaction traverse an adversarially deep Flight row. JSON.parse remains the
|
|
32
|
+
* authority for syntax; this only rejects nesting beyond the safe budget. */
|
|
33
|
+
function hasBoundedJsonNesting(value: string, maxDepth = MAX_FLIGHT_JSON_NESTING_DEPTH): boolean {
|
|
34
|
+
let depth = 0;
|
|
35
|
+
let inString = false;
|
|
36
|
+
let escaped = false;
|
|
37
|
+
for (let i = 0; i < value.length; i++) {
|
|
38
|
+
const char = value.charCodeAt(i);
|
|
39
|
+
if (inString) {
|
|
40
|
+
if (escaped) escaped = false;
|
|
41
|
+
else if (char === 0x5c) escaped = true;
|
|
42
|
+
else if (char === 0x22) inString = false;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (char === 0x22) {
|
|
46
|
+
inString = true;
|
|
47
|
+
} else if (char === 0x5b || char === 0x7b) {
|
|
48
|
+
depth++;
|
|
49
|
+
if (depth > maxDepth) return false;
|
|
50
|
+
} else if (char === 0x5d || char === 0x7d) {
|
|
51
|
+
depth--;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
23
56
|
|
|
24
57
|
/**
|
|
25
58
|
* Well-known XSSI (cross-site script inclusion) guards that sites prepend to
|
|
@@ -188,7 +221,15 @@ export function redactJsonBody(
|
|
|
188
221
|
placeholderByPath?: Map<string, string>,
|
|
189
222
|
freeform = true,
|
|
190
223
|
markerContext?: RedactionMarkerContext,
|
|
224
|
+
maxNestingDepth?: number,
|
|
225
|
+
jsonNodeBudget?: { remaining: number },
|
|
226
|
+
countRoot = true,
|
|
191
227
|
): BodyRedaction {
|
|
228
|
+
if (jsonNodeBudget && countRoot) {
|
|
229
|
+
const nodes = boundedJsonNodeCount(body, jsonNodeBudget.remaining);
|
|
230
|
+
if (nodes === null) throw new Error('JSON width exceeds redaction budget');
|
|
231
|
+
jsonNodeBudget.remaining -= nodes;
|
|
232
|
+
}
|
|
192
233
|
let parsed: unknown;
|
|
193
234
|
try {
|
|
194
235
|
parsed = JSON.parse(body);
|
|
@@ -199,9 +240,48 @@ export function redactJsonBody(
|
|
|
199
240
|
let count = 0;
|
|
200
241
|
let placeholders = 0;
|
|
201
242
|
let freeformCount = 0;
|
|
202
|
-
|
|
243
|
+
function visit(node: unknown, pathSoFar: string[], traversalDepth = 0): unknown {
|
|
244
|
+
if (maxNestingDepth !== undefined && traversalDepth > maxNestingDepth) {
|
|
245
|
+
throw new Error('JSON traversal depth exceeds redaction budget');
|
|
246
|
+
}
|
|
247
|
+
if (typeof node === 'string') {
|
|
248
|
+
const jsonCandidate = node.trimStart();
|
|
249
|
+
if (jsonCandidate.length > 1 && (jsonCandidate[0] === '{' || jsonCandidate[0] === '[')) {
|
|
250
|
+
// JSON-in-JSON may appear at an object property, an array index, or the
|
|
251
|
+
// root. Apply the same bounded structural redaction in every position.
|
|
252
|
+
if (
|
|
253
|
+
maxNestingDepth !== undefined &&
|
|
254
|
+
!hasBoundedJsonNesting(jsonCandidate, maxNestingDepth)
|
|
255
|
+
) {
|
|
256
|
+
throw new Error('JSON-in-JSON nesting exceeds redaction budget');
|
|
257
|
+
}
|
|
258
|
+
if (jsonNodeBudget) {
|
|
259
|
+
const nodes = boundedJsonNodeCount(jsonCandidate, jsonNodeBudget.remaining);
|
|
260
|
+
if (nodes === null) throw new Error('JSON-in-JSON width exceeds redaction budget');
|
|
261
|
+
jsonNodeBudget.remaining -= nodes;
|
|
262
|
+
}
|
|
263
|
+
let inner: unknown;
|
|
264
|
+
try {
|
|
265
|
+
inner = JSON.parse(jsonCandidate);
|
|
266
|
+
} catch {
|
|
267
|
+
const fallback =
|
|
268
|
+
freeform && !looksLikeRpcEnvelope(node)
|
|
269
|
+
? redactFreeformText(node)
|
|
270
|
+
: { redacted: node, redactionsCount: 0 };
|
|
271
|
+
freeformCount += fallback.redactionsCount;
|
|
272
|
+
return fallback.redacted;
|
|
273
|
+
}
|
|
274
|
+
return JSON.stringify(visit(inner, pathSoFar, traversalDepth + 1));
|
|
275
|
+
}
|
|
276
|
+
if (freeform && !looksLikeRpcEnvelope(node)) {
|
|
277
|
+
const redacted = redactFreeformText(node);
|
|
278
|
+
freeformCount += redacted.redactionsCount;
|
|
279
|
+
return redacted.redacted;
|
|
280
|
+
}
|
|
281
|
+
return node;
|
|
282
|
+
}
|
|
203
283
|
if (Array.isArray(node)) {
|
|
204
|
-
return node.map((v, i) => visit(v, [...pathSoFar, String(i)]));
|
|
284
|
+
return node.map((v, i) => visit(v, [...pathSoFar, String(i)], traversalDepth + 1));
|
|
205
285
|
}
|
|
206
286
|
if (node && typeof node === 'object') {
|
|
207
287
|
const out: Record<string, unknown> = {};
|
|
@@ -214,34 +294,14 @@ export function redactJsonBody(
|
|
|
214
294
|
} else if (isSensitiveKey(k) && (typeof v === 'string' || typeof v === 'number')) {
|
|
215
295
|
count++;
|
|
216
296
|
out[k] = markerFor(String(v), markerContext);
|
|
217
|
-
} else if (typeof v === 'string' && v.length > 1 && (v[0] === '{' || v[0] === '[')) {
|
|
218
|
-
// JSON-in-JSON: try to parse and redact the nested string.
|
|
219
|
-
try {
|
|
220
|
-
const inner = JSON.parse(v);
|
|
221
|
-
const visited = visit(inner, [...pathSoFar, k]);
|
|
222
|
-
out[k] = JSON.stringify(visited);
|
|
223
|
-
} catch {
|
|
224
|
-
// Nested string that isn't parseable JSON: scan it as free text,
|
|
225
|
-
// unless it's a structured RPC envelope (flat-scanning corrupts it).
|
|
226
|
-
const r =
|
|
227
|
-
freeform && !looksLikeRpcEnvelope(v)
|
|
228
|
-
? redactFreeformText(v)
|
|
229
|
-
: { redacted: v, redactionsCount: 0 };
|
|
230
|
-
freeformCount += r.redactionsCount;
|
|
231
|
-
out[k] = r.redacted;
|
|
232
|
-
}
|
|
233
|
-
} else if (typeof v === 'string' && freeform) {
|
|
234
|
-
const r = redactFreeformText(v);
|
|
235
|
-
freeformCount += r.redactionsCount;
|
|
236
|
-
out[k] = r.redacted;
|
|
237
297
|
} else {
|
|
238
|
-
out[k] = visit(v, [...pathSoFar, k]);
|
|
298
|
+
out[k] = visit(v, [...pathSoFar, k], traversalDepth + 1);
|
|
239
299
|
}
|
|
240
300
|
}
|
|
241
301
|
return out;
|
|
242
302
|
}
|
|
243
303
|
return node;
|
|
244
|
-
}
|
|
304
|
+
}
|
|
245
305
|
const redacted = JSON.stringify(visit(parsed, []));
|
|
246
306
|
return {
|
|
247
307
|
redacted,
|
|
@@ -251,6 +311,147 @@ export function redactJsonBody(
|
|
|
251
311
|
};
|
|
252
312
|
}
|
|
253
313
|
|
|
314
|
+
function isAsciiHex(byte: number | undefined): boolean {
|
|
315
|
+
return (
|
|
316
|
+
byte !== undefined &&
|
|
317
|
+
((byte >= 0x30 && byte <= 0x39) ||
|
|
318
|
+
(byte >= 0x41 && byte <= 0x46) ||
|
|
319
|
+
(byte >= 0x61 && byte <= 0x66))
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Redact JSON rows inside a React Flight body without changing its framing. */
|
|
324
|
+
function redactFlightBody(
|
|
325
|
+
body: string,
|
|
326
|
+
freeform = false,
|
|
327
|
+
markerContext?: RedactionMarkerContext,
|
|
328
|
+
): BodyRedaction {
|
|
329
|
+
const failClosed = (): BodyRedaction => ({
|
|
330
|
+
redacted: `[REDACTED:UNSAFE_FLIGHT_BODY:${Buffer.byteLength(body, 'utf8')}]`,
|
|
331
|
+
redactionsCount: 1,
|
|
332
|
+
placeholdersInjected: 0,
|
|
333
|
+
freeformRedactions: 0,
|
|
334
|
+
});
|
|
335
|
+
if (!body.endsWith('\n')) return failClosed();
|
|
336
|
+
|
|
337
|
+
const bytes = Buffer.from(body, 'utf8');
|
|
338
|
+
const output: Buffer[] = [];
|
|
339
|
+
let offset = 0;
|
|
340
|
+
let redactionsCount = 0;
|
|
341
|
+
let freeformRedactions = 0;
|
|
342
|
+
let rowCount = 0;
|
|
343
|
+
const jsonNodeBudget = { remaining: MAX_REACT_FLIGHT_JSON_NODES };
|
|
344
|
+
|
|
345
|
+
const redactJsonPayload = (json: string): BodyRedaction | null => {
|
|
346
|
+
try {
|
|
347
|
+
if (!hasBoundedJsonNesting(json)) return null;
|
|
348
|
+
const nodes = boundedJsonNodeCount(json, jsonNodeBudget.remaining);
|
|
349
|
+
if (nodes === null) return null;
|
|
350
|
+
jsonNodeBudget.remaining -= nodes;
|
|
351
|
+
JSON.parse(json);
|
|
352
|
+
return redactJsonBody(
|
|
353
|
+
json,
|
|
354
|
+
undefined,
|
|
355
|
+
freeform,
|
|
356
|
+
markerContext,
|
|
357
|
+
MAX_FLIGHT_JSON_NESTING_DEPTH,
|
|
358
|
+
jsonNodeBudget,
|
|
359
|
+
false,
|
|
360
|
+
);
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
while (offset < bytes.length) {
|
|
367
|
+
if (++rowCount > MAX_REACT_FLIGHT_ROWS) return failClosed();
|
|
368
|
+
const lineEnd = bytes.indexOf(0x0a, offset);
|
|
369
|
+
if (lineEnd < 0 || lineEnd === offset) return failClosed();
|
|
370
|
+
if (bytes[offset] === 0x3a) {
|
|
371
|
+
const hint = bytes.toString('utf8', offset, lineEnd);
|
|
372
|
+
const match = /^:([A-Z]+)(.+)$/.exec(hint);
|
|
373
|
+
const redacted = match?.[2] ? redactJsonPayload(match[2]) : null;
|
|
374
|
+
if (!match?.[1] || !redacted) return failClosed();
|
|
375
|
+
redactionsCount += redacted.redactionsCount;
|
|
376
|
+
freeformRedactions += redacted.freeformRedactions;
|
|
377
|
+
output.push(Buffer.from(`:${match[1]}${redacted.redacted}\n`, 'utf8'));
|
|
378
|
+
offset = lineEnd + 1;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let idStart = offset;
|
|
383
|
+
if (bytes[idStart] === 0x23) idStart++;
|
|
384
|
+
let colon = idStart;
|
|
385
|
+
while (colon < bytes.length && isAsciiHex(bytes[colon])) colon++;
|
|
386
|
+
if (colon === idStart || bytes[colon] !== 0x3a) return failClosed();
|
|
387
|
+
const payloadStart = colon + 1;
|
|
388
|
+
if (payloadStart >= bytes.length) return failClosed();
|
|
389
|
+
|
|
390
|
+
if (bytes[payloadStart] === 0x54) {
|
|
391
|
+
let comma = payloadStart + 1;
|
|
392
|
+
while (comma < bytes.length && isAsciiHex(bytes[comma])) comma++;
|
|
393
|
+
if (comma === payloadStart + 1 || bytes[comma] !== 0x2c) return failClosed();
|
|
394
|
+
const length = Number.parseInt(bytes.toString('ascii', payloadStart + 1, comma), 16);
|
|
395
|
+
if (!Number.isSafeInteger(length)) return failClosed();
|
|
396
|
+
const textEnd = comma + 1 + length;
|
|
397
|
+
if (textEnd > bytes.length) return failClosed();
|
|
398
|
+
const text = bytes.toString('utf8', comma + 1, textEnd);
|
|
399
|
+
const redactedText = freeform
|
|
400
|
+
? redactFreeformText(text)
|
|
401
|
+
: { redacted: text, redactionsCount: 0 };
|
|
402
|
+
freeformRedactions += redactedText.redactionsCount;
|
|
403
|
+
output.push(bytes.subarray(offset, payloadStart));
|
|
404
|
+
output.push(
|
|
405
|
+
Buffer.from(
|
|
406
|
+
`T${Buffer.byteLength(redactedText.redacted, 'utf8').toString(16)},${redactedText.redacted}`,
|
|
407
|
+
'utf8',
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
offset = textEnd;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const payload = bytes.toString('utf8', payloadStart, lineEnd);
|
|
415
|
+
if (/^[RrXx]$/.test(payload)) {
|
|
416
|
+
output.push(bytes.subarray(offset, lineEnd + 1));
|
|
417
|
+
offset = lineEnd + 1;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (payload.startsWith('C')) {
|
|
421
|
+
if (payload === 'C') {
|
|
422
|
+
output.push(bytes.subarray(offset, lineEnd + 1));
|
|
423
|
+
offset = lineEnd + 1;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const redacted = redactJsonPayload(payload.slice(1));
|
|
427
|
+
if (!redacted) return failClosed();
|
|
428
|
+
redactionsCount += redacted.redactionsCount;
|
|
429
|
+
freeformRedactions += redacted.freeformRedactions;
|
|
430
|
+
output.push(bytes.subarray(offset, payloadStart));
|
|
431
|
+
output.push(Buffer.from(`C${redacted.redacted}\n`, 'utf8'));
|
|
432
|
+
offset = lineEnd + 1;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const tagged = /^([A-Z]+)(?=[\[{\"\d\-ntf])(.*)$/.exec(payload);
|
|
436
|
+
const tag = tagged?.[1] ?? '';
|
|
437
|
+
const json = tagged?.[2] ?? payload;
|
|
438
|
+
const redacted = redactJsonPayload(json);
|
|
439
|
+
if (!redacted) return failClosed();
|
|
440
|
+
redactionsCount += redacted.redactionsCount;
|
|
441
|
+
freeformRedactions += redacted.freeformRedactions;
|
|
442
|
+
output.push(bytes.subarray(offset, payloadStart));
|
|
443
|
+
output.push(Buffer.from(`${tag}${redacted.redacted}\n`, 'utf8'));
|
|
444
|
+
offset = lineEnd + 1;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
redacted: Buffer.concat(output).toString('utf8'),
|
|
449
|
+
redactionsCount,
|
|
450
|
+
placeholdersInjected: 0,
|
|
451
|
+
freeformRedactions,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
254
455
|
/** Redact a request body of unknown content-type. Tries JSON first, falls back to form. */
|
|
255
456
|
export function redactBody(
|
|
256
457
|
body: string,
|
|
@@ -264,6 +465,9 @@ export function redactBody(
|
|
|
264
465
|
if (ct.includes('urlencoded')) {
|
|
265
466
|
return redactFormBody(body, formPlaceholders, markerContext);
|
|
266
467
|
}
|
|
468
|
+
if (ct.includes('text/x-component')) {
|
|
469
|
+
return redactFlightBody(body, freeform, markerContext);
|
|
470
|
+
}
|
|
267
471
|
// Try JSON first — many APIs send JSON as text/plain or with no content-type.
|
|
268
472
|
const jsonR = redactJsonBody(body, jsonPlaceholders, freeform, markerContext);
|
|
269
473
|
if (jsonR.redactionsCount > 0 || jsonR.placeholdersInjected > 0 || jsonR.freeformRedactions > 0) {
|
|
@@ -509,16 +713,22 @@ export function redactSession(
|
|
|
509
713
|
touched += respHeadersR.redactionsCount;
|
|
510
714
|
let respBody = response.body;
|
|
511
715
|
if (respBody) {
|
|
716
|
+
const responseContentType =
|
|
717
|
+
response.mimeType ||
|
|
718
|
+
Object.entries(response.headers).find(
|
|
719
|
+
([name]) => name.toLowerCase() === 'content-type',
|
|
720
|
+
)?.[1];
|
|
721
|
+
const isFlight =
|
|
722
|
+
(responseContentType ?? '').toLowerCase().split(';', 1)[0]?.trim() === 'text/x-component';
|
|
512
723
|
const respBodyR = redactBody(
|
|
513
724
|
respBody,
|
|
514
|
-
|
|
725
|
+
responseContentType,
|
|
515
726
|
undefined,
|
|
516
727
|
undefined,
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
false,
|
|
728
|
+
// Flight rows have a framing-aware redactor, so rendered free-form
|
|
729
|
+
// PII can be scrubbed without corrupting JSON or T-row byte lengths.
|
|
730
|
+
// Other response envelopes remain key-based only.
|
|
731
|
+
isFlight ? useFreeform : false,
|
|
522
732
|
markerContext,
|
|
523
733
|
);
|
|
524
734
|
respBody = respBodyR.redacted;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { RSC_USER_INTENT_WINDOW_MS, matchesIntendedRoute } from './response-body-stream.ts';
|
|
2
|
+
|
|
3
|
+
interface UserIntent {
|
|
4
|
+
atMs: number;
|
|
5
|
+
url: string;
|
|
6
|
+
activation: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const MAX_TRACKED_INTENTS = 256;
|
|
10
|
+
const MAX_INTENT_URL_CHARS = 4_096;
|
|
11
|
+
|
|
12
|
+
export type ResponseBodyIntentKind = 'pointerover' | 'pointerdown' | 'focusin' | 'click';
|
|
13
|
+
|
|
14
|
+
function isActivation(kind: ResponseBodyIntentKind | undefined): boolean {
|
|
15
|
+
return kind === 'pointerdown' || kind === 'click';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Correlates browser-timestamped DOM intent with requests independently of the
|
|
20
|
+
* order in which Runtime and Network domain events reach the CDP client.
|
|
21
|
+
*/
|
|
22
|
+
export class ResponseBodyIntentTracker {
|
|
23
|
+
readonly #intents: UserIntent[] = [];
|
|
24
|
+
|
|
25
|
+
record(atMs: number, url: string | undefined, kind?: ResponseBodyIntentKind): void {
|
|
26
|
+
if (!Number.isFinite(atMs) || !url || url.length > MAX_INTENT_URL_CHARS) return;
|
|
27
|
+
const intent = { atMs, url, activation: isActivation(kind) };
|
|
28
|
+
let low = 0;
|
|
29
|
+
let high = this.#intents.length;
|
|
30
|
+
while (low < high) {
|
|
31
|
+
const middle = (low + high) >>> 1;
|
|
32
|
+
if ((this.#intents[middle]?.atMs ?? atMs) <= atMs) low = middle + 1;
|
|
33
|
+
else high = middle;
|
|
34
|
+
}
|
|
35
|
+
this.#intents.splice(low, 0, intent);
|
|
36
|
+
|
|
37
|
+
const newest = this.#intents.at(-1)?.atMs ?? atMs;
|
|
38
|
+
const cutoff = newest - RSC_USER_INTENT_WINDOW_MS * 2;
|
|
39
|
+
while ((this.#intents[0]?.atMs ?? newest) < cutoff) this.#intents.shift();
|
|
40
|
+
if (this.#intents.length > MAX_TRACKED_INTENTS) {
|
|
41
|
+
this.#intents.splice(0, this.#intents.length - MAX_TRACKED_INTENTS);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
match(requestUrl: string, requestAtMs: number): string | undefined {
|
|
46
|
+
return this.#match(requestUrl, requestAtMs, false);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
matchActivation(requestUrl: string, requestAtMs: number): string | undefined {
|
|
50
|
+
return this.#match(requestUrl, requestAtMs, true);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#match(requestUrl: string, requestAtMs: number, requireActivation: boolean): string | undefined {
|
|
54
|
+
for (let index = this.#intents.length - 1; index >= 0; index--) {
|
|
55
|
+
const intent = this.#intents[index];
|
|
56
|
+
if (!intent) continue;
|
|
57
|
+
if (intent.atMs > requestAtMs) continue;
|
|
58
|
+
if (requestAtMs - intent.atMs > RSC_USER_INTENT_WINDOW_MS) break;
|
|
59
|
+
if (requireActivation && !intent.activation) continue;
|
|
60
|
+
if (matchesIntendedRoute(requestUrl, intent.url)) return intent.url;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
hasMatchingActivationSince(requestUrl: string, requestAtMs: number): boolean {
|
|
66
|
+
return this.#intents.some(
|
|
67
|
+
(intent) =>
|
|
68
|
+
intent.activation &&
|
|
69
|
+
intent.atMs >= requestAtMs &&
|
|
70
|
+
matchesIntendedRoute(requestUrl, intent.url),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coordinates terminal events with response-body work after the 30-second
|
|
3
|
+
* loading wait has expired. A terminal event must not invalidate a request
|
|
4
|
+
* generation while its final `getResponseBody` call is still in flight.
|
|
5
|
+
*/
|
|
6
|
+
export class RequestBodyLifecycleTracker {
|
|
7
|
+
readonly #active = new Set<string>();
|
|
8
|
+
readonly #terminal = new Set<string>();
|
|
9
|
+
|
|
10
|
+
#key(requestId: string, generation: number): string {
|
|
11
|
+
return `${requestId}\0${generation}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
begin(requestId: string, generation: number): void {
|
|
15
|
+
this.#active.add(this.#key(requestId, generation));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Returns true when the generation can be deleted immediately. */
|
|
19
|
+
markTerminal(requestId: string, generation: number): boolean {
|
|
20
|
+
const key = this.#key(requestId, generation);
|
|
21
|
+
if (!this.#active.has(key)) return true;
|
|
22
|
+
this.#terminal.add(key);
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Returns true when a terminal event arrived while the body work was active. */
|
|
27
|
+
finish(requestId: string, generation: number): boolean {
|
|
28
|
+
const key = this.#key(requestId, generation);
|
|
29
|
+
this.#active.delete(key);
|
|
30
|
+
return this.#terminal.delete(key);
|
|
31
|
+
}
|
|
32
|
+
}
|