@yolk-sdk/conformance 0.1.0-canary.96 → 0.1.0-canary.97
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 +4 -4
- package/dist/case.mjs +2 -2
- package/dist/case.mjs.map +1 -1
- package/dist/fixture.d.mts +10 -10
- package/dist/fixture.mjs +1 -1
- package/dist/fixture.mjs.map +1 -1
- package/dist/record.d.mts +1 -1
- package/dist/record.mjs +1 -1
- package/dist/record.mjs.map +1 -1
- package/dist/replay.d.mts +1 -1
- package/dist/replay.mjs +1 -1
- package/dist/replay.mjs.map +1 -1
- package/dist/wire-internal.d.mts +1 -1
- package/dist/wire-internal.d.mts.map +1 -1
- package/dist/wire-internal.mjs +5 -3
- package/dist/wire-internal.mjs.map +1 -1
- package/package.json +2 -2
- package/src/case.ts +2 -2
- package/src/fixture.ts +1 -1
- package/src/record.ts +1 -1
- package/src/replay.ts +1 -1
- package/src/wire-internal.ts +7 -4
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Canary APIs are unstable. Keep all `@yolk-sdk/*` packages on the same version.
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
pnpm add -D @yolk-sdk/conformance@canary effect@4.0.0
|
|
17
|
+
pnpm add -D @yolk-sdk/conformance@canary effect@4.0.0
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
## Subpaths
|
|
@@ -105,7 +105,7 @@ fixture warnings.
|
|
|
105
105
|
|
|
106
106
|
```ts
|
|
107
107
|
import { Effect } from 'effect'
|
|
108
|
-
import { HttpClient } from 'effect/
|
|
108
|
+
import { HttpClient } from 'effect/http'
|
|
109
109
|
import { ReplayHttpClient, ReplayLedger, WireFault } from '@yolk-sdk/conformance/replay'
|
|
110
110
|
|
|
111
111
|
const program = Effect.gen(function* () {
|
|
@@ -161,7 +161,7 @@ This package **never performs network I/O itself**. For live recording, the host
|
|
|
161
161
|
|
|
162
162
|
```ts
|
|
163
163
|
import { Effect, Layer } from 'effect'
|
|
164
|
-
import { FetchHttpClient } from 'effect/
|
|
164
|
+
import { FetchHttpClient } from 'effect/http'
|
|
165
165
|
import { makeWireFixture, WireRecorder } from '@yolk-sdk/conformance/record'
|
|
166
166
|
|
|
167
167
|
const recording = WireRecorder.layer().pipe(Layer.provide(FetchHttpClient.layer))
|
|
@@ -202,7 +202,7 @@ emulator process, or a real practice account: only the layers change.
|
|
|
202
202
|
|
|
203
203
|
```ts
|
|
204
204
|
import { Effect } from 'effect'
|
|
205
|
-
import { HttpClient, HttpClientRequest } from 'effect/
|
|
205
|
+
import { HttpClient, HttpClientRequest } from 'effect/http'
|
|
206
206
|
import { defineConformanceCase, expectEqual } from '@yolk-sdk/conformance/case'
|
|
207
207
|
|
|
208
208
|
export const missingItemCase = defineConformanceCase({
|
package/dist/case.mjs
CHANGED
|
@@ -31,8 +31,8 @@ const ConformanceSafety = Schema.Literals([
|
|
|
31
31
|
* Dotted lower-case case id: two or more segments of `a-z`, `0-9`, and inner
|
|
32
32
|
* hyphens, for example `vendor.stream.plain-text`.
|
|
33
33
|
*/
|
|
34
|
-
const ConformanceCaseId = Schema.String.check(Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/));
|
|
35
|
-
const CalendarDate = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/));
|
|
34
|
+
const ConformanceCaseId = Schema.String.check(Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/u));
|
|
35
|
+
const CalendarDate = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/u));
|
|
36
36
|
/** When and where a person last watched the claim hold against the real service. */
|
|
37
37
|
const ConformanceObservation = Schema.Struct({
|
|
38
38
|
/** Synthetic account label, for example `synthetic`; never a real account name. */
|
package/dist/case.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"case.mjs","names":[],"sources":["../src/case.ts"],"sourcesContent":["/**\n * Conformance cases: small, named, pure Effect programs that each prove one\n * claim about how an outside service really behaves on the wire.\n *\n * A case only states what it needs (its `R`); the same case runs unchanged\n * against replayed fixtures, an in-process emulator, a local emulator\n * process, or a real practice account. Only the layers a host provides\n * change. See `@yolk-sdk/conformance/runner` for the safety policy.\n *\n * @experimental\n */\nimport { Data, Effect, Equal, Result } from 'effect'\nimport * as Schema from 'effect/Schema'\n\n/**\n * What a case does to the account it runs against:\n *\n * - `read`: only reads.\n * - `write-reversible`: writes but leaves the account as it found it (it\n * creates its own records and cleans them up, or the write is rejected and\n * changes nothing).\n * - `write-irreversible`: a write that cannot be undone (for example sending\n * an email). Never automated against a live account.\n */\nexport const ConformanceSafety = Schema.Literals(['read', 'write-reversible', 'write-irreversible'])\n\nexport type ConformanceSafety = typeof ConformanceSafety.Type\n\n/**\n * Dotted lower-case case id: two or more segments of `a-z`, `0-9`, and inner\n * hyphens, for example `vendor.stream.plain-text`.\n */\nexport const ConformanceCaseId = Schema.String.check(\n Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/)\n)\n\nconst CalendarDate = Schema.String.check(Schema.isPattern(/^\\d{4}-\\d{2}-\\d{2}$/))\n\n/** When and where a person last watched the claim hold against the real service. */\nexport const ConformanceObservation = Schema.Struct({\n /** Synthetic account label, for example `synthetic`; never a real account name. */\n account: Schema.NonEmptyString,\n /** Calendar date of the observation (`YYYY-MM-DD`, UTC). */\n date: CalendarDate\n})\n\nexport type ConformanceObservation = typeof ConformanceObservation.Type\n\n/**\n * One wire claim. `run` succeeds when the claim holds and fails (with a\n * `ConformanceMismatch`, its own error, or a port's error) when it does not.\n */\nexport type ConformanceCase<E = never, R = never> = {\n /** Dotted lower-case id (see `ConformanceCaseId`). */\n readonly id: string\n readonly title?: string\n readonly safety: ConformanceSafety\n /** What the service's documentation claims. */\n readonly docs: string\n /** What the wire actually does (the claim this case proves). */\n readonly wire: string\n /** Last live observation. Absent means the claim is unverified against the real service. */\n readonly observed?: ConformanceObservation\n /** Ids of the fixtures (`WireFixture`s or `PortFixture`s) that back replay of this case. */\n readonly fixtures: ReadonlyArray<string>\n readonly run: Effect.Effect<void, E, R>\n}\n\nconst ConformanceCaseMetadata = Schema.Struct({\n id: ConformanceCaseId,\n title: Schema.optionalKey(Schema.NonEmptyString),\n safety: ConformanceSafety,\n docs: Schema.NonEmptyString,\n wire: Schema.NonEmptyString,\n observed: Schema.optionalKey(ConformanceObservation),\n fixtures: Schema.Array(Schema.NonEmptyString)\n})\n\nconst validateMetadata = Schema.decodeUnknownResult(ConformanceCaseMetadata)\n\n/**\n * Thrown by `defineConformanceCase` for an invalid definition. A programmer\n * error, surfaced when the defining module loads (like `makeTool`).\n */\nexport class ConformanceCaseInvalid extends Data.TaggedError('ConformanceCaseInvalid')<{\n readonly caseId: string\n readonly reason: string\n}> {\n override get message(): string {\n return `Invalid conformance case ${JSON.stringify(this.caseId)}: ${this.reason}`\n }\n}\n\n/**\n * Define a conformance case. The metadata (id format, safety, non-empty\n * `docs`/`wire`, observation date, fixture ids) is validated here and an\n * invalid definition throws `ConformanceCaseInvalid`: definitions are\n * module-level constants, so a bad one fails fast when the module loads\n * instead of surfacing mid-run.\n */\nexport const defineConformanceCase = <E = never, R = never>(\n spec: ConformanceCase<E, R>\n): ConformanceCase<E, R> => {\n const { run: _run, ...metadata } = spec\n const result = validateMetadata(metadata)\n\n if (Result.isFailure(result)) {\n throw new ConformanceCaseInvalid({\n caseId: spec.id,\n reason: new Schema.SchemaError(result.failure.issue).message\n })\n }\n\n return spec\n}\n\n/**\n * A wire claim did not hold. Model-free: `expected` / `actual` are optional\n * JSON values chosen by the case author; keep them small and synthetic.\n */\nexport class ConformanceMismatch extends Data.TaggedError('ConformanceMismatch')<{\n readonly message: string\n readonly expected?: Schema.Json\n readonly actual?: Schema.Json\n}> {}\n\nexport type ConformanceMismatchDetails = {\n readonly expected?: Schema.Json\n readonly actual?: Schema.Json\n}\n\nconst mismatch = (message: string, details: ConformanceMismatchDetails = {}) =>\n new ConformanceMismatch({ message, ...details })\n\n/** Succeed when `condition` holds; otherwise fail with a `ConformanceMismatch`. */\nexport const expectConformance = (\n condition: boolean,\n message: string,\n details?: ConformanceMismatchDetails\n): Effect.Effect<void, ConformanceMismatch> =>\n condition ? Effect.void : Effect.fail(mismatch(message, details))\n\n/**\n * Succeed when `actual` and `expected` are structurally equal JSON values\n * (effect `Equal.equals`: same primitives, same array order, same object keys\n * and values); otherwise fail with a `ConformanceMismatch` carrying both.\n */\nexport const expectEqual = (\n actual: Schema.Json,\n expected: Schema.Json,\n message: string\n): Effect.Effect<void, ConformanceMismatch> =>\n expectConformance(Equal.equals(actual, expected), message, { expected, actual })\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAwBA,MAAa,oBAAoB,OAAO,SAAS;CAAC;CAAQ;CAAoB;AAAoB,CAAC;;;;;AAQnG,MAAa,oBAAoB,OAAO,OAAO,MAC7C,OAAO,UAAU,
|
|
1
|
+
{"version":3,"file":"case.mjs","names":[],"sources":["../src/case.ts"],"sourcesContent":["/**\n * Conformance cases: small, named, pure Effect programs that each prove one\n * claim about how an outside service really behaves on the wire.\n *\n * A case only states what it needs (its `R`); the same case runs unchanged\n * against replayed fixtures, an in-process emulator, a local emulator\n * process, or a real practice account. Only the layers a host provides\n * change. See `@yolk-sdk/conformance/runner` for the safety policy.\n *\n * @experimental\n */\nimport { Data, Effect, Equal, Result } from 'effect'\nimport * as Schema from 'effect/Schema'\n\n/**\n * What a case does to the account it runs against:\n *\n * - `read`: only reads.\n * - `write-reversible`: writes but leaves the account as it found it (it\n * creates its own records and cleans them up, or the write is rejected and\n * changes nothing).\n * - `write-irreversible`: a write that cannot be undone (for example sending\n * an email). Never automated against a live account.\n */\nexport const ConformanceSafety = Schema.Literals(['read', 'write-reversible', 'write-irreversible'])\n\nexport type ConformanceSafety = typeof ConformanceSafety.Type\n\n/**\n * Dotted lower-case case id: two or more segments of `a-z`, `0-9`, and inner\n * hyphens, for example `vendor.stream.plain-text`.\n */\nexport const ConformanceCaseId = Schema.String.check(\n Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/u)\n)\n\nconst CalendarDate = Schema.String.check(Schema.isPattern(/^\\d{4}-\\d{2}-\\d{2}$/u))\n\n/** When and where a person last watched the claim hold against the real service. */\nexport const ConformanceObservation = Schema.Struct({\n /** Synthetic account label, for example `synthetic`; never a real account name. */\n account: Schema.NonEmptyString,\n /** Calendar date of the observation (`YYYY-MM-DD`, UTC). */\n date: CalendarDate\n})\n\nexport type ConformanceObservation = typeof ConformanceObservation.Type\n\n/**\n * One wire claim. `run` succeeds when the claim holds and fails (with a\n * `ConformanceMismatch`, its own error, or a port's error) when it does not.\n */\nexport type ConformanceCase<E = never, R = never> = {\n /** Dotted lower-case id (see `ConformanceCaseId`). */\n readonly id: string\n readonly title?: string\n readonly safety: ConformanceSafety\n /** What the service's documentation claims. */\n readonly docs: string\n /** What the wire actually does (the claim this case proves). */\n readonly wire: string\n /** Last live observation. Absent means the claim is unverified against the real service. */\n readonly observed?: ConformanceObservation\n /** Ids of the fixtures (`WireFixture`s or `PortFixture`s) that back replay of this case. */\n readonly fixtures: ReadonlyArray<string>\n readonly run: Effect.Effect<void, E, R>\n}\n\nconst ConformanceCaseMetadata = Schema.Struct({\n id: ConformanceCaseId,\n title: Schema.optionalKey(Schema.NonEmptyString),\n safety: ConformanceSafety,\n docs: Schema.NonEmptyString,\n wire: Schema.NonEmptyString,\n observed: Schema.optionalKey(ConformanceObservation),\n fixtures: Schema.Array(Schema.NonEmptyString)\n})\n\nconst validateMetadata = Schema.decodeUnknownResult(ConformanceCaseMetadata)\n\n/**\n * Thrown by `defineConformanceCase` for an invalid definition. A programmer\n * error, surfaced when the defining module loads (like `makeTool`).\n */\nexport class ConformanceCaseInvalid extends Data.TaggedError('ConformanceCaseInvalid')<{\n readonly caseId: string\n readonly reason: string\n}> {\n override get message(): string {\n return `Invalid conformance case ${JSON.stringify(this.caseId)}: ${this.reason}`\n }\n}\n\n/**\n * Define a conformance case. The metadata (id format, safety, non-empty\n * `docs`/`wire`, observation date, fixture ids) is validated here and an\n * invalid definition throws `ConformanceCaseInvalid`: definitions are\n * module-level constants, so a bad one fails fast when the module loads\n * instead of surfacing mid-run.\n */\nexport const defineConformanceCase = <E = never, R = never>(\n spec: ConformanceCase<E, R>\n): ConformanceCase<E, R> => {\n const { run: _run, ...metadata } = spec\n const result = validateMetadata(metadata)\n\n if (Result.isFailure(result)) {\n throw new ConformanceCaseInvalid({\n caseId: spec.id,\n reason: new Schema.SchemaError(result.failure.issue).message\n })\n }\n\n return spec\n}\n\n/**\n * A wire claim did not hold. Model-free: `expected` / `actual` are optional\n * JSON values chosen by the case author; keep them small and synthetic.\n */\nexport class ConformanceMismatch extends Data.TaggedError('ConformanceMismatch')<{\n readonly message: string\n readonly expected?: Schema.Json\n readonly actual?: Schema.Json\n}> {}\n\nexport type ConformanceMismatchDetails = {\n readonly expected?: Schema.Json\n readonly actual?: Schema.Json\n}\n\nconst mismatch = (message: string, details: ConformanceMismatchDetails = {}) =>\n new ConformanceMismatch({ message, ...details })\n\n/** Succeed when `condition` holds; otherwise fail with a `ConformanceMismatch`. */\nexport const expectConformance = (\n condition: boolean,\n message: string,\n details?: ConformanceMismatchDetails\n): Effect.Effect<void, ConformanceMismatch> =>\n condition ? Effect.void : Effect.fail(mismatch(message, details))\n\n/**\n * Succeed when `actual` and `expected` are structurally equal JSON values\n * (effect `Equal.equals`: same primitives, same array order, same object keys\n * and values); otherwise fail with a `ConformanceMismatch` carrying both.\n */\nexport const expectEqual = (\n actual: Schema.Json,\n expected: Schema.Json,\n message: string\n): Effect.Effect<void, ConformanceMismatch> =>\n expectConformance(Equal.equals(actual, expected), message, { expected, actual })\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAwBA,MAAa,oBAAoB,OAAO,SAAS;CAAC;CAAQ;CAAoB;AAAoB,CAAC;;;;;AAQnG,MAAa,oBAAoB,OAAO,OAAO,MAC7C,OAAO,UAAU,4DAA4D,CAC/E;AAEA,MAAM,eAAe,OAAO,OAAO,MAAM,OAAO,UAAU,sBAAsB,CAAC;;AAGjF,MAAa,yBAAyB,OAAO,OAAO;;CAElD,SAAS,OAAO;;CAEhB,MAAM;AACR,CAAC;AAwBD,MAAM,0BAA0B,OAAO,OAAO;CAC5C,IAAI;CACJ,OAAO,OAAO,YAAY,OAAO,cAAc;CAC/C,QAAQ;CACR,MAAM,OAAO;CACb,MAAM,OAAO;CACb,UAAU,OAAO,YAAY,sBAAsB;CACnD,UAAU,OAAO,MAAM,OAAO,cAAc;AAC9C,CAAC;AAED,MAAM,mBAAmB,OAAO,oBAAoB,uBAAuB;;;;;AAM3E,IAAa,yBAAb,cAA4C,KAAK,YAAY,wBAAwB,EAGlF;CACD,IAAa,UAAkB;EAC7B,OAAO,4BAA4B,KAAK,UAAU,KAAK,MAAM,EAAE,IAAI,KAAK;CAC1E;AACF;;;;;;;;AASA,MAAa,yBACX,SAC0B;CAC1B,MAAM,EAAE,KAAK,MAAM,GAAG,aAAa;CACnC,MAAM,SAAS,iBAAiB,QAAQ;CAExC,IAAI,OAAO,UAAU,MAAM,GACzB,MAAM,IAAI,uBAAuB;EAC/B,QAAQ,KAAK;EACb,QAAQ,IAAI,OAAO,YAAY,OAAO,QAAQ,KAAK,EAAE;CACvD,CAAC;CAGH,OAAO;AACT;;;;;AAMA,IAAa,sBAAb,cAAyC,KAAK,YAAY,qBAAqB,EAI5E,CAAC;AAOJ,MAAM,YAAY,SAAiB,UAAsC,CAAC,MACxE,IAAI,oBAAoB;CAAE;CAAS,GAAG;AAAQ,CAAC;;AAGjD,MAAa,qBACX,WACA,SACA,YAEA,YAAY,OAAO,OAAO,OAAO,KAAK,SAAS,SAAS,OAAO,CAAC;;;;;;AAOlE,MAAa,eACX,QACA,UACA,YAEA,kBAAkB,MAAM,OAAO,QAAQ,QAAQ,GAAG,SAAS;CAAE;CAAU;AAAO,CAAC"}
|
package/dist/fixture.d.mts
CHANGED
|
@@ -166,6 +166,7 @@ declare const WireFixture: Schema.Struct<{
|
|
|
166
166
|
type WireFixture = typeof WireFixture.Type;
|
|
167
167
|
/** Decode unknown input (for example a JSON file) into a `WireFixture`. */
|
|
168
168
|
declare const decodeWireFixture: (input: unknown, options?: import("effect/SchemaAST").ParseOptions) => import("effect/Effect").Effect<{
|
|
169
|
+
readonly account: string;
|
|
169
170
|
readonly exchanges: readonly [{
|
|
170
171
|
readonly request: {
|
|
171
172
|
readonly method: string;
|
|
@@ -176,26 +177,26 @@ declare const decodeWireFixture: (input: unknown, options?: import("effect/Schem
|
|
|
176
177
|
readonly body?: Schema.Json | undefined;
|
|
177
178
|
};
|
|
178
179
|
readonly response: {
|
|
180
|
+
readonly status: number;
|
|
179
181
|
readonly headers: {
|
|
180
182
|
readonly [x: string]: string;
|
|
181
183
|
};
|
|
182
184
|
readonly body: string;
|
|
183
|
-
readonly status: number;
|
|
184
|
-
readonly bodyBase64?: undefined;
|
|
185
185
|
readonly chunks?: undefined;
|
|
186
|
+
readonly bodyBase64?: undefined;
|
|
186
187
|
} | {
|
|
188
|
+
readonly status: number;
|
|
187
189
|
readonly headers: {
|
|
188
190
|
readonly [x: string]: string;
|
|
189
191
|
};
|
|
190
|
-
readonly status: number;
|
|
191
192
|
readonly bodyBase64: string;
|
|
192
193
|
readonly body?: undefined;
|
|
193
194
|
readonly chunks?: undefined;
|
|
194
195
|
} | {
|
|
196
|
+
readonly status: number;
|
|
195
197
|
readonly headers: {
|
|
196
198
|
readonly [x: string]: string;
|
|
197
199
|
};
|
|
198
|
-
readonly status: number;
|
|
199
200
|
readonly chunks: readonly (string | {
|
|
200
201
|
readonly base64: string;
|
|
201
202
|
})[];
|
|
@@ -212,26 +213,26 @@ declare const decodeWireFixture: (input: unknown, options?: import("effect/Schem
|
|
|
212
213
|
readonly body?: Schema.Json | undefined;
|
|
213
214
|
};
|
|
214
215
|
readonly response: {
|
|
216
|
+
readonly status: number;
|
|
215
217
|
readonly headers: {
|
|
216
218
|
readonly [x: string]: string;
|
|
217
219
|
};
|
|
218
220
|
readonly body: string;
|
|
219
|
-
readonly status: number;
|
|
220
|
-
readonly bodyBase64?: undefined;
|
|
221
221
|
readonly chunks?: undefined;
|
|
222
|
+
readonly bodyBase64?: undefined;
|
|
222
223
|
} | {
|
|
224
|
+
readonly status: number;
|
|
223
225
|
readonly headers: {
|
|
224
226
|
readonly [x: string]: string;
|
|
225
227
|
};
|
|
226
|
-
readonly status: number;
|
|
227
228
|
readonly bodyBase64: string;
|
|
228
229
|
readonly body?: undefined;
|
|
229
230
|
readonly chunks?: undefined;
|
|
230
231
|
} | {
|
|
232
|
+
readonly status: number;
|
|
231
233
|
readonly headers: {
|
|
232
234
|
readonly [x: string]: string;
|
|
233
235
|
};
|
|
234
|
-
readonly status: number;
|
|
235
236
|
readonly chunks: readonly (string | {
|
|
236
237
|
readonly base64: string;
|
|
237
238
|
})[];
|
|
@@ -243,7 +244,6 @@ declare const decodeWireFixture: (input: unknown, options?: import("effect/Schem
|
|
|
243
244
|
readonly caseId: string;
|
|
244
245
|
readonly evidence: "verified" | "unverified";
|
|
245
246
|
readonly recordedAt: string;
|
|
246
|
-
readonly account: string;
|
|
247
247
|
readonly endpoint: string;
|
|
248
248
|
readonly model?: string | undefined;
|
|
249
249
|
readonly note?: string | undefined;
|
|
@@ -367,11 +367,11 @@ declare const decodePortFixture: (input: unknown, options?: import("effect/Schem
|
|
|
367
367
|
readonly id: string;
|
|
368
368
|
readonly port: string;
|
|
369
369
|
readonly note?: string | undefined;
|
|
370
|
-
readonly failure?: undefined;
|
|
371
370
|
readonly observed?: {
|
|
372
371
|
readonly account: string;
|
|
373
372
|
readonly date: string;
|
|
374
373
|
} | undefined;
|
|
374
|
+
readonly failure?: undefined;
|
|
375
375
|
} | {
|
|
376
376
|
readonly method: string;
|
|
377
377
|
readonly request: Schema.Json;
|
package/dist/fixture.mjs
CHANGED
|
@@ -77,7 +77,7 @@ const WireExchange = Schema.Struct({
|
|
|
77
77
|
request: WireRequest,
|
|
78
78
|
response: WireResponse
|
|
79
79
|
});
|
|
80
|
-
const RecordedAtDate = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/));
|
|
80
|
+
const RecordedAtDate = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/u));
|
|
81
81
|
const WireFixture = Schema.Struct({
|
|
82
82
|
id: Schema.NonEmptyString,
|
|
83
83
|
caseId: Schema.NonEmptyString,
|
package/dist/fixture.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fixture.mjs","names":[],"sources":["../src/fixture.ts"],"sourcesContent":["/**\n * Fixture data models: recorded HTTP exchanges with outside services (`WireFixture`) and recorded\n * calls through a host-provided port that is not HTTP (`PortFixture`, for example an email client\n * port).\n *\n * Fixtures are plain, serializable data. They must contain synthetic or\n * scrubbed content only: never credentials, cookies, or customer data. Use\n * `scanFixtureForSecrets` / `scanPortFixtureForSecrets` before committing a fixture.\n *\n * @experimental\n */\nimport { Option, Predicate } from 'effect'\nimport * as Schema from 'effect/Schema'\nimport { ConformanceObservation } from './case.ts'\nimport {\n apiKeyPatterns,\n bearerPattern,\n credentialFieldPattern,\n credentialParamPattern,\n decodeBase64Bytes,\n hasLiveCredentialParam,\n isCredentialHeaderName\n} from './wire-internal.ts'\n\nexport { syntheticPortCredentialParams } from './wire-internal.ts'\n\n/** `verified` = recorded from a live service; `unverified` = synthetic placeholder. */\nexport const WireFixtureEvidence = Schema.Literals(['verified', 'unverified'])\n\nexport type WireFixtureEvidence = typeof WireFixtureEvidence.Type\n\n/** Lowercase header name to value. Request headers are allowlisted and never credentials. */\nexport const WireHeaders = Schema.Record(Schema.String, Schema.String)\n\nexport type WireHeaders = typeof WireHeaders.Type\n\nconst HttpStatus = Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 }))\n\nexport const WireRequest = Schema.Struct({\n method: Schema.NonEmptyString,\n /** Absolute URL including any query string. */\n url: Schema.NonEmptyString,\n headers: Schema.optionalKey(WireHeaders),\n /** Parsed JSON request body, or the raw text when it is not JSON (for example a form body). */\n body: Schema.optionalKey(Schema.Json)\n})\n\nexport type WireRequest = typeof WireRequest.Type\n\nconst Base64String = Schema.String.check(Schema.isBase64())\n\n/**\n * One recorded network chunk. A chunk that is valid UTF-8 on its own is stored\n * as readable text (empty chunks as `\"\"`); any other chunk is stored as\n * `{ base64 }` holding its exact bytes. Replay emits exactly these bytes.\n */\nexport const WireChunk = Schema.Union([Schema.String, Schema.Struct({ base64: Base64String })])\n\nexport type WireChunk = typeof WireChunk.Type\n\n// `Never` keys keep the response shapes mutually exclusive when decoding: a\n// response carries exactly one of `body`, `bodyBase64`, or `chunks`.\nconst absent = Schema.optionalKey(Schema.Never)\n\n/** A response whose whole body is valid UTF-8, stored as one string. */\nexport const WireTextBodyResponse = Schema.Struct({\n status: HttpStatus,\n headers: WireHeaders,\n body: Schema.String,\n bodyBase64: absent,\n chunks: absent\n})\n\nexport type WireTextBodyResponse = typeof WireTextBodyResponse.Type\n\n/** A response whose whole body is not valid UTF-8 (for example a PDF), stored as base64 bytes. */\nexport const WireBase64BodyResponse = Schema.Struct({\n status: HttpStatus,\n headers: WireHeaders,\n bodyBase64: Base64String,\n body: absent,\n chunks: absent\n})\n\nexport type WireBase64BodyResponse = typeof WireBase64BodyResponse.Type\n\n/** A response recorded as one whole body: exactly one of `body` or `bodyBase64`. */\nexport const WireBodyResponse = Schema.Union([WireTextBodyResponse, WireBase64BodyResponse])\n\nexport type WireBodyResponse = typeof WireBodyResponse.Type\n\n/**\n * A streamed response (for example `text/event-stream`). Each entry is one\n * network chunk (see `WireChunk`); chunk boundaries and bytes are preserved on\n * replay.\n */\nexport const WireStreamResponse = Schema.Struct({\n status: HttpStatus,\n headers: WireHeaders,\n chunks: Schema.Array(WireChunk),\n body: absent,\n bodyBase64: absent\n})\n\nexport type WireStreamResponse = typeof WireStreamResponse.Type\n\nexport const WireResponse = Schema.Union([\n WireTextBodyResponse,\n WireBase64BodyResponse,\n WireStreamResponse\n])\n\nexport type WireResponse = typeof WireResponse.Type\n\nexport const WireExchange = Schema.Struct({\n request: WireRequest,\n response: WireResponse\n})\n\nexport type WireExchange = typeof WireExchange.Type\n\nconst RecordedAtDate = Schema.String.check(Schema.isPattern(/^\\d{4}-\\d{2}-\\d{2}$/))\n\nexport const WireFixture = Schema.Struct({\n id: Schema.NonEmptyString,\n caseId: Schema.NonEmptyString,\n evidence: WireFixtureEvidence,\n /** Calendar date of the recording (`YYYY-MM-DD`, UTC). */\n recordedAt: RecordedAtDate,\n /** Synthetic account label, for example `synthetic`; never a real account name. */\n account: Schema.NonEmptyString,\n endpoint: Schema.NonEmptyString,\n model: Schema.optionalKey(Schema.String),\n note: Schema.optionalKey(Schema.String),\n exchanges: Schema.NonEmptyArray(WireExchange)\n})\n\nexport type WireFixture = typeof WireFixture.Type\n\n/** Decode unknown input (for example a JSON file) into a `WireFixture`. */\nexport const decodeWireFixture = Schema.decodeUnknownEffect(WireFixture)\n\nexport const isWireStreamResponse = (response: WireResponse): response is WireStreamResponse =>\n Predicate.hasProperty(response, 'chunks')\n\nexport const isWireBase64BodyResponse = (\n response: WireResponse\n): response is WireBase64BodyResponse => Predicate.hasProperty(response, 'bodyBase64')\n\nconst millisPerDay = 86_400_000\n\nconst recordedAtPattern = /^(\\d{4})-(\\d{2})-(\\d{2})$/\n\nconst recordedAtMillis = (recordedAt: string): number => {\n const match = recordedAtPattern.exec(recordedAt)\n\n if (match === null) {\n return Number.NaN\n }\n\n return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))\n}\n\n/**\n * Whole days between `recordedAt` (UTC midnight) and `now`. `NaN` when\n * `recordedAt` is not a `YYYY-MM-DD` date.\n */\nexport const fixtureAgeDays = (fixture: Pick<WireFixture, 'recordedAt'>, now: Date): number =>\n Math.floor((now.getTime() - recordedAtMillis(fixture.recordedAt)) / millisPerDay)\n\n/**\n * True when the fixture is older than `maxAgeDays` (default 30). Fixtures with\n * an unreadable `recordedAt` are treated as stale.\n */\nexport const isFixtureStale = (\n fixture: Pick<WireFixture, 'recordedAt'>,\n now: Date,\n maxAgeDays = 30\n): boolean => {\n const age = fixtureAgeDays(fixture, now)\n\n return Number.isNaN(age) || age > maxAgeDays\n}\n\nexport type FixtureSecretIssueKind =\n | 'credential_header'\n | 'bearer_token'\n | 'api_key'\n | 'credential_query_param'\n | 'credential_field'\n\n/** A secret-scan finding. `location` is a path into the fixture; the secret itself is never echoed. */\nexport type FixtureSecretIssue = {\n readonly kind: FixtureSecretIssueKind\n readonly location: string\n}\n\ntype IssueSink = Array<FixtureSecretIssue>\n\nconst scanText = (text: string, location: string, issues: IssueSink): void => {\n if (bearerPattern.test(text)) {\n issues.push({ kind: 'bearer_token', location })\n }\n\n if (apiKeyPatterns.some(pattern => pattern.test(text))) {\n issues.push({ kind: 'api_key', location })\n }\n}\n\nconst scanHeaders = (\n headers: WireHeaders | undefined,\n location: string,\n issues: IssueSink\n): void => {\n for (const [name, value] of Object.entries(headers ?? {})) {\n const headerLocation = `${location}.${name}`\n\n if (isCredentialHeaderName(name)) {\n issues.push({ kind: 'credential_header', location: headerLocation })\n }\n\n scanText(value, headerLocation, issues)\n }\n}\n\nconst scanUrl = (url: string, location: string, issues: IssueSink): void => {\n if (credentialParamPattern.test(url)) {\n issues.push({ kind: 'credential_query_param', location })\n }\n\n scanText(url, location, issues)\n}\n\nconst scanJson = (value: Schema.Json, location: string, issues: IssueSink): void => {\n if (Predicate.isString(value)) {\n scanText(value, location, issues)\n\n return\n }\n\n if (Array.isArray(value)) {\n value.forEach((item, index) => scanJson(item, `${location}[${index}]`, issues))\n\n return\n }\n\n if (value !== null && !Predicate.isNumber(value) && !Predicate.isBoolean(value)) {\n for (const [key, item] of Object.entries(value)) {\n const itemLocation = `${location}.${key}`\n\n if (credentialFieldPattern.test(key) && Predicate.isString(item) && item.length > 0) {\n issues.push({ kind: 'credential_field', location: itemLocation })\n }\n\n scanJson(item, itemLocation, issues)\n }\n }\n}\n\nconst parseJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json))\n\n// `data:` payloads of each server-sent event, multi-line data joined with `\\n`.\n// SSE allows CRLF, LF, or bare CR line endings; normalize before splitting events.\nconst sseDataPayloads = (text: string): ReadonlyArray<string> =>\n text\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n\\n')\n .flatMap(event => {\n const data = event\n .split('\\n')\n .filter(line => line.startsWith('data:'))\n .map(line => line.slice('data:'.length).replace(/^ /, ''))\n\n return data.length > 0 ? [data.join('\\n')] : []\n })\n\n/**\n * Scan a whole payload (request/response body or reassembled stream): token\n * patterns, form-encoded credential parameters, and credential fields in the\n * payload itself when it is JSON, or in each SSE `data:` payload that is JSON\n * (located as `<location>.events[n]`).\n */\nconst scanPayload = (text: string, location: string, issues: IssueSink): void => {\n scanText(text, location, issues)\n\n if (credentialParamPattern.test(text)) {\n issues.push({ kind: 'credential_query_param', location })\n }\n\n const json = parseJsonOption(text)\n\n if (Option.isSome(json)) {\n scanJson(json.value, location, issues)\n\n return\n }\n\n sseDataPayloads(text).forEach((payload, index) => {\n const event = parseJsonOption(payload)\n\n if (Option.isSome(event)) {\n scanJson(event.value, `${location}.events[${index}]`, issues)\n }\n })\n}\n\nconst lossyText = (bytes: Uint8Array): string => new TextDecoder().decode(bytes)\n\nconst chunkBytes = (chunk: WireChunk): Uint8Array =>\n Predicate.isString(chunk)\n ? new TextEncoder().encode(chunk)\n : Option.getOrElse(decodeBase64Bytes(chunk.base64), () => new Uint8Array())\n\nconst concatBytes = (parts: ReadonlyArray<Uint8Array>): Uint8Array => {\n const joined = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))\n let offset = 0\n\n for (const part of parts) {\n joined.set(part, offset)\n offset += part.length\n }\n\n return joined\n}\n\nconst scanStream = (\n chunks: ReadonlyArray<WireChunk>,\n location: string,\n issues: IssueSink\n): void => {\n const parts = chunks.map(chunkBytes)\n\n parts.forEach((bytes, index) => scanText(lossyText(bytes), `${location}[${index}]`, issues))\n\n // The reassembled stream catches secrets split across chunks and JSON\n // credential fields inside SSE events. Bytes are decoded non-fatally.\n scanPayload(lossyText(concatBytes(parts)), location, issues)\n}\n\nconst uniqueIssues = (issues: IssueSink): ReadonlyArray<FixtureSecretIssue> => {\n const seen = new Set<string>()\n\n return issues.filter(issue => {\n const key = `${issue.kind} ${issue.location}`\n\n if (seen.has(key)) {\n return false\n }\n\n seen.add(key)\n\n return true\n })\n}\n\n/**\n * Pure secret scan. Flags credential headers, bearer tokens, common API-key\n * prefixes, JWTs, private keys, credential query/form parameters, and\n * credential JSON fields anywhere in the fixture: metadata, URLs, headers,\n * request bodies, response bodies (text or decodable base64), each stream\n * chunk, and the reassembled stream (so a secret split across chunks is still\n * found). JSON bodies and SSE `data:` payloads get the credential-field scan.\n * Issues name locations only. Returns an empty array when clean.\n */\nexport const scanFixtureForSecrets = (fixture: WireFixture): ReadonlyArray<FixtureSecretIssue> => {\n const issues: IssueSink = []\n\n scanText(fixture.id, 'id', issues)\n scanText(fixture.caseId, 'caseId', issues)\n scanText(fixture.account, 'account', issues)\n scanUrl(fixture.endpoint, 'endpoint', issues)\n\n if (fixture.model !== undefined) {\n scanText(fixture.model, 'model', issues)\n }\n\n if (fixture.note !== undefined) {\n scanText(fixture.note, 'note', issues)\n }\n\n fixture.exchanges.forEach((exchange, index) => {\n const base = `exchanges[${index}]`\n const requestBody = exchange.request.body\n\n scanUrl(exchange.request.url, `${base}.request.url`, issues)\n scanHeaders(exchange.request.headers, `${base}.request.headers`, issues)\n\n if (Predicate.isString(requestBody)) {\n scanPayload(requestBody, `${base}.request.body`, issues)\n } else if (requestBody !== undefined) {\n scanJson(requestBody, `${base}.request.body`, issues)\n }\n\n const response = exchange.response\n\n scanHeaders(response.headers, `${base}.response.headers`, issues)\n\n if (isWireStreamResponse(response)) {\n scanStream(response.chunks, `${base}.response.chunks`, issues)\n } else if (isWireBase64BodyResponse(response)) {\n const bytes = decodeBase64Bytes(response.bodyBase64)\n\n if (Option.isSome(bytes)) {\n scanPayload(lossyText(bytes.value), `${base}.response.bodyBase64`, issues)\n }\n } else {\n scanPayload(response.body, `${base}.response.body`, issues)\n }\n })\n\n return uniqueIssues(issues)\n}\n\n// Port fixtures: one recorded call through a host-provided port that is not HTTP.\n\n/**\n * A failure a port answered instead of a value. `expected` is the port's value-level failure (for\n * example a connector `ActionResult.failure`); `error` is the port's typed error channel. `code`\n * is a sanitized classification, never raw provider text.\n */\nexport const PortFailure = Schema.Struct({\n kind: Schema.Literals(['expected', 'error']),\n code: Schema.NonEmptyString,\n message: Schema.String,\n status: Schema.optionalKey(Schema.Int)\n})\n\nexport type PortFailure = typeof PortFailure.Type\n\nconst portFixtureFields = {\n id: Schema.NonEmptyString,\n /** Port name, for example `EmailClient`. */\n port: Schema.NonEmptyString,\n /** Port method, for example `listMessages`. */\n method: Schema.NonEmptyString,\n /** The request as plain JSON, with every credential field removed (`redactPortPayload`). */\n request: Schema.Json,\n /**\n * The live observation this call was recorded from. Absent means a synthetic placeholder\n * (`unverified`); present means `verified` as of `observed.date`.\n */\n observed: Schema.optionalKey(ConformanceObservation),\n note: Schema.optionalKey(Schema.String)\n}\n\n/** A port call answered with a value (`response`, plain JSON). */\nexport const PortValueFixture = Schema.Struct({\n ...portFixtureFields,\n response: Schema.Json,\n failure: absent\n})\n\nexport type PortValueFixture = typeof PortValueFixture.Type\n\n/** A port call answered with a failure. */\nexport const PortFailureFixture = Schema.Struct({\n ...portFixtureFields,\n failure: PortFailure,\n response: absent\n})\n\nexport type PortFailureFixture = typeof PortFailureFixture.Type\n\n/**\n * One recorded call through a host-provided port: the port, the method, the credential-free JSON\n * request, and exactly one of `response` or `failure`. Plain JSON only, so one fixture backs\n * replay, an emulator, and parity checks alike.\n */\nexport const PortFixture = Schema.Union([PortValueFixture, PortFailureFixture])\n\nexport type PortFixture = typeof PortFixture.Type\n\n/** Decode unknown input (for example a JSON file) into a `PortFixture`. */\nexport const decodePortFixture = Schema.decodeUnknownEffect(PortFixture)\n\n/** Any fixture a conformance case may cite: an HTTP `WireFixture` or a `PortFixture`. */\nexport type ConformanceFixture = WireFixture | PortFixture\n\nexport const isPortFixture = (fixture: ConformanceFixture): fixture is PortFixture =>\n !Predicate.hasProperty(fixture, 'exchanges')\n\nexport const isPortFailureFixture = (fixture: PortFixture): fixture is PortFailureFixture =>\n Predicate.hasProperty(fixture, 'failure') && fixture.failure !== undefined\n\n/** Evidence and (when known) date of a fixture. */\nexport type ConformanceFixtureEvidence = {\n readonly evidence: WireFixtureEvidence\n readonly date?: string\n}\n\n/**\n * Evidence and date of any fixture: a `WireFixture`'s `evidence` and `recordedAt`; a\n * `PortFixture` is `verified` as of `observed.date` when observed, else `unverified` without a\n * date.\n */\nexport const conformanceFixtureEvidence = (\n fixture: ConformanceFixture\n): ConformanceFixtureEvidence => {\n if (!isPortFixture(fixture)) {\n return { evidence: fixture.evidence, date: fixture.recordedAt }\n }\n\n return fixture.observed === undefined\n ? { evidence: 'unverified' }\n : { evidence: 'verified', date: fixture.observed.date }\n}\n\nconst portCredentialKeyPattern = /^credentials?$/i\n\nconst isJsonRecord = (value: Schema.Json): value is Schema.JsonObject =>\n value !== null && Predicate.isObject(value) && !Array.isArray(value)\n\n/** True for a port payload key that holds a credential (`credential`, `password`, `token`, ...). */\nexport const isPortCredentialKey = (key: string): boolean =>\n portCredentialKeyPattern.test(key) || credentialFieldPattern.test(key)\n\n/**\n * A copy of a port payload without credential fields (see `isPortCredentialKey`: `credential(s)`\n * plus the credential field names, AWS-style `accessKeyId` / `secretAccessKey` / `sessionToken`\n * and their snake_case forms included), at any depth.\n * Port requests often carry the resolved credential; record and compare requests only after this.\n */\nexport const redactPortPayload = (value: Schema.Json): Schema.Json => {\n if (Array.isArray(value)) {\n return value.map(redactPortPayload)\n }\n\n if (!isJsonRecord(value)) {\n return value\n }\n\n const copy: Record<string, Schema.Json> = {}\n\n for (const [key, item] of Object.entries(value)) {\n if (!isPortCredentialKey(key)) {\n copy[key] = redactPortPayload(item)\n }\n }\n\n return copy\n}\n\n/** A port string: the token patterns plus credential query or form parameters (a signed URL). */\nconst scanPortText = (text: string, location: string, issues: IssueSink): void => {\n scanText(text, location, issues)\n\n if (hasLiveCredentialParam(text)) {\n issues.push({ kind: 'credential_query_param', location })\n }\n}\n\nconst scanPortJson = (value: Schema.Json, location: string, issues: IssueSink): void => {\n scanJson(value, location, issues)\n\n const visit = (item: Schema.Json, itemLocation: string): void => {\n if (Predicate.isString(item)) {\n if (hasLiveCredentialParam(item)) {\n issues.push({ kind: 'credential_query_param', location: itemLocation })\n }\n\n return\n }\n\n if (Array.isArray(item)) {\n item.forEach((entry, index) => visit(entry, `${itemLocation}[${index}]`))\n\n return\n }\n\n if (!isJsonRecord(item)) {\n return\n }\n\n for (const [key, entry] of Object.entries(item)) {\n if (portCredentialKeyPattern.test(key) && entry !== null) {\n issues.push({ kind: 'credential_field', location: `${itemLocation}.${key}` })\n }\n\n visit(entry, `${itemLocation}.${key}`)\n }\n }\n\n visit(value, location)\n}\n\n/**\n * Pure secret scan of a `PortFixture`: the same token patterns and credential JSON fields as\n * `scanFixtureForSecrets`, plus any non-null `credential` / `credentials` field (port requests must\n * be recorded through `redactPortPayload`), plus credential query or form parameters inside every\n * JSON string value, `note`, and `failure.message` (a signed URL a port answered, such as an S3\n * presigned URL's `X-Amz-Signature` / `X-Amz-Credential` / `X-Amz-Security-Token`), each `name=`\n * found and judged on its own. The only exemption: the raw value, up to `&`, `#`, whitespace, `\"`,\n * `<`, `>`, or the end (never `?` or `'`), percent-decoded, exactly equals that name's entry in\n * `syntheticPortCredentialParams`. Parameters with escaped names (`&`, percent-encoded) are not\n * found. Covers metadata, the request, the response, and the failure. Issues name locations only.\n * Returns an empty array when clean.\n */\nexport const scanPortFixtureForSecrets = (\n fixture: PortFixture\n): ReadonlyArray<FixtureSecretIssue> => {\n const issues: IssueSink = []\n\n scanText(fixture.id, 'id', issues)\n scanText(fixture.port, 'port', issues)\n scanText(fixture.method, 'method', issues)\n\n if (fixture.observed !== undefined) {\n scanText(fixture.observed.account, 'observed.account', issues)\n }\n\n if (fixture.note !== undefined) {\n scanPortText(fixture.note, 'note', issues)\n }\n\n scanPortJson(fixture.request, 'request', issues)\n\n if (isPortFailureFixture(fixture)) {\n scanText(fixture.failure.code, 'failure.code', issues)\n scanPortText(fixture.failure.message, 'failure.message', issues)\n } else {\n scanPortJson(fixture.response, 'response', issues)\n }\n\n return uniqueIssues(issues)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"fixture.mjs","names":[],"sources":["../src/fixture.ts"],"sourcesContent":["/**\n * Fixture data models: recorded HTTP exchanges with outside services (`WireFixture`) and recorded\n * calls through a host-provided port that is not HTTP (`PortFixture`, for example an email client\n * port).\n *\n * Fixtures are plain, serializable data. They must contain synthetic or\n * scrubbed content only: never credentials, cookies, or customer data. Use\n * `scanFixtureForSecrets` / `scanPortFixtureForSecrets` before committing a fixture.\n *\n * @experimental\n */\nimport { Option, Predicate } from 'effect'\nimport * as Schema from 'effect/Schema'\nimport { ConformanceObservation } from './case.ts'\nimport {\n apiKeyPatterns,\n bearerPattern,\n credentialFieldPattern,\n credentialParamPattern,\n decodeBase64Bytes,\n hasLiveCredentialParam,\n isCredentialHeaderName\n} from './wire-internal.ts'\n\nexport { syntheticPortCredentialParams } from './wire-internal.ts'\n\n/** `verified` = recorded from a live service; `unverified` = synthetic placeholder. */\nexport const WireFixtureEvidence = Schema.Literals(['verified', 'unverified'])\n\nexport type WireFixtureEvidence = typeof WireFixtureEvidence.Type\n\n/** Lowercase header name to value. Request headers are allowlisted and never credentials. */\nexport const WireHeaders = Schema.Record(Schema.String, Schema.String)\n\nexport type WireHeaders = typeof WireHeaders.Type\n\nconst HttpStatus = Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 }))\n\nexport const WireRequest = Schema.Struct({\n method: Schema.NonEmptyString,\n /** Absolute URL including any query string. */\n url: Schema.NonEmptyString,\n headers: Schema.optionalKey(WireHeaders),\n /** Parsed JSON request body, or the raw text when it is not JSON (for example a form body). */\n body: Schema.optionalKey(Schema.Json)\n})\n\nexport type WireRequest = typeof WireRequest.Type\n\nconst Base64String = Schema.String.check(Schema.isBase64())\n\n/**\n * One recorded network chunk. A chunk that is valid UTF-8 on its own is stored\n * as readable text (empty chunks as `\"\"`); any other chunk is stored as\n * `{ base64 }` holding its exact bytes. Replay emits exactly these bytes.\n */\nexport const WireChunk = Schema.Union([Schema.String, Schema.Struct({ base64: Base64String })])\n\nexport type WireChunk = typeof WireChunk.Type\n\n// `Never` keys keep the response shapes mutually exclusive when decoding: a\n// response carries exactly one of `body`, `bodyBase64`, or `chunks`.\nconst absent = Schema.optionalKey(Schema.Never)\n\n/** A response whose whole body is valid UTF-8, stored as one string. */\nexport const WireTextBodyResponse = Schema.Struct({\n status: HttpStatus,\n headers: WireHeaders,\n body: Schema.String,\n bodyBase64: absent,\n chunks: absent\n})\n\nexport type WireTextBodyResponse = typeof WireTextBodyResponse.Type\n\n/** A response whose whole body is not valid UTF-8 (for example a PDF), stored as base64 bytes. */\nexport const WireBase64BodyResponse = Schema.Struct({\n status: HttpStatus,\n headers: WireHeaders,\n bodyBase64: Base64String,\n body: absent,\n chunks: absent\n})\n\nexport type WireBase64BodyResponse = typeof WireBase64BodyResponse.Type\n\n/** A response recorded as one whole body: exactly one of `body` or `bodyBase64`. */\nexport const WireBodyResponse = Schema.Union([WireTextBodyResponse, WireBase64BodyResponse])\n\nexport type WireBodyResponse = typeof WireBodyResponse.Type\n\n/**\n * A streamed response (for example `text/event-stream`). Each entry is one\n * network chunk (see `WireChunk`); chunk boundaries and bytes are preserved on\n * replay.\n */\nexport const WireStreamResponse = Schema.Struct({\n status: HttpStatus,\n headers: WireHeaders,\n chunks: Schema.Array(WireChunk),\n body: absent,\n bodyBase64: absent\n})\n\nexport type WireStreamResponse = typeof WireStreamResponse.Type\n\nexport const WireResponse = Schema.Union([\n WireTextBodyResponse,\n WireBase64BodyResponse,\n WireStreamResponse\n])\n\nexport type WireResponse = typeof WireResponse.Type\n\nexport const WireExchange = Schema.Struct({\n request: WireRequest,\n response: WireResponse\n})\n\nexport type WireExchange = typeof WireExchange.Type\n\nconst RecordedAtDate = Schema.String.check(Schema.isPattern(/^\\d{4}-\\d{2}-\\d{2}$/u))\n\nexport const WireFixture = Schema.Struct({\n id: Schema.NonEmptyString,\n caseId: Schema.NonEmptyString,\n evidence: WireFixtureEvidence,\n /** Calendar date of the recording (`YYYY-MM-DD`, UTC). */\n recordedAt: RecordedAtDate,\n /** Synthetic account label, for example `synthetic`; never a real account name. */\n account: Schema.NonEmptyString,\n endpoint: Schema.NonEmptyString,\n model: Schema.optionalKey(Schema.String),\n note: Schema.optionalKey(Schema.String),\n exchanges: Schema.NonEmptyArray(WireExchange)\n})\n\nexport type WireFixture = typeof WireFixture.Type\n\n/** Decode unknown input (for example a JSON file) into a `WireFixture`. */\nexport const decodeWireFixture = Schema.decodeUnknownEffect(WireFixture)\n\nexport const isWireStreamResponse = (response: WireResponse): response is WireStreamResponse =>\n Predicate.hasProperty(response, 'chunks')\n\nexport const isWireBase64BodyResponse = (\n response: WireResponse\n): response is WireBase64BodyResponse => Predicate.hasProperty(response, 'bodyBase64')\n\nconst millisPerDay = 86_400_000\n\nconst recordedAtPattern = /^(\\d{4})-(\\d{2})-(\\d{2})$/\n\nconst recordedAtMillis = (recordedAt: string): number => {\n const match = recordedAtPattern.exec(recordedAt)\n\n if (match === null) {\n return Number.NaN\n }\n\n return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))\n}\n\n/**\n * Whole days between `recordedAt` (UTC midnight) and `now`. `NaN` when\n * `recordedAt` is not a `YYYY-MM-DD` date.\n */\nexport const fixtureAgeDays = (fixture: Pick<WireFixture, 'recordedAt'>, now: Date): number =>\n Math.floor((now.getTime() - recordedAtMillis(fixture.recordedAt)) / millisPerDay)\n\n/**\n * True when the fixture is older than `maxAgeDays` (default 30). Fixtures with\n * an unreadable `recordedAt` are treated as stale.\n */\nexport const isFixtureStale = (\n fixture: Pick<WireFixture, 'recordedAt'>,\n now: Date,\n maxAgeDays = 30\n): boolean => {\n const age = fixtureAgeDays(fixture, now)\n\n return Number.isNaN(age) || age > maxAgeDays\n}\n\nexport type FixtureSecretIssueKind =\n | 'credential_header'\n | 'bearer_token'\n | 'api_key'\n | 'credential_query_param'\n | 'credential_field'\n\n/** A secret-scan finding. `location` is a path into the fixture; the secret itself is never echoed. */\nexport type FixtureSecretIssue = {\n readonly kind: FixtureSecretIssueKind\n readonly location: string\n}\n\ntype IssueSink = Array<FixtureSecretIssue>\n\nconst scanText = (text: string, location: string, issues: IssueSink): void => {\n if (bearerPattern.test(text)) {\n issues.push({ kind: 'bearer_token', location })\n }\n\n if (apiKeyPatterns.some(pattern => pattern.test(text))) {\n issues.push({ kind: 'api_key', location })\n }\n}\n\nconst scanHeaders = (\n headers: WireHeaders | undefined,\n location: string,\n issues: IssueSink\n): void => {\n for (const [name, value] of Object.entries(headers ?? {})) {\n const headerLocation = `${location}.${name}`\n\n if (isCredentialHeaderName(name)) {\n issues.push({ kind: 'credential_header', location: headerLocation })\n }\n\n scanText(value, headerLocation, issues)\n }\n}\n\nconst scanUrl = (url: string, location: string, issues: IssueSink): void => {\n if (credentialParamPattern.test(url)) {\n issues.push({ kind: 'credential_query_param', location })\n }\n\n scanText(url, location, issues)\n}\n\nconst scanJson = (value: Schema.Json, location: string, issues: IssueSink): void => {\n if (Predicate.isString(value)) {\n scanText(value, location, issues)\n\n return\n }\n\n if (Array.isArray(value)) {\n value.forEach((item, index) => scanJson(item, `${location}[${index}]`, issues))\n\n return\n }\n\n if (value !== null && !Predicate.isNumber(value) && !Predicate.isBoolean(value)) {\n for (const [key, item] of Object.entries(value)) {\n const itemLocation = `${location}.${key}`\n\n if (credentialFieldPattern.test(key) && Predicate.isString(item) && item.length > 0) {\n issues.push({ kind: 'credential_field', location: itemLocation })\n }\n\n scanJson(item, itemLocation, issues)\n }\n }\n}\n\nconst parseJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json))\n\n// `data:` payloads of each server-sent event, multi-line data joined with `\\n`.\n// SSE allows CRLF, LF, or bare CR line endings; normalize before splitting events.\nconst sseDataPayloads = (text: string): ReadonlyArray<string> =>\n text\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n\\n')\n .flatMap(event => {\n const data = event\n .split('\\n')\n .filter(line => line.startsWith('data:'))\n .map(line => line.slice('data:'.length).replace(/^ /, ''))\n\n return data.length > 0 ? [data.join('\\n')] : []\n })\n\n/**\n * Scan a whole payload (request/response body or reassembled stream): token\n * patterns, form-encoded credential parameters, and credential fields in the\n * payload itself when it is JSON, or in each SSE `data:` payload that is JSON\n * (located as `<location>.events[n]`).\n */\nconst scanPayload = (text: string, location: string, issues: IssueSink): void => {\n scanText(text, location, issues)\n\n if (credentialParamPattern.test(text)) {\n issues.push({ kind: 'credential_query_param', location })\n }\n\n const json = parseJsonOption(text)\n\n if (Option.isSome(json)) {\n scanJson(json.value, location, issues)\n\n return\n }\n\n sseDataPayloads(text).forEach((payload, index) => {\n const event = parseJsonOption(payload)\n\n if (Option.isSome(event)) {\n scanJson(event.value, `${location}.events[${index}]`, issues)\n }\n })\n}\n\nconst lossyText = (bytes: Uint8Array): string => new TextDecoder().decode(bytes)\n\nconst chunkBytes = (chunk: WireChunk): Uint8Array =>\n Predicate.isString(chunk)\n ? new TextEncoder().encode(chunk)\n : Option.getOrElse(decodeBase64Bytes(chunk.base64), () => new Uint8Array())\n\nconst concatBytes = (parts: ReadonlyArray<Uint8Array>): Uint8Array => {\n const joined = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))\n let offset = 0\n\n for (const part of parts) {\n joined.set(part, offset)\n offset += part.length\n }\n\n return joined\n}\n\nconst scanStream = (\n chunks: ReadonlyArray<WireChunk>,\n location: string,\n issues: IssueSink\n): void => {\n const parts = chunks.map(chunkBytes)\n\n parts.forEach((bytes, index) => scanText(lossyText(bytes), `${location}[${index}]`, issues))\n\n // The reassembled stream catches secrets split across chunks and JSON\n // credential fields inside SSE events. Bytes are decoded non-fatally.\n scanPayload(lossyText(concatBytes(parts)), location, issues)\n}\n\nconst uniqueIssues = (issues: IssueSink): ReadonlyArray<FixtureSecretIssue> => {\n const seen = new Set<string>()\n\n return issues.filter(issue => {\n const key = `${issue.kind} ${issue.location}`\n\n if (seen.has(key)) {\n return false\n }\n\n seen.add(key)\n\n return true\n })\n}\n\n/**\n * Pure secret scan. Flags credential headers, bearer tokens, common API-key\n * prefixes, JWTs, private keys, credential query/form parameters, and\n * credential JSON fields anywhere in the fixture: metadata, URLs, headers,\n * request bodies, response bodies (text or decodable base64), each stream\n * chunk, and the reassembled stream (so a secret split across chunks is still\n * found). JSON bodies and SSE `data:` payloads get the credential-field scan.\n * Issues name locations only. Returns an empty array when clean.\n */\nexport const scanFixtureForSecrets = (fixture: WireFixture): ReadonlyArray<FixtureSecretIssue> => {\n const issues: IssueSink = []\n\n scanText(fixture.id, 'id', issues)\n scanText(fixture.caseId, 'caseId', issues)\n scanText(fixture.account, 'account', issues)\n scanUrl(fixture.endpoint, 'endpoint', issues)\n\n if (fixture.model !== undefined) {\n scanText(fixture.model, 'model', issues)\n }\n\n if (fixture.note !== undefined) {\n scanText(fixture.note, 'note', issues)\n }\n\n fixture.exchanges.forEach((exchange, index) => {\n const base = `exchanges[${index}]`\n const requestBody = exchange.request.body\n\n scanUrl(exchange.request.url, `${base}.request.url`, issues)\n scanHeaders(exchange.request.headers, `${base}.request.headers`, issues)\n\n if (Predicate.isString(requestBody)) {\n scanPayload(requestBody, `${base}.request.body`, issues)\n } else if (requestBody !== undefined) {\n scanJson(requestBody, `${base}.request.body`, issues)\n }\n\n const response = exchange.response\n\n scanHeaders(response.headers, `${base}.response.headers`, issues)\n\n if (isWireStreamResponse(response)) {\n scanStream(response.chunks, `${base}.response.chunks`, issues)\n } else if (isWireBase64BodyResponse(response)) {\n const bytes = decodeBase64Bytes(response.bodyBase64)\n\n if (Option.isSome(bytes)) {\n scanPayload(lossyText(bytes.value), `${base}.response.bodyBase64`, issues)\n }\n } else {\n scanPayload(response.body, `${base}.response.body`, issues)\n }\n })\n\n return uniqueIssues(issues)\n}\n\n// Port fixtures: one recorded call through a host-provided port that is not HTTP.\n\n/**\n * A failure a port answered instead of a value. `expected` is the port's value-level failure (for\n * example a connector `ActionResult.failure`); `error` is the port's typed error channel. `code`\n * is a sanitized classification, never raw provider text.\n */\nexport const PortFailure = Schema.Struct({\n kind: Schema.Literals(['expected', 'error']),\n code: Schema.NonEmptyString,\n message: Schema.String,\n status: Schema.optionalKey(Schema.Int)\n})\n\nexport type PortFailure = typeof PortFailure.Type\n\nconst portFixtureFields = {\n id: Schema.NonEmptyString,\n /** Port name, for example `EmailClient`. */\n port: Schema.NonEmptyString,\n /** Port method, for example `listMessages`. */\n method: Schema.NonEmptyString,\n /** The request as plain JSON, with every credential field removed (`redactPortPayload`). */\n request: Schema.Json,\n /**\n * The live observation this call was recorded from. Absent means a synthetic placeholder\n * (`unverified`); present means `verified` as of `observed.date`.\n */\n observed: Schema.optionalKey(ConformanceObservation),\n note: Schema.optionalKey(Schema.String)\n}\n\n/** A port call answered with a value (`response`, plain JSON). */\nexport const PortValueFixture = Schema.Struct({\n ...portFixtureFields,\n response: Schema.Json,\n failure: absent\n})\n\nexport type PortValueFixture = typeof PortValueFixture.Type\n\n/** A port call answered with a failure. */\nexport const PortFailureFixture = Schema.Struct({\n ...portFixtureFields,\n failure: PortFailure,\n response: absent\n})\n\nexport type PortFailureFixture = typeof PortFailureFixture.Type\n\n/**\n * One recorded call through a host-provided port: the port, the method, the credential-free JSON\n * request, and exactly one of `response` or `failure`. Plain JSON only, so one fixture backs\n * replay, an emulator, and parity checks alike.\n */\nexport const PortFixture = Schema.Union([PortValueFixture, PortFailureFixture])\n\nexport type PortFixture = typeof PortFixture.Type\n\n/** Decode unknown input (for example a JSON file) into a `PortFixture`. */\nexport const decodePortFixture = Schema.decodeUnknownEffect(PortFixture)\n\n/** Any fixture a conformance case may cite: an HTTP `WireFixture` or a `PortFixture`. */\nexport type ConformanceFixture = WireFixture | PortFixture\n\nexport const isPortFixture = (fixture: ConformanceFixture): fixture is PortFixture =>\n !Predicate.hasProperty(fixture, 'exchanges')\n\nexport const isPortFailureFixture = (fixture: PortFixture): fixture is PortFailureFixture =>\n Predicate.hasProperty(fixture, 'failure') && fixture.failure !== undefined\n\n/** Evidence and (when known) date of a fixture. */\nexport type ConformanceFixtureEvidence = {\n readonly evidence: WireFixtureEvidence\n readonly date?: string\n}\n\n/**\n * Evidence and date of any fixture: a `WireFixture`'s `evidence` and `recordedAt`; a\n * `PortFixture` is `verified` as of `observed.date` when observed, else `unverified` without a\n * date.\n */\nexport const conformanceFixtureEvidence = (\n fixture: ConformanceFixture\n): ConformanceFixtureEvidence => {\n if (!isPortFixture(fixture)) {\n return { evidence: fixture.evidence, date: fixture.recordedAt }\n }\n\n return fixture.observed === undefined\n ? { evidence: 'unverified' }\n : { evidence: 'verified', date: fixture.observed.date }\n}\n\nconst portCredentialKeyPattern = /^credentials?$/i\n\nconst isJsonRecord = (value: Schema.Json): value is Schema.JsonObject =>\n value !== null && Predicate.isObject(value) && !Array.isArray(value)\n\n/** True for a port payload key that holds a credential (`credential`, `password`, `token`, ...). */\nexport const isPortCredentialKey = (key: string): boolean =>\n portCredentialKeyPattern.test(key) || credentialFieldPattern.test(key)\n\n/**\n * A copy of a port payload without credential fields (see `isPortCredentialKey`: `credential(s)`\n * plus the credential field names, AWS-style `accessKeyId` / `secretAccessKey` / `sessionToken`\n * and their snake_case forms included), at any depth.\n * Port requests often carry the resolved credential; record and compare requests only after this.\n */\nexport const redactPortPayload = (value: Schema.Json): Schema.Json => {\n if (Array.isArray(value)) {\n return value.map(redactPortPayload)\n }\n\n if (!isJsonRecord(value)) {\n return value\n }\n\n const copy: Record<string, Schema.Json> = {}\n\n for (const [key, item] of Object.entries(value)) {\n if (!isPortCredentialKey(key)) {\n copy[key] = redactPortPayload(item)\n }\n }\n\n return copy\n}\n\n/** A port string: the token patterns plus credential query or form parameters (a signed URL). */\nconst scanPortText = (text: string, location: string, issues: IssueSink): void => {\n scanText(text, location, issues)\n\n if (hasLiveCredentialParam(text)) {\n issues.push({ kind: 'credential_query_param', location })\n }\n}\n\nconst scanPortJson = (value: Schema.Json, location: string, issues: IssueSink): void => {\n scanJson(value, location, issues)\n\n const visit = (item: Schema.Json, itemLocation: string): void => {\n if (Predicate.isString(item)) {\n if (hasLiveCredentialParam(item)) {\n issues.push({ kind: 'credential_query_param', location: itemLocation })\n }\n\n return\n }\n\n if (Array.isArray(item)) {\n item.forEach((entry, index) => visit(entry, `${itemLocation}[${index}]`))\n\n return\n }\n\n if (!isJsonRecord(item)) {\n return\n }\n\n for (const [key, entry] of Object.entries(item)) {\n if (portCredentialKeyPattern.test(key) && entry !== null) {\n issues.push({ kind: 'credential_field', location: `${itemLocation}.${key}` })\n }\n\n visit(entry, `${itemLocation}.${key}`)\n }\n }\n\n visit(value, location)\n}\n\n/**\n * Pure secret scan of a `PortFixture`: the same token patterns and credential JSON fields as\n * `scanFixtureForSecrets`, plus any non-null `credential` / `credentials` field (port requests must\n * be recorded through `redactPortPayload`), plus credential query or form parameters inside every\n * JSON string value, `note`, and `failure.message` (a signed URL a port answered, such as an S3\n * presigned URL's `X-Amz-Signature` / `X-Amz-Credential` / `X-Amz-Security-Token`), each `name=`\n * found and judged on its own. The only exemption: the raw value, up to `&`, `#`, whitespace, `\"`,\n * `<`, `>`, or the end (never `?` or `'`), percent-decoded, exactly equals that name's entry in\n * `syntheticPortCredentialParams`. Parameters with escaped names (`&`, percent-encoded) are not\n * found. Covers metadata, the request, the response, and the failure. Issues name locations only.\n * Returns an empty array when clean.\n */\nexport const scanPortFixtureForSecrets = (\n fixture: PortFixture\n): ReadonlyArray<FixtureSecretIssue> => {\n const issues: IssueSink = []\n\n scanText(fixture.id, 'id', issues)\n scanText(fixture.port, 'port', issues)\n scanText(fixture.method, 'method', issues)\n\n if (fixture.observed !== undefined) {\n scanText(fixture.observed.account, 'observed.account', issues)\n }\n\n if (fixture.note !== undefined) {\n scanPortText(fixture.note, 'note', issues)\n }\n\n scanPortJson(fixture.request, 'request', issues)\n\n if (isPortFailureFixture(fixture)) {\n scanText(fixture.failure.code, 'failure.code', issues)\n scanPortText(fixture.failure.message, 'failure.message', issues)\n } else {\n scanPortJson(fixture.response, 'response', issues)\n }\n\n return uniqueIssues(issues)\n}\n"],"mappings":"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"}
|
package/dist/record.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FixtureSecretIssue, WireExchange, WireFixture } from "./fixture.mjs";
|
|
2
2
|
import { Context, Effect, Layer } from "effect";
|
|
3
|
-
import { HttpClient } from "effect/
|
|
3
|
+
import { HttpClient } from "effect/http";
|
|
4
4
|
|
|
5
5
|
//#region src/record.d.ts
|
|
6
6
|
/** Default allowlisted request headers. Credential headers are always dropped. */
|
package/dist/record.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { headerRecord, isCredentialHeaderName, isNullBodyStatus, mediaType, parseJsonText, recordBytes, requestBodyText } from "./wire-internal.mjs";
|
|
2
2
|
import { decodeWireFixture, scanFixtureForSecrets } from "./fixture.mjs";
|
|
3
3
|
import { Context, Data, Effect, Exit, Layer, Option, Ref, Stream } from "effect";
|
|
4
|
-
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/
|
|
4
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
5
5
|
//#region src/record.ts
|
|
6
6
|
/**
|
|
7
7
|
* Record live HTTP exchanges as wire fixtures.
|
package/dist/record.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"record.mjs","names":[],"sources":["../src/record.ts"],"sourcesContent":["/**\n * Record live HTTP exchanges as wire fixtures.\n *\n * This module never constructs a network client: hosts supply the real\n * `HttpClient` (for example `FetchHttpClient.layer`) and this wrapper records\n * what flows through it without changing the bytes the caller receives.\n *\n * @experimental\n */\nimport { Context, Data, Effect, Exit, Layer, Option, Ref, Stream } from 'effect'\nimport {\n HttpClient,\n HttpClientRequest,\n HttpClientResponse,\n type HttpClientError\n} from 'effect/unstable/http'\nimport {\n decodeWireFixture,\n scanFixtureForSecrets,\n type FixtureSecretIssue,\n type WireBodyResponse,\n type WireChunk,\n type WireExchange,\n type WireFixture,\n type WireHeaders,\n type WireRequest,\n type WireResponse\n} from './fixture.ts'\nimport {\n headerRecord,\n isCredentialHeaderName,\n isNullBodyStatus,\n mediaType,\n parseJsonText,\n recordBytes,\n requestBodyText\n} from './wire-internal.ts'\n\n/** Default allowlisted request headers. Credential headers are always dropped. */\nexport const defaultRecordedRequestHeaders: ReadonlyArray<string> = ['content-type', 'accept']\n\n/**\n * Default allowlisted response headers. Entries ending in `*` are prefixes.\n * Credential headers (including `set-cookie`) are always dropped.\n */\nexport const defaultRecordedResponseHeaders: ReadonlyArray<string> = [\n 'content-type',\n 'retry-after',\n 'retry-after-ms',\n 'x-ratelimit-*',\n 'ratelimit-*',\n 'anthropic-ratelimit-*'\n]\n\n/** Response media types recorded as `chunks` instead of a single `body`. */\nexport const defaultStreamMediaTypes: ReadonlyArray<string> = ['text/event-stream']\n\nexport type WireRecorderOptions = {\n readonly requestHeaders?: ReadonlyArray<string>\n readonly responseHeaders?: ReadonlyArray<string>\n readonly streamMediaTypes?: ReadonlyArray<string>\n}\n\n/** Raised by `drain` when a recorded request failed or its body was not read to the end. */\nexport class WireRecordingIncomplete extends Data.TaggedError('WireRecordingIncomplete')<{\n /** `METHOD url` (query string removed) of each incomplete exchange. */\n readonly requests: ReadonlyArray<string>\n}> {\n override get message(): string {\n return `Recording incomplete for ${this.requests.length} request(s): ${this.requests.join(', ')}`\n }\n}\n\nexport type WireRecorderApi = {\n /**\n * Take every recorded exchange, in request order, and clear the recorder.\n * Fails with `WireRecordingIncomplete` (and discards the drained entries)\n * when any request failed or its body was not fully read.\n *\n * A request still pending at drain time is reported in that drain's\n * `WireRecordingIncomplete` and is then dropped: if its body finishes later,\n * the result is ignored and never appears in, or overwrites an entry of, a\n * later drain.\n */\n readonly drain: Effect.Effect<ReadonlyArray<WireExchange>, WireRecordingIncomplete>\n}\n\nexport class WireRecorder extends Context.Service<WireRecorder, WireRecorderApi>()(\n '@yolk-sdk/conformance/WireRecorder'\n) {\n /**\n * Wrap the `HttpClient` already in context and provide the recording client\n * plus its `WireRecorder`. Provide the real client below this layer.\n */\n static layer = (\n options: WireRecorderOptions = {}\n ): Layer.Layer<HttpClient.HttpClient | WireRecorder, never, HttpClient.HttpClient> =>\n Layer.unwrap(\n Effect.gen(function* () {\n const upstream = yield* HttpClient.HttpClient\n const { client, recorder } = yield* makeRecordingHttpClient(upstream, options)\n\n return Layer.mergeAll(\n Layer.succeed(HttpClient.HttpClient, client),\n Layer.succeed(WireRecorder, recorder)\n )\n })\n )\n}\n\ntype RecordingEntry =\n | { readonly status: 'pending'; readonly label: string }\n | { readonly status: 'failed'; readonly label: string }\n | { readonly status: 'complete'; readonly label: string; readonly exchange: WireExchange }\n\n// `id` is a monotonic reservation id that is never reused, so a finalizer from\n// a request reserved before a `drain` cannot settle an entry reserved after it.\ntype RecordingState = {\n readonly nextId: number\n readonly entries: ReadonlyArray<{ readonly id: number; readonly entry: RecordingEntry }>\n}\n\nconst recordedChunk = (bytes: Uint8Array): WireChunk => {\n const recorded = recordBytes(bytes)\n\n return 'text' in recorded ? recorded.text : { base64: recorded.base64 }\n}\n\nconst recordedBody = (\n status: number,\n headers: WireHeaders,\n bytes: Uint8Array\n): WireBodyResponse => {\n const recorded = recordBytes(bytes)\n\n return 'text' in recorded\n ? { status, headers, body: recorded.text }\n : { status, headers, bodyBase64: recorded.base64 }\n}\n\nconst headerAllowed = (allowlist: ReadonlyArray<string>, name: string): boolean =>\n !isCredentialHeaderName(name) &&\n allowlist.some(entry => {\n const lower = entry.toLowerCase()\n\n return lower.endsWith('*') ? name.startsWith(lower.slice(0, -1)) : name === lower\n })\n\nconst allowlistHeaders = (\n headers: Readonly<Record<string, string | undefined>>,\n allowlist: ReadonlyArray<string>\n): WireHeaders => {\n const recorded: Record<string, string> = {}\n\n for (const [name, value] of Object.entries(headerRecord(headers))) {\n if (headerAllowed(allowlist, name)) {\n recorded[name] = value\n }\n }\n\n return recorded\n}\n\ntype WireRequestFields = {\n method: string\n url: string\n headers?: WireHeaders\n body?: WireRequest['body']\n}\n\nconst recordRequest = (\n request: HttpClientRequest.HttpClientRequest,\n allowlist: ReadonlyArray<string>\n): Effect.Effect<WireRequest> =>\n Effect.gen(function* () {\n const url = Option.match(HttpClientRequest.toUrl(request), {\n onNone: () => request.url,\n onSome: resolved => {\n resolved.hash = ''\n\n return resolved.toString()\n }\n })\n\n const fields: WireRequestFields = { method: request.method, url }\n const headers = allowlistHeaders(request.headers, allowlist)\n\n if (Object.keys(headers).length > 0) {\n fields.headers = headers\n }\n\n const text = requestBodyText(request)\n\n if (text !== undefined && text.length > 0) {\n const json = yield* parseJsonText(text)\n\n fields.body = Option.getOrElse(json, () => text)\n }\n\n return fields\n })\n\n/**\n * Wrap a host-provided `HttpClient` so every exchange is recorded losslessly.\n * Streamed responses (see `streamMediaTypes`) are teed chunk by chunk with\n * network boundaries preserved: each chunk is stored as text when it is valid\n * UTF-8 on its own (empty chunks as `\"\"`), otherwise as `{ base64 }` of its\n * exact bytes. Other responses are recorded as one `body` (valid UTF-8) or\n * `bodyBase64` (anything else). The caller receives the same status, headers,\n * and bytes. Body read failures still reach the caller as `HttpClientError`s\n * (the upstream error is kept as the cause), matching how `FetchHttpClient`\n * reports them.\n */\nexport const makeRecordingHttpClient = (\n upstream: HttpClient.HttpClient,\n options: WireRecorderOptions = {}\n): Effect.Effect<{ readonly client: HttpClient.HttpClient; readonly recorder: WireRecorderApi }> =>\n Effect.gen(function* () {\n const requestAllowlist = options.requestHeaders ?? defaultRecordedRequestHeaders\n const responseAllowlist = options.responseHeaders ?? defaultRecordedResponseHeaders\n\n const streamMediaTypes = (options.streamMediaTypes ?? defaultStreamMediaTypes).map(type =>\n type.toLowerCase()\n )\n\n const state = yield* Ref.make<RecordingState>({ nextId: 0, entries: [] })\n\n const reserve = (label: string) =>\n Ref.modify(state, (current): [number, RecordingState] => [\n current.nextId,\n {\n nextId: current.nextId + 1,\n entries: [...current.entries, { id: current.nextId, entry: { status: 'pending', label } }]\n }\n ])\n\n // Settles only a still-pending entry with this id; an entry already drained\n // (or settled) is left untouched.\n const settle = (id: number, entry: RecordingEntry) =>\n Ref.update(state, current => ({\n ...current,\n entries: current.entries.map(item =>\n item.id === id && item.entry.status === 'pending' ? { id, entry } : item\n )\n }))\n\n const client = HttpClient.transform(\n upstream,\n (\n effect: Effect.Effect<\n HttpClientResponse.HttpClientResponse,\n HttpClientError.HttpClientError\n >,\n request\n ) =>\n Effect.gen(function* () {\n const wireRequest = yield* recordRequest(request, requestAllowlist)\n // Query strings may carry credentials: labels keep origin and path only.\n const label = `${wireRequest.method} ${wireRequest.url.split('?', 1)[0]}`\n const id = yield* reserve(label)\n const failed = settle(id, { status: 'failed', label })\n const response = yield* effect.pipe(Effect.tapError(() => failed))\n const headers = headerRecord(response.headers)\n const recordedHeaders = allowlistHeaders(headers, responseAllowlist)\n const init = { status: response.status, headers }\n\n const complete = (wireResponse: WireResponse) =>\n settle(id, {\n status: 'complete',\n label,\n exchange: { request: wireRequest, response: wireResponse }\n })\n\n const type = mediaType(headers['content-type'])\n\n if (type !== undefined && streamMediaTypes.includes(type)) {\n const chunks: Array<WireChunk> = []\n\n // Each network chunk is recorded standalone (no decoder carry-over),\n // so replay can reproduce the original bytes and boundaries.\n const teed = response.stream.pipe(\n Stream.tap(bytes => Effect.sync(() => chunks.push(recordedChunk(bytes)))),\n Stream.onExit(exit =>\n Exit.isFailure(exit)\n ? failed\n : complete({ status: response.status, headers: recordedHeaders, chunks })\n )\n )\n\n const readable = Stream.toReadableStream(teed, { strategy: { highWaterMark: 0 } })\n\n return HttpClientResponse.fromWeb(request, new Response(readable, init))\n }\n\n const bytes = yield* response.arrayBuffer.pipe(Effect.tapError(() => failed))\n\n yield* complete(recordedBody(response.status, recordedHeaders, new Uint8Array(bytes)))\n\n return HttpClientResponse.fromWeb(\n request,\n new Response(isNullBodyStatus(response.status) ? null : bytes, init)\n )\n })\n )\n\n const recorder: WireRecorderApi = {\n drain: Ref.modify(state, (current): [RecordingState['entries'], RecordingState] => [\n current.entries,\n { nextId: current.nextId, entries: [] }\n ]).pipe(\n Effect.flatMap(drained => {\n const exchanges: Array<WireExchange> = []\n const incomplete: Array<string> = []\n\n for (const { entry } of drained) {\n if (entry.status === 'complete') {\n exchanges.push(entry.exchange)\n } else {\n incomplete.push(entry.label)\n }\n }\n\n return incomplete.length > 0\n ? Effect.fail(new WireRecordingIncomplete({ requests: incomplete }))\n : Effect.succeed(exchanges)\n })\n )\n }\n\n return { client, recorder }\n })\n\n/** A fixture failed schema validation (for example an empty exchange list or bad date). */\nexport class WireFixtureInvalid extends Data.TaggedError('WireFixtureInvalid')<{\n readonly fixtureId: string\n readonly cause: unknown\n}> {\n override get message(): string {\n return `Wire fixture ${this.fixtureId} is invalid`\n }\n}\n\n/** The secret scan found credentials or credential-like values in a fixture. */\nexport class WireFixtureSecretsFound extends Data.TaggedError('WireFixtureSecretsFound')<{\n readonly fixtureId: string\n readonly issues: ReadonlyArray<FixtureSecretIssue>\n}> {\n override get message(): string {\n return `Wire fixture ${this.fixtureId} contains ${this.issues.length} secret-scan issue(s)`\n }\n}\n\nexport type WireFixtureInput = Omit<WireFixture, 'exchanges'> & {\n readonly exchanges: ReadonlyArray<WireExchange>\n}\n\n/**\n * Validate a fixture and run `scanFixtureForSecrets`. Fails with\n * `WireFixtureInvalid` or `WireFixtureSecretsFound`; never returns a fixture\n * with scan issues.\n */\nexport const makeWireFixture = (\n input: WireFixtureInput\n): Effect.Effect<WireFixture, WireFixtureInvalid | WireFixtureSecretsFound> =>\n Effect.gen(function* () {\n const fixture = yield* decodeWireFixture(input).pipe(\n Effect.mapError(cause => new WireFixtureInvalid({ fixtureId: input.id, cause }))\n )\n\n const issues = scanFixtureForSecrets(fixture)\n\n if (issues.length > 0) {\n return yield* Effect.fail(new WireFixtureSecretsFound({ fixtureId: fixture.id, issues }))\n }\n\n return fixture\n })\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"record.mjs","names":[],"sources":["../src/record.ts"],"sourcesContent":["/**\n * Record live HTTP exchanges as wire fixtures.\n *\n * This module never constructs a network client: hosts supply the real\n * `HttpClient` (for example `FetchHttpClient.layer`) and this wrapper records\n * what flows through it without changing the bytes the caller receives.\n *\n * @experimental\n */\nimport { Context, Data, Effect, Exit, Layer, Option, Ref, Stream } from 'effect'\nimport {\n HttpClient,\n HttpClientRequest,\n HttpClientResponse,\n type HttpClientError\n} from 'effect/http'\nimport {\n decodeWireFixture,\n scanFixtureForSecrets,\n type FixtureSecretIssue,\n type WireBodyResponse,\n type WireChunk,\n type WireExchange,\n type WireFixture,\n type WireHeaders,\n type WireRequest,\n type WireResponse\n} from './fixture.ts'\nimport {\n headerRecord,\n isCredentialHeaderName,\n isNullBodyStatus,\n mediaType,\n parseJsonText,\n recordBytes,\n requestBodyText\n} from './wire-internal.ts'\n\n/** Default allowlisted request headers. Credential headers are always dropped. */\nexport const defaultRecordedRequestHeaders: ReadonlyArray<string> = ['content-type', 'accept']\n\n/**\n * Default allowlisted response headers. Entries ending in `*` are prefixes.\n * Credential headers (including `set-cookie`) are always dropped.\n */\nexport const defaultRecordedResponseHeaders: ReadonlyArray<string> = [\n 'content-type',\n 'retry-after',\n 'retry-after-ms',\n 'x-ratelimit-*',\n 'ratelimit-*',\n 'anthropic-ratelimit-*'\n]\n\n/** Response media types recorded as `chunks` instead of a single `body`. */\nexport const defaultStreamMediaTypes: ReadonlyArray<string> = ['text/event-stream']\n\nexport type WireRecorderOptions = {\n readonly requestHeaders?: ReadonlyArray<string>\n readonly responseHeaders?: ReadonlyArray<string>\n readonly streamMediaTypes?: ReadonlyArray<string>\n}\n\n/** Raised by `drain` when a recorded request failed or its body was not read to the end. */\nexport class WireRecordingIncomplete extends Data.TaggedError('WireRecordingIncomplete')<{\n /** `METHOD url` (query string removed) of each incomplete exchange. */\n readonly requests: ReadonlyArray<string>\n}> {\n override get message(): string {\n return `Recording incomplete for ${this.requests.length} request(s): ${this.requests.join(', ')}`\n }\n}\n\nexport type WireRecorderApi = {\n /**\n * Take every recorded exchange, in request order, and clear the recorder.\n * Fails with `WireRecordingIncomplete` (and discards the drained entries)\n * when any request failed or its body was not fully read.\n *\n * A request still pending at drain time is reported in that drain's\n * `WireRecordingIncomplete` and is then dropped: if its body finishes later,\n * the result is ignored and never appears in, or overwrites an entry of, a\n * later drain.\n */\n readonly drain: Effect.Effect<ReadonlyArray<WireExchange>, WireRecordingIncomplete>\n}\n\nexport class WireRecorder extends Context.Service<WireRecorder, WireRecorderApi>()(\n '@yolk-sdk/conformance/WireRecorder'\n) {\n /**\n * Wrap the `HttpClient` already in context and provide the recording client\n * plus its `WireRecorder`. Provide the real client below this layer.\n */\n static layer = (\n options: WireRecorderOptions = {}\n ): Layer.Layer<HttpClient.HttpClient | WireRecorder, never, HttpClient.HttpClient> =>\n Layer.unwrap(\n Effect.gen(function* () {\n const upstream = yield* HttpClient.HttpClient\n const { client, recorder } = yield* makeRecordingHttpClient(upstream, options)\n\n return Layer.mergeAll(\n Layer.succeed(HttpClient.HttpClient, client),\n Layer.succeed(WireRecorder, recorder)\n )\n })\n )\n}\n\ntype RecordingEntry =\n | { readonly status: 'pending'; readonly label: string }\n | { readonly status: 'failed'; readonly label: string }\n | { readonly status: 'complete'; readonly label: string; readonly exchange: WireExchange }\n\n// `id` is a monotonic reservation id that is never reused, so a finalizer from\n// a request reserved before a `drain` cannot settle an entry reserved after it.\ntype RecordingState = {\n readonly nextId: number\n readonly entries: ReadonlyArray<{ readonly id: number; readonly entry: RecordingEntry }>\n}\n\nconst recordedChunk = (bytes: Uint8Array): WireChunk => {\n const recorded = recordBytes(bytes)\n\n return 'text' in recorded ? recorded.text : { base64: recorded.base64 }\n}\n\nconst recordedBody = (\n status: number,\n headers: WireHeaders,\n bytes: Uint8Array\n): WireBodyResponse => {\n const recorded = recordBytes(bytes)\n\n return 'text' in recorded\n ? { status, headers, body: recorded.text }\n : { status, headers, bodyBase64: recorded.base64 }\n}\n\nconst headerAllowed = (allowlist: ReadonlyArray<string>, name: string): boolean =>\n !isCredentialHeaderName(name) &&\n allowlist.some(entry => {\n const lower = entry.toLowerCase()\n\n return lower.endsWith('*') ? name.startsWith(lower.slice(0, -1)) : name === lower\n })\n\nconst allowlistHeaders = (\n headers: Readonly<Record<string, string | undefined>>,\n allowlist: ReadonlyArray<string>\n): WireHeaders => {\n const recorded: Record<string, string> = {}\n\n for (const [name, value] of Object.entries(headerRecord(headers))) {\n if (headerAllowed(allowlist, name)) {\n recorded[name] = value\n }\n }\n\n return recorded\n}\n\ntype WireRequestFields = {\n method: string\n url: string\n headers?: WireHeaders\n body?: WireRequest['body']\n}\n\nconst recordRequest = (\n request: HttpClientRequest.HttpClientRequest,\n allowlist: ReadonlyArray<string>\n): Effect.Effect<WireRequest> =>\n Effect.gen(function* () {\n const url = Option.match(HttpClientRequest.toUrl(request), {\n onNone: () => request.url,\n onSome: resolved => {\n resolved.hash = ''\n\n return resolved.toString()\n }\n })\n\n const fields: WireRequestFields = { method: request.method, url }\n const headers = allowlistHeaders(request.headers, allowlist)\n\n if (Object.keys(headers).length > 0) {\n fields.headers = headers\n }\n\n const text = requestBodyText(request)\n\n if (text !== undefined && text.length > 0) {\n const json = yield* parseJsonText(text)\n\n fields.body = Option.getOrElse(json, () => text)\n }\n\n return fields\n })\n\n/**\n * Wrap a host-provided `HttpClient` so every exchange is recorded losslessly.\n * Streamed responses (see `streamMediaTypes`) are teed chunk by chunk with\n * network boundaries preserved: each chunk is stored as text when it is valid\n * UTF-8 on its own (empty chunks as `\"\"`), otherwise as `{ base64 }` of its\n * exact bytes. Other responses are recorded as one `body` (valid UTF-8) or\n * `bodyBase64` (anything else). The caller receives the same status, headers,\n * and bytes. Body read failures still reach the caller as `HttpClientError`s\n * (the upstream error is kept as the cause), matching how `FetchHttpClient`\n * reports them.\n */\nexport const makeRecordingHttpClient = (\n upstream: HttpClient.HttpClient,\n options: WireRecorderOptions = {}\n): Effect.Effect<{ readonly client: HttpClient.HttpClient; readonly recorder: WireRecorderApi }> =>\n Effect.gen(function* () {\n const requestAllowlist = options.requestHeaders ?? defaultRecordedRequestHeaders\n const responseAllowlist = options.responseHeaders ?? defaultRecordedResponseHeaders\n\n const streamMediaTypes = (options.streamMediaTypes ?? defaultStreamMediaTypes).map(type =>\n type.toLowerCase()\n )\n\n const state = yield* Ref.make<RecordingState>({ nextId: 0, entries: [] })\n\n const reserve = (label: string) =>\n Ref.modify(state, (current): [number, RecordingState] => [\n current.nextId,\n {\n nextId: current.nextId + 1,\n entries: [...current.entries, { id: current.nextId, entry: { status: 'pending', label } }]\n }\n ])\n\n // Settles only a still-pending entry with this id; an entry already drained\n // (or settled) is left untouched.\n const settle = (id: number, entry: RecordingEntry) =>\n Ref.update(state, current => ({\n ...current,\n entries: current.entries.map(item =>\n item.id === id && item.entry.status === 'pending' ? { id, entry } : item\n )\n }))\n\n const client = HttpClient.transform(\n upstream,\n (\n effect: Effect.Effect<\n HttpClientResponse.HttpClientResponse,\n HttpClientError.HttpClientError\n >,\n request\n ) =>\n Effect.gen(function* () {\n const wireRequest = yield* recordRequest(request, requestAllowlist)\n // Query strings may carry credentials: labels keep origin and path only.\n const label = `${wireRequest.method} ${wireRequest.url.split('?', 1)[0]}`\n const id = yield* reserve(label)\n const failed = settle(id, { status: 'failed', label })\n const response = yield* effect.pipe(Effect.tapError(() => failed))\n const headers = headerRecord(response.headers)\n const recordedHeaders = allowlistHeaders(headers, responseAllowlist)\n const init = { status: response.status, headers }\n\n const complete = (wireResponse: WireResponse) =>\n settle(id, {\n status: 'complete',\n label,\n exchange: { request: wireRequest, response: wireResponse }\n })\n\n const type = mediaType(headers['content-type'])\n\n if (type !== undefined && streamMediaTypes.includes(type)) {\n const chunks: Array<WireChunk> = []\n\n // Each network chunk is recorded standalone (no decoder carry-over),\n // so replay can reproduce the original bytes and boundaries.\n const teed = response.stream.pipe(\n Stream.tap(bytes => Effect.sync(() => chunks.push(recordedChunk(bytes)))),\n Stream.onExit(exit =>\n Exit.isFailure(exit)\n ? failed\n : complete({ status: response.status, headers: recordedHeaders, chunks })\n )\n )\n\n const readable = Stream.toReadableStream(teed, { strategy: { highWaterMark: 0 } })\n\n return HttpClientResponse.fromWeb(request, new Response(readable, init))\n }\n\n const bytes = yield* response.arrayBuffer.pipe(Effect.tapError(() => failed))\n\n yield* complete(recordedBody(response.status, recordedHeaders, new Uint8Array(bytes)))\n\n return HttpClientResponse.fromWeb(\n request,\n new Response(isNullBodyStatus(response.status) ? null : bytes, init)\n )\n })\n )\n\n const recorder: WireRecorderApi = {\n drain: Ref.modify(state, (current): [RecordingState['entries'], RecordingState] => [\n current.entries,\n { nextId: current.nextId, entries: [] }\n ]).pipe(\n Effect.flatMap(drained => {\n const exchanges: Array<WireExchange> = []\n const incomplete: Array<string> = []\n\n for (const { entry } of drained) {\n if (entry.status === 'complete') {\n exchanges.push(entry.exchange)\n } else {\n incomplete.push(entry.label)\n }\n }\n\n return incomplete.length > 0\n ? Effect.fail(new WireRecordingIncomplete({ requests: incomplete }))\n : Effect.succeed(exchanges)\n })\n )\n }\n\n return { client, recorder }\n })\n\n/** A fixture failed schema validation (for example an empty exchange list or bad date). */\nexport class WireFixtureInvalid extends Data.TaggedError('WireFixtureInvalid')<{\n readonly fixtureId: string\n readonly cause: unknown\n}> {\n override get message(): string {\n return `Wire fixture ${this.fixtureId} is invalid`\n }\n}\n\n/** The secret scan found credentials or credential-like values in a fixture. */\nexport class WireFixtureSecretsFound extends Data.TaggedError('WireFixtureSecretsFound')<{\n readonly fixtureId: string\n readonly issues: ReadonlyArray<FixtureSecretIssue>\n}> {\n override get message(): string {\n return `Wire fixture ${this.fixtureId} contains ${this.issues.length} secret-scan issue(s)`\n }\n}\n\nexport type WireFixtureInput = Omit<WireFixture, 'exchanges'> & {\n readonly exchanges: ReadonlyArray<WireExchange>\n}\n\n/**\n * Validate a fixture and run `scanFixtureForSecrets`. Fails with\n * `WireFixtureInvalid` or `WireFixtureSecretsFound`; never returns a fixture\n * with scan issues.\n */\nexport const makeWireFixture = (\n input: WireFixtureInput\n): Effect.Effect<WireFixture, WireFixtureInvalid | WireFixtureSecretsFound> =>\n Effect.gen(function* () {\n const fixture = yield* decodeWireFixture(input).pipe(\n Effect.mapError(cause => new WireFixtureInvalid({ fixtureId: input.id, cause }))\n )\n\n const issues = scanFixtureForSecrets(fixture)\n\n if (issues.length > 0) {\n return yield* Effect.fail(new WireFixtureSecretsFound({ fixtureId: fixture.id, issues }))\n }\n\n return fixture\n })\n"],"mappings":"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"}
|
package/dist/replay.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WireFixture, WireHeaders } from "./fixture.mjs";
|
|
2
2
|
import { Cause, Context, Data, Effect, Layer } from "effect";
|
|
3
3
|
import * as Schema from "effect/Schema";
|
|
4
|
-
import { HttpClient } from "effect/
|
|
4
|
+
import { HttpClient } from "effect/http";
|
|
5
5
|
|
|
6
6
|
//#region src/replay.d.ts
|
|
7
7
|
/**
|
package/dist/replay.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { decodeBase64Bytes, headerRecord, isNullBodyStatus, normalizeWireUrl, parseJsonText, redactHeaders, requestBodyText, urlMatchesPattern } from "./wire-internal.mjs";
|
|
2
2
|
import { isWireBase64BodyResponse, isWireStreamResponse } from "./fixture.mjs";
|
|
3
3
|
import { Cause, Context, Data, Effect, Exit, Fiber, Layer, Match, Option, Predicate, Ref, Result } from "effect";
|
|
4
|
-
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/
|
|
4
|
+
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http";
|
|
5
5
|
//#region src/replay.ts
|
|
6
6
|
/**
|
|
7
7
|
* Offline replay of recorded wire fixtures as an Effect `HttpClient`.
|
package/dist/replay.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replay.mjs","names":[],"sources":["../src/replay.ts"],"sourcesContent":["/**\n * Offline replay of recorded wire fixtures as an Effect `HttpClient`.\n *\n * Replay never performs network I/O. Requests are matched by method and\n * normalized absolute URL (hash removed, query parameters sorted); each\n * recorded exchange is consumed once, in recorded order among exchanges with\n * the same method and URL. A request with no remaining match fails closed with\n * a typed `HttpClientError` and is still written to the ledger.\n *\n * @experimental\n */\nimport {\n Cause,\n Context,\n Data,\n Effect,\n Exit,\n Fiber,\n Layer,\n Match,\n Option,\n Predicate,\n Ref,\n Result\n} from 'effect'\nimport type * as Schema from 'effect/Schema'\nimport {\n HttpClient,\n HttpClientError,\n HttpClientResponse,\n type HttpClientRequest\n} from 'effect/unstable/http'\nimport {\n isWireBase64BodyResponse,\n isWireStreamResponse,\n type WireChunk,\n type WireExchange,\n type WireFixture,\n type WireHeaders,\n type WireResponse\n} from './fixture.ts'\nimport {\n decodeBase64Bytes,\n headerRecord,\n isNullBodyStatus,\n normalizeWireUrl,\n parseJsonText,\n redactHeaders,\n requestBodyText,\n urlMatchesPattern\n} from './wire-internal.ts'\n\n/**\n * Optional fault filter. `method` compares case-insensitively. `url` matches\n * the normalized request URL exactly, or as a prefix when it ends with `*`.\n * An omitted field matches every request.\n */\nexport type WireFaultMatch = {\n readonly method?: string\n readonly url?: string\n}\n\n/**\n * Wire faults injected by the replay client. `attempt` is 1-based and counted\n * per method + normalized URL.\n *\n * - `StatusOnAttempt`: respond with this status instead of consuming a recorded\n * exchange, so the next attempt still gets the recording. It fires only when\n * an unconsumed recorded exchange exists for the method + normalized URL;\n * unknown or exhausted requests still fail closed as `unmatched`.\n * - `FailAfterChunks`: emit the first `chunks` recorded chunks, then fail the\n * body stream the way a dropped connection does under `FetchHttpClient`.\n * - `TruncateAfterChunks`: emit the first `chunks` recorded chunks, then end\n * the body cleanly.\n * - `HoldAfterChunks`: emit the first `chunks` recorded chunks, run `release`,\n * then emit the rest.\n *\n * `attempt` omitted means every matching attempt. A chunk fault that cannot\n * take effect fails the request with a typed `HttpClientError` (and a ledger\n * `invalid` outcome) instead of silently doing nothing: any chunk fault matched\n * against a whole-body response, `TruncateAfterChunks`/`HoldAfterChunks` with\n * `chunks` >= the recorded chunk count, or `FailAfterChunks` with `chunks` >\n * the recorded chunk count. That error's reason is a `TransportError`, so\n * transient-retry policies may retry past it: assert the ledger has no\n * `invalid` outcome when testing through a retrying client.\n */\nexport type WireFault = Data.TaggedEnum<{\n StatusOnAttempt: {\n readonly match?: WireFaultMatch\n readonly attempt: number\n readonly status: number\n readonly headers?: WireHeaders\n readonly body?: string\n }\n FailAfterChunks: {\n readonly match?: WireFaultMatch\n readonly attempt?: number\n readonly chunks: number\n }\n TruncateAfterChunks: {\n readonly match?: WireFaultMatch\n readonly attempt?: number\n readonly chunks: number\n }\n HoldAfterChunks: {\n readonly match?: WireFaultMatch\n readonly attempt?: number\n readonly chunks: number\n readonly release: Effect.Effect<void>\n }\n}>\n\nexport const WireFault = Data.taggedEnum<WireFault>()\n\nexport type WireFaultTag = WireFault['_tag']\n\ntype ChunkFault = Exclude<WireFault, { readonly _tag: 'StatusOnAttempt' }>\n\n/** Cause attached to a body stream failed by `FailAfterChunks`. */\nexport class WireTransportFault extends Data.TaggedError('WireTransportFault')<{\n readonly afterChunks: number\n}> {\n override get message(): string {\n return `injected transport failure after ${this.afterChunks} chunks`\n }\n}\n\n/**\n * How a replayed request was answered: a recorded exchange, a response\n * injected by a `StatusOnAttempt` fault, nothing (fail closed), or a matched\n * exchange that could not be replayed (`invalid`: a chunk fault that cannot\n * take effect, or undecodable base64). `invalid` requests fail with a typed\n * `HttpClientError` and do not consume the exchange.\n */\nexport type ReplayLedgerMatch =\n | { readonly outcome: 'matched'; readonly fixtureId: string; readonly exchangeIndex: number }\n | { readonly outcome: 'injected' }\n | { readonly outcome: 'unmatched' }\n | {\n readonly outcome: 'invalid'\n readonly fixtureId: string\n readonly exchangeIndex: number\n /** Why the recording could not be replayed; never contains request or response data. */\n readonly reason: string\n }\n\nexport type ReplayLedgerEntry = {\n readonly method: string\n /** Normalized absolute URL. */\n readonly url: string\n /** Request headers with credential headers replaced by `<redacted>`. */\n readonly headers: Readonly<Record<string, string>>\n readonly bodyText?: string\n /** Parsed request body when `bodyText` is valid JSON. */\n readonly bodyJson?: Schema.Json\n /** 1-based attempt number for this method + URL. */\n readonly attempt: number\n readonly match: ReplayLedgerMatch\n /** Tag of the fault that shaped this response, if any (never set for a fault that did not apply). */\n readonly fault?: WireFaultTag\n}\n\nexport type ReplayExchangeRef = {\n readonly fixtureId: string\n readonly exchangeIndex: number\n}\n\nexport type ReplayLedgerApi = {\n /** Every request seen by the replay client, in arrival order. */\n readonly entries: Effect.Effect<ReadonlyArray<ReplayLedgerEntry>>\n /** Recorded exchanges not consumed yet, in fixture order. */\n readonly remaining: Effect.Effect<ReadonlyArray<ReplayExchangeRef>>\n}\n\nexport class ReplayLedger extends Context.Service<ReplayLedger, ReplayLedgerApi>()(\n '@yolk-sdk/conformance/ReplayLedger'\n) {}\n\nexport type ReplayHttpClientOptions = {\n readonly faults?: ReadonlyArray<WireFault>\n}\n\ntype Candidate = {\n readonly id: number\n readonly key: string\n readonly fixtureId: string\n readonly exchangeIndex: number\n readonly exchange: WireExchange\n}\n\ntype ReplayState = {\n readonly consumed: ReadonlySet<number>\n readonly attempts: ReadonlyMap<string, number>\n readonly entries: ReadonlyArray<ReplayLedgerEntry>\n}\n\n// Bytes to replay: one whole body, or one Uint8Array per recorded chunk.\ntype ReplayBody =\n | { readonly kind: 'body'; readonly body: string | Uint8Array<ArrayBuffer> }\n | {\n readonly kind: 'chunks'\n readonly chunks: ReadonlyArray<Uint8Array>\n readonly fault: ChunkFault | undefined\n }\n\ntype ReplayDecision =\n | { readonly kind: 'status'; readonly fault: Extract<WireFault, { _tag: 'StatusOnAttempt' }> }\n | {\n readonly kind: 'exchange'\n readonly response: WireResponse\n readonly body: ReplayBody\n }\n | { readonly kind: 'invalid'; readonly reason: string }\n | { readonly kind: 'unmatched' }\n\ntype LedgerEntryFields = {\n method: string\n url: string\n headers: Readonly<Record<string, string>>\n bodyText?: string\n bodyJson?: Schema.Json\n attempt: number\n match: ReplayLedgerMatch\n fault?: WireFaultTag\n}\n\nconst requestKey = (method: string, normalizedUrl: string) =>\n `${method.toUpperCase()} ${normalizedUrl}`\n\nconst faultMatches = (\n match: WireFaultMatch | undefined,\n method: string,\n normalizedUrl: string\n): boolean =>\n (match?.method === undefined || match.method.toUpperCase() === method.toUpperCase()) &&\n (match?.url === undefined || urlMatchesPattern(match.url, normalizedUrl))\n\nconst candidatesFrom = (fixtures: ReadonlyArray<WireFixture>): ReadonlyArray<Candidate> => {\n const candidates: Array<Candidate> = []\n\n for (const fixture of fixtures) {\n fixture.exchanges.forEach((exchange, exchangeIndex) => {\n candidates.push({\n id: candidates.length,\n key: requestKey(exchange.request.method, normalizeWireUrl(exchange.request.url)),\n fixtureId: fixture.id,\n exchangeIndex,\n exchange\n })\n })\n }\n\n return candidates\n}\n\nconst chunkBytes = (chunk: WireChunk): Option.Option<Uint8Array> =>\n Predicate.isString(chunk)\n ? Option.some(new TextEncoder().encode(chunk))\n : decodeBase64Bytes(chunk.base64)\n\nconst chunkFaultProblem = (fault: ChunkFault, recorded: number): string | undefined => {\n if (!Number.isSafeInteger(fault.chunks) || fault.chunks < 0) {\n return `${fault._tag} needs a non-negative integer chunk count`\n }\n\n // `FailAfterChunks` may fail after the last chunk; truncating or holding\n // there would change nothing.\n const applies = WireFault.$is('FailAfterChunks')(fault)\n ? fault.chunks <= recorded\n : fault.chunks < recorded\n\n return applies\n ? undefined\n : `${fault._tag} after ${fault.chunks} chunk(s) cannot apply to a response with ${recorded} recorded chunk(s)`\n}\n\n/** Resolve the exact bytes to replay, or why the recording cannot be replayed as requested. */\nconst replayBody = (\n response: WireResponse,\n fault: ChunkFault | undefined\n): Result.Result<ReplayBody, string> => {\n if (isWireStreamResponse(response)) {\n const chunks = Option.all(response.chunks.map(chunkBytes))\n\n if (Option.isNone(chunks)) {\n return Result.fail('a recorded chunk is not valid base64')\n }\n\n const problem = fault === undefined ? undefined : chunkFaultProblem(fault, chunks.value.length)\n\n return problem === undefined\n ? Result.succeed({ kind: 'chunks', chunks: chunks.value, fault })\n : Result.fail(problem)\n }\n\n if (fault !== undefined) {\n return Result.fail(`${fault._tag} cannot apply to a whole-body response`)\n }\n\n if (isWireBase64BodyResponse(response)) {\n return Option.match(decodeBase64Bytes(response.bodyBase64), {\n onNone: () => Result.fail('the recorded bodyBase64 is not valid base64'),\n // Copy into an ArrayBuffer-backed view, as `Response` requires.\n onSome: bytes => Result.succeed({ kind: 'body', body: new Uint8Array(bytes) })\n })\n }\n\n return Result.succeed({ kind: 'body', body: response.body })\n}\n\n/**\n * Strictly pull-driven body: one `Uint8Array` per recorded chunk (including\n * empty ones), and chunk `k` is produced only when the consumer pulls it,\n * never ahead of demand. The fault (already validated against the chunk count)\n * applies when the consumer pulls past `fault.chunks` chunks.\n */\nconst bodyReadable = (\n chunks: ReadonlyArray<Uint8Array>,\n fault: ChunkFault | undefined\n): ReadableStream<Uint8Array> => {\n let next = 0\n let faultApplied = false\n let cancelled = false\n let held: Fiber.Fiber<void> | undefined\n\n const emitNext = (controller: ReadableStreamDefaultController<Uint8Array>): void => {\n const chunk = chunks[next]\n\n if (chunk === undefined) {\n controller.close()\n\n return\n }\n\n next += 1\n controller.enqueue(chunk)\n }\n\n return new ReadableStream<Uint8Array>(\n {\n pull: controller => {\n if (fault === undefined || faultApplied || next !== fault.chunks) {\n emitNext(controller)\n\n return\n }\n\n faultApplied = true\n\n return Match.valueTags(fault, {\n FailAfterChunks: ({ chunks: afterChunks }) =>\n controller.error(new WireTransportFault({ afterChunks })),\n TruncateAfterChunks: () => controller.close(),\n HoldAfterChunks: ({ release }) => {\n // Keep the release fiber so cancelling the body interrupts it (and runs its finalizers).\n // `runFork` starts `release` synchronously, so a cancel can land before `held` is set.\n const fiber = Effect.runFork(release)\n held = fiber\n\n if (cancelled) {\n held = undefined\n\n return Effect.runPromise(Fiber.interrupt(fiber))\n }\n\n return Effect.runPromise(Fiber.await(fiber)).then(exit => {\n held = undefined\n\n if (cancelled) return\n\n if (Exit.isSuccess(exit)) {\n emitNext(controller)\n } else {\n controller.error(Cause.squash(exit.cause))\n }\n })\n }\n })\n },\n cancel: () => {\n cancelled = true\n\n if (held === undefined) return\n\n return Effect.runPromise(Fiber.interrupt(held))\n }\n },\n { highWaterMark: 0 }\n )\n}\n\nconst webResponse = (status: number, headers: WireHeaders, body: ReplayBody): Response => {\n const init = { status, headers: { ...headers } }\n\n if (isNullBodyStatus(status)) {\n return new Response(null, init)\n }\n\n if (body.kind === 'body') {\n return new Response(body.body, init)\n }\n\n return new Response(bodyReadable(body.chunks, body.fault), init)\n}\n\nconst unmatchedError = (request: HttpClientRequest.HttpClientRequest) =>\n new HttpClientError.HttpClientError({\n reason: new HttpClientError.TransportError({\n request,\n description: 'replay has no remaining recorded exchange'\n })\n })\n\nconst invalidRecordingError = (request: HttpClientRequest.HttpClientRequest, cause: unknown) =>\n new HttpClientError.HttpClientError({\n reason: new HttpClientError.TransportError({\n request,\n cause,\n description: 'recorded response could not be replayed'\n })\n })\n\n/**\n * Cause attached to the `HttpClientError` (reason `TransportError`) of a request\n * whose recording could not be replayed. Retrying clients may retry past it;\n * check the ledger for an `invalid` outcome.\n */\nexport class WireReplayInvalid extends Data.TaggedError('WireReplayInvalid')<{\n readonly reason: string\n}> {\n override get message(): string {\n return this.reason\n }\n}\n\n/**\n * Build a replay `HttpClient` and its ledger over the given fixtures. Each\n * call has independent consumption, attempt, and ledger state.\n */\nexport const makeReplayHttpClient = (\n fixtures: ReadonlyArray<WireFixture>,\n options: ReplayHttpClientOptions = {}\n): Effect.Effect<{ readonly client: HttpClient.HttpClient; readonly ledger: ReplayLedgerApi }> =>\n Effect.gen(function* () {\n const candidates = candidatesFrom(fixtures)\n const faults = options.faults ?? []\n\n const state = yield* Ref.make<ReplayState>({\n consumed: new Set(),\n attempts: new Map(),\n entries: []\n })\n\n const decide = (\n method: string,\n normalizedUrl: string,\n entry: Omit<LedgerEntryFields, 'attempt' | 'match' | 'fault'>\n ) =>\n Ref.modify(state, (current): [ReplayDecision, ReplayState] => {\n const key = requestKey(method, normalizedUrl)\n const attempt = (current.attempts.get(key) ?? 0) + 1\n const attempts = new Map(current.attempts).set(key, attempt)\n const fields: LedgerEntryFields = { ...entry, attempt, match: { outcome: 'unmatched' } }\n\n const candidate = candidates.find(\n item => item.key === key && !current.consumed.has(item.id)\n )\n\n // Fail closed first: faults never answer unknown or exhausted requests.\n if (candidate === undefined) {\n return [\n { kind: 'unmatched' },\n { ...current, attempts, entries: [...current.entries, fields] }\n ]\n }\n\n const statusFault = faults\n .filter(WireFault.$is('StatusOnAttempt'))\n .find(\n fault => fault.attempt === attempt && faultMatches(fault.match, method, normalizedUrl)\n )\n\n if (statusFault !== undefined) {\n fields.match = { outcome: 'injected' }\n fields.fault = statusFault._tag\n\n return [\n { kind: 'status', fault: statusFault },\n { ...current, attempts, entries: [...current.entries, fields] }\n ]\n }\n\n const response = candidate.exchange.response\n\n const chunkFault = faults\n .filter((fault): fault is ChunkFault => !WireFault.$is('StatusOnAttempt')(fault))\n .find(\n fault =>\n (fault.attempt === undefined || fault.attempt === attempt) &&\n faultMatches(fault.match, method, normalizedUrl)\n )\n\n const body = replayBody(response, chunkFault)\n const ref = { fixtureId: candidate.fixtureId, exchangeIndex: candidate.exchangeIndex }\n\n if (Result.isFailure(body)) {\n // The fault (or recording) did not apply: record the failure, not the\n // fault, and leave the exchange unconsumed.\n fields.match = { outcome: 'invalid', ...ref, reason: body.failure }\n\n return [\n { kind: 'invalid', reason: body.failure },\n { ...current, attempts, entries: [...current.entries, fields] }\n ]\n }\n\n fields.match = { outcome: 'matched', ...ref }\n\n if (chunkFault !== undefined) {\n fields.fault = chunkFault._tag\n }\n\n return [\n { kind: 'exchange', response, body: body.success },\n {\n consumed: new Set(current.consumed).add(candidate.id),\n attempts,\n entries: [...current.entries, fields]\n }\n ]\n })\n\n const client = HttpClient.make((request, url) =>\n Effect.gen(function* () {\n const normalizedUrl = normalizeWireUrl(url.toString())\n const bodyText = requestBodyText(request)\n\n const entry: Omit<LedgerEntryFields, 'attempt' | 'match' | 'fault'> = {\n method: request.method,\n url: normalizedUrl,\n headers: redactHeaders(headerRecord(request.headers))\n }\n\n if (bodyText !== undefined) {\n entry.bodyText = bodyText\n\n const bodyJson = yield* parseJsonText(bodyText)\n\n if (Option.isSome(bodyJson)) {\n entry.bodyJson = bodyJson.value\n }\n }\n\n const decision = yield* decide(request.method, normalizedUrl, entry)\n\n if (decision.kind === 'unmatched') {\n return yield* Effect.fail(unmatchedError(request))\n }\n\n if (decision.kind === 'invalid') {\n return yield* Effect.fail(\n invalidRecordingError(request, new WireReplayInvalid({ reason: decision.reason }))\n )\n }\n\n const source = yield* Effect.try({\n try: () =>\n decision.kind === 'status'\n ? webResponse(decision.fault.status, decision.fault.headers ?? {}, {\n kind: 'body',\n body: decision.fault.body ?? ''\n })\n : webResponse(decision.response.status, decision.response.headers, decision.body),\n catch: cause => invalidRecordingError(request, cause)\n })\n\n return HttpClientResponse.fromWeb(request, source)\n })\n )\n\n const ledger: ReplayLedgerApi = {\n entries: Ref.get(state).pipe(Effect.map(current => current.entries)),\n remaining: Ref.get(state).pipe(\n Effect.map(current =>\n candidates\n .filter(candidate => !current.consumed.has(candidate.id))\n .map(candidate => ({\n fixtureId: candidate.fixtureId,\n exchangeIndex: candidate.exchangeIndex\n }))\n )\n )\n }\n\n return { client, ledger }\n })\n\nexport const ReplayHttpClient = {\n /**\n * Layer providing a replay `HttpClient` and its `ReplayLedger`. Each layer\n * build gets fresh consumption and ledger state.\n */\n layer: (\n fixtures: ReadonlyArray<WireFixture>,\n options: ReplayHttpClientOptions = {}\n ): Layer.Layer<HttpClient.HttpClient | ReplayLedger> =>\n Layer.unwrap(\n makeReplayHttpClient(fixtures, options).pipe(\n Effect.map(({ client, ledger }) =>\n Layer.mergeAll(\n Layer.succeed(HttpClient.HttpClient, client),\n Layer.succeed(ReplayLedger, ledger)\n )\n )\n )\n )\n} as const\n"],"mappings":";;;;;;;;;;;;;;;;AAgHA,MAAa,YAAY,KAAK,WAAsB;;AAOpD,IAAa,qBAAb,cAAwC,KAAK,YAAY,oBAAoB,EAE1E;CACD,IAAa,UAAkB;EAC7B,OAAO,oCAAoC,KAAK,YAAY;CAC9D;AACF;AAiDA,IAAa,eAAb,cAAkC,QAAQ,QAAuC,EAC/E,oCACF,EAAE,CAAC;AAkDH,MAAM,cAAc,QAAgB,kBAClC,GAAG,OAAO,YAAY,EAAE,GAAG;AAE7B,MAAM,gBACJ,OACA,QACA,mBAEC,OAAO,WAAW,KAAA,KAAa,MAAM,OAAO,YAAY,MAAM,OAAO,YAAY,OACjF,OAAO,QAAQ,KAAA,KAAa,kBAAkB,MAAM,KAAK,aAAa;AAEzE,MAAM,kBAAkB,aAAmE;CACzF,MAAM,aAA+B,CAAC;CAEtC,KAAK,MAAM,WAAW,UACpB,QAAQ,UAAU,SAAS,UAAU,kBAAkB;EACrD,WAAW,KAAK;GACd,IAAI,WAAW;GACf,KAAK,WAAW,SAAS,QAAQ,QAAQ,iBAAiB,SAAS,QAAQ,GAAG,CAAC;GAC/E,WAAW,QAAQ;GACnB;GACA;EACF,CAAC;CACH,CAAC;CAGH,OAAO;AACT;AAEA,MAAM,cAAc,UAClB,UAAU,SAAS,KAAK,IACpB,OAAO,KAAK,IAAI,YAAY,EAAE,OAAO,KAAK,CAAC,IAC3C,kBAAkB,MAAM,MAAM;AAEpC,MAAM,qBAAqB,OAAmB,aAAyC;CACrF,IAAI,CAAC,OAAO,cAAc,MAAM,MAAM,KAAK,MAAM,SAAS,GACxD,OAAO,GAAG,MAAM,KAAK;CASvB,QAJgB,UAAU,IAAI,iBAAiB,EAAE,KAAK,IAClD,MAAM,UAAU,WAChB,MAAM,SAAS,YAGf,KAAA,IACA,GAAG,MAAM,KAAK,SAAS,MAAM,OAAO,4CAA4C,SAAS;AAC/F;;AAGA,MAAM,cACJ,UACA,UACsC;CACtC,IAAI,qBAAqB,QAAQ,GAAG;EAClC,MAAM,SAAS,OAAO,IAAI,SAAS,OAAO,IAAI,UAAU,CAAC;EAEzD,IAAI,OAAO,OAAO,MAAM,GACtB,OAAO,OAAO,KAAK,sCAAsC;EAG3D,MAAM,UAAU,UAAU,KAAA,IAAY,KAAA,IAAY,kBAAkB,OAAO,OAAO,MAAM,MAAM;EAE9F,OAAO,YAAY,KAAA,IACf,OAAO,QAAQ;GAAE,MAAM;GAAU,QAAQ,OAAO;GAAO;EAAM,CAAC,IAC9D,OAAO,KAAK,OAAO;CACzB;CAEA,IAAI,UAAU,KAAA,GACZ,OAAO,OAAO,KAAK,GAAG,MAAM,KAAK,uCAAuC;CAG1E,IAAI,yBAAyB,QAAQ,GACnC,OAAO,OAAO,MAAM,kBAAkB,SAAS,UAAU,GAAG;EAC1D,cAAc,OAAO,KAAK,6CAA6C;EAEvE,SAAQ,UAAS,OAAO,QAAQ;GAAE,MAAM;GAAQ,MAAM,IAAI,WAAW,KAAK;EAAE,CAAC;CAC/E,CAAC;CAGH,OAAO,OAAO,QAAQ;EAAE,MAAM;EAAQ,MAAM,SAAS;CAAK,CAAC;AAC7D;;;;;;;AAQA,MAAM,gBACJ,QACA,UAC+B;CAC/B,IAAI,OAAO;CACX,IAAI,eAAe;CACnB,IAAI,YAAY;CAChB,IAAI;CAEJ,MAAM,YAAY,eAAkE;EAClF,MAAM,QAAQ,OAAO;EAErB,IAAI,UAAU,KAAA,GAAW;GACvB,WAAW,MAAM;GAEjB;EACF;EAEA,QAAQ;EACR,WAAW,QAAQ,KAAK;CAC1B;CAEA,OAAO,IAAI,eACT;EACE,OAAM,eAAc;GAClB,IAAI,UAAU,KAAA,KAAa,gBAAgB,SAAS,MAAM,QAAQ;IAChE,SAAS,UAAU;IAEnB;GACF;GAEA,eAAe;GAEf,OAAO,MAAM,UAAU,OAAO;IAC5B,kBAAkB,EAAE,QAAQ,kBAC1B,WAAW,MAAM,IAAI,mBAAmB,EAAE,YAAY,CAAC,CAAC;IAC1D,2BAA2B,WAAW,MAAM;IAC5C,kBAAkB,EAAE,cAAc;KAGhC,MAAM,QAAQ,OAAO,QAAQ,OAAO;KACpC,OAAO;KAEP,IAAI,WAAW;MACb,OAAO,KAAA;MAEP,OAAO,OAAO,WAAW,MAAM,UAAU,KAAK,CAAC;KACjD;KAEA,OAAO,OAAO,WAAW,MAAM,MAAM,KAAK,CAAC,EAAE,MAAK,SAAQ;MACxD,OAAO,KAAA;MAEP,IAAI,WAAW;MAEf,IAAI,KAAK,UAAU,IAAI,GACrB,SAAS,UAAU;WAEnB,WAAW,MAAM,MAAM,OAAO,KAAK,KAAK,CAAC;KAE7C,CAAC;IACH;GACF,CAAC;EACH;EACA,cAAc;GACZ,YAAY;GAEZ,IAAI,SAAS,KAAA,GAAW;GAExB,OAAO,OAAO,WAAW,MAAM,UAAU,IAAI,CAAC;EAChD;CACF,GACA,EAAE,eAAe,EAAE,CACrB;AACF;AAEA,MAAM,eAAe,QAAgB,SAAsB,SAA+B;CACxF,MAAM,OAAO;EAAE;EAAQ,SAAS,EAAE,GAAG,QAAQ;CAAE;CAE/C,IAAI,iBAAiB,MAAM,GACzB,OAAO,IAAI,SAAS,MAAM,IAAI;CAGhC,IAAI,KAAK,SAAS,QAChB,OAAO,IAAI,SAAS,KAAK,MAAM,IAAI;CAGrC,OAAO,IAAI,SAAS,aAAa,KAAK,QAAQ,KAAK,KAAK,GAAG,IAAI;AACjE;AAEA,MAAM,kBAAkB,YACtB,IAAI,gBAAgB,gBAAgB,EAClC,QAAQ,IAAI,gBAAgB,eAAe;CACzC;CACA,aAAa;AACf,CAAC,EACH,CAAC;AAEH,MAAM,yBAAyB,SAA8C,UAC3E,IAAI,gBAAgB,gBAAgB,EAClC,QAAQ,IAAI,gBAAgB,eAAe;CACzC;CACA;CACA,aAAa;AACf,CAAC,EACH,CAAC;;;;;;AAOH,IAAa,oBAAb,cAAuC,KAAK,YAAY,mBAAmB,EAExE;CACD,IAAa,UAAkB;EAC7B,OAAO,KAAK;CACd;AACF;;;;;AAMA,MAAa,wBACX,UACA,UAAmC,CAAC,MAEpC,OAAO,IAAI,aAAa;CACtB,MAAM,aAAa,eAAe,QAAQ;CAC1C,MAAM,SAAS,QAAQ,UAAU,CAAC;CAElC,MAAM,QAAQ,OAAO,IAAI,KAAkB;EACzC,0BAAU,IAAI,IAAI;EAClB,0BAAU,IAAI,IAAI;EAClB,SAAS,CAAC;CACZ,CAAC;CAED,MAAM,UACJ,QACA,eACA,UAEA,IAAI,OAAO,QAAQ,YAA2C;EAC5D,MAAM,MAAM,WAAW,QAAQ,aAAa;EAC5C,MAAM,WAAW,QAAQ,SAAS,IAAI,GAAG,KAAK,KAAK;EACnD,MAAM,WAAW,IAAI,IAAI,QAAQ,QAAQ,EAAE,IAAI,KAAK,OAAO;EAC3D,MAAM,SAA4B;GAAE,GAAG;GAAO;GAAS,OAAO,EAAE,SAAS,YAAY;EAAE;EAEvF,MAAM,YAAY,WAAW,MAC3B,SAAQ,KAAK,QAAQ,OAAO,CAAC,QAAQ,SAAS,IAAI,KAAK,EAAE,CAC3D;EAGA,IAAI,cAAc,KAAA,GAChB,OAAO,CACL,EAAE,MAAM,YAAY,GACpB;GAAE,GAAG;GAAS;GAAU,SAAS,CAAC,GAAG,QAAQ,SAAS,MAAM;EAAE,CAChE;EAGF,MAAM,cAAc,OACjB,OAAO,UAAU,IAAI,iBAAiB,CAAC,EACvC,MACC,UAAS,MAAM,YAAY,WAAW,aAAa,MAAM,OAAO,QAAQ,aAAa,CACvF;EAEF,IAAI,gBAAgB,KAAA,GAAW;GAC7B,OAAO,QAAQ,EAAE,SAAS,WAAW;GACrC,OAAO,QAAQ,YAAY;GAE3B,OAAO,CACL;IAAE,MAAM;IAAU,OAAO;GAAY,GACrC;IAAE,GAAG;IAAS;IAAU,SAAS,CAAC,GAAG,QAAQ,SAAS,MAAM;GAAE,CAChE;EACF;EAEA,MAAM,WAAW,UAAU,SAAS;EAEpC,MAAM,aAAa,OAChB,QAAQ,UAA+B,CAAC,UAAU,IAAI,iBAAiB,EAAE,KAAK,CAAC,EAC/E,MACC,WACG,MAAM,YAAY,KAAA,KAAa,MAAM,YAAY,YAClD,aAAa,MAAM,OAAO,QAAQ,aAAa,CACnD;EAEF,MAAM,OAAO,WAAW,UAAU,UAAU;EAC5C,MAAM,MAAM;GAAE,WAAW,UAAU;GAAW,eAAe,UAAU;EAAc;EAErF,IAAI,OAAO,UAAU,IAAI,GAAG;GAG1B,OAAO,QAAQ;IAAE,SAAS;IAAW,GAAG;IAAK,QAAQ,KAAK;GAAQ;GAElE,OAAO,CACL;IAAE,MAAM;IAAW,QAAQ,KAAK;GAAQ,GACxC;IAAE,GAAG;IAAS;IAAU,SAAS,CAAC,GAAG,QAAQ,SAAS,MAAM;GAAE,CAChE;EACF;EAEA,OAAO,QAAQ;GAAE,SAAS;GAAW,GAAG;EAAI;EAE5C,IAAI,eAAe,KAAA,GACjB,OAAO,QAAQ,WAAW;EAG5B,OAAO,CACL;GAAE,MAAM;GAAY;GAAU,MAAM,KAAK;EAAQ,GACjD;GACE,UAAU,IAAI,IAAI,QAAQ,QAAQ,EAAE,IAAI,UAAU,EAAE;GACpD;GACA,SAAS,CAAC,GAAG,QAAQ,SAAS,MAAM;EACtC,CACF;CACF,CAAC;CAgEH,OAAO;EAAE,QA9DM,WAAW,MAAM,SAAS,QACvC,OAAO,IAAI,aAAa;GACtB,MAAM,gBAAgB,iBAAiB,IAAI,SAAS,CAAC;GACrD,MAAM,WAAW,gBAAgB,OAAO;GAExC,MAAM,QAAgE;IACpE,QAAQ,QAAQ;IAChB,KAAK;IACL,SAAS,cAAc,aAAa,QAAQ,OAAO,CAAC;GACtD;GAEA,IAAI,aAAa,KAAA,GAAW;IAC1B,MAAM,WAAW;IAEjB,MAAM,WAAW,OAAO,cAAc,QAAQ;IAE9C,IAAI,OAAO,OAAO,QAAQ,GACxB,MAAM,WAAW,SAAS;GAE9B;GAEA,MAAM,WAAW,OAAO,OAAO,QAAQ,QAAQ,eAAe,KAAK;GAEnE,IAAI,SAAS,SAAS,aACpB,OAAO,OAAO,OAAO,KAAK,eAAe,OAAO,CAAC;GAGnD,IAAI,SAAS,SAAS,WACpB,OAAO,OAAO,OAAO,KACnB,sBAAsB,SAAS,IAAI,kBAAkB,EAAE,QAAQ,SAAS,OAAO,CAAC,CAAC,CACnF;GAGF,MAAM,SAAS,OAAO,OAAO,IAAI;IAC/B,WACE,SAAS,SAAS,WACd,YAAY,SAAS,MAAM,QAAQ,SAAS,MAAM,WAAW,CAAC,GAAG;KAC/D,MAAM;KACN,MAAM,SAAS,MAAM,QAAQ;IAC/B,CAAC,IACD,YAAY,SAAS,SAAS,QAAQ,SAAS,SAAS,SAAS,SAAS,IAAI;IACpF,QAAO,UAAS,sBAAsB,SAAS,KAAK;GACtD,CAAC;GAED,OAAO,mBAAmB,QAAQ,SAAS,MAAM;EACnD,CAAC,CAiBW;EAAG,QAAA;GAbf,SAAS,IAAI,IAAI,KAAK,EAAE,KAAK,OAAO,KAAI,YAAW,QAAQ,OAAO,CAAC;GACnE,WAAW,IAAI,IAAI,KAAK,EAAE,KACxB,OAAO,KAAI,YACT,WACG,QAAO,cAAa,CAAC,QAAQ,SAAS,IAAI,UAAU,EAAE,CAAC,EACvD,KAAI,eAAc;IACjB,WAAW,UAAU;IACrB,eAAe,UAAU;GAC3B,EAAE,CACN,CACF;EAGoB;CAAE;AAC1B,CAAC;AAEH,MAAa,mBAAmB;;;;;AAK9B,QACE,UACA,UAAmC,CAAC,MAEpC,MAAM,OACJ,qBAAqB,UAAU,OAAO,EAAE,KACtC,OAAO,KAAK,EAAE,QAAQ,aACpB,MAAM,SACJ,MAAM,QAAQ,WAAW,YAAY,MAAM,GAC3C,MAAM,QAAQ,cAAc,MAAM,CACpC,CACF,CACF,CACF,EACJ"}
|
|
1
|
+
{"version":3,"file":"replay.mjs","names":[],"sources":["../src/replay.ts"],"sourcesContent":["/**\n * Offline replay of recorded wire fixtures as an Effect `HttpClient`.\n *\n * Replay never performs network I/O. Requests are matched by method and\n * normalized absolute URL (hash removed, query parameters sorted); each\n * recorded exchange is consumed once, in recorded order among exchanges with\n * the same method and URL. A request with no remaining match fails closed with\n * a typed `HttpClientError` and is still written to the ledger.\n *\n * @experimental\n */\nimport {\n Cause,\n Context,\n Data,\n Effect,\n Exit,\n Fiber,\n Layer,\n Match,\n Option,\n Predicate,\n Ref,\n Result\n} from 'effect'\nimport type * as Schema from 'effect/Schema'\nimport {\n HttpClient,\n HttpClientError,\n HttpClientResponse,\n type HttpClientRequest\n} from 'effect/http'\nimport {\n isWireBase64BodyResponse,\n isWireStreamResponse,\n type WireChunk,\n type WireExchange,\n type WireFixture,\n type WireHeaders,\n type WireResponse\n} from './fixture.ts'\nimport {\n decodeBase64Bytes,\n headerRecord,\n isNullBodyStatus,\n normalizeWireUrl,\n parseJsonText,\n redactHeaders,\n requestBodyText,\n urlMatchesPattern\n} from './wire-internal.ts'\n\n/**\n * Optional fault filter. `method` compares case-insensitively. `url` matches\n * the normalized request URL exactly, or as a prefix when it ends with `*`.\n * An omitted field matches every request.\n */\nexport type WireFaultMatch = {\n readonly method?: string\n readonly url?: string\n}\n\n/**\n * Wire faults injected by the replay client. `attempt` is 1-based and counted\n * per method + normalized URL.\n *\n * - `StatusOnAttempt`: respond with this status instead of consuming a recorded\n * exchange, so the next attempt still gets the recording. It fires only when\n * an unconsumed recorded exchange exists for the method + normalized URL;\n * unknown or exhausted requests still fail closed as `unmatched`.\n * - `FailAfterChunks`: emit the first `chunks` recorded chunks, then fail the\n * body stream the way a dropped connection does under `FetchHttpClient`.\n * - `TruncateAfterChunks`: emit the first `chunks` recorded chunks, then end\n * the body cleanly.\n * - `HoldAfterChunks`: emit the first `chunks` recorded chunks, run `release`,\n * then emit the rest.\n *\n * `attempt` omitted means every matching attempt. A chunk fault that cannot\n * take effect fails the request with a typed `HttpClientError` (and a ledger\n * `invalid` outcome) instead of silently doing nothing: any chunk fault matched\n * against a whole-body response, `TruncateAfterChunks`/`HoldAfterChunks` with\n * `chunks` >= the recorded chunk count, or `FailAfterChunks` with `chunks` >\n * the recorded chunk count. That error's reason is a `TransportError`, so\n * transient-retry policies may retry past it: assert the ledger has no\n * `invalid` outcome when testing through a retrying client.\n */\nexport type WireFault = Data.TaggedEnum<{\n StatusOnAttempt: {\n readonly match?: WireFaultMatch\n readonly attempt: number\n readonly status: number\n readonly headers?: WireHeaders\n readonly body?: string\n }\n FailAfterChunks: {\n readonly match?: WireFaultMatch\n readonly attempt?: number\n readonly chunks: number\n }\n TruncateAfterChunks: {\n readonly match?: WireFaultMatch\n readonly attempt?: number\n readonly chunks: number\n }\n HoldAfterChunks: {\n readonly match?: WireFaultMatch\n readonly attempt?: number\n readonly chunks: number\n readonly release: Effect.Effect<void>\n }\n}>\n\nexport const WireFault = Data.taggedEnum<WireFault>()\n\nexport type WireFaultTag = WireFault['_tag']\n\ntype ChunkFault = Exclude<WireFault, { readonly _tag: 'StatusOnAttempt' }>\n\n/** Cause attached to a body stream failed by `FailAfterChunks`. */\nexport class WireTransportFault extends Data.TaggedError('WireTransportFault')<{\n readonly afterChunks: number\n}> {\n override get message(): string {\n return `injected transport failure after ${this.afterChunks} chunks`\n }\n}\n\n/**\n * How a replayed request was answered: a recorded exchange, a response\n * injected by a `StatusOnAttempt` fault, nothing (fail closed), or a matched\n * exchange that could not be replayed (`invalid`: a chunk fault that cannot\n * take effect, or undecodable base64). `invalid` requests fail with a typed\n * `HttpClientError` and do not consume the exchange.\n */\nexport type ReplayLedgerMatch =\n | { readonly outcome: 'matched'; readonly fixtureId: string; readonly exchangeIndex: number }\n | { readonly outcome: 'injected' }\n | { readonly outcome: 'unmatched' }\n | {\n readonly outcome: 'invalid'\n readonly fixtureId: string\n readonly exchangeIndex: number\n /** Why the recording could not be replayed; never contains request or response data. */\n readonly reason: string\n }\n\nexport type ReplayLedgerEntry = {\n readonly method: string\n /** Normalized absolute URL. */\n readonly url: string\n /** Request headers with credential headers replaced by `<redacted>`. */\n readonly headers: Readonly<Record<string, string>>\n readonly bodyText?: string\n /** Parsed request body when `bodyText` is valid JSON. */\n readonly bodyJson?: Schema.Json\n /** 1-based attempt number for this method + URL. */\n readonly attempt: number\n readonly match: ReplayLedgerMatch\n /** Tag of the fault that shaped this response, if any (never set for a fault that did not apply). */\n readonly fault?: WireFaultTag\n}\n\nexport type ReplayExchangeRef = {\n readonly fixtureId: string\n readonly exchangeIndex: number\n}\n\nexport type ReplayLedgerApi = {\n /** Every request seen by the replay client, in arrival order. */\n readonly entries: Effect.Effect<ReadonlyArray<ReplayLedgerEntry>>\n /** Recorded exchanges not consumed yet, in fixture order. */\n readonly remaining: Effect.Effect<ReadonlyArray<ReplayExchangeRef>>\n}\n\nexport class ReplayLedger extends Context.Service<ReplayLedger, ReplayLedgerApi>()(\n '@yolk-sdk/conformance/ReplayLedger'\n) {}\n\nexport type ReplayHttpClientOptions = {\n readonly faults?: ReadonlyArray<WireFault>\n}\n\ntype Candidate = {\n readonly id: number\n readonly key: string\n readonly fixtureId: string\n readonly exchangeIndex: number\n readonly exchange: WireExchange\n}\n\ntype ReplayState = {\n readonly consumed: ReadonlySet<number>\n readonly attempts: ReadonlyMap<string, number>\n readonly entries: ReadonlyArray<ReplayLedgerEntry>\n}\n\n// Bytes to replay: one whole body, or one Uint8Array per recorded chunk.\ntype ReplayBody =\n | { readonly kind: 'body'; readonly body: string | Uint8Array<ArrayBuffer> }\n | {\n readonly kind: 'chunks'\n readonly chunks: ReadonlyArray<Uint8Array>\n readonly fault: ChunkFault | undefined\n }\n\ntype ReplayDecision =\n | { readonly kind: 'status'; readonly fault: Extract<WireFault, { _tag: 'StatusOnAttempt' }> }\n | {\n readonly kind: 'exchange'\n readonly response: WireResponse\n readonly body: ReplayBody\n }\n | { readonly kind: 'invalid'; readonly reason: string }\n | { readonly kind: 'unmatched' }\n\ntype LedgerEntryFields = {\n method: string\n url: string\n headers: Readonly<Record<string, string>>\n bodyText?: string\n bodyJson?: Schema.Json\n attempt: number\n match: ReplayLedgerMatch\n fault?: WireFaultTag\n}\n\nconst requestKey = (method: string, normalizedUrl: string) =>\n `${method.toUpperCase()} ${normalizedUrl}`\n\nconst faultMatches = (\n match: WireFaultMatch | undefined,\n method: string,\n normalizedUrl: string\n): boolean =>\n (match?.method === undefined || match.method.toUpperCase() === method.toUpperCase()) &&\n (match?.url === undefined || urlMatchesPattern(match.url, normalizedUrl))\n\nconst candidatesFrom = (fixtures: ReadonlyArray<WireFixture>): ReadonlyArray<Candidate> => {\n const candidates: Array<Candidate> = []\n\n for (const fixture of fixtures) {\n fixture.exchanges.forEach((exchange, exchangeIndex) => {\n candidates.push({\n id: candidates.length,\n key: requestKey(exchange.request.method, normalizeWireUrl(exchange.request.url)),\n fixtureId: fixture.id,\n exchangeIndex,\n exchange\n })\n })\n }\n\n return candidates\n}\n\nconst chunkBytes = (chunk: WireChunk): Option.Option<Uint8Array> =>\n Predicate.isString(chunk)\n ? Option.some(new TextEncoder().encode(chunk))\n : decodeBase64Bytes(chunk.base64)\n\nconst chunkFaultProblem = (fault: ChunkFault, recorded: number): string | undefined => {\n if (!Number.isSafeInteger(fault.chunks) || fault.chunks < 0) {\n return `${fault._tag} needs a non-negative integer chunk count`\n }\n\n // `FailAfterChunks` may fail after the last chunk; truncating or holding\n // there would change nothing.\n const applies = WireFault.$is('FailAfterChunks')(fault)\n ? fault.chunks <= recorded\n : fault.chunks < recorded\n\n return applies\n ? undefined\n : `${fault._tag} after ${fault.chunks} chunk(s) cannot apply to a response with ${recorded} recorded chunk(s)`\n}\n\n/** Resolve the exact bytes to replay, or why the recording cannot be replayed as requested. */\nconst replayBody = (\n response: WireResponse,\n fault: ChunkFault | undefined\n): Result.Result<ReplayBody, string> => {\n if (isWireStreamResponse(response)) {\n const chunks = Option.all(response.chunks.map(chunkBytes))\n\n if (Option.isNone(chunks)) {\n return Result.fail('a recorded chunk is not valid base64')\n }\n\n const problem = fault === undefined ? undefined : chunkFaultProblem(fault, chunks.value.length)\n\n return problem === undefined\n ? Result.succeed({ kind: 'chunks', chunks: chunks.value, fault })\n : Result.fail(problem)\n }\n\n if (fault !== undefined) {\n return Result.fail(`${fault._tag} cannot apply to a whole-body response`)\n }\n\n if (isWireBase64BodyResponse(response)) {\n return Option.match(decodeBase64Bytes(response.bodyBase64), {\n onNone: () => Result.fail('the recorded bodyBase64 is not valid base64'),\n // Copy into an ArrayBuffer-backed view, as `Response` requires.\n onSome: bytes => Result.succeed({ kind: 'body', body: new Uint8Array(bytes) })\n })\n }\n\n return Result.succeed({ kind: 'body', body: response.body })\n}\n\n/**\n * Strictly pull-driven body: one `Uint8Array` per recorded chunk (including\n * empty ones), and chunk `k` is produced only when the consumer pulls it,\n * never ahead of demand. The fault (already validated against the chunk count)\n * applies when the consumer pulls past `fault.chunks` chunks.\n */\nconst bodyReadable = (\n chunks: ReadonlyArray<Uint8Array>,\n fault: ChunkFault | undefined\n): ReadableStream<Uint8Array> => {\n let next = 0\n let faultApplied = false\n let cancelled = false\n let held: Fiber.Fiber<void> | undefined\n\n const emitNext = (controller: ReadableStreamDefaultController<Uint8Array>): void => {\n const chunk = chunks[next]\n\n if (chunk === undefined) {\n controller.close()\n\n return\n }\n\n next += 1\n controller.enqueue(chunk)\n }\n\n return new ReadableStream<Uint8Array>(\n {\n pull: controller => {\n if (fault === undefined || faultApplied || next !== fault.chunks) {\n emitNext(controller)\n\n return\n }\n\n faultApplied = true\n\n return Match.valueTags(fault, {\n FailAfterChunks: ({ chunks: afterChunks }) =>\n controller.error(new WireTransportFault({ afterChunks })),\n TruncateAfterChunks: () => controller.close(),\n HoldAfterChunks: ({ release }) => {\n // Keep the release fiber so cancelling the body interrupts it (and runs its finalizers).\n // `runFork` starts `release` synchronously, so a cancel can land before `held` is set.\n const fiber = Effect.runFork(release)\n held = fiber\n\n if (cancelled) {\n held = undefined\n\n return Effect.runPromise(Fiber.interrupt(fiber))\n }\n\n return Effect.runPromise(Fiber.await(fiber)).then(exit => {\n held = undefined\n\n if (cancelled) return\n\n if (Exit.isSuccess(exit)) {\n emitNext(controller)\n } else {\n controller.error(Cause.squash(exit.cause))\n }\n })\n }\n })\n },\n cancel: () => {\n cancelled = true\n\n if (held === undefined) return\n\n return Effect.runPromise(Fiber.interrupt(held))\n }\n },\n { highWaterMark: 0 }\n )\n}\n\nconst webResponse = (status: number, headers: WireHeaders, body: ReplayBody): Response => {\n const init = { status, headers: { ...headers } }\n\n if (isNullBodyStatus(status)) {\n return new Response(null, init)\n }\n\n if (body.kind === 'body') {\n return new Response(body.body, init)\n }\n\n return new Response(bodyReadable(body.chunks, body.fault), init)\n}\n\nconst unmatchedError = (request: HttpClientRequest.HttpClientRequest) =>\n new HttpClientError.HttpClientError({\n reason: new HttpClientError.TransportError({\n request,\n description: 'replay has no remaining recorded exchange'\n })\n })\n\nconst invalidRecordingError = (request: HttpClientRequest.HttpClientRequest, cause: unknown) =>\n new HttpClientError.HttpClientError({\n reason: new HttpClientError.TransportError({\n request,\n cause,\n description: 'recorded response could not be replayed'\n })\n })\n\n/**\n * Cause attached to the `HttpClientError` (reason `TransportError`) of a request\n * whose recording could not be replayed. Retrying clients may retry past it;\n * check the ledger for an `invalid` outcome.\n */\nexport class WireReplayInvalid extends Data.TaggedError('WireReplayInvalid')<{\n readonly reason: string\n}> {\n override get message(): string {\n return this.reason\n }\n}\n\n/**\n * Build a replay `HttpClient` and its ledger over the given fixtures. Each\n * call has independent consumption, attempt, and ledger state.\n */\nexport const makeReplayHttpClient = (\n fixtures: ReadonlyArray<WireFixture>,\n options: ReplayHttpClientOptions = {}\n): Effect.Effect<{ readonly client: HttpClient.HttpClient; readonly ledger: ReplayLedgerApi }> =>\n Effect.gen(function* () {\n const candidates = candidatesFrom(fixtures)\n const faults = options.faults ?? []\n\n const state = yield* Ref.make<ReplayState>({\n consumed: new Set(),\n attempts: new Map(),\n entries: []\n })\n\n const decide = (\n method: string,\n normalizedUrl: string,\n entry: Omit<LedgerEntryFields, 'attempt' | 'match' | 'fault'>\n ) =>\n Ref.modify(state, (current): [ReplayDecision, ReplayState] => {\n const key = requestKey(method, normalizedUrl)\n const attempt = (current.attempts.get(key) ?? 0) + 1\n const attempts = new Map(current.attempts).set(key, attempt)\n const fields: LedgerEntryFields = { ...entry, attempt, match: { outcome: 'unmatched' } }\n\n const candidate = candidates.find(\n item => item.key === key && !current.consumed.has(item.id)\n )\n\n // Fail closed first: faults never answer unknown or exhausted requests.\n if (candidate === undefined) {\n return [\n { kind: 'unmatched' },\n { ...current, attempts, entries: [...current.entries, fields] }\n ]\n }\n\n const statusFault = faults\n .filter(WireFault.$is('StatusOnAttempt'))\n .find(\n fault => fault.attempt === attempt && faultMatches(fault.match, method, normalizedUrl)\n )\n\n if (statusFault !== undefined) {\n fields.match = { outcome: 'injected' }\n fields.fault = statusFault._tag\n\n return [\n { kind: 'status', fault: statusFault },\n { ...current, attempts, entries: [...current.entries, fields] }\n ]\n }\n\n const response = candidate.exchange.response\n\n const chunkFault = faults\n .filter((fault): fault is ChunkFault => !WireFault.$is('StatusOnAttempt')(fault))\n .find(\n fault =>\n (fault.attempt === undefined || fault.attempt === attempt) &&\n faultMatches(fault.match, method, normalizedUrl)\n )\n\n const body = replayBody(response, chunkFault)\n const ref = { fixtureId: candidate.fixtureId, exchangeIndex: candidate.exchangeIndex }\n\n if (Result.isFailure(body)) {\n // The fault (or recording) did not apply: record the failure, not the\n // fault, and leave the exchange unconsumed.\n fields.match = { outcome: 'invalid', ...ref, reason: body.failure }\n\n return [\n { kind: 'invalid', reason: body.failure },\n { ...current, attempts, entries: [...current.entries, fields] }\n ]\n }\n\n fields.match = { outcome: 'matched', ...ref }\n\n if (chunkFault !== undefined) {\n fields.fault = chunkFault._tag\n }\n\n return [\n { kind: 'exchange', response, body: body.success },\n {\n consumed: new Set(current.consumed).add(candidate.id),\n attempts,\n entries: [...current.entries, fields]\n }\n ]\n })\n\n const client = HttpClient.make((request, url) =>\n Effect.gen(function* () {\n const normalizedUrl = normalizeWireUrl(url.toString())\n const bodyText = requestBodyText(request)\n\n const entry: Omit<LedgerEntryFields, 'attempt' | 'match' | 'fault'> = {\n method: request.method,\n url: normalizedUrl,\n headers: redactHeaders(headerRecord(request.headers))\n }\n\n if (bodyText !== undefined) {\n entry.bodyText = bodyText\n\n const bodyJson = yield* parseJsonText(bodyText)\n\n if (Option.isSome(bodyJson)) {\n entry.bodyJson = bodyJson.value\n }\n }\n\n const decision = yield* decide(request.method, normalizedUrl, entry)\n\n if (decision.kind === 'unmatched') {\n return yield* Effect.fail(unmatchedError(request))\n }\n\n if (decision.kind === 'invalid') {\n return yield* Effect.fail(\n invalidRecordingError(request, new WireReplayInvalid({ reason: decision.reason }))\n )\n }\n\n const source = yield* Effect.try({\n try: () =>\n decision.kind === 'status'\n ? webResponse(decision.fault.status, decision.fault.headers ?? {}, {\n kind: 'body',\n body: decision.fault.body ?? ''\n })\n : webResponse(decision.response.status, decision.response.headers, decision.body),\n catch: cause => invalidRecordingError(request, cause)\n })\n\n return HttpClientResponse.fromWeb(request, source)\n })\n )\n\n const ledger: ReplayLedgerApi = {\n entries: Ref.get(state).pipe(Effect.map(current => current.entries)),\n remaining: Ref.get(state).pipe(\n Effect.map(current =>\n candidates\n .filter(candidate => !current.consumed.has(candidate.id))\n .map(candidate => ({\n fixtureId: candidate.fixtureId,\n exchangeIndex: candidate.exchangeIndex\n }))\n )\n )\n }\n\n return { client, ledger }\n })\n\nexport const ReplayHttpClient = {\n /**\n * Layer providing a replay `HttpClient` and its `ReplayLedger`. Each layer\n * build gets fresh consumption and ledger state.\n */\n layer: (\n fixtures: ReadonlyArray<WireFixture>,\n options: ReplayHttpClientOptions = {}\n ): Layer.Layer<HttpClient.HttpClient | ReplayLedger> =>\n Layer.unwrap(\n makeReplayHttpClient(fixtures, options).pipe(\n Effect.map(({ client, ledger }) =>\n Layer.mergeAll(\n Layer.succeed(HttpClient.HttpClient, client),\n Layer.succeed(ReplayLedger, ledger)\n )\n )\n )\n )\n} as const\n"],"mappings":"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"}
|
package/dist/wire-internal.d.mts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wire-internal.d.mts","names":[],"sources":["../src/wire-internal.ts"],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"wire-internal.d.mts","names":[],"sources":["../src/wire-internal.ts"],"mappings":";;;;;;;AAiCA;;cAAa,sBAAA,GAA0B,IAAY;AAAA,cAUtC,mBAAA;AAVsC;AAAA,cAgBtC,aAAA,EAAa,MAAuC;;cAwBpD,cAAA,EAAgB,aAAa,CAAC,MAAA;;AA9BX;AAMhC;;cAiCa,sBAAA,EAAsB,MAAgE;;AAjClC;AAwBjE;;;;AAAiD;AASjD;;;cAqBa,6BAAA,EAA6B,QAAA;;;;;;;;;;;AAwC1C;cAAa,sBAAA,GAA0B,IAAY;;cAkBtC,sBAAA,EAAsB,MAAiD;AAlBjC;AAkBnD;;;;AAAoF;AAmFpF;;;;AArGmD,cAqGtC,oBAAA,GAAwB,IAAY;AAAA,cAsBpC,aAAA,GAAiB,OAAA,EAAS,QAAA,CAAS,MAAA,sBAAuB,MAAA;;;;;cAsB1D,gBAAA,GAAoB,KAAa;;;;;cA4BjC,iBAAA,GAAqB,OAAA,UAAiB,aAAqB;;cAM3D,eAAA,GACX,OAAA,EAAS,iBAAA,CAAkB,iBAAiB;AAzDyB;AAAA,cAyE1D,iBAAA,GAAqB,IAAA,aAAe,MAAA,CAAO,MAAM,CAAC,UAAA;;;;AAnDjB;cA0DjC,gBAAA,GAAoB,KAAA,EAAO,UAAA,KAAa,MAAA,CAAO,MAAM;;cAMrD,WAAA,GACX,KAAA,EAAO,UAAU;EAAA,SACL,IAAA;AAAA;EAAA,SAA4B,MAAA;AAAA;;cAS7B,aAAA,GAAiB,IAAA,aAAe,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,IAAA;AAAA,cAGnE,YAAA,GAAgB,OAAA,EAAS,QAAA,CAAS,MAAA,kCAAmC,MAAA;AAAA,cAYrE,SAAA,GAAa,WAA+B;AAAA,cAM5C,gBAAA,GAAoB,MAAc"}
|
package/dist/wire-internal.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Effect,
|
|
1
|
+
import { Effect, Option, Predicate, Result } from "effect";
|
|
2
2
|
import * as Schema from "effect/Schema";
|
|
3
|
+
import * as Base64 from "effect/encoding/Base64";
|
|
3
4
|
//#region src/wire-internal.ts
|
|
4
5
|
const credentialHeaderNames = new Set([
|
|
5
6
|
"authorization",
|
|
@@ -41,6 +42,7 @@ const tokenPrefixPatterns = [
|
|
|
41
42
|
/\bAKIA[0-9A-Z]{16}\b/,
|
|
42
43
|
/\bAIza[0-9A-Za-z_-]{35}/,
|
|
43
44
|
/\bxox[abprs]-[A-Za-z0-9-]{10,}/,
|
|
45
|
+
/\bafloat_[A-Za-z0-9]{16,}/,
|
|
44
46
|
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/
|
|
45
47
|
];
|
|
46
48
|
/** Common API-key prefixes, JSON Web Tokens, and PEM private keys. */
|
|
@@ -175,7 +177,7 @@ const requestBodyText = (request) => {
|
|
|
175
177
|
if (Predicate.isTagged(body, "Raw") && Predicate.isString(body.body)) return body.body;
|
|
176
178
|
};
|
|
177
179
|
/** Exact bytes of standard base64 text; `None` when the text is not valid base64. */
|
|
178
|
-
const decodeBase64Bytes = (text) => Result.getSuccess(
|
|
180
|
+
const decodeBase64Bytes = (text) => Result.getSuccess(Base64.decode(text));
|
|
179
181
|
/**
|
|
180
182
|
* Decode bytes as UTF-8 only when they are valid UTF-8 on their own. A leading
|
|
181
183
|
* byte-order mark is kept so that re-encoding yields the same bytes.
|
|
@@ -186,7 +188,7 @@ const decodeUtf8Strict = (bytes) => Option.liftThrowable(() => new TextDecoder("
|
|
|
186
188
|
}).decode(bytes))();
|
|
187
189
|
/** Lossless recording of bytes: readable text when valid UTF-8, otherwise base64. */
|
|
188
190
|
const recordBytes = (bytes) => Option.match(decodeUtf8Strict(bytes), {
|
|
189
|
-
onNone: () => ({ base64:
|
|
191
|
+
onNone: () => ({ base64: Base64.encode(bytes) }),
|
|
190
192
|
onSome: (text) => ({ text })
|
|
191
193
|
});
|
|
192
194
|
const decodeJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wire-internal.mjs","names":[],"sources":["../src/wire-internal.ts"],"sourcesContent":["import { Effect, Encoding, Option, Predicate, Result } from 'effect'\nimport * as Schema from 'effect/Schema'\nimport type { HttpClientRequest } from 'effect/unstable/http'\n\n// Header names that carry credentials or session state. Fixtures must never\n// contain them and replay ledgers redact them.\nconst credentialHeaderNames: ReadonlySet<string> = new Set([\n 'authorization',\n 'proxy-authorization',\n 'cookie',\n 'set-cookie',\n 'x-api-key',\n 'api-key',\n 'x-goog-api-key',\n 'x-auth-token',\n 'x-access-token',\n 'x-amz-security-token',\n 'x-vercel-oidc-token',\n 'x-csrf-token'\n])\n\nconst credentialHeaderPattern = /(api[-_]?key|secret|password|cookie|authorization)/i\n\n// A `-`/`_`-separated name segment that is exactly `token`, `key`, or `auth`\n// (`x-auth-token`, `private-token`, `x-figma-token`, `x-*-key`). Plural\n// segments such as `x-ratelimit-remaining-tokens` do not match.\nconst credentialHeaderSegmentPattern = /(^|[-_])(token|key|auth)([-_]|$)/i\n\n/**\n * True for header names that carry credentials or session state. Used by the\n * fixture secret scan, recorder drop rules, and ledger redaction.\n */\nexport const isCredentialHeaderName = (name: string): boolean => {\n const lower = name.toLowerCase()\n\n return (\n credentialHeaderNames.has(lower) ||\n credentialHeaderPattern.test(lower) ||\n credentialHeaderSegmentPattern.test(lower)\n )\n}\n\nexport const redactedHeaderValue = '<redacted>'\n\n// Credential text patterns. The fixture secret scan (`scanFixtureForSecrets`) tests them and the\n// runner's report sanitizer redacts with them, so both share this one definition.\n\n/** `Bearer <token>` with a token-shaped value. */\nexport const bearerPattern = /\\bbearer\\s+[A-Za-z0-9._~+/=-]{8,}/i\n\n/** PEM private key header. */\nconst privateKeyPattern = /-----BEGIN [A-Z ]*PRIVATE KEY-----/\n\n/** Common API-key prefixes and JSON Web Tokens. */\nconst tokenPrefixPatterns: ReadonlyArray<RegExp> = [\n // OpenAI/Anthropic/DeepSeek-style secret keys (sk-..., sk-ant-..., sk-proj-...)\n /\\bsk-[A-Za-z0-9_-]{16,}/,\n /\\b[sr]k_(live|test)_[A-Za-z0-9]{16,}/,\n /\\bxai-[A-Za-z0-9]{20,}/,\n /\\bvck_[A-Za-z0-9]{16,}/,\n /\\bgh[pousr]_[A-Za-z0-9]{20,}/,\n /\\bgithub_pat_[A-Za-z0-9_]{20,}/,\n /\\bAKIA[0-9A-Z]{16}\\b/,\n /\\bAIza[0-9A-Za-z_-]{35}/,\n /\\bxox[abprs]-[A-Za-z0-9-]{10,}/,\n // JSON Web Tokens (OIDC/OAuth access tokens)\n /\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/\n]\n\n/** Common API-key prefixes, JSON Web Tokens, and PEM private keys. */\nexport const apiKeyPatterns: ReadonlyArray<RegExp> = [...tokenPrefixPatterns, privateKeyPattern]\n\nconst credentialParamNames =\n 'api[_-]?key|key|token|access[_-]?token|refresh[_-]?token|id[_-]?token|auth|secret|password|client[_-]?secret|x-amz-signature|x-amz-credential|x-amz-security-token'\n\n/**\n * Query-string or form-encoded credential parameter, anchored at the start of\n * the text or after `?`/`&` (URLs and `application/x-www-form-urlencoded` bodies).\n */\nexport const credentialParamPattern = new RegExp(`(?:^|[?&])(${credentialParamNames})=[^&#]+`, 'i')\n\n// Every `name=` of a credential parameter, found on its own: the pattern stops at `=` and never\n// consumes the value, so a later `?name=` or `&name=` is always found, whatever the value holds.\nconst credentialParamNamesAt = new RegExp(`(?:^|[?&])(${credentialParamNames})=`, 'gi')\n\n// What ends a raw parameter value: a character that cannot appear raw inside a URL query value\n// (RFC 3986): `&`, `#`, whitespace, `\"`, `<`, `>`. `?` and `'` are NOT boundaries (both are legal\n// raw inside a query value), and neither is any percent-encoded delimiter.\nconst valueBoundary = /[&#\\s\"<>]/\n\n/**\n * The exact synthetic credential values a `PortFixture` may carry, keyed by lower-case parameter\n * name: SigV4 presigned-URL placeholders for S3-compatible ports (the R2 conformance fixtures sign\n * with them). `scanPortFixtureForSecrets` exempts a credential parameter only when its name is a\n * key here and its whole raw value (up to `&`, `#`, whitespace, `\"`, `<`, `>`, or the end),\n * percent-decoded, equals that key's value exactly; anything else inside the value (a raw `?`, an\n * encoded delimiter, any suffix), another parameter name, or another scope is flagged.\n * `scanFixtureForSecrets` exempts nothing. Never build a placeholder by prefixing or suffixing a\n * real value.\n */\nexport const syntheticPortCredentialParams = Object.freeze({\n 'x-amz-signature': 'yolk-synthetic-signature',\n 'x-amz-credential': 'yolk-synthetic-access-key-id/20260930/auto/s3/aws4_request'\n})\n\nconst exactPlaceholders = new Map<string, string>(Object.entries(syntheticPortCredentialParams))\n\n/**\n * `text` with `%XX` escapes decoded, repeatedly (at most three layers, then left as it is). The R2\n * guard (`findR2PortFixtureSecrets` in `@yolk-sdk/connectors`) applies at most the same three\n * percent-decoding rounds (plus its escape rounds): keep the percent depth in step.\n */\nconst percentDecodedLayers = (text: string): string => {\n let current = text\n\n for (let round = 0; round < 3 && /%[0-9A-Fa-f]{2}/.test(current); round++) {\n current = current.replace(/%([0-9A-Fa-f]{2})/g, (_match, hex: string) =>\n String.fromCharCode(Number.parseInt(hex, 16))\n )\n }\n\n return current\n}\n\n/** The raw, undecoded value starting at `start`: up to the first structural boundary. */\nconst rawParamValueAt = (text: string, start: number): string => {\n const rest = text.slice(start)\n const end = rest.search(valueBoundary)\n\n return end === -1 ? rest : rest.slice(0, end)\n}\n\n/**\n * True when `text` carries a credential query or form parameter that is not an exact synthetic\n * placeholder (`syntheticPortCredentialParams`). Every `name=` is found and judged on its own: its\n * raw value runs to the next structural boundary (see `rawParamValueAt`), is percent-decoded\n * (`percentDecodedLayers`), and is exempt only when it equals that name's placeholder exactly. It\n * flags at least whatever `credentialParamPattern` flags (a `name=` followed by any character but\n * `&` or `#`), except the exact placeholders.\n */\nexport const hasLiveCredentialParam = (text: string): boolean =>\n [...text.matchAll(credentialParamNamesAt)].some(match => {\n const name = (match[1] ?? '').toLowerCase()\n const start = match.index + match[0].length\n const value = rawParamValueAt(text, start)\n const next = text.charAt(start)\n const present = value.length > 0 || (next !== '' && next !== '&' && next !== '#')\n\n return present && exactPlaceholders.get(name) !== percentDecodedLayers(value)\n })\n\n// Singular credential field names (snake, kebab, or camel case), including the AWS-style\n// `accessKeyId` / `secretAccessKey` / `sessionToken` of S3-compatible signing inputs. Plural usage\n// counters such as `max_tokens` or `prompt_tokens` never match.\nconst credentialFieldNames =\n '(?:access|refresh|id|auth|api|session|private|bearer|oauth)[_-]?token|token|client[_-]?secret|secret(?:[_-]?key)?|private[_-]?key|password|passwd|api[_-]?key|access[_-]?key[_-]?id|secret[_-]?access[_-]?key|authorization'\n\n/** A JSON object key (or similar field name) that holds a credential. */\nexport const credentialFieldPattern = new RegExp(`^(${credentialFieldNames})$`, 'i')\n\nconst globally = (pattern: RegExp): RegExp =>\n new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)\n\n// Redaction forms of the patterns above. They are deliberately broader than the scan (any bearer\n// value, parameters after whitespace or punctuation, `name: value` field pairs) because\n// over-redacting a report message is harmless.\nconst bearerRedaction = /\\bbearer\\s+\\S+/gi\n\nconst apiKeyRedactions: ReadonlyArray<RegExp> = [\n ...tokenPrefixPatterns.map(globally),\n /-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g\n]\n\nconst credentialParamRedaction = new RegExp(\n `(^|[?&\\\\s;,(])(${credentialParamNames})=[^&#\\\\s;,)]+`,\n 'gi'\n)\n\n// `name: value` / `\"name\": \"value\"` / `name=value`. An unquoted value may carry an auth scheme\n// (`Authorization: Basic <token>`); a value already redacted as `Bearer <redacted>` is kept.\nconst credentialFieldRedaction = new RegExp(\n `(^|[^A-Za-z0-9_-])([\"']?)(${credentialFieldNames})\\\\2(\\\\s*[:=]\\\\s*)(?!bearer <redacted>)(?:\"(?:[^\"\\\\\\\\]|\\\\\\\\.)*\"?|'[^']*'?|(?:(?:basic|bearer|digest|negotiate|token)\\\\s+)?[^\\\\s,;&}\\\\]]+)`,\n 'gi'\n)\n\n// Candidate unquoted `Name:` header-like names inside one line (colon only). Quoted keys such\n// as `\"x-api-key\": \"...\"` are handled by `quotedCredentialHeaderRedaction`; `name=value` pairs\n// are left to the quote-aware field and parameter passes.\nconst headerLikeNamePattern = /(?:^|[^A-Za-z0-9_-])([A-Za-z][A-Za-z0-9_-]*)\\s*:/g\n\n/**\n * Redact the rest of a line after the first header-like `Name:` that `isCredentialHeaderName`\n * accepts (`X-Api-Key: ...`, `Proxy-Authorization: Basic ...`, `Cookie: ...`), so every header the\n * fixture scan flags is also redacted in report text. Runs on the raw text before any other pass,\n * so a partially matched value (for example a key-shaped cookie name) never shields the rest of\n * the line.\n */\nconst redactCredentialHeaderLines = (text: string): string =>\n text\n .split(/(\\r\\n|\\r|\\n)/)\n .map(line => {\n headerLikeNamePattern.lastIndex = 0\n\n for (let match = headerLikeNamePattern.exec(line); match !== null;) {\n const name = match[1]\n\n if (name !== undefined && isCredentialHeaderName(name)) {\n return `${line.slice(0, match.index + match[0].length)} ${redactedHeaderValue}`\n }\n\n match = headerLikeNamePattern.exec(line)\n }\n\n return line\n })\n .join('')\n\n// A quoted key that `isCredentialHeaderName` accepts (`\"x-api-key\": \"...\"`,\n// `'Proxy-Authorization': '...'`): only the value is redacted, so JSON stays balanced.\nconst quotedCredentialHeaderRedaction =\n /([\"'])([A-Za-z][A-Za-z0-9_-]*)\\1(\\s*:\\s*)(\"(?:[^\"\\\\]|\\\\.)*\"?|'[^']*'?|[^\\s,;&{}[\\]]+)/g\n\nconst redactQuotedCredentialHeaders = (text: string): string =>\n text.replace(\n quotedCredentialHeaderRedaction,\n (match, quote: string, name: string, separator: string) =>\n isCredentialHeaderName(name)\n ? `${quote}${name}${quote}${separator}${redactedHeaderValue}`\n : match\n )\n\n/**\n * Redact every credential pattern shared with the fixture secret scan: credential header lines\n * (first, to the end of the line), quoted credential header keys (value only), bearer tokens,\n * API-key prefixes, JWTs, private keys, credential field pairs (`\"api_key\": \"...\"`,\n * `password=\"...\"`), and credential query/form parameters. Best effort; used on report messages.\n * Known gap: escaped JSON inside a quoted string value (`\"body\":\"{\\\\\"x-api-key\\\\\":...}\"`) is not\n * parsed.\n * Quote-aware field redaction runs before parameter redaction so a quoted value with spaces is\n * removed whole.\n */\nexport const redactCredentialText = (text: string): string => {\n let result = redactCredentialHeaderLines(text).replace(\n bearerRedaction,\n `Bearer ${redactedHeaderValue}`\n )\n\n for (const pattern of apiKeyRedactions) {\n result = result.replace(pattern, redactedHeaderValue)\n }\n\n return redactQuotedCredentialHeaders(result)\n .replace(\n credentialFieldRedaction,\n (_match, prefix: string, quote: string, name: string, separator: string) =>\n `${prefix}${quote}${name}${quote}${separator}${redactedHeaderValue}`\n )\n .replace(\n credentialParamRedaction,\n (_match, prefix: string, name: string) => `${prefix}${name}=${redactedHeaderValue}`\n )\n}\n\nexport const redactHeaders = (headers: Readonly<Record<string, string>>) => {\n const redacted: Record<string, string> = {}\n\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = isCredentialHeaderName(name) ? redactedHeaderValue : value\n }\n\n return redacted\n}\n\nconst compareStrings = (left: string, right: string): number => {\n if (left < right) {\n return -1\n }\n\n return left > right ? 1 : 0\n}\n\n/**\n * Canonical absolute URL used for matching: hash removed and query parameters\n * sorted by name, then value. Unparseable input is returned unchanged.\n */\nexport const normalizeWireUrl = (input: string): string => {\n if (!URL.canParse(input)) {\n return input\n }\n\n const url = new URL(input)\n url.hash = ''\n\n const params = [...url.searchParams.entries()].sort(\n ([leftName, leftValue], [rightName, rightValue]) =>\n leftName === rightName\n ? compareStrings(leftValue, rightValue)\n : compareStrings(leftName, rightName)\n )\n\n url.search = ''\n\n for (const [name, value] of params) {\n url.searchParams.append(name, value)\n }\n\n return url.toString()\n}\n\n/**\n * Match an optional URL filter. A pattern ending in `*` is a prefix match on\n * the normalized URL; otherwise the normalized URLs must be equal.\n */\nexport const urlMatchesPattern = (pattern: string, normalizedUrl: string): boolean =>\n pattern.endsWith('*')\n ? normalizedUrl.startsWith(pattern.slice(0, -1))\n : normalizeWireUrl(pattern) === normalizedUrl\n\n/** Request body as UTF-8 text when it is an in-memory body; otherwise undefined. */\nexport const requestBodyText = (\n request: HttpClientRequest.HttpClientRequest\n): string | undefined => {\n const body = request.body\n\n if (Predicate.isTagged(body, 'Uint8Array')) {\n return new TextDecoder().decode(body.body)\n }\n\n if (Predicate.isTagged(body, 'Raw') && Predicate.isString(body.body)) {\n return body.body\n }\n\n return undefined\n}\n\n/** Exact bytes of standard base64 text; `None` when the text is not valid base64. */\nexport const decodeBase64Bytes = (text: string): Option.Option<Uint8Array> =>\n Result.getSuccess(Encoding.decodeBase64(text))\n\n/**\n * Decode bytes as UTF-8 only when they are valid UTF-8 on their own. A leading\n * byte-order mark is kept so that re-encoding yields the same bytes.\n */\nexport const decodeUtf8Strict = (bytes: Uint8Array): Option.Option<string> =>\n Option.liftThrowable(() =>\n new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)\n )()\n\n/** Lossless recording of bytes: readable text when valid UTF-8, otherwise base64. */\nexport const recordBytes = (\n bytes: Uint8Array\n): { readonly text: string } | { readonly base64: string } =>\n Option.match(decodeUtf8Strict(bytes), {\n onNone: () => ({ base64: Encoding.encodeBase64(bytes) }),\n onSome: text => ({ text })\n })\n\nconst decodeJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json))\n\n/** Parse text as JSON; `None` when it is not valid JSON. */\nexport const parseJsonText = (text: string): Effect.Effect<Option.Option<Schema.Json>> =>\n decodeJsonText(text).pipe(Effect.option)\n\nexport const headerRecord = (headers: Readonly<Record<string, string | undefined>>) => {\n const record: Record<string, string> = {}\n\n for (const [name, value] of Object.entries(headers)) {\n if (value !== undefined) {\n record[name.toLowerCase()] = value\n }\n }\n\n return record\n}\n\nexport const mediaType = (contentType: string | undefined): string | undefined =>\n contentType?.split(';', 1)[0]?.trim().toLowerCase()\n\n// Web `Response` rejects a body for these statuses.\nconst nullBodyStatuses: ReadonlySet<number> = new Set([101, 103, 204, 205, 304])\n\nexport const isNullBodyStatus = (status: number): boolean => nullBodyStatuses.has(status)\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"wire-internal.mjs","names":[],"sources":["../src/wire-internal.ts"],"sourcesContent":["import { Effect, Option, Predicate, Result } from 'effect'\nimport * as Base64 from 'effect/encoding/Base64'\nimport * as Schema from 'effect/Schema'\nimport type { HttpClientRequest } from 'effect/http'\n\n// Header names that carry credentials or session state. Fixtures must never\n// contain them and replay ledgers redact them.\nconst credentialHeaderNames: ReadonlySet<string> = new Set([\n 'authorization',\n 'proxy-authorization',\n 'cookie',\n 'set-cookie',\n 'x-api-key',\n 'api-key',\n 'x-goog-api-key',\n 'x-auth-token',\n 'x-access-token',\n 'x-amz-security-token',\n 'x-vercel-oidc-token',\n 'x-csrf-token'\n])\n\nconst credentialHeaderPattern = /(api[-_]?key|secret|password|cookie|authorization)/i\n\n// A `-`/`_`-separated name segment that is exactly `token`, `key`, or `auth`\n// (`x-auth-token`, `private-token`, `x-figma-token`, `x-*-key`). Plural\n// segments such as `x-ratelimit-remaining-tokens` do not match.\nconst credentialHeaderSegmentPattern = /(^|[-_])(token|key|auth)([-_]|$)/i\n\n/**\n * True for header names that carry credentials or session state. Used by the\n * fixture secret scan, recorder drop rules, and ledger redaction.\n */\nexport const isCredentialHeaderName = (name: string): boolean => {\n const lower = name.toLowerCase()\n\n return (\n credentialHeaderNames.has(lower) ||\n credentialHeaderPattern.test(lower) ||\n credentialHeaderSegmentPattern.test(lower)\n )\n}\n\nexport const redactedHeaderValue = '<redacted>'\n\n// Credential text patterns. The fixture secret scan (`scanFixtureForSecrets`) tests them and the\n// runner's report sanitizer redacts with them, so both share this one definition.\n\n/** `Bearer <token>` with a token-shaped value. */\nexport const bearerPattern = /\\bbearer\\s+[A-Za-z0-9._~+/=-]{8,}/i\n\n/** PEM private key header. */\nconst privateKeyPattern = /-----BEGIN [A-Z ]*PRIVATE KEY-----/\n\n/** Common API-key prefixes and JSON Web Tokens. */\nconst tokenPrefixPatterns: ReadonlyArray<RegExp> = [\n // OpenAI/Anthropic/DeepSeek-style secret keys (sk-..., sk-ant-..., sk-proj-...)\n /\\bsk-[A-Za-z0-9_-]{16,}/,\n /\\b[sr]k_(live|test)_[A-Za-z0-9]{16,}/,\n /\\bxai-[A-Za-z0-9]{20,}/,\n /\\bvck_[A-Za-z0-9]{16,}/,\n /\\bgh[pousr]_[A-Za-z0-9]{20,}/,\n /\\bgithub_pat_[A-Za-z0-9_]{20,}/,\n /\\bAKIA[0-9A-Z]{16}\\b/,\n /\\bAIza[0-9A-Za-z_-]{35}/,\n /\\bxox[abprs]-[A-Za-z0-9-]{10,}/,\n // Afloat API keys (`afloat_` and a long alphanumeric remainder)\n /\\bafloat_[A-Za-z0-9]{16,}/,\n // JSON Web Tokens (OIDC/OAuth access tokens)\n /\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/\n]\n\n/** Common API-key prefixes, JSON Web Tokens, and PEM private keys. */\nexport const apiKeyPatterns: ReadonlyArray<RegExp> = [...tokenPrefixPatterns, privateKeyPattern]\n\nconst credentialParamNames =\n 'api[_-]?key|key|token|access[_-]?token|refresh[_-]?token|id[_-]?token|auth|secret|password|client[_-]?secret|x-amz-signature|x-amz-credential|x-amz-security-token'\n\n/**\n * Query-string or form-encoded credential parameter, anchored at the start of\n * the text or after `?`/`&` (URLs and `application/x-www-form-urlencoded` bodies).\n */\nexport const credentialParamPattern = new RegExp(`(?:^|[?&])(${credentialParamNames})=[^&#]+`, 'i')\n\n// Every `name=` of a credential parameter, found on its own: the pattern stops at `=` and never\n// consumes the value, so a later `?name=` or `&name=` is always found, whatever the value holds.\nconst credentialParamNamesAt = new RegExp(`(?:^|[?&])(${credentialParamNames})=`, 'gi')\n\n// What ends a raw parameter value: a character that cannot appear raw inside a URL query value\n// (RFC 3986): `&`, `#`, whitespace, `\"`, `<`, `>`. `?` and `'` are NOT boundaries (both are legal\n// raw inside a query value), and neither is any percent-encoded delimiter.\nconst valueBoundary = /[&#\\s\"<>]/\n\n/**\n * The exact synthetic credential values a `PortFixture` may carry, keyed by lower-case parameter\n * name: SigV4 presigned-URL placeholders for S3-compatible ports (the R2 conformance fixtures sign\n * with them). `scanPortFixtureForSecrets` exempts a credential parameter only when its name is a\n * key here and its whole raw value (up to `&`, `#`, whitespace, `\"`, `<`, `>`, or the end),\n * percent-decoded, equals that key's value exactly; anything else inside the value (a raw `?`, an\n * encoded delimiter, any suffix), another parameter name, or another scope is flagged.\n * `scanFixtureForSecrets` exempts nothing. Never build a placeholder by prefixing or suffixing a\n * real value.\n */\nexport const syntheticPortCredentialParams = Object.freeze({\n 'x-amz-signature': 'yolk-synthetic-signature',\n 'x-amz-credential': 'yolk-synthetic-access-key-id/20260930/auto/s3/aws4_request'\n})\n\nconst exactPlaceholders = new Map<string, string>(Object.entries(syntheticPortCredentialParams))\n\n/**\n * `text` with `%XX` escapes decoded, repeatedly (at most three layers, then left as it is). The R2\n * guard (`findR2PortFixtureSecrets` in `@yolk-sdk/connectors`) applies at most the same three\n * percent-decoding rounds (plus its escape rounds): keep the percent depth in step.\n */\nconst percentDecodedLayers = (text: string): string => {\n let current = text\n\n for (let round = 0; round < 3 && /%[0-9A-Fa-f]{2}/.test(current); round++) {\n current = current.replace(/%([0-9A-Fa-f]{2})/g, (_match, hex: string) =>\n String.fromCharCode(Number.parseInt(hex, 16))\n )\n }\n\n return current\n}\n\n/** The raw, undecoded value starting at `start`: up to the first structural boundary. */\nconst rawParamValueAt = (text: string, start: number): string => {\n const rest = text.slice(start)\n const end = rest.search(valueBoundary)\n\n return end === -1 ? rest : rest.slice(0, end)\n}\n\n/**\n * True when `text` carries a credential query or form parameter that is not an exact synthetic\n * placeholder (`syntheticPortCredentialParams`). Every `name=` is found and judged on its own: its\n * raw value runs to the next structural boundary (see `rawParamValueAt`), is percent-decoded\n * (`percentDecodedLayers`), and is exempt only when it equals that name's placeholder exactly. It\n * flags at least whatever `credentialParamPattern` flags (a `name=` followed by any character but\n * `&` or `#`), except the exact placeholders.\n */\nexport const hasLiveCredentialParam = (text: string): boolean =>\n [...text.matchAll(credentialParamNamesAt)].some(match => {\n const name = (match[1] ?? '').toLowerCase()\n const start = match.index + match[0].length\n const value = rawParamValueAt(text, start)\n const next = text.charAt(start)\n const present = value.length > 0 || (next !== '' && next !== '&' && next !== '#')\n\n return present && exactPlaceholders.get(name) !== percentDecodedLayers(value)\n })\n\n// Singular credential field names (snake, kebab, or camel case), including the AWS-style\n// `accessKeyId` / `secretAccessKey` / `sessionToken` of S3-compatible signing inputs. Plural usage\n// counters such as `max_tokens` or `prompt_tokens` never match.\nconst credentialFieldNames =\n '(?:access|refresh|id|auth|api|session|private|bearer|oauth)[_-]?token|token|client[_-]?secret|secret(?:[_-]?key)?|private[_-]?key|password|passwd|api[_-]?key|access[_-]?key[_-]?id|secret[_-]?access[_-]?key|authorization'\n\n/** A JSON object key (or similar field name) that holds a credential. */\nexport const credentialFieldPattern = new RegExp(`^(${credentialFieldNames})$`, 'i')\n\nconst globally = (pattern: RegExp): RegExp =>\n new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)\n\n// Redaction forms of the patterns above. They are deliberately broader than the scan (any bearer\n// value, parameters after whitespace or punctuation, `name: value` field pairs) because\n// over-redacting a report message is harmless.\nconst bearerRedaction = /\\bbearer\\s+\\S+/gi\n\nconst apiKeyRedactions: ReadonlyArray<RegExp> = [\n ...tokenPrefixPatterns.map(globally),\n /-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g\n]\n\nconst credentialParamRedaction = new RegExp(\n `(^|[?&\\\\s;,(])(${credentialParamNames})=[^&#\\\\s;,)]+`,\n 'gi'\n)\n\n// `name: value` / `\"name\": \"value\"` / `name=value`. An unquoted value may carry an auth scheme\n// (`Authorization: Basic <token>`); a value already redacted as `Bearer <redacted>` is kept.\nconst credentialFieldRedaction = new RegExp(\n `(^|[^A-Za-z0-9_-])([\"']?)(${credentialFieldNames})\\\\2(\\\\s*[:=]\\\\s*)(?!bearer <redacted>)(?:\"(?:[^\"\\\\\\\\]|\\\\\\\\.)*\"?|'[^']*'?|(?:(?:basic|bearer|digest|negotiate|token)\\\\s+)?[^\\\\s,;&}\\\\]]+)`,\n 'gi'\n)\n\n// Candidate unquoted `Name:` header-like names inside one line (colon only). Quoted keys such\n// as `\"x-api-key\": \"...\"` are handled by `quotedCredentialHeaderRedaction`; `name=value` pairs\n// are left to the quote-aware field and parameter passes.\nconst headerLikeNamePattern = /(?:^|[^A-Za-z0-9_-])([A-Za-z][A-Za-z0-9_-]*)\\s*:/g\n\n/**\n * Redact the rest of a line after the first header-like `Name:` that `isCredentialHeaderName`\n * accepts (`X-Api-Key: ...`, `Proxy-Authorization: Basic ...`, `Cookie: ...`), so every header the\n * fixture scan flags is also redacted in report text. Runs on the raw text before any other pass,\n * so a partially matched value (for example a key-shaped cookie name) never shields the rest of\n * the line.\n */\nconst redactCredentialHeaderLines = (text: string): string =>\n text\n .split(/(\\r\\n|\\r|\\n)/)\n .map(line => {\n headerLikeNamePattern.lastIndex = 0\n\n for (let match = headerLikeNamePattern.exec(line); match !== null;) {\n const name = match[1]\n\n if (name !== undefined && isCredentialHeaderName(name)) {\n return `${line.slice(0, match.index + match[0].length)} ${redactedHeaderValue}`\n }\n\n match = headerLikeNamePattern.exec(line)\n }\n\n return line\n })\n .join('')\n\n// A quoted key that `isCredentialHeaderName` accepts (`\"x-api-key\": \"...\"`,\n// `'Proxy-Authorization': '...'`): only the value is redacted, so JSON stays balanced.\nconst quotedCredentialHeaderRedaction =\n /([\"'])([A-Za-z][A-Za-z0-9_-]*)\\1(\\s*:\\s*)(\"(?:[^\"\\\\]|\\\\.)*\"?|'[^']*'?|[^\\s,;&{}[\\]]+)/g\n\nconst redactQuotedCredentialHeaders = (text: string): string =>\n text.replace(\n quotedCredentialHeaderRedaction,\n (match, quote: string, name: string, separator: string) =>\n isCredentialHeaderName(name)\n ? `${quote}${name}${quote}${separator}${redactedHeaderValue}`\n : match\n )\n\n/**\n * Redact every credential pattern shared with the fixture secret scan: credential header lines\n * (first, to the end of the line), quoted credential header keys (value only), bearer tokens,\n * API-key prefixes, JWTs, private keys, credential field pairs (`\"api_key\": \"...\"`,\n * `password=\"...\"`), and credential query/form parameters. Best effort; used on report messages.\n * Known gap: escaped JSON inside a quoted string value (`\"body\":\"{\\\\\"x-api-key\\\\\":...}\"`) is not\n * parsed.\n * Quote-aware field redaction runs before parameter redaction so a quoted value with spaces is\n * removed whole.\n */\nexport const redactCredentialText = (text: string): string => {\n let result = redactCredentialHeaderLines(text).replace(\n bearerRedaction,\n `Bearer ${redactedHeaderValue}`\n )\n\n for (const pattern of apiKeyRedactions) {\n result = result.replace(pattern, redactedHeaderValue)\n }\n\n return redactQuotedCredentialHeaders(result)\n .replace(\n credentialFieldRedaction,\n (_match, prefix: string, quote: string, name: string, separator: string) =>\n `${prefix}${quote}${name}${quote}${separator}${redactedHeaderValue}`\n )\n .replace(\n credentialParamRedaction,\n (_match, prefix: string, name: string) => `${prefix}${name}=${redactedHeaderValue}`\n )\n}\n\nexport const redactHeaders = (headers: Readonly<Record<string, string>>) => {\n const redacted: Record<string, string> = {}\n\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = isCredentialHeaderName(name) ? redactedHeaderValue : value\n }\n\n return redacted\n}\n\nconst compareStrings = (left: string, right: string): number => {\n if (left < right) {\n return -1\n }\n\n return left > right ? 1 : 0\n}\n\n/**\n * Canonical absolute URL used for matching: hash removed and query parameters\n * sorted by name, then value. Unparseable input is returned unchanged.\n */\nexport const normalizeWireUrl = (input: string): string => {\n if (!URL.canParse(input)) {\n return input\n }\n\n const url = new URL(input)\n url.hash = ''\n\n const params = [...url.searchParams.entries()].sort(\n ([leftName, leftValue], [rightName, rightValue]) =>\n leftName === rightName\n ? compareStrings(leftValue, rightValue)\n : compareStrings(leftName, rightName)\n )\n\n url.search = ''\n\n for (const [name, value] of params) {\n url.searchParams.append(name, value)\n }\n\n return url.toString()\n}\n\n/**\n * Match an optional URL filter. A pattern ending in `*` is a prefix match on\n * the normalized URL; otherwise the normalized URLs must be equal.\n */\nexport const urlMatchesPattern = (pattern: string, normalizedUrl: string): boolean =>\n pattern.endsWith('*')\n ? normalizedUrl.startsWith(pattern.slice(0, -1))\n : normalizeWireUrl(pattern) === normalizedUrl\n\n/** Request body as UTF-8 text when it is an in-memory body; otherwise undefined. */\nexport const requestBodyText = (\n request: HttpClientRequest.HttpClientRequest\n): string | undefined => {\n const body = request.body\n\n if (Predicate.isTagged(body, 'Uint8Array')) {\n return new TextDecoder().decode(body.body)\n }\n\n if (Predicate.isTagged(body, 'Raw') && Predicate.isString(body.body)) {\n return body.body\n }\n\n return undefined\n}\n\n/** Exact bytes of standard base64 text; `None` when the text is not valid base64. */\nexport const decodeBase64Bytes = (text: string): Option.Option<Uint8Array> =>\n Result.getSuccess(Base64.decode(text))\n\n/**\n * Decode bytes as UTF-8 only when they are valid UTF-8 on their own. A leading\n * byte-order mark is kept so that re-encoding yields the same bytes.\n */\nexport const decodeUtf8Strict = (bytes: Uint8Array): Option.Option<string> =>\n Option.liftThrowable(() =>\n new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)\n )()\n\n/** Lossless recording of bytes: readable text when valid UTF-8, otherwise base64. */\nexport const recordBytes = (\n bytes: Uint8Array\n): { readonly text: string } | { readonly base64: string } =>\n Option.match(decodeUtf8Strict(bytes), {\n onNone: () => ({ base64: Base64.encode(bytes) }),\n onSome: text => ({ text })\n })\n\nconst decodeJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json))\n\n/** Parse text as JSON; `None` when it is not valid JSON. */\nexport const parseJsonText = (text: string): Effect.Effect<Option.Option<Schema.Json>> =>\n decodeJsonText(text).pipe(Effect.option)\n\nexport const headerRecord = (headers: Readonly<Record<string, string | undefined>>) => {\n const record: Record<string, string> = {}\n\n for (const [name, value] of Object.entries(headers)) {\n if (value !== undefined) {\n record[name.toLowerCase()] = value\n }\n }\n\n return record\n}\n\nexport const mediaType = (contentType: string | undefined): string | undefined =>\n contentType?.split(';', 1)[0]?.trim().toLowerCase()\n\n// Web `Response` rejects a body for these statuses.\nconst nullBodyStatuses: ReadonlySet<number> = new Set([101, 103, 204, 205, 304])\n\nexport const isNullBodyStatus = (status: number): boolean => nullBodyStatuses.has(status)\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolk-sdk/conformance",
|
|
3
|
-
"version": "0.1.0-canary.
|
|
3
|
+
"version": "0.1.0-canary.97",
|
|
4
4
|
"description": "Experimental Effect toolkit to record, replay, and fault-inject HTTP wire fixtures, and to define and run conformance cases.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"provenance": true
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"effect": "4.0.0
|
|
67
|
+
"effect": "4.0.0"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
70
|
"build": "tsdown",
|
package/src/case.ts
CHANGED
|
@@ -31,10 +31,10 @@ export type ConformanceSafety = typeof ConformanceSafety.Type
|
|
|
31
31
|
* hyphens, for example `vendor.stream.plain-text`.
|
|
32
32
|
*/
|
|
33
33
|
export const ConformanceCaseId = Schema.String.check(
|
|
34
|
-
Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/)
|
|
34
|
+
Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/u)
|
|
35
35
|
)
|
|
36
36
|
|
|
37
|
-
const CalendarDate = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/))
|
|
37
|
+
const CalendarDate = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/u))
|
|
38
38
|
|
|
39
39
|
/** When and where a person last watched the claim hold against the real service. */
|
|
40
40
|
export const ConformanceObservation = Schema.Struct({
|
package/src/fixture.ts
CHANGED
|
@@ -119,7 +119,7 @@ export const WireExchange = Schema.Struct({
|
|
|
119
119
|
|
|
120
120
|
export type WireExchange = typeof WireExchange.Type
|
|
121
121
|
|
|
122
|
-
const RecordedAtDate = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/))
|
|
122
|
+
const RecordedAtDate = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/u))
|
|
123
123
|
|
|
124
124
|
export const WireFixture = Schema.Struct({
|
|
125
125
|
id: Schema.NonEmptyString,
|
package/src/record.ts
CHANGED
package/src/replay.ts
CHANGED
package/src/wire-internal.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Effect,
|
|
1
|
+
import { Effect, Option, Predicate, Result } from 'effect'
|
|
2
|
+
import * as Base64 from 'effect/encoding/Base64'
|
|
2
3
|
import * as Schema from 'effect/Schema'
|
|
3
|
-
import type { HttpClientRequest } from 'effect/
|
|
4
|
+
import type { HttpClientRequest } from 'effect/http'
|
|
4
5
|
|
|
5
6
|
// Header names that carry credentials or session state. Fixtures must never
|
|
6
7
|
// contain them and replay ledgers redact them.
|
|
@@ -63,6 +64,8 @@ const tokenPrefixPatterns: ReadonlyArray<RegExp> = [
|
|
|
63
64
|
/\bAKIA[0-9A-Z]{16}\b/,
|
|
64
65
|
/\bAIza[0-9A-Za-z_-]{35}/,
|
|
65
66
|
/\bxox[abprs]-[A-Za-z0-9-]{10,}/,
|
|
67
|
+
// Afloat API keys (`afloat_` and a long alphanumeric remainder)
|
|
68
|
+
/\bafloat_[A-Za-z0-9]{16,}/,
|
|
66
69
|
// JSON Web Tokens (OIDC/OAuth access tokens)
|
|
67
70
|
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/
|
|
68
71
|
]
|
|
@@ -335,7 +338,7 @@ export const requestBodyText = (
|
|
|
335
338
|
|
|
336
339
|
/** Exact bytes of standard base64 text; `None` when the text is not valid base64. */
|
|
337
340
|
export const decodeBase64Bytes = (text: string): Option.Option<Uint8Array> =>
|
|
338
|
-
Result.getSuccess(
|
|
341
|
+
Result.getSuccess(Base64.decode(text))
|
|
339
342
|
|
|
340
343
|
/**
|
|
341
344
|
* Decode bytes as UTF-8 only when they are valid UTF-8 on their own. A leading
|
|
@@ -351,7 +354,7 @@ export const recordBytes = (
|
|
|
351
354
|
bytes: Uint8Array
|
|
352
355
|
): { readonly text: string } | { readonly base64: string } =>
|
|
353
356
|
Option.match(decodeUtf8Strict(bytes), {
|
|
354
|
-
onNone: () => ({ base64:
|
|
357
|
+
onNone: () => ({ base64: Base64.encode(bytes) }),
|
|
355
358
|
onSome: text => ({ text })
|
|
356
359
|
})
|
|
357
360
|
|