@apifuse/provider-sdk 2.2.0-beta.11 → 2.2.0-beta.13
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/AUTHORING.md +238 -0
- package/CHANGELOG.md +14 -0
- package/README.md +44 -2
- package/bin/apifuse-pack-smoke.ts +14 -0
- package/bin/apifuse-pack-types.ts +40 -1
- package/bin/apifuse-record.ts +622 -57
- package/bin/apifuse-submit-check.ts +43 -10
- package/dist/config/loader.d.ts +9 -1
- package/dist/config/loader.js +9 -0
- package/dist/define.d.ts +2 -1
- package/dist/define.js +61 -3
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +15 -0
- package/dist/fixture-sanitization.d.ts +26 -0
- package/dist/fixture-sanitization.js +216 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/provider.d.ts +2 -1
- package/dist/provider.js +1 -0
- package/dist/runtime/http.js +86 -32
- package/dist/runtime/instrumentation.js +295 -9
- package/dist/runtime/native-network.d.ts +53 -0
- package/dist/runtime/native-network.js +477 -0
- package/dist/runtime/proxy-nodemaven.d.ts +14 -0
- package/dist/runtime/proxy-nodemaven.js +20 -2
- package/dist/runtime/request-options.d.ts +68 -1
- package/dist/runtime/request-options.js +548 -0
- package/dist/runtime/stealth.d.ts +3 -1
- package/dist/runtime/stealth.js +352 -86
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/self-test-input-tokens.d.ts +2 -1
- package/dist/server/self-test-input-tokens.js +18 -14
- package/dist/stream-evidence.d.ts +74 -0
- package/dist/stream-evidence.js +785 -0
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/run.d.ts +32 -2
- package/dist/testing/run.js +451 -19
- package/dist/types.d.ts +201 -7
- package/package.json +3 -1
- package/src/config/loader.ts +22 -1
- package/src/define.ts +81 -3
- package/src/errors.ts +15 -0
- package/src/fixture-sanitization.ts +247 -0
- package/src/index.ts +45 -1
- package/src/provider.ts +37 -0
- package/src/runtime/http.ts +144 -38
- package/src/runtime/instrumentation.ts +424 -8
- package/src/runtime/native-network.ts +600 -0
- package/src/runtime/proxy-nodemaven.ts +37 -2
- package/src/runtime/request-options.ts +680 -1
- package/src/runtime/stealth.ts +420 -88
- package/src/server/index.ts +4 -1
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/stream-evidence.ts +988 -0
- package/src/testing/index.ts +9 -1
- package/src/testing/run.ts +608 -12
- package/src/types.ts +235 -7
package/bin/apifuse-record.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// @ts-nocheck
|
|
3
2
|
|
|
4
3
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
5
4
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
@@ -14,14 +13,49 @@ import {
|
|
|
14
13
|
createSttClientFromEnv,
|
|
15
14
|
executeOperation,
|
|
16
15
|
type HttpClient,
|
|
16
|
+
type HttpResponse,
|
|
17
|
+
type HttpStreamResponse,
|
|
17
18
|
type ProviderContext,
|
|
18
19
|
type ProviderDefinition,
|
|
19
20
|
ProviderError,
|
|
21
|
+
type RequestOptions,
|
|
20
22
|
type StealthClient,
|
|
21
23
|
TransportError,
|
|
22
24
|
ValidationError,
|
|
23
25
|
} from "../src/index.js";
|
|
26
|
+
import type { JsonValue } from "../src/contract-json.js";
|
|
27
|
+
import {
|
|
28
|
+
isSensitiveFixtureKey,
|
|
29
|
+
requestPathForFixture,
|
|
30
|
+
sanitizeDiagnosticText,
|
|
31
|
+
sanitizeFixtureString,
|
|
32
|
+
} from "../src/fixture-sanitization.js";
|
|
24
33
|
import { createMemoryProviderRuntimeState } from "../src/runtime/state.js";
|
|
34
|
+
import {
|
|
35
|
+
REDACTED_QUERY_VALUE,
|
|
36
|
+
isSensitiveKey,
|
|
37
|
+
normalizeSensitiveParams,
|
|
38
|
+
parseHttpRequestInvocation,
|
|
39
|
+
redactSensitiveError,
|
|
40
|
+
redactSensitiveText,
|
|
41
|
+
redactUrlQueryParams,
|
|
42
|
+
replaceRequestOptionsInHttpInvocation,
|
|
43
|
+
requestOptionsFromHttpInvocation,
|
|
44
|
+
serializeRequestUrl,
|
|
45
|
+
} from "../src/runtime/request-options.js";
|
|
46
|
+
import { parseSchema } from "../src/schema.js";
|
|
47
|
+
import {
|
|
48
|
+
captureStreamEvidence,
|
|
49
|
+
createStreamCaptureEnvelope,
|
|
50
|
+
findStreamCaptureGroup,
|
|
51
|
+
findStreamEvidenceRecords,
|
|
52
|
+
hasStreamEvidenceMarker,
|
|
53
|
+
parseStreamEvidenceRecord,
|
|
54
|
+
STREAM_PREVIEW_BYTES,
|
|
55
|
+
type StreamCaptureGroupItem,
|
|
56
|
+
type StreamEvidenceCapture,
|
|
57
|
+
type StreamEvidenceRequest,
|
|
58
|
+
} from "../src/stream-evidence.js";
|
|
25
59
|
|
|
26
60
|
type CliArgs = {
|
|
27
61
|
append: boolean;
|
|
@@ -39,57 +73,95 @@ const HELP_TEXT = `Usage: apifuse record [path] --operation <operation> --params
|
|
|
39
73
|
|
|
40
74
|
Calls a real upstream-backed operation through ctx.http or ctx.stealth and writes __fixtures__/raw.json.
|
|
41
75
|
|
|
76
|
+
Streaming responses are recorded as evidence (status, selected headers, full-body SHA-256 and byte
|
|
77
|
+
count, plus a ${STREAM_PREVIEW_BYTES}-byte base64 preview). Test replay is evidence-only: ctx.http.stream exposes the
|
|
78
|
+
preview as its body and the original body_sha256/body_bytes as response metadata.
|
|
79
|
+
When an operation opens multiple streams, all evidence records are saved in stream call order.
|
|
80
|
+
Mixed JSON/stream operations save a tagged call-ordered envelope so snapshot replay can route each response.
|
|
81
|
+
ctx.http.sse() recording is unsupported and fails explicitly.
|
|
82
|
+
|
|
42
83
|
Options:
|
|
43
84
|
--operation, -o <name> operation to call
|
|
44
85
|
--params, -p <json> JSON input passed to the operation (default: {})
|
|
45
86
|
--append preserve the existing fixture and append this capture
|
|
46
87
|
--sanitize redact common token/header fields (default)
|
|
47
|
-
--no-sanitize
|
|
88
|
+
--no-sanitize disable common-field redaction (sensitiveParams are always redacted)
|
|
48
89
|
--help, -h show this help
|
|
49
90
|
|
|
50
91
|
Example:
|
|
51
92
|
apifuse record providers/korea-air-quality --operation realtime --params '{"stationName":"jongno"}'`;
|
|
52
93
|
|
|
53
94
|
export async function main() {
|
|
95
|
+
let capture: ReturnType<typeof createCaptureContext> | undefined;
|
|
54
96
|
try {
|
|
55
97
|
const args = parseArgs(normalizeArgs(process.argv.slice(2)));
|
|
56
98
|
const location = resolveProviderLocation(args.providerPath);
|
|
57
99
|
const provider = await loadProvider(location.rootDir);
|
|
58
100
|
const operationName = resolveOperationName(provider, args.operation);
|
|
59
101
|
const operation = provider.operations[operationName];
|
|
60
|
-
const parsedParams = parseParams(operation, args.params);
|
|
102
|
+
const parsedParams = await parseParams(operation, args.params);
|
|
61
103
|
|
|
62
|
-
|
|
104
|
+
capture = createCaptureContext(
|
|
63
105
|
provider,
|
|
64
106
|
resolveOperationBaseUrl(provider, operationName),
|
|
107
|
+
args.sanitize,
|
|
65
108
|
);
|
|
66
109
|
|
|
67
110
|
console.log(`[apifuse record] Calling ${operationName} on ${provider.id}...`);
|
|
68
111
|
|
|
69
|
-
|
|
70
|
-
|
|
112
|
+
let result: unknown;
|
|
113
|
+
try {
|
|
114
|
+
result = await executeOperation(provider, operationName, capture.ctx, parsedParams);
|
|
115
|
+
} catch (operationError) {
|
|
116
|
+
let partial: unknown;
|
|
117
|
+
try {
|
|
118
|
+
partial = await capture.getCapturedRaw();
|
|
119
|
+
} catch (finalizationError) {
|
|
120
|
+
throw new StreamRecorderError("Operation and stream finalization both failed.", [
|
|
121
|
+
operationError,
|
|
122
|
+
finalizationError,
|
|
123
|
+
]);
|
|
124
|
+
}
|
|
125
|
+
const streamCount = findStreamEvidenceRecords(partial).length;
|
|
126
|
+
if (streamCount > 0) {
|
|
127
|
+
throw new StreamRecorderError(
|
|
128
|
+
`Operation failed after finalizing ${streamCount} stream capture${streamCount === 1 ? "" : "s"}.`,
|
|
129
|
+
[operationError],
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
throw operationError;
|
|
133
|
+
}
|
|
134
|
+
const captured = await capture.getCapturedRaw();
|
|
71
135
|
|
|
72
136
|
if (captured === undefined) {
|
|
73
137
|
throw new Error(`No upstream response was captured for ${provider.id}.${operationName}.`);
|
|
74
138
|
}
|
|
75
139
|
|
|
76
|
-
const
|
|
140
|
+
const sensitiveParams = capture.getCapturedSensitiveParams();
|
|
141
|
+
const rawPayload = jsonFixtureValue(captured);
|
|
77
142
|
const fixturePath = resolve(location.rootDir, "__fixtures__", "raw.json");
|
|
78
|
-
const
|
|
143
|
+
const redactedCapture = redactFixture(rawPayload, sensitiveParams, args.sanitize);
|
|
144
|
+
const mergedPayload = await prepareFixturePayload(fixturePath, redactedCapture, args.append);
|
|
145
|
+
// Mandatory query-secret redaction applies to the merged history, including
|
|
146
|
+
// values discovered in older declared-key URL positions. Optional common-
|
|
147
|
+
// field sanitization applies only to this run's new capture so --append does
|
|
148
|
+
// not rewrite deliberately preserved historical fields.
|
|
149
|
+
const historicalSensitiveParams = discoverSensitiveQueryValues(mergedPayload, sensitiveParams);
|
|
150
|
+
const nextPayload = redactFixture(mergedPayload, historicalSensitiveParams, false);
|
|
79
151
|
|
|
80
152
|
await mkdir(dirname(fixturePath), { recursive: true });
|
|
81
153
|
await writeFile(fixturePath, `${JSON.stringify(nextPayload, null, 2)}\n`);
|
|
82
154
|
|
|
83
155
|
console.log(
|
|
84
156
|
`[apifuse record] Captured response (${formatBytes(
|
|
85
|
-
Buffer.byteLength(JSON.stringify(
|
|
157
|
+
Buffer.byteLength(JSON.stringify(redactedCapture)),
|
|
86
158
|
)})`,
|
|
87
159
|
);
|
|
88
160
|
console.log(`[apifuse record] Saved to ${relative(process.cwd(), fixturePath)}`);
|
|
89
161
|
|
|
90
162
|
void result;
|
|
91
163
|
} catch (error) {
|
|
92
|
-
handleCliError(error);
|
|
164
|
+
handleCliError(error, capture?.getCapturedSensitiveParams().values);
|
|
93
165
|
}
|
|
94
166
|
}
|
|
95
167
|
|
|
@@ -174,13 +246,29 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
174
246
|
return { append, providerPath, operation, params, sanitize };
|
|
175
247
|
}
|
|
176
248
|
|
|
177
|
-
function handleCliError(error: unknown): never {
|
|
178
|
-
const message = formatCliError(error);
|
|
249
|
+
function handleCliError(error: unknown, sensitiveValues: readonly string[] = []): never {
|
|
250
|
+
const message = redactSensitiveText(formatCliError(error), sensitiveValues);
|
|
179
251
|
console.error(`[apifuse record] ${message}`);
|
|
180
252
|
process.exit(1);
|
|
181
253
|
}
|
|
182
254
|
|
|
183
|
-
|
|
255
|
+
class StreamRecorderError extends Error {
|
|
256
|
+
readonly diagnosticCauses: readonly unknown[];
|
|
257
|
+
|
|
258
|
+
constructor(message: string, diagnosticCauses: readonly unknown[]) {
|
|
259
|
+
super(message, { cause: diagnosticCauses[0] });
|
|
260
|
+
this.name = "StreamRecorderError";
|
|
261
|
+
this.diagnosticCauses = diagnosticCauses;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function formatCliError(error: unknown): string {
|
|
266
|
+
if (error instanceof StreamRecorderError) {
|
|
267
|
+
return [
|
|
268
|
+
sanitizeDiagnosticText(error.message),
|
|
269
|
+
...error.diagnosticCauses.map((cause) => `cause=${formatDiagnosticCause(cause)}`),
|
|
270
|
+
].join(" ");
|
|
271
|
+
}
|
|
184
272
|
if (error instanceof TransportError) {
|
|
185
273
|
return [
|
|
186
274
|
error.message,
|
|
@@ -201,12 +289,32 @@ function formatCliError(error: unknown): string {
|
|
|
201
289
|
}
|
|
202
290
|
|
|
203
291
|
if (error instanceof Error) {
|
|
292
|
+
if (/^Stream capture\b/.test(error.message) && error.cause !== undefined) {
|
|
293
|
+
return `${sanitizeDiagnosticText(error.message)} cause=${formatDiagnosticCause(error.cause)}`;
|
|
294
|
+
}
|
|
204
295
|
return error.message;
|
|
205
296
|
}
|
|
206
297
|
|
|
207
298
|
return String(error);
|
|
208
299
|
}
|
|
209
300
|
|
|
301
|
+
function formatDiagnosticCause(cause: unknown): string {
|
|
302
|
+
if (cause instanceof StreamRecorderError) {
|
|
303
|
+
return [
|
|
304
|
+
sanitizeDiagnosticText(cause.message),
|
|
305
|
+
...cause.diagnosticCauses.map((nested) => `cause=${formatDiagnosticCause(nested)}`),
|
|
306
|
+
].join(" ");
|
|
307
|
+
}
|
|
308
|
+
if (!(cause instanceof Error)) return sanitizeDiagnosticText(String(cause));
|
|
309
|
+
const code =
|
|
310
|
+
"code" in cause && typeof cause.code === "string"
|
|
311
|
+
? ` code=${sanitizeDiagnosticText(cause.code)}`
|
|
312
|
+
: "";
|
|
313
|
+
const nestedCause =
|
|
314
|
+
cause.cause === undefined ? "" : ` cause=${formatDiagnosticCause(cause.cause)}`;
|
|
315
|
+
return `${sanitizeDiagnosticText(cause.message)}${code}${nestedCause}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
210
318
|
function resolveProviderLocation(inputPath?: string) {
|
|
211
319
|
const originalInput = inputPath ?? process.cwd();
|
|
212
320
|
const resolvedInput = resolve(process.cwd(), originalInput);
|
|
@@ -284,7 +392,10 @@ function resolveOperationName(provider: ProviderRuntime, operationName?: string)
|
|
|
284
392
|
return firstOperation;
|
|
285
393
|
}
|
|
286
394
|
|
|
287
|
-
function parseParams(
|
|
395
|
+
async function parseParams(
|
|
396
|
+
operation: ProviderRuntime["operations"][string],
|
|
397
|
+
value: string,
|
|
398
|
+
): Promise<unknown> {
|
|
288
399
|
let parsed: unknown;
|
|
289
400
|
|
|
290
401
|
try {
|
|
@@ -295,7 +406,7 @@ function parseParams(operation: ProviderRuntime["operations"][string], value: st
|
|
|
295
406
|
);
|
|
296
407
|
}
|
|
297
408
|
|
|
298
|
-
return operation.input ? operation.input.
|
|
409
|
+
return operation.input ? parseSchema(operation.input, parsed, "record.params") : parsed;
|
|
299
410
|
}
|
|
300
411
|
|
|
301
412
|
function resolveOperationBaseUrl(provider: ProviderRuntime, operationName: string): string {
|
|
@@ -309,15 +420,78 @@ function resolveOperationBaseUrl(provider: ProviderRuntime, operationName: strin
|
|
|
309
420
|
return baseUrl;
|
|
310
421
|
}
|
|
311
422
|
|
|
312
|
-
function createCaptureContext(provider: ProviderRuntime, baseUrl: string) {
|
|
313
|
-
let
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
423
|
+
function createCaptureContext(provider: ProviderRuntime, baseUrl: string, sanitize: boolean) {
|
|
424
|
+
let nextCaptureOrder = 0;
|
|
425
|
+
let nextStreamOrdinal = 0;
|
|
426
|
+
let capturedRaw: JsonValue | undefined;
|
|
427
|
+
const rawCaptures: Array<{ order: number; value: JsonValue }> = [];
|
|
428
|
+
const streamCaptures: Array<{
|
|
429
|
+
order: number;
|
|
430
|
+
request: StreamEvidenceRequest;
|
|
431
|
+
capture: StreamEvidenceCapture;
|
|
432
|
+
}> = [];
|
|
433
|
+
let capturedSse: { order: number; method: string; path: string } | undefined;
|
|
434
|
+
const sensitiveParamNames = new Set<string>();
|
|
435
|
+
const sensitiveParamValues = new Set<string>();
|
|
436
|
+
const captureSensitiveParams = (url: string, options?: RequestOptions) => {
|
|
437
|
+
captureSensitiveRequestValues(url, options, sensitiveParamNames, sensitiveParamValues);
|
|
438
|
+
};
|
|
439
|
+
const getCapturedSensitiveParams = (): CapturedSensitiveParams => ({
|
|
440
|
+
names: [...sensitiveParamNames],
|
|
441
|
+
values: [...sensitiveParamValues],
|
|
317
442
|
});
|
|
318
|
-
const
|
|
319
|
-
|
|
443
|
+
const reserveCaptureOrder = () => {
|
|
444
|
+
nextCaptureOrder += 1;
|
|
445
|
+
return nextCaptureOrder;
|
|
446
|
+
};
|
|
447
|
+
const retainRawCapture = (order: number, value: unknown) => {
|
|
448
|
+
const json = jsonFixtureValue(value);
|
|
449
|
+
capturedRaw = json;
|
|
450
|
+
rawCaptures.push({ order, value: json });
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const http = captureHttpClient(createHttpClient(baseUrl), {
|
|
454
|
+
reserveOrder: reserveCaptureOrder,
|
|
455
|
+
reserveStreamOrdinal: () => {
|
|
456
|
+
nextStreamOrdinal += 1;
|
|
457
|
+
return nextStreamOrdinal;
|
|
458
|
+
},
|
|
459
|
+
onSensitiveParams: captureSensitiveParams,
|
|
460
|
+
onResponse: (order, response) => retainRawCapture(order, response.data),
|
|
461
|
+
onStreamResponse: (order, ordinal, requestUrl, method, response) => {
|
|
462
|
+
const resolvedRequestUrl = new URL(requestUrl, baseUrl).toString();
|
|
463
|
+
const request = {
|
|
464
|
+
ordinal,
|
|
465
|
+
method,
|
|
466
|
+
path: requestPathForFixture(resolvedRequestUrl),
|
|
467
|
+
};
|
|
468
|
+
const capture = captureStreamEvidence(response, {
|
|
469
|
+
requestUrl: resolvedRequestUrl,
|
|
470
|
+
request,
|
|
471
|
+
...(sanitize
|
|
472
|
+
? {
|
|
473
|
+
sanitizeFixture: (value: JsonValue) =>
|
|
474
|
+
jsonFixtureValue(sanitizeStreamFixture(value, getCapturedSensitiveParams())),
|
|
475
|
+
}
|
|
476
|
+
: {}),
|
|
477
|
+
});
|
|
478
|
+
streamCaptures.push({ order, request, capture });
|
|
479
|
+
return capture.response;
|
|
480
|
+
},
|
|
481
|
+
onSseResponse: (order, requestUrl, method) => {
|
|
482
|
+
capturedSse = {
|
|
483
|
+
order,
|
|
484
|
+
method,
|
|
485
|
+
path: requestPathForFixture(new URL(requestUrl, baseUrl).toString()),
|
|
486
|
+
};
|
|
487
|
+
},
|
|
320
488
|
});
|
|
489
|
+
const stealth = proxyStealthClient(
|
|
490
|
+
createStealthClient(baseUrl),
|
|
491
|
+
captureSensitiveParams,
|
|
492
|
+
(order, response) => retainRawCapture(order, normalizeCapturedStealthResponse(response)),
|
|
493
|
+
reserveCaptureOrder,
|
|
494
|
+
);
|
|
321
495
|
|
|
322
496
|
const env = {
|
|
323
497
|
get: (key: string) => process.env[key],
|
|
@@ -374,58 +548,258 @@ function createCaptureContext(provider: ProviderRuntime, baseUrl: string) {
|
|
|
374
548
|
|
|
375
549
|
return {
|
|
376
550
|
ctx,
|
|
377
|
-
getCapturedRaw: () =>
|
|
551
|
+
getCapturedRaw: async () => {
|
|
552
|
+
if (streamCaptures.length === 0) {
|
|
553
|
+
if (capturedSse) throw unsupportedSseCaptureError(capturedSse);
|
|
554
|
+
return capturedRaw;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const settled = await Promise.allSettled(
|
|
558
|
+
streamCaptures.map(async ({ order, request, capture }) => ({
|
|
559
|
+
order,
|
|
560
|
+
request,
|
|
561
|
+
value: redactStreamEvidence(await capture.getEvidence(), getCapturedSensitiveParams()),
|
|
562
|
+
})),
|
|
563
|
+
);
|
|
564
|
+
const failures = settled.flatMap((result, index) =>
|
|
565
|
+
result.status === "rejected"
|
|
566
|
+
? [
|
|
567
|
+
new StreamRecorderError(
|
|
568
|
+
`Stream finalization failed: method=${streamCaptures[index]!.request.method} path=${streamCaptures[index]!.request.path} ordinal=${streamCaptures[index]!.request.ordinal}.`,
|
|
569
|
+
[result.reason],
|
|
570
|
+
),
|
|
571
|
+
]
|
|
572
|
+
: [],
|
|
573
|
+
);
|
|
574
|
+
if (failures.length > 0) {
|
|
575
|
+
throw new StreamRecorderError(
|
|
576
|
+
`${failures.length} stream capture${failures.length === 1 ? "" : "s"} failed to finalize.`,
|
|
577
|
+
failures,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
const evidence = settled.flatMap((result) =>
|
|
581
|
+
result.status === "fulfilled" ? [result.value] : [],
|
|
582
|
+
);
|
|
583
|
+
if (capturedSse) throw unsupportedSseCaptureError(capturedSse);
|
|
584
|
+
const timeline: StreamCaptureGroupItem[] = [...rawCaptures, ...evidence]
|
|
585
|
+
.sort((left, right) => left.order - right.order)
|
|
586
|
+
.map((item) =>
|
|
587
|
+
"request" in item
|
|
588
|
+
? { kind: "stream" as const, evidence: item.value }
|
|
589
|
+
: { kind: "response" as const, value: item.value },
|
|
590
|
+
);
|
|
591
|
+
return createStreamCaptureEnvelope(timeline);
|
|
592
|
+
},
|
|
593
|
+
getCapturedSensitiveParams,
|
|
378
594
|
};
|
|
379
595
|
}
|
|
380
596
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
return new Proxy(client, {
|
|
386
|
-
get(target, prop, receiver) {
|
|
387
|
-
const value = Reflect.get(target, prop, receiver);
|
|
597
|
+
type CapturedSensitiveParams = {
|
|
598
|
+
names: readonly string[];
|
|
599
|
+
values: readonly string[];
|
|
600
|
+
};
|
|
388
601
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
602
|
+
function captureSensitiveRequestValues(
|
|
603
|
+
url: string,
|
|
604
|
+
options: RequestOptions | undefined,
|
|
605
|
+
names: Set<string>,
|
|
606
|
+
values: Set<string>,
|
|
607
|
+
): void {
|
|
608
|
+
const sensitiveParams = normalizeSensitiveParams(options?.sensitiveParams);
|
|
609
|
+
if (sensitiveParams === undefined) return;
|
|
610
|
+
if (!sensitiveParams || typeof sensitiveParams !== "object" || Array.isArray(sensitiveParams)) {
|
|
611
|
+
throw new TypeError("sensitiveParams must be an object whose values are strings.");
|
|
612
|
+
}
|
|
392
613
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
614
|
+
const entries = Object.entries(sensitiveParams);
|
|
615
|
+
for (const [key, value] of entries) {
|
|
616
|
+
if (typeof value !== "string") {
|
|
617
|
+
throw new TypeError(`sensitiveParams.${key} must be a string.`);
|
|
618
|
+
}
|
|
619
|
+
names.add(key);
|
|
620
|
+
if (value !== "") values.add(value);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
let serializedUrl: ReturnType<typeof serializeRequestUrl>;
|
|
624
|
+
try {
|
|
625
|
+
const absoluteUrl = new URL(String(url), "http://apifuse.invalid").toString();
|
|
626
|
+
serializedUrl = serializeRequestUrl(absoluteUrl, options?.params, sensitiveParams);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
const structural = redactUrlQueryParams(String(url), [...names]);
|
|
629
|
+
const safeUrl = redactSensitiveText(structural.redactedUrl, [
|
|
630
|
+
...values,
|
|
631
|
+
...structural.sensitiveValues,
|
|
632
|
+
]);
|
|
633
|
+
const causeKind = error instanceof Error ? error.name : typeof error;
|
|
634
|
+
throw new TypeError(`Cannot securely record sensitiveParams for "${safeUrl}" (${causeKind}).`, {
|
|
635
|
+
cause: redactSensitiveError(
|
|
636
|
+
error,
|
|
637
|
+
[...values, ...structural.sensitiveValues],
|
|
638
|
+
String(url),
|
|
639
|
+
structural.redactedUrl,
|
|
640
|
+
),
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
for (const value of serializedUrl.sensitiveValues) {
|
|
645
|
+
if (value !== "") values.add(value);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function snapshotRequestOptions<T extends RequestOptions>(options: T): T {
|
|
650
|
+
return {
|
|
651
|
+
...options,
|
|
652
|
+
...(options.params
|
|
653
|
+
? {
|
|
654
|
+
params: Object.fromEntries(
|
|
655
|
+
Object.entries(options.params).map(([key, value]) => [
|
|
656
|
+
key,
|
|
657
|
+
Array.isArray(value) ? [...value] : value,
|
|
658
|
+
]),
|
|
659
|
+
),
|
|
660
|
+
}
|
|
661
|
+
: {}),
|
|
662
|
+
...(normalizeSensitiveParams(options.sensitiveParams)
|
|
663
|
+
? { sensitiveParams: { ...options.sensitiveParams } }
|
|
664
|
+
: {}),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
type HttpCaptureCallbacks = {
|
|
669
|
+
reserveOrder(): number;
|
|
670
|
+
reserveStreamOrdinal(): number;
|
|
671
|
+
onSensitiveParams(url: string, options?: RequestOptions): void;
|
|
672
|
+
onResponse(order: number, response: HttpResponse): void;
|
|
673
|
+
onStreamResponse(
|
|
674
|
+
order: number,
|
|
675
|
+
ordinal: number,
|
|
676
|
+
requestUrl: string,
|
|
677
|
+
method: string,
|
|
678
|
+
response: HttpStreamResponse,
|
|
679
|
+
): HttpStreamResponse;
|
|
680
|
+
onSseResponse(order: number, requestUrl: string, method: string): void;
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
function captureHttpClient(client: HttpClient, callbacks: HttpCaptureCallbacks): HttpClient {
|
|
684
|
+
const captureResponse = async (
|
|
685
|
+
order: number,
|
|
686
|
+
responsePromise: Promise<HttpResponse>,
|
|
687
|
+
): Promise<HttpResponse> => {
|
|
688
|
+
const response = await responsePromise;
|
|
689
|
+
callbacks.onResponse(order, response);
|
|
690
|
+
return response;
|
|
691
|
+
};
|
|
692
|
+
const captureRequestOptions = (method: PropertyKey, args: unknown[]) => {
|
|
693
|
+
const invocation = parseHttpRequestInvocation(method, args);
|
|
694
|
+
const options = invocation ? requestOptionsFromHttpInvocation(invocation) : undefined;
|
|
695
|
+
if (!invocation || !options) return;
|
|
696
|
+
const snapshot = snapshotRequestOptions(options);
|
|
697
|
+
callbacks.onSensitiveParams(String(args[0]), snapshot);
|
|
698
|
+
replaceRequestOptionsInHttpInvocation(invocation, snapshot);
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
return {
|
|
702
|
+
request: (...args: Parameters<HttpClient["request"]>) => {
|
|
703
|
+
captureRequestOptions("request", args);
|
|
704
|
+
return captureResponse(callbacks.reserveOrder(), client.request(...args));
|
|
705
|
+
},
|
|
706
|
+
get: (...args: Parameters<HttpClient["get"]>) => {
|
|
707
|
+
captureRequestOptions("get", args);
|
|
708
|
+
return captureResponse(callbacks.reserveOrder(), client.get(...args));
|
|
398
709
|
},
|
|
399
|
-
|
|
710
|
+
post: (...args: Parameters<HttpClient["post"]>) => {
|
|
711
|
+
captureRequestOptions("post", args);
|
|
712
|
+
return captureResponse(callbacks.reserveOrder(), client.post(...args));
|
|
713
|
+
},
|
|
714
|
+
put: (...args: Parameters<HttpClient["put"]>) => {
|
|
715
|
+
captureRequestOptions("put", args);
|
|
716
|
+
return captureResponse(callbacks.reserveOrder(), client.put(...args));
|
|
717
|
+
},
|
|
718
|
+
delete: (...args: Parameters<HttpClient["delete"]>) => {
|
|
719
|
+
captureRequestOptions("delete", args);
|
|
720
|
+
return captureResponse(callbacks.reserveOrder(), client.delete(...args));
|
|
721
|
+
},
|
|
722
|
+
stream: async (...args: Parameters<HttpClient["stream"]>) => {
|
|
723
|
+
captureRequestOptions("stream", args);
|
|
724
|
+
const order = callbacks.reserveOrder();
|
|
725
|
+
const ordinal = callbacks.reserveStreamOrdinal();
|
|
726
|
+
const method = (args[1]?.method ?? "GET").toUpperCase();
|
|
727
|
+
const response = await client.stream(...args);
|
|
728
|
+
return callbacks.onStreamResponse(order, ordinal, args[0], method, response);
|
|
729
|
+
},
|
|
730
|
+
sse: async (...args: Parameters<HttpClient["sse"]>) => {
|
|
731
|
+
captureRequestOptions("sse", args);
|
|
732
|
+
const order = callbacks.reserveOrder();
|
|
733
|
+
const response = await client.sse(...args);
|
|
734
|
+
callbacks.onSseResponse(order, args[0], (args[1]?.method ?? "GET").toUpperCase());
|
|
735
|
+
return response;
|
|
736
|
+
},
|
|
737
|
+
};
|
|
400
738
|
}
|
|
401
739
|
|
|
402
740
|
type StealthSession = ReturnType<StealthClient["createSession"]>;
|
|
403
741
|
|
|
404
742
|
function proxyStealthClient(
|
|
405
743
|
client: StealthClient,
|
|
406
|
-
|
|
744
|
+
onSensitiveParams: (url: string, options?: RequestOptions) => void,
|
|
745
|
+
onResponse: (order: number, response: Awaited<ReturnType<StealthClient["fetch"]>>) => void,
|
|
746
|
+
reserveOrder: () => number,
|
|
407
747
|
): StealthClient {
|
|
408
748
|
return {
|
|
409
749
|
fetch: async (...args: Parameters<StealthClient["fetch"]>) => {
|
|
750
|
+
const order = reserveOrder();
|
|
751
|
+
if (args[1]) args[1] = snapshotRequestOptions(args[1]);
|
|
752
|
+
onSensitiveParams(args[0], args[1]);
|
|
410
753
|
const response = await client.fetch(...args);
|
|
411
|
-
|
|
754
|
+
if (response.url) onSensitiveParams(response.url, args[1]);
|
|
755
|
+
onResponse(order, response);
|
|
412
756
|
return response;
|
|
413
757
|
},
|
|
414
758
|
createSession: (...args: Parameters<StealthClient["createSession"]>) =>
|
|
415
|
-
proxyStealthSession(
|
|
759
|
+
proxyStealthSession(
|
|
760
|
+
client.createSession(...args),
|
|
761
|
+
onSensitiveParams,
|
|
762
|
+
onResponse,
|
|
763
|
+
reserveOrder,
|
|
764
|
+
),
|
|
416
765
|
};
|
|
417
766
|
}
|
|
418
767
|
|
|
419
768
|
function proxyStealthSession(
|
|
420
769
|
session: StealthSession,
|
|
421
|
-
|
|
770
|
+
onSensitiveParams: (url: string, options?: RequestOptions) => void,
|
|
771
|
+
onResponse: (order: number, response: Awaited<ReturnType<StealthClient["fetch"]>>) => void,
|
|
772
|
+
reserveOrder: () => number,
|
|
422
773
|
): StealthSession {
|
|
423
774
|
return {
|
|
424
775
|
fetch: async (...args: Parameters<StealthSession["fetch"]>) => {
|
|
776
|
+
const order = reserveOrder();
|
|
777
|
+
if (args[1]) args[1] = snapshotRequestOptions(args[1]);
|
|
778
|
+
onSensitiveParams(args[0], args[1]);
|
|
425
779
|
const response = await session.fetch(...args);
|
|
426
|
-
|
|
780
|
+
if (response.url) onSensitiveParams(response.url, args[1]);
|
|
781
|
+
onResponse(order, response);
|
|
427
782
|
return response;
|
|
428
783
|
},
|
|
784
|
+
cookies: session.cookies,
|
|
785
|
+
redirects: {
|
|
786
|
+
run: async (...args: Parameters<StealthSession["redirects"]["run"]>) => {
|
|
787
|
+
const order = reserveOrder();
|
|
788
|
+
args[0] = snapshotRequestOptions(args[0]);
|
|
789
|
+
onSensitiveParams(args[0].url, args[0]);
|
|
790
|
+
const callerStopWhen = args[0].stopWhen;
|
|
791
|
+
args[0].stopWhen = async (hop) => {
|
|
792
|
+
for (const url of [hop.url, hop.location, hop.nextUrl]) {
|
|
793
|
+
if (url) onSensitiveParams(url, args[0]);
|
|
794
|
+
}
|
|
795
|
+
return callerStopWhen ? await callerStopWhen(hop) : false;
|
|
796
|
+
};
|
|
797
|
+
const result = await session.redirects.run(...args);
|
|
798
|
+
if (result.final.url) onSensitiveParams(result.final.url, args[0]);
|
|
799
|
+
onResponse(order, result.final);
|
|
800
|
+
return result;
|
|
801
|
+
},
|
|
802
|
+
},
|
|
429
803
|
close: () => session.close(),
|
|
430
804
|
};
|
|
431
805
|
}
|
|
@@ -438,30 +812,192 @@ function normalizeCapturedStealthResponse(response: Awaited<ReturnType<StealthCl
|
|
|
438
812
|
}
|
|
439
813
|
}
|
|
440
814
|
|
|
441
|
-
function
|
|
815
|
+
function redactStreamEvidence(
|
|
816
|
+
evidence: Awaited<ReturnType<StreamEvidenceCapture["getEvidence"]>>,
|
|
817
|
+
sensitiveParams: CapturedSensitiveParams,
|
|
818
|
+
): Awaited<ReturnType<StreamEvidenceCapture["getEvidence"]>> {
|
|
819
|
+
return parseStreamEvidenceRecord(redactFixture(evidence, sensitiveParams, false));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function sanitizeStreamFixture(value: unknown, sensitiveParams: CapturedSensitiveParams): unknown {
|
|
823
|
+
if (typeof value === "string") {
|
|
824
|
+
if (value !== "" && sensitiveParams.values.includes(value)) return REDACTED_QUERY_VALUE;
|
|
825
|
+
return sanitizeFixtureString(redactFixtureText(value, sensitiveParams));
|
|
826
|
+
}
|
|
442
827
|
if (Array.isArray(value)) {
|
|
443
|
-
return value.map((item) =>
|
|
828
|
+
return value.map((item) => sanitizeStreamFixture(item, sensitiveParams));
|
|
444
829
|
}
|
|
445
|
-
|
|
446
830
|
if (!value || typeof value !== "object") {
|
|
447
|
-
return value;
|
|
831
|
+
return redactFixture(value, sensitiveParams, false);
|
|
448
832
|
}
|
|
449
833
|
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
834
|
+
const result: MutableRecord = Object.create(null) as MutableRecord;
|
|
835
|
+
for (const [key, entryValue] of Object.entries(value as MutableRecord)) {
|
|
836
|
+
const redactedKey = sensitiveParams.values.includes(key)
|
|
837
|
+
? REDACTED_QUERY_VALUE
|
|
838
|
+
: redactFixtureText(key, sensitiveParams);
|
|
839
|
+
const uniqueKey = collisionSafeKey(result, redactedKey);
|
|
840
|
+
result[uniqueKey] =
|
|
841
|
+
isSensitiveFixtureKey(key) && !sensitiveParams.names.includes(key)
|
|
842
|
+
? REDACTED_QUERY_VALUE
|
|
843
|
+
: sanitizeStreamFixture(entryValue, sensitiveParams);
|
|
844
|
+
}
|
|
845
|
+
return result;
|
|
846
|
+
}
|
|
454
847
|
|
|
455
|
-
|
|
456
|
-
|
|
848
|
+
function redactStreamPreview(
|
|
849
|
+
bodyPreviewBase64: string,
|
|
850
|
+
sensitiveParams: CapturedSensitiveParams,
|
|
851
|
+
): { bodyPreviewBase64: string; changed: boolean } {
|
|
852
|
+
const preview = Buffer.from(bodyPreviewBase64, "base64");
|
|
853
|
+
if (preview.byteLength === 0) return { bodyPreviewBase64, changed: false };
|
|
854
|
+
|
|
855
|
+
const text = new TextDecoder().decode(preview);
|
|
856
|
+
let redactedText = redactFixtureText(text, sensitiveParams);
|
|
857
|
+
const trimmed = text.trimEnd();
|
|
858
|
+
try {
|
|
859
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
860
|
+
const structurallyRedacted = JSON.stringify(redactFixture(parsed, sensitiveParams, false));
|
|
861
|
+
if (structurallyRedacted !== JSON.stringify(parsed)) redactedText = structurallyRedacted;
|
|
862
|
+
} catch {
|
|
863
|
+
// Non-JSON previews still use the shared free-text sensitive-value policy.
|
|
864
|
+
}
|
|
865
|
+
if (redactedText === text) return { bodyPreviewBase64, changed: false };
|
|
457
866
|
|
|
458
|
-
|
|
867
|
+
const fitted = Buffer.alloc(preview.byteLength, 0x20);
|
|
868
|
+
let redactedBytes = Buffer.from(redactedText);
|
|
869
|
+
if (redactedBytes.byteLength > fitted.byteLength) {
|
|
870
|
+
redactedBytes = Buffer.from(REDACTED_QUERY_VALUE);
|
|
871
|
+
}
|
|
872
|
+
redactedBytes.copy(fitted, 0, 0, Math.min(redactedBytes.byteLength, fitted.byteLength));
|
|
873
|
+
return { bodyPreviewBase64: fitted.toString("base64"), changed: true };
|
|
459
874
|
}
|
|
460
875
|
|
|
461
|
-
function
|
|
462
|
-
|
|
876
|
+
function redactFixture(
|
|
877
|
+
value: unknown,
|
|
878
|
+
sensitiveParams: CapturedSensitiveParams,
|
|
879
|
+
sanitizeCommonFields: boolean,
|
|
880
|
+
): unknown {
|
|
881
|
+
if (typeof value === "string") {
|
|
882
|
+
if (value !== "" && sensitiveParams.values.includes(value)) return REDACTED_QUERY_VALUE;
|
|
883
|
+
return redactFixtureText(value, sensitiveParams);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
887
|
+
return sensitiveParams.values.includes(String(value)) ? REDACTED_QUERY_VALUE : value;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (Array.isArray(value)) {
|
|
891
|
+
return value.map((item) => redactFixture(item, sensitiveParams, sanitizeCommonFields));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (!value || typeof value !== "object") return value;
|
|
895
|
+
|
|
896
|
+
const result: MutableRecord = Object.create(null) as MutableRecord;
|
|
897
|
+
for (const [key, entryValue] of Object.entries(value as MutableRecord)) {
|
|
898
|
+
const redactedKey = sensitiveParams.values.includes(key)
|
|
899
|
+
? REDACTED_QUERY_VALUE
|
|
900
|
+
: redactFixtureText(key, sensitiveParams);
|
|
901
|
+
const uniqueKey = collisionSafeKey(result, redactedKey);
|
|
902
|
+
result[uniqueKey] =
|
|
903
|
+
sanitizeCommonFields &&
|
|
904
|
+
(isSensitiveKey(key) || isSensitiveFixtureKey(key)) &&
|
|
905
|
+
!sensitiveParams.names.includes(key)
|
|
906
|
+
? REDACTED_QUERY_VALUE
|
|
907
|
+
: redactFixture(entryValue, sensitiveParams, sanitizeCommonFields);
|
|
908
|
+
}
|
|
909
|
+
const record = value as MutableRecord;
|
|
910
|
+
if (record.__apifuse_stream__ === true && typeof record.body_preview_base64 === "string") {
|
|
911
|
+
const preview = redactStreamPreview(record.body_preview_base64, sensitiveParams);
|
|
912
|
+
result.body_preview_base64 = preview.bodyPreviewBase64;
|
|
913
|
+
if (preview.changed) result.preview_sanitized = true;
|
|
914
|
+
}
|
|
915
|
+
return result;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function redactFixtureText(text: string, sensitiveParams: CapturedSensitiveParams): string {
|
|
919
|
+
// Shared free-text policy: long values are unconditional substrings; values
|
|
920
|
+
// shorter than four characters require token boundaries. Exact scalar echoes
|
|
921
|
+
// and declared query-key positions are structurally redacted for every length.
|
|
922
|
+
let redacted = redactSensitiveText(text, sensitiveParams.values);
|
|
923
|
+
for (const name of sensitiveParams.names) {
|
|
924
|
+
let componentEncodedName = name;
|
|
925
|
+
try {
|
|
926
|
+
componentEncodedName = encodeURIComponent(name);
|
|
927
|
+
} catch {
|
|
928
|
+
// Lone surrogates remain covered by the raw and form-encoded variants.
|
|
929
|
+
}
|
|
930
|
+
const keyVariants = new Set([
|
|
931
|
+
name,
|
|
932
|
+
componentEncodedName,
|
|
933
|
+
new URLSearchParams({ [name]: "" }).toString().slice(0, -1),
|
|
934
|
+
]);
|
|
935
|
+
for (const key of keyVariants) {
|
|
936
|
+
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
937
|
+
const queryValue = new RegExp(`(^|[?&])(${escapedKey}=)[^&#\\s"]*`, "g");
|
|
938
|
+
redacted = redacted.replace(queryValue, (_match, prefix, assignment) => {
|
|
939
|
+
return `${prefix}${assignment}${REDACTED_QUERY_VALUE}`;
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return redacted;
|
|
463
944
|
}
|
|
464
945
|
|
|
946
|
+
function discoverSensitiveQueryValues(
|
|
947
|
+
value: unknown,
|
|
948
|
+
sensitiveParams: CapturedSensitiveParams,
|
|
949
|
+
): CapturedSensitiveParams {
|
|
950
|
+
const values = new Set(sensitiveParams.values);
|
|
951
|
+
const absoluteUrl = /\b[A-Za-z][A-Za-z\d+.-]*:\/\/[^\s<>"']+/g;
|
|
952
|
+
const discoverFromUrl = (url: string) => {
|
|
953
|
+
for (const discovered of redactUrlQueryParams(url, sensitiveParams.names).sensitiveValues) {
|
|
954
|
+
if (discovered !== "" && discovered !== REDACTED_QUERY_VALUE) values.add(discovered);
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
const visitText = (text: string) => {
|
|
958
|
+
if (!/\s/.test(text)) {
|
|
959
|
+
try {
|
|
960
|
+
new URL(text, "http://apifuse.invalid");
|
|
961
|
+
discoverFromUrl(text);
|
|
962
|
+
return;
|
|
963
|
+
} catch {
|
|
964
|
+
// Fall through to extracting absolute URL spans from prose.
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
for (const match of text.matchAll(absoluteUrl)) {
|
|
968
|
+
try {
|
|
969
|
+
new URL(match[0]);
|
|
970
|
+
discoverFromUrl(match[0]);
|
|
971
|
+
} catch {
|
|
972
|
+
// Ignore URI-like prose that is not a parseable URL.
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
const visit = (current: unknown): void => {
|
|
977
|
+
if (typeof current === "string") {
|
|
978
|
+
visitText(current);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (Array.isArray(current)) {
|
|
982
|
+
for (const item of current) visit(item);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (!current || typeof current !== "object") return;
|
|
986
|
+
for (const [key, entryValue] of Object.entries(current as MutableRecord)) {
|
|
987
|
+
visitText(key);
|
|
988
|
+
visit(entryValue);
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
visit(value);
|
|
992
|
+
return { names: sensitiveParams.names, values: [...values] };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function collisionSafeKey(record: MutableRecord, preferredKey: string): string {
|
|
996
|
+
if (!(preferredKey in record)) return preferredKey;
|
|
997
|
+
let suffix = 2;
|
|
998
|
+
while (`${preferredKey}#${suffix}` in record) suffix += 1;
|
|
999
|
+
return `${preferredKey}#${suffix}`;
|
|
1000
|
+
}
|
|
465
1001
|
export async function prepareFixturePayload(
|
|
466
1002
|
fixturePath: string,
|
|
467
1003
|
payload: unknown,
|
|
@@ -493,7 +1029,18 @@ export async function prepareFixturePayload(
|
|
|
493
1029
|
);
|
|
494
1030
|
}
|
|
495
1031
|
|
|
1032
|
+
if (hasStreamEvidenceMarker(existing)) {
|
|
1033
|
+
const evidence = parseStreamEvidenceRecord(existing);
|
|
1034
|
+
return [createStreamCaptureEnvelope([{ kind: "stream", evidence }]), payload];
|
|
1035
|
+
}
|
|
496
1036
|
if (Array.isArray(existing)) {
|
|
1037
|
+
if (existing.some((item) => hasStreamEvidenceMarker(item))) {
|
|
1038
|
+
const legacyGroup = findStreamCaptureGroup(existing);
|
|
1039
|
+
if (legacyGroup) {
|
|
1040
|
+
const prefix = existing.slice(0, -legacyGroup.items.length);
|
|
1041
|
+
return [...prefix, createStreamCaptureEnvelope(legacyGroup.items), payload];
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
497
1044
|
return [...existing, payload];
|
|
498
1045
|
}
|
|
499
1046
|
if (existing !== null) {
|
|
@@ -510,6 +1057,24 @@ function formatBytes(bytes: number): string {
|
|
|
510
1057
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
511
1058
|
}
|
|
512
1059
|
|
|
1060
|
+
function jsonFixtureValue(value: unknown): JsonValue {
|
|
1061
|
+
const serialized = JSON.stringify(value);
|
|
1062
|
+
if (serialized === undefined) {
|
|
1063
|
+
throw new Error("Captured upstream response is not JSON-serializable.");
|
|
1064
|
+
}
|
|
1065
|
+
return JSON.parse(serialized) as JsonValue;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function unsupportedSseCaptureError(request: {
|
|
1069
|
+
order: number;
|
|
1070
|
+
method: string;
|
|
1071
|
+
path: string;
|
|
1072
|
+
}): Error {
|
|
1073
|
+
return new Error(
|
|
1074
|
+
`apifuse record does not support ctx.http.sse(): method=${request.method} path=${request.path} call=${request.order}.`,
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
513
1078
|
if (import.meta.main) {
|
|
514
1079
|
await main();
|
|
515
1080
|
}
|