stubsmith 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,806 @@
1
+ /// <reference lib="esnext.disposable" />
2
+ /** Headers as accepted by the maskers. */
3
+ type HeaderInput = Record<string, string> | ReadonlyMap<string, string>;
4
+ type TemplatePart = string | {
5
+ group: number | string;
6
+ };
7
+ /** One compiled regex mask. */
8
+ interface RegexMask {
9
+ regex: RegExp;
10
+ /** Python replacement template. */
11
+ replace: string;
12
+ groupCount: number;
13
+ groupNames: ReadonlySet<string>;
14
+ /** Parsed lazily, so an invalid template fails at apply time like Python. */
15
+ parsed?: TemplatePart[];
16
+ }
17
+ /** Immutable pre-compiled masking rules. */
18
+ declare class CompiledRules {
19
+ /** Lower-cased, stripped field names. */
20
+ readonly fieldMasks: ReadonlySet<string>;
21
+ readonly regexMasks: readonly RegexMask[];
22
+ /**
23
+ * True when at least one synced regex mask cannot be used: Python rejects
24
+ * it, or it has no JS translation. The rule's effect is unknown, so
25
+ * {@link applyRegexes} masks every string it is given (as Python does).
26
+ */
27
+ readonly unusable: boolean;
28
+ constructor(
29
+ /** Lower-cased, stripped field names. */
30
+ fieldMasks: ReadonlySet<string>, regexMasks: readonly RegexMask[],
31
+ /**
32
+ * True when at least one synced regex mask cannot be used: Python rejects
33
+ * it, or it has no JS translation. The rule's effect is unknown, so
34
+ * {@link applyRegexes} masks every string it is given (as Python does).
35
+ */
36
+ unusable?: boolean);
37
+ }
38
+
39
+ /** `(status, path)` pair of a status-scoped rule. */
40
+ type ScopedRule = readonly [number, string];
41
+ /** Immutable pre-compiled field rules. */
42
+ declare class CompiledFieldRules {
43
+ readonly keepBody: ReadonlySet<string>;
44
+ readonly keepQuery: ReadonlySet<string>;
45
+ /** Lower-cased request header names. */
46
+ readonly keepHeader: ReadonlySet<string>;
47
+ readonly keepResp: ReadonlySet<string>;
48
+ /** Lower-cased response header names. */
49
+ readonly keepRespHeader: ReadonlySet<string>;
50
+ readonly keepRespStatus: readonly ScopedRule[];
51
+ readonly maskRespStatus: readonly ScopedRule[];
52
+ readonly keepRespHeaderStatus: readonly ScopedRule[];
53
+ readonly maskRespHeaderStatus: readonly ScopedRule[];
54
+ /** Semantic type hints of mask rules, keyed by namespace-relative path / name. */
55
+ readonly bodyMaskTypes: ReadonlyMap<string, string>;
56
+ readonly queryMaskTypes: ReadonlyMap<string, string>;
57
+ readonly respMaskTypes: ReadonlyMap<string, string>;
58
+ constructor(keepBody: ReadonlySet<string>, keepQuery: ReadonlySet<string>,
59
+ /** Lower-cased request header names. */
60
+ keepHeader: ReadonlySet<string>, keepResp: ReadonlySet<string>,
61
+ /** Lower-cased response header names. */
62
+ keepRespHeader: ReadonlySet<string>, keepRespStatus: readonly ScopedRule[], maskRespStatus: readonly ScopedRule[], keepRespHeaderStatus: readonly ScopedRule[], maskRespHeaderStatus: readonly ScopedRule[],
63
+ /** Semantic type hints of mask rules, keyed by namespace-relative path / name. */
64
+ bodyMaskTypes: ReadonlyMap<string, string>, queryMaskTypes: ReadonlyMap<string, string>, respMaskTypes: ReadonlyMap<string, string>);
65
+ }
66
+
67
+ /** A curated path template with its split segments and literal-segment count precomputed once at load time. */
68
+ interface CuratedTemplate {
69
+ template: string;
70
+ literalCount: number;
71
+ /** Non-empty `/`-separated segments of `template`. */
72
+ segments: readonly string[];
73
+ /** Per segment: true when it is a `{name}` wildcard. */
74
+ wildcards: readonly boolean[];
75
+ }
76
+
77
+ /** What {@link RulesCache.lookup} returns: `null` is a novel request. */
78
+ type LookupResult = CompiledFieldRules | null;
79
+ declare class RulesCache {
80
+ private rules;
81
+ private _cursor;
82
+ private _projectDefaults;
83
+ private _emailDomain;
84
+ private templates;
85
+ private _groupIdsInPaths;
86
+ private _valueConfig;
87
+ private _firstSyncDone;
88
+ /** Compiled rules for `(endpointId, fp)`, or `null` for an unknown fingerprint (a novel request). */
89
+ lookup(endpointId: string, fp: string): LookupResult;
90
+ /** Curated path templates, sorted for matching. A frozen array, replaced (never mutated) by each sync, so no per-call copy. */
91
+ get pathTemplates(): readonly CuratedTemplate[];
92
+ /** Synced project defaults, or `null` when none compiled to a rule. */
93
+ get projectDefaults(): CompiledRules | null;
94
+ /** Email placeholder domain; `stub.invalid` until a sync supplies one. */
95
+ get emailDomain(): string;
96
+ /** Whether path segments that look like IDs become `{id}`; true until a sync sets `project_settings.group_ids_in_paths` to false. */
97
+ get groupIdsInPaths(): boolean;
98
+ /** `endpoint_id` to value-discrimination paths (snapshot). */
99
+ get valueConfig(): Map<string, string[]>;
100
+ /** Value-discrimination paths for `endpointId` (empty when none). */
101
+ getValuePaths(endpointId: string): string[];
102
+ /** Sync cursor, `"0"` initially. */
103
+ get cursor(): string;
104
+ /** True once a sync has completed; never cleared. */
105
+ get firstSyncDone(): boolean;
106
+ /**
107
+ * Merge a successful `GET /v1/sdk/sync` body (parsed JSON; `Map` or plain
108
+ * object). Rules are additive per `(endpoint_id, fingerprint)`; an empty
109
+ * fingerprint is skipped. The cursor moves only when it is not `"0"`.
110
+ * `path_templates` is a full replacement (absent clears it);
111
+ * `project_defaults` is stored only when it compiles to at least one rule,
112
+ * `email_placeholder_domain` overrides only when a non-empty string;
113
+ * `request_type_value_config` is a full replacement when an object;
114
+ * `project_settings.group_ids_in_paths` is stored only when a boolean. A
115
+ * non-object body is ignored; malformed rule entries are
116
+ * skipped. All inputs are processed before any state changes.
117
+ */
118
+ applySyncResponse(body: unknown): void;
119
+ }
120
+
121
+ /** One captured request/response pair. Bodies are text; headers are name to value. */
122
+ interface PipelineInput {
123
+ method: string;
124
+ /** Full outbound URL including scheme, host, path and query. */
125
+ url: string;
126
+ reqHeaders: HeaderInput;
127
+ reqBody: string;
128
+ status: number;
129
+ respHeaders: HeaderInput;
130
+ respBody: string;
131
+ }
132
+ /** Wire payload minus `source` and `duration` (the client adds those). */
133
+ interface Payload {
134
+ sdk_version: string;
135
+ classifier_spec_version: number;
136
+ sdk_masked: true;
137
+ sdk_rule_version: string;
138
+ domain: string;
139
+ path_template: string;
140
+ path: string;
141
+ method: string;
142
+ status: number;
143
+ req_fingerprint: string;
144
+ resp_fingerprint: string;
145
+ key_paths: string[];
146
+ resp_key_paths: string[];
147
+ req_header_names: string[];
148
+ resp_header_names: string[];
149
+ query_names: string[];
150
+ headers: Record<string, string>;
151
+ req_body: string;
152
+ resp_headers: Record<string, string>;
153
+ resp_body: string;
154
+ novel: boolean;
155
+ req_body_encoding?: "base64";
156
+ resp_body_encoding?: "base64";
157
+ fingerprint_value_paths?: string[];
158
+ req_value_types?: Record<string, string>;
159
+ resp_value_types?: Record<string, string>;
160
+ /**
161
+ * Raw path (no query) of a capture templated before the first rules sync, so the sender can template
162
+ * it again once "Group IDs in paths automatically" is known. Private: held in memory, never sent.
163
+ */
164
+ _raw_path?: string;
165
+ }
166
+ interface PipelineOptions {
167
+ /** Overrides `sdk_version` in every payload; defaults to the package version. */
168
+ sdkVersion?: string;
169
+ /**
170
+ * Placeholder salt. When omitted, `STUBSMITH_MASK_SALT` is read at call time
171
+ * (the conformance behaviour); an empty string means no salt.
172
+ */
173
+ salt?: string;
174
+ }
175
+ declare class PrivacyPipeline {
176
+ private readonly cache;
177
+ private readonly sdkVersion;
178
+ /** `undefined` defers to the environment; `null` is an explicit "no salt". */
179
+ private readonly salt;
180
+ constructor(rulesCache: RulesCache, opts?: PipelineOptions);
181
+ /** Privacy-processed payload, or `null` on catastrophic failure (drop the capture). Never throws. */
182
+ process(input: PipelineInput): Payload | null;
183
+ private processInner;
184
+ /** Python `_apply_masking`: returns `[mb, mh, mq, respMb, respMh, novel]`. */
185
+ private applyMasking;
186
+ }
187
+
188
+ /** Shared public types for the capture runtime. */
189
+ /** Header name to value; repeated headers are joined with `, ` as Node does. */
190
+ type HeaderRecord = Record<string, string>;
191
+ /** Where a capture came from; the wire `source` field. */
192
+ type CaptureSource = "node-http" | "node-fetch";
193
+ /** One finished request/response pair handed to {@link StubsmithClient.capture}. */
194
+ interface CapturedExchange {
195
+ method: string;
196
+ /** Full outbound URL including scheme, host, path and query (the ORIGINAL url for redirects). */
197
+ url: string;
198
+ reqHeaders: HeaderRecord;
199
+ reqBody: string;
200
+ status: number;
201
+ respHeaders: HeaderRecord;
202
+ respBody: string;
203
+ /** Milliseconds from sending the request to receiving the response headers. */
204
+ durationMs: number;
205
+ source: CaptureSource;
206
+ }
207
+ interface StubsmithOptions {
208
+ /** Defaults to `STUBSMITH_API_KEY`. An empty key disables the SDK entirely. */
209
+ apiKey?: string;
210
+ /** Ingest endpoint; defaults to `STUBSMITH_URL`. */
211
+ url?: string;
212
+ /** Backend for rules sync; defaults to `STUBSMITH_BACKEND_URL`. */
213
+ backendUrl?: string;
214
+ /** Ingest POST timeout in ms. Default 5000. */
215
+ timeoutMs?: number;
216
+ /** Fraction of captures sent, 0 to 1. Default 1. */
217
+ sampleRate?: number;
218
+ /** Rules poll interval in ms. Default 60000. */
219
+ rulesPollInterval?: number;
220
+ /** Gzip payloads of 1024 bytes or more. Default true. */
221
+ compress?: boolean;
222
+ /** Debug logging to stderr; defaults to `STUBSMITH_DEBUG`. */
223
+ debug?: boolean;
224
+ /**
225
+ * Placeholder salt; defaults to `STUBSMITH_MASK_SALT`. A value given here is
226
+ * passed to the privacy pipeline directly; the environment is not modified.
227
+ */
228
+ maskSalt?: string;
229
+ /** Advanced: override the queue and payload ceilings (defaults 1000 items, 32 MiB, 10 MiB). */
230
+ limits?: {
231
+ maxQueueItems?: number;
232
+ maxQueueBytes?: number;
233
+ maxPayloadBytes?: number;
234
+ };
235
+ }
236
+ interface FlushOptions {
237
+ /** Default 5000. */
238
+ timeoutMs?: number;
239
+ }
240
+
241
+ declare class StubsmithClient {
242
+ readonly enabled: boolean;
243
+ readonly url: string;
244
+ readonly backendUrl: string;
245
+ readonly timeoutMs: number;
246
+ readonly sampleRate: number;
247
+ readonly compress: boolean;
248
+ readonly debug: boolean;
249
+ /** Present only when enabled and the pipeline initialised. */
250
+ readonly pipeline: PrivacyPipeline | null;
251
+ readonly rulesCache: RulesCache | null;
252
+ /** Hold, in ms from client start, for the first rules sync (see `resolveHeld`). Test aid: lower it to run fast. */
253
+ static firstSyncHoldMs: number;
254
+ /** Failed sends so far (network error or HTTP status >= 400). */
255
+ sendFailures: number;
256
+ /** Captures dropped by the queue caps. */
257
+ droppedCount: number;
258
+ private readonly apiKey;
259
+ private readonly poller;
260
+ private startedAt;
261
+ /** waitIdle() calls in progress; while non-zero the sender does not hold captures for the first sync. */
262
+ private flushing;
263
+ /** Releases a capture the sender is holding for the first sync (see `resolveHeld`). */
264
+ private releaseHold;
265
+ private readonly maxPayloadBytes;
266
+ private readonly maxQueueBytes;
267
+ private readonly maxQueueItems;
268
+ private readonly exitFlushMs;
269
+ private readonly setsDebug;
270
+ private readonly queue;
271
+ private queueBytes;
272
+ private pumping;
273
+ private _stopped;
274
+ private readonly counts;
275
+ private inflight;
276
+ private closing;
277
+ /** Origin and path prefix of the ingest and backend URLs; exchanges under them are never captured. */
278
+ private readonly ownPrefixes;
279
+ private readonly oversizeReported;
280
+ private closeHooks;
281
+ private readonly exitHook;
282
+ constructor(options?: StubsmithOptions);
283
+ /** True once `close()` completed. */
284
+ get stopped(): boolean;
285
+ /** True when the first rules sync has completed. */
286
+ get rulesSynced(): boolean;
287
+ /**
288
+ * Resolves true once rules are loaded, false on timeout or when disabled.
289
+ * The wait holds the process open until it settles, even when the backend fails fast.
290
+ */
291
+ waitForRules(timeoutMs: number): Promise<boolean>;
292
+ /** Run `fn` from `close()` (the instrumentation uses this to unpatch). */
293
+ onClose(fn: () => void): void;
294
+ /**
295
+ * Mask one exchange and queue it. Synchronous and never throws. Cost is the
296
+ * privacy pipeline plus one `JSON.stringify` of the payload.
297
+ */
298
+ capture(ex: CapturedExchange): void;
299
+ private enqueue;
300
+ /**
301
+ * Both bodies go, together, when the payload is over the ceiling: a partial
302
+ * body is a sample that looks captured and is not. The shape is still
303
+ * recorded server-side. Warned once per `METHOD path_template`.
304
+ */
305
+ private omitBodies;
306
+ private isOwnUrl;
307
+ /** `bodies`: also wait for exchanges whose body is still being read (explicit flush/close only). */
308
+ private idle;
309
+ /** Start the single sender loop on the next macrotask when it is not running. */
310
+ private kick;
311
+ private pump;
312
+ /**
313
+ * A capture processed before the first rules sync was templated with ID grouping on, because the
314
+ * project's "Group IDs in paths automatically" setting was not yet known. The sender (never the
315
+ * capturing call) waits for the first sync, at most `StubsmithClient.firstSyncHoldMs` from client start, then templates
316
+ * the raw path again with the synced curated templates and setting. If the bound runs out the
317
+ * capture-time values stand. The wait ends when a flush starts (`flush()`, `close()` or the exit hook: a
318
+ * flush means send now, as before the hold existed) or `close()` stops the poller, and its timer is
319
+ * unref'd.
320
+ */
321
+ private resolveHeld;
322
+ private clearQueue;
323
+ /** POST one serialised capture. Failures are counted, never thrown. */
324
+ private send;
325
+ /**
326
+ * Resolve when no capture is pending, the queue and the in-flight send are
327
+ * empty, the timeout elapses, or a new send failure happened since the call
328
+ * started (the endpoint is unreachable, so waiting longer only costs the
329
+ * application). The poll timer is ref'd: the sender's sockets are not, so
330
+ * this is what keeps the process alive while it waits.
331
+ */
332
+ flush(opts?: FlushOptions): Promise<void>;
333
+ private waitIdle;
334
+ /**
335
+ * Stop the poller, flush (default 5 s, or `timeoutMs`; `STUBSMITH_FLUSH_TIMEOUT`
336
+ * is the exit-hook budget and does not apply here), abort a send still in
337
+ * flight, un-patch and mark stopped. Idempotent.
338
+ */
339
+ close(opts?: FlushOptions): Promise<void>;
340
+ private doClose;
341
+ }
342
+
343
+ /**
344
+ * Create the client and patch the hooks. Idempotent: returns the existing
345
+ * client while it is not stopped and ignores the new options.
346
+ */
347
+ declare function install(options?: StubsmithOptions): StubsmithClient;
348
+ /** True while an installed client exists and is not stopped. */
349
+ declare function isInstalled(): boolean;
350
+
351
+ declare const VERSION = "0.2.1";
352
+
353
+ interface FixturesOptions {
354
+ /** Only `"status"`: at most one fixture per distinct HTTP status, newest first. */
355
+ distinct?: "status";
356
+ /** Maximum number of fixtures (1-100; server default 20). */
357
+ limit?: number;
358
+ /** Defaults to `STUBSMITH_API_URL`, then the hosted service. */
359
+ apiUrl?: string;
360
+ /** Defaults to `STUBSMITH_API_KEY`. */
361
+ apiKey?: string;
362
+ }
363
+ type Obj = Record<string, unknown>;
364
+ /** Headers and body for one side (request or response) of a {@link Fixture}. */
365
+ declare class FixtureSide {
366
+ readonly headers: Record<string, unknown>;
367
+ readonly body: string | null;
368
+ constructor(headers: Record<string, unknown> | null | undefined, body: string | null | undefined);
369
+ /** Parse `body` as JSON. Throws when the body is null or not valid JSON. */
370
+ json(): unknown;
371
+ }
372
+ /** One recorded HTTP exchange from `/v1/fixtures`. */
373
+ declare class Fixture {
374
+ readonly id: string;
375
+ readonly capturedAt: string;
376
+ readonly method: string;
377
+ readonly path: string;
378
+ readonly status: number;
379
+ readonly durationMs: number | null;
380
+ readonly request: FixtureSide;
381
+ readonly response: FixtureSide;
382
+ constructor(id: string, capturedAt: string, method: string, path: string, status: number, durationMs: number | null, request: FixtureSide, response: FixtureSide);
383
+ /** @internal */
384
+ static fromObject(d: unknown): Fixture;
385
+ toString(): string;
386
+ }
387
+ /** The full `/v1/fixtures` envelope. */
388
+ declare class FixtureBundle {
389
+ readonly ok: boolean;
390
+ readonly requestType: Record<string, unknown> | null;
391
+ readonly fixtures: Fixture[];
392
+ readonly count: number;
393
+ constructor(data: Obj);
394
+ /** The fixture for `status`; throws an Error naming the statuses present when there is none. */
395
+ byStatus(status: number): Fixture;
396
+ }
397
+ /** Fetch the full `/v1/fixtures` envelope for `"METHOD /path"`. */
398
+ declare function fixturesBundle(spec: string, opts?: FixturesOptions): Promise<FixtureBundle>;
399
+ /** Fetch recorded fixtures for `"METHOD /path"`, newest first. */
400
+ declare function fixtures(spec: string, opts?: FixturesOptions): Promise<Fixture[]>;
401
+
402
+ /** One stored response sample inside a variant (bundle fetched with `samples > 1`), newest first. */
403
+ interface BundleSample {
404
+ capture_id?: string | null;
405
+ captured_at?: string | null;
406
+ duration_ms?: number | null;
407
+ headers?: Record<string, string>;
408
+ /** A JSON-encoded string, or null. An object/array body is serialised like Python `json.dumps`. */
409
+ body?: string | null | object;
410
+ body_capped?: boolean;
411
+ body_encoding?: string | null;
412
+ }
413
+ /** One recorded response status of a stub. */
414
+ interface BundleVariant {
415
+ status: number;
416
+ count: number;
417
+ duration_ms?: number | null;
418
+ headers?: Record<string, string>;
419
+ body?: string | null | object;
420
+ body_capped?: boolean;
421
+ body_encoding?: string | null;
422
+ samples?: BundleSample[];
423
+ }
424
+ /** One request shape (fingerprint) of an endpoint. */
425
+ interface BundleStub {
426
+ fingerprint: string;
427
+ key_paths: string[];
428
+ query_names?: string[] | null;
429
+ content_type?: string | null;
430
+ field_rules?: unknown[];
431
+ degraded?: boolean;
432
+ variants: BundleVariant[];
433
+ }
434
+ interface BundleEndpoint {
435
+ domain: string;
436
+ method: string;
437
+ path_template: string;
438
+ is_dynamic?: boolean;
439
+ fingerprint_value_paths?: string[];
440
+ stubs: BundleStub[];
441
+ }
442
+ interface BundleTruncated {
443
+ stubs?: {
444
+ dropped: number;
445
+ limit: number;
446
+ };
447
+ variants?: Array<{
448
+ endpoint: string;
449
+ fingerprint: string;
450
+ dropped: number;
451
+ limit: number;
452
+ }>;
453
+ body_bytes?: {
454
+ capped: number;
455
+ limit: number;
456
+ };
457
+ }
458
+ /** The `.stubsmith/bundle.json` document produced by `stubsmith pull`. */
459
+ interface Bundle {
460
+ ok?: boolean;
461
+ version?: number;
462
+ cursor?: string;
463
+ generated_at?: string;
464
+ truncated?: BundleTruncated;
465
+ /** Project settings as of the pull; absent in bundles from before the settings existed. */
466
+ project_settings?: {
467
+ group_ids_in_paths?: boolean;
468
+ };
469
+ endpoints: BundleEndpoint[];
470
+ }
471
+ /** Anything `replay()` accepts as a bundle: the typed schema, a loosely typed parsed document (such as `fetchBundle().bundle`), or a file path. */
472
+ type BundleSource = Bundle | Record<string, unknown> | string;
473
+ /** A bundle together with the file it was read from (null for an in-memory object). */
474
+ interface ResolvedBundle {
475
+ bundle: Bundle;
476
+ path: string | null;
477
+ }
478
+ /** Load a bundle from an in-memory object (returned as-is) or a file path. */
479
+ declare function loadBundle(source: BundleSource): Bundle;
480
+ /**
481
+ * Resolve the bundle: the argument (object or path), else `$STUBSMITH_BUNDLE`,
482
+ * else `.stubsmith/bundle.json` searched upward from the working directory up
483
+ * to the first `.git` / `package.json` boundary. Throws when none is found.
484
+ */
485
+ declare function resolveBundle(arg?: BundleSource | null): ResolvedBundle;
486
+
487
+ interface FetchBundleOptions {
488
+ /** Recordings per response; above one (or `"all"`) needs an endpoint. */
489
+ samples?: number | "all" | string;
490
+ /** Defaults to `STUBSMITH_API_URL`, then `STUBSMITH_BACKEND_URL`, then the hosted service. */
491
+ apiUrl?: string;
492
+ /** Defaults to `STUBSMITH_API_KEY`. */
493
+ apiKey?: string;
494
+ }
495
+ interface PullBundleOptions {
496
+ /** Defaults to `STUBSMITH_API_URL`, then `STUBSMITH_BACKEND_URL`, then the hosted service. */
497
+ apiUrl?: string;
498
+ /** Defaults to `STUBSMITH_API_KEY`. */
499
+ apiKey?: string;
500
+ /** `"METHOD /path"` filters, one request each; omitted means one unfiltered request. */
501
+ endpoints?: string[];
502
+ /** Recordings per response; above one (or `"all"`) needs `endpoints`. */
503
+ samples?: number | "all" | string;
504
+ /** Called before each filtered request (the CLI prints progress). */
505
+ onFetch?: (method: string, path: string) => void;
506
+ }
507
+ interface BundleStubRef {
508
+ domain: string;
509
+ method: string;
510
+ pathTemplate: string;
511
+ fingerprint: string;
512
+ }
513
+ interface BundleSummary {
514
+ endpoints: number;
515
+ stubs: number;
516
+ variants: number;
517
+ /** Every stub the server marked degraded. */
518
+ degraded: BundleStubRef[];
519
+ /** Variants with `body_capped` set. */
520
+ bodyCapped: number;
521
+ }
522
+ interface PullBundleResult {
523
+ bundle: Bundle;
524
+ summary: BundleSummary;
525
+ }
526
+ /**
527
+ * Fetch the raw replay bundle for one request: the whole project, or the
528
+ * `"METHOD /path"` endpoint. Returned as the server sent it, unmerged and
529
+ * unsorted, so `replay(await fetchBundle())` works. Does not touch disk.
530
+ */
531
+ declare function fetchBundle(endpoint?: string, opts?: FetchBundleOptions): Promise<Bundle>;
532
+ /**
533
+ * Fetch the replay bundle, one request per endpoint (or one unfiltered
534
+ * request), merged and sorted. Does not touch disk.
535
+ */
536
+ declare function pullBundle(opts?: PullBundleOptions): Promise<PullBundleResult>;
537
+
538
+ /** Raised on a miss: the request matched no recorded stub. Replay never falls through to the network. */
539
+ declare class StubNotFound extends Error {
540
+ readonly method: string;
541
+ readonly pathTemplate: string;
542
+ readonly fingerprint: string;
543
+ constructor(method: string, pathTemplate: string, fingerprint: string, message?: string);
544
+ }
545
+ /** What the engine needs to know about an outbound request. */
546
+ interface ReplayRequest {
547
+ method: string;
548
+ /** Absolute URL, query string included. */
549
+ url: string;
550
+ /** Header names are matched case-insensitively; only `content-type` is read. */
551
+ headers?: Record<string, string | string[] | number | undefined> | null;
552
+ /** Bytes are decoded as UTF-8 (an undecodable body counts as empty). */
553
+ body?: string | Uint8Array | null;
554
+ }
555
+ /**
556
+ * One recorded response, as handed to a `select` callback: a variant (or one
557
+ * sample of a variant) flattened to the unit a test iterates.
558
+ */
559
+ interface RecordedResponse {
560
+ status: number;
561
+ /** How often the variant's status was observed. */
562
+ count: number;
563
+ durationMs: number | null;
564
+ headers: Record<string, string>;
565
+ /** The stored body: a JSON-encoded string, null, or an object/array. */
566
+ body: unknown;
567
+ bodyEncoding: string | null;
568
+ captureId: string | null;
569
+ capturedAt: string | null;
570
+ bodyCapped: boolean;
571
+ }
572
+ /**
573
+ * Chooses which recorded response to serve. Receives the shape's recordings,
574
+ * newest first within each status and ordered so the default choice is first.
575
+ * Return one of them, or null to force a {@link StubNotFound}.
576
+ */
577
+ type SelectFn = (responses: RecordedResponse[]) => RecordedResponse | null | undefined;
578
+ /** A `select` that serves the first (newest) recording of `status`, or null when none exists. */
579
+ declare function byStatus(status: number): SelectFn;
580
+ /** The response the engine hands to a transport hook. */
581
+ interface ServedPayload {
582
+ status: number;
583
+ statusText: string;
584
+ /** Recorded headers minus hop-by-hop ones. */
585
+ headers: Record<string, string>;
586
+ body: Uint8Array;
587
+ durationMs: number;
588
+ }
589
+ /** One recorded response a replay context served. */
590
+ declare class ServedResponse {
591
+ readonly domain: string;
592
+ readonly method: string;
593
+ readonly pathTemplate: string;
594
+ readonly fingerprint: string;
595
+ readonly status: number;
596
+ readonly captureId: string | null;
597
+ readonly capturedAt: string | null;
598
+ /** Position of this response in its stub's recorded list. */
599
+ readonly index: number;
600
+ /** How many responses that stub has recorded in total. */
601
+ readonly total: number;
602
+ constructor(domain: string, method: string, pathTemplate: string, fingerprint: string, status: number, captureId: string | null, capturedAt: string | null,
603
+ /** Position of this response in its stub's recorded list. */
604
+ index: number,
605
+ /** How many responses that stub has recorded in total. */
606
+ total: number);
607
+ /** `"GET api.example.com/v1/orders/{id}"`, for test ids and messages. */
608
+ get endpoint(): string;
609
+ /** True when this was the last recorded response for its stub (a clamped repeat, not a new recording). */
610
+ get exhausted(): boolean;
611
+ }
612
+ type StubKey = readonly [string, string, string, string];
613
+ /**
614
+ * Cursor shared by the contexts `replayAll` yields. One cursor per request
615
+ * shape (stub key), not one global pass index: which shapes a pass touches is
616
+ * only discovered by running the caller's code, and a shape found late must
617
+ * still be looped from its first recording. Each shape advances once per pass,
618
+ * from the pass on which it was first served.
619
+ */
620
+ declare class PassState {
621
+ /** 0-based index of the current pass. */
622
+ passNo: number;
623
+ private readonly cursors;
624
+ private readonly totals;
625
+ private readonly servedPairs;
626
+ private touched;
627
+ /**
628
+ * Which recording this shape serves in the current pass. Fixed for the whole
629
+ * pass: a shape called twice in one pass serves the same recording both times.
630
+ */
631
+ indexFor(stubKey: StubKey, total: number): number;
632
+ /** End the pass; returns whether another is needed. */
633
+ advance(): boolean;
634
+ }
635
+ interface ReplayEngineOptions {
636
+ select?: SelectFn;
637
+ /** Serve only recordings with one of these statuses (used by `replayAll`). */
638
+ statuses?: Iterable<number>;
639
+ /** Cursor shared across passes (used by `replayAll`). */
640
+ passState?: PassState;
641
+ /** File the bundle came from; appended to miss messages. */
642
+ bundlePath?: string | null;
643
+ }
644
+ /** The result of matching a request: the stub key and its ordered recordings. */
645
+ interface Match {
646
+ stubKey: StubKey;
647
+ responses: RecordedResponse[];
648
+ }
649
+ /** Matches requests against a bundle and builds responses. Holds only the list of what it served. */
650
+ declare class ReplayEngine {
651
+ readonly bundlePath: string | null;
652
+ private readonly index;
653
+ /** The project's "Group IDs in paths automatically" setting as of the pull; only an explicit false turns it off. */
654
+ private readonly groupIds;
655
+ private readonly select;
656
+ private readonly statuses;
657
+ private readonly passState;
658
+ private readonly log;
659
+ constructor(bundle: Bundle, opts?: ReplayEngineOptions);
660
+ /** 1-based pass number under `replayAll`, else 1. */
661
+ get passNumber(): number;
662
+ /** Every response served through this engine, in call order. */
663
+ served(): ServedResponse[];
664
+ private fail;
665
+ /** Find the stub for `request`. Throws {@link StubNotFound} on a miss; never reaches the network. */
666
+ match(request: ReplayRequest): Match;
667
+ private choose;
668
+ /** Match `request`, pick the recording, record it in `served()` and build the response. */
669
+ serve(request: ReplayRequest): ServedPayload;
670
+ }
671
+
672
+ /**
673
+ * An active replay: while it is not restored, outbound `http`, `https` and
674
+ * `fetch` calls are answered from the bundle and never reach the network, and
675
+ * capture is suppressed. Create one with `replay()` or take one from `replayAll()`.
676
+ *
677
+ * Restore contexts in reverse order of creation. Also usable with
678
+ * `using` / `await using`: `[Symbol.dispose]()` and `[Symbol.asyncDispose]()`
679
+ * call `restore()`. The methods are attached only where the runtime defines
680
+ * the symbols, which every supported Node version does.
681
+ */
682
+ declare class ReplayContext {
683
+ private readonly engine;
684
+ /** 0-based pass index under `replayAll()`, else 0. */
685
+ readonly passIndex: number;
686
+ private readonly installation;
687
+ /** @internal Use `replay()` / `replayAll()`. Installs the hooks immediately. */
688
+ constructor(engine: ReplayEngine,
689
+ /** 0-based pass index under `replayAll()`, else 0. */
690
+ passIndex?: number);
691
+ /** File the bundle was loaded from, or null for an in-memory bundle. */
692
+ get bundlePath(): string | null;
693
+ /** 1-based pass number under `replayAll()`, else 1. */
694
+ get passNumber(): number;
695
+ /** True until `restore()` is called. */
696
+ get active(): boolean;
697
+ /**
698
+ * Every response served through this context, in call order. Repeated calls
699
+ * to the same endpoint appear once per call. Use it to assert coverage
700
+ * instead of trusting that the code under test reached what you expected.
701
+ */
702
+ served(): ServedResponse[];
703
+ /** Put the original `http`, `https` and `fetch` functions back and re-enable capture. Idempotent. */
704
+ restore(): void;
705
+ }
706
+ interface ReplayContext extends Disposable, AsyncDisposable {
707
+ }
708
+
709
+ interface ReplayAllOptions {
710
+ /** As for `replay()`: a bundle object, a path, or omitted to resolve `$STUBSMITH_BUNDLE` / `.stubsmith/bundle.json`. */
711
+ bundle?: BundleSource;
712
+ /**
713
+ * Loop only recordings whose response status is in this set, e.g. `[200]` or
714
+ * `[429, 500]`. Undefined loops every recording.
715
+ *
716
+ * Usually what you want: it decides what a red build means. Unfiltered, one
717
+ * test body has to hold for successes and failures alike, and a status that
718
+ * enters the window later breaks assertions written when only the other kind
719
+ * existed. A shape with recordings but none of the requested status throws
720
+ * `StubNotFound` naming the filter and the statuses it does have, rather
721
+ * than substituting another status. Under a filter the first pass no longer
722
+ * matches plain `replay()`, whose choice may not be in the set.
723
+ */
724
+ statuses?: Iterable<number>;
725
+ /**
726
+ * Hard ceiling on passes, as a backstop (default 64). Unfiltered, the pass
727
+ * count is the largest recorded window among the shapes touched, roughly
728
+ * (samples per status) x (distinct statuses), so it grows with traffic
729
+ * without any code change; a `statuses` filter bounds it to one status's window.
730
+ */
731
+ maxPasses?: number;
732
+ }
733
+ /**
734
+ * Run a block once per recorded response, to exercise the whole recorded window
735
+ * rather than the one response plain `replay()` serves:
736
+ *
737
+ * ```ts
738
+ * for await (const pass of replayAll({ statuses: [500, 502] })) {
739
+ * await expect(connector.syncOrders()).rejects.toThrow(UpstreamUnavailable);
740
+ * console.log(pass.passNumber, pass.served());
741
+ * }
742
+ * ```
743
+ *
744
+ * Each iteration yields an active `ReplayContext` (hooks installed for the loop
745
+ * body, restored when the body finishes, throws, or `break`s). Semantics:
746
+ *
747
+ * - Pass one serves exactly what `replay()` serves (without a `statuses`
748
+ * filter); pass two serves the next recording of every shape, and so on.
749
+ * A shape's recordings are ordered primary status first (most frequent, then
750
+ * lowest status), then the other statuses ascending, samples newest first.
751
+ * - Endpoints are never named. There is one cursor per request shape, fixed
752
+ * within a pass (a shape called twice in a pass serves the same recording
753
+ * both times) and advanced once per pass for the shapes touched in that pass.
754
+ * A shape first reached on a later pass is still looped from its first
755
+ * recording.
756
+ * - A shape with fewer recordings than the current pass keeps serving its last
757
+ * one (clamped) rather than throwing; check `served()[i].exhausted` to tell.
758
+ * - The loop stops when no touched shape has an unseen recording left, or after
759
+ * `maxPasses`.
760
+ * - The body runs against a different recording each pass, so write assertions
761
+ * that hold for the whole range you are looping, or filter with `statuses`.
762
+ *
763
+ * A bundle with one recording per status (a default `stubsmith pull`) yields one
764
+ * pass per status; for several recordings per status pull with `--samples all`.
765
+ *
766
+ * Misconfiguration (`maxPasses` below 1, an unreadable bundle) throws from this
767
+ * call, before the first iteration.
768
+ */
769
+ declare function replayAll(opts?: ReplayAllOptions): AsyncGenerator<ReplayContext, void, undefined>;
770
+
771
+ interface ReplayOptions {
772
+ /** A bundle object, a path, or omitted to resolve `$STUBSMITH_BUNDLE` / `.stubsmith/bundle.json` upward from the cwd. */
773
+ bundle?: BundleSource;
774
+ /**
775
+ * Chooses which recorded response to serve. Receives the shape's recordings,
776
+ * newest first within each status and ordered so the default choice is first;
777
+ * return one of them, or null to force a `StubNotFound`. The default serves
778
+ * the first: the newest recording of the most frequent status. `byStatus(n)`
779
+ * covers the common case; to loop every recording use `replayAll()`.
780
+ */
781
+ select?: SelectFn;
782
+ /** Only `"strict"` exists: every miss throws `StubNotFound`. */
783
+ onMiss?: "strict";
784
+ }
785
+ /**
786
+ * Answer outbound `http`, `https` and `fetch` calls from the recorded bundle,
787
+ * without touching the network, until `restore()` is called. Capture is
788
+ * suppressed while any replay is active.
789
+ *
790
+ * ```ts
791
+ * const r = replay();
792
+ * try { await checkout(cart); } finally { r.restore(); }
793
+ * ```
794
+ *
795
+ * Throws when the bundle cannot be located or parsed, before any hook is installed.
796
+ */
797
+ declare function replay(opts?: ReplayOptions): ReplayContext;
798
+
799
+ declare const _default: {
800
+ install: typeof install;
801
+ isInstalled: typeof isInstalled;
802
+ VERSION: string;
803
+ };
804
+
805
+ export { Fixture, FixtureBundle, PrivacyPipeline, ReplayContext, ServedResponse, StubNotFound, StubsmithClient, VERSION, byStatus, _default as default, fetchBundle, fixtures, fixturesBundle, install, isInstalled, loadBundle, pullBundle, replay, replayAll, resolveBundle };
806
+ export type { Bundle, BundleEndpoint, BundleSample, BundleSource, BundleStub, BundleSummary, BundleTruncated, BundleVariant, CaptureSource, CapturedExchange, FetchBundleOptions, FixturesOptions, FlushOptions, HeaderRecord, PullBundleOptions, PullBundleResult, RecordedResponse, ReplayAllOptions, ReplayOptions, SelectFn, StubsmithOptions };