@yolk-sdk/conformance 0.1.0-canary.96

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/src/replay.ts ADDED
@@ -0,0 +1,617 @@
1
+ /**
2
+ * Offline replay of recorded wire fixtures as an Effect `HttpClient`.
3
+ *
4
+ * Replay never performs network I/O. Requests are matched by method and
5
+ * normalized absolute URL (hash removed, query parameters sorted); each
6
+ * recorded exchange is consumed once, in recorded order among exchanges with
7
+ * the same method and URL. A request with no remaining match fails closed with
8
+ * a typed `HttpClientError` and is still written to the ledger.
9
+ *
10
+ * @experimental
11
+ */
12
+ import {
13
+ Cause,
14
+ Context,
15
+ Data,
16
+ Effect,
17
+ Exit,
18
+ Fiber,
19
+ Layer,
20
+ Match,
21
+ Option,
22
+ Predicate,
23
+ Ref,
24
+ Result
25
+ } from 'effect'
26
+ import type * as Schema from 'effect/Schema'
27
+ import {
28
+ HttpClient,
29
+ HttpClientError,
30
+ HttpClientResponse,
31
+ type HttpClientRequest
32
+ } from 'effect/unstable/http'
33
+ import {
34
+ isWireBase64BodyResponse,
35
+ isWireStreamResponse,
36
+ type WireChunk,
37
+ type WireExchange,
38
+ type WireFixture,
39
+ type WireHeaders,
40
+ type WireResponse
41
+ } from './fixture.ts'
42
+ import {
43
+ decodeBase64Bytes,
44
+ headerRecord,
45
+ isNullBodyStatus,
46
+ normalizeWireUrl,
47
+ parseJsonText,
48
+ redactHeaders,
49
+ requestBodyText,
50
+ urlMatchesPattern
51
+ } from './wire-internal.ts'
52
+
53
+ /**
54
+ * Optional fault filter. `method` compares case-insensitively. `url` matches
55
+ * the normalized request URL exactly, or as a prefix when it ends with `*`.
56
+ * An omitted field matches every request.
57
+ */
58
+ export type WireFaultMatch = {
59
+ readonly method?: string
60
+ readonly url?: string
61
+ }
62
+
63
+ /**
64
+ * Wire faults injected by the replay client. `attempt` is 1-based and counted
65
+ * per method + normalized URL.
66
+ *
67
+ * - `StatusOnAttempt`: respond with this status instead of consuming a recorded
68
+ * exchange, so the next attempt still gets the recording. It fires only when
69
+ * an unconsumed recorded exchange exists for the method + normalized URL;
70
+ * unknown or exhausted requests still fail closed as `unmatched`.
71
+ * - `FailAfterChunks`: emit the first `chunks` recorded chunks, then fail the
72
+ * body stream the way a dropped connection does under `FetchHttpClient`.
73
+ * - `TruncateAfterChunks`: emit the first `chunks` recorded chunks, then end
74
+ * the body cleanly.
75
+ * - `HoldAfterChunks`: emit the first `chunks` recorded chunks, run `release`,
76
+ * then emit the rest.
77
+ *
78
+ * `attempt` omitted means every matching attempt. A chunk fault that cannot
79
+ * take effect fails the request with a typed `HttpClientError` (and a ledger
80
+ * `invalid` outcome) instead of silently doing nothing: any chunk fault matched
81
+ * against a whole-body response, `TruncateAfterChunks`/`HoldAfterChunks` with
82
+ * `chunks` >= the recorded chunk count, or `FailAfterChunks` with `chunks` >
83
+ * the recorded chunk count. That error's reason is a `TransportError`, so
84
+ * transient-retry policies may retry past it: assert the ledger has no
85
+ * `invalid` outcome when testing through a retrying client.
86
+ */
87
+ export type WireFault = Data.TaggedEnum<{
88
+ StatusOnAttempt: {
89
+ readonly match?: WireFaultMatch
90
+ readonly attempt: number
91
+ readonly status: number
92
+ readonly headers?: WireHeaders
93
+ readonly body?: string
94
+ }
95
+ FailAfterChunks: {
96
+ readonly match?: WireFaultMatch
97
+ readonly attempt?: number
98
+ readonly chunks: number
99
+ }
100
+ TruncateAfterChunks: {
101
+ readonly match?: WireFaultMatch
102
+ readonly attempt?: number
103
+ readonly chunks: number
104
+ }
105
+ HoldAfterChunks: {
106
+ readonly match?: WireFaultMatch
107
+ readonly attempt?: number
108
+ readonly chunks: number
109
+ readonly release: Effect.Effect<void>
110
+ }
111
+ }>
112
+
113
+ export const WireFault = Data.taggedEnum<WireFault>()
114
+
115
+ export type WireFaultTag = WireFault['_tag']
116
+
117
+ type ChunkFault = Exclude<WireFault, { readonly _tag: 'StatusOnAttempt' }>
118
+
119
+ /** Cause attached to a body stream failed by `FailAfterChunks`. */
120
+ export class WireTransportFault extends Data.TaggedError('WireTransportFault')<{
121
+ readonly afterChunks: number
122
+ }> {
123
+ override get message(): string {
124
+ return `injected transport failure after ${this.afterChunks} chunks`
125
+ }
126
+ }
127
+
128
+ /**
129
+ * How a replayed request was answered: a recorded exchange, a response
130
+ * injected by a `StatusOnAttempt` fault, nothing (fail closed), or a matched
131
+ * exchange that could not be replayed (`invalid`: a chunk fault that cannot
132
+ * take effect, or undecodable base64). `invalid` requests fail with a typed
133
+ * `HttpClientError` and do not consume the exchange.
134
+ */
135
+ export type ReplayLedgerMatch =
136
+ | { readonly outcome: 'matched'; readonly fixtureId: string; readonly exchangeIndex: number }
137
+ | { readonly outcome: 'injected' }
138
+ | { readonly outcome: 'unmatched' }
139
+ | {
140
+ readonly outcome: 'invalid'
141
+ readonly fixtureId: string
142
+ readonly exchangeIndex: number
143
+ /** Why the recording could not be replayed; never contains request or response data. */
144
+ readonly reason: string
145
+ }
146
+
147
+ export type ReplayLedgerEntry = {
148
+ readonly method: string
149
+ /** Normalized absolute URL. */
150
+ readonly url: string
151
+ /** Request headers with credential headers replaced by `<redacted>`. */
152
+ readonly headers: Readonly<Record<string, string>>
153
+ readonly bodyText?: string
154
+ /** Parsed request body when `bodyText` is valid JSON. */
155
+ readonly bodyJson?: Schema.Json
156
+ /** 1-based attempt number for this method + URL. */
157
+ readonly attempt: number
158
+ readonly match: ReplayLedgerMatch
159
+ /** Tag of the fault that shaped this response, if any (never set for a fault that did not apply). */
160
+ readonly fault?: WireFaultTag
161
+ }
162
+
163
+ export type ReplayExchangeRef = {
164
+ readonly fixtureId: string
165
+ readonly exchangeIndex: number
166
+ }
167
+
168
+ export type ReplayLedgerApi = {
169
+ /** Every request seen by the replay client, in arrival order. */
170
+ readonly entries: Effect.Effect<ReadonlyArray<ReplayLedgerEntry>>
171
+ /** Recorded exchanges not consumed yet, in fixture order. */
172
+ readonly remaining: Effect.Effect<ReadonlyArray<ReplayExchangeRef>>
173
+ }
174
+
175
+ export class ReplayLedger extends Context.Service<ReplayLedger, ReplayLedgerApi>()(
176
+ '@yolk-sdk/conformance/ReplayLedger'
177
+ ) {}
178
+
179
+ export type ReplayHttpClientOptions = {
180
+ readonly faults?: ReadonlyArray<WireFault>
181
+ }
182
+
183
+ type Candidate = {
184
+ readonly id: number
185
+ readonly key: string
186
+ readonly fixtureId: string
187
+ readonly exchangeIndex: number
188
+ readonly exchange: WireExchange
189
+ }
190
+
191
+ type ReplayState = {
192
+ readonly consumed: ReadonlySet<number>
193
+ readonly attempts: ReadonlyMap<string, number>
194
+ readonly entries: ReadonlyArray<ReplayLedgerEntry>
195
+ }
196
+
197
+ // Bytes to replay: one whole body, or one Uint8Array per recorded chunk.
198
+ type ReplayBody =
199
+ | { readonly kind: 'body'; readonly body: string | Uint8Array<ArrayBuffer> }
200
+ | {
201
+ readonly kind: 'chunks'
202
+ readonly chunks: ReadonlyArray<Uint8Array>
203
+ readonly fault: ChunkFault | undefined
204
+ }
205
+
206
+ type ReplayDecision =
207
+ | { readonly kind: 'status'; readonly fault: Extract<WireFault, { _tag: 'StatusOnAttempt' }> }
208
+ | {
209
+ readonly kind: 'exchange'
210
+ readonly response: WireResponse
211
+ readonly body: ReplayBody
212
+ }
213
+ | { readonly kind: 'invalid'; readonly reason: string }
214
+ | { readonly kind: 'unmatched' }
215
+
216
+ type LedgerEntryFields = {
217
+ method: string
218
+ url: string
219
+ headers: Readonly<Record<string, string>>
220
+ bodyText?: string
221
+ bodyJson?: Schema.Json
222
+ attempt: number
223
+ match: ReplayLedgerMatch
224
+ fault?: WireFaultTag
225
+ }
226
+
227
+ const requestKey = (method: string, normalizedUrl: string) =>
228
+ `${method.toUpperCase()} ${normalizedUrl}`
229
+
230
+ const faultMatches = (
231
+ match: WireFaultMatch | undefined,
232
+ method: string,
233
+ normalizedUrl: string
234
+ ): boolean =>
235
+ (match?.method === undefined || match.method.toUpperCase() === method.toUpperCase()) &&
236
+ (match?.url === undefined || urlMatchesPattern(match.url, normalizedUrl))
237
+
238
+ const candidatesFrom = (fixtures: ReadonlyArray<WireFixture>): ReadonlyArray<Candidate> => {
239
+ const candidates: Array<Candidate> = []
240
+
241
+ for (const fixture of fixtures) {
242
+ fixture.exchanges.forEach((exchange, exchangeIndex) => {
243
+ candidates.push({
244
+ id: candidates.length,
245
+ key: requestKey(exchange.request.method, normalizeWireUrl(exchange.request.url)),
246
+ fixtureId: fixture.id,
247
+ exchangeIndex,
248
+ exchange
249
+ })
250
+ })
251
+ }
252
+
253
+ return candidates
254
+ }
255
+
256
+ const chunkBytes = (chunk: WireChunk): Option.Option<Uint8Array> =>
257
+ Predicate.isString(chunk)
258
+ ? Option.some(new TextEncoder().encode(chunk))
259
+ : decodeBase64Bytes(chunk.base64)
260
+
261
+ const chunkFaultProblem = (fault: ChunkFault, recorded: number): string | undefined => {
262
+ if (!Number.isSafeInteger(fault.chunks) || fault.chunks < 0) {
263
+ return `${fault._tag} needs a non-negative integer chunk count`
264
+ }
265
+
266
+ // `FailAfterChunks` may fail after the last chunk; truncating or holding
267
+ // there would change nothing.
268
+ const applies = WireFault.$is('FailAfterChunks')(fault)
269
+ ? fault.chunks <= recorded
270
+ : fault.chunks < recorded
271
+
272
+ return applies
273
+ ? undefined
274
+ : `${fault._tag} after ${fault.chunks} chunk(s) cannot apply to a response with ${recorded} recorded chunk(s)`
275
+ }
276
+
277
+ /** Resolve the exact bytes to replay, or why the recording cannot be replayed as requested. */
278
+ const replayBody = (
279
+ response: WireResponse,
280
+ fault: ChunkFault | undefined
281
+ ): Result.Result<ReplayBody, string> => {
282
+ if (isWireStreamResponse(response)) {
283
+ const chunks = Option.all(response.chunks.map(chunkBytes))
284
+
285
+ if (Option.isNone(chunks)) {
286
+ return Result.fail('a recorded chunk is not valid base64')
287
+ }
288
+
289
+ const problem = fault === undefined ? undefined : chunkFaultProblem(fault, chunks.value.length)
290
+
291
+ return problem === undefined
292
+ ? Result.succeed({ kind: 'chunks', chunks: chunks.value, fault })
293
+ : Result.fail(problem)
294
+ }
295
+
296
+ if (fault !== undefined) {
297
+ return Result.fail(`${fault._tag} cannot apply to a whole-body response`)
298
+ }
299
+
300
+ if (isWireBase64BodyResponse(response)) {
301
+ return Option.match(decodeBase64Bytes(response.bodyBase64), {
302
+ onNone: () => Result.fail('the recorded bodyBase64 is not valid base64'),
303
+ // Copy into an ArrayBuffer-backed view, as `Response` requires.
304
+ onSome: bytes => Result.succeed({ kind: 'body', body: new Uint8Array(bytes) })
305
+ })
306
+ }
307
+
308
+ return Result.succeed({ kind: 'body', body: response.body })
309
+ }
310
+
311
+ /**
312
+ * Strictly pull-driven body: one `Uint8Array` per recorded chunk (including
313
+ * empty ones), and chunk `k` is produced only when the consumer pulls it,
314
+ * never ahead of demand. The fault (already validated against the chunk count)
315
+ * applies when the consumer pulls past `fault.chunks` chunks.
316
+ */
317
+ const bodyReadable = (
318
+ chunks: ReadonlyArray<Uint8Array>,
319
+ fault: ChunkFault | undefined
320
+ ): ReadableStream<Uint8Array> => {
321
+ let next = 0
322
+ let faultApplied = false
323
+ let cancelled = false
324
+ let held: Fiber.Fiber<void> | undefined
325
+
326
+ const emitNext = (controller: ReadableStreamDefaultController<Uint8Array>): void => {
327
+ const chunk = chunks[next]
328
+
329
+ if (chunk === undefined) {
330
+ controller.close()
331
+
332
+ return
333
+ }
334
+
335
+ next += 1
336
+ controller.enqueue(chunk)
337
+ }
338
+
339
+ return new ReadableStream<Uint8Array>(
340
+ {
341
+ pull: controller => {
342
+ if (fault === undefined || faultApplied || next !== fault.chunks) {
343
+ emitNext(controller)
344
+
345
+ return
346
+ }
347
+
348
+ faultApplied = true
349
+
350
+ return Match.valueTags(fault, {
351
+ FailAfterChunks: ({ chunks: afterChunks }) =>
352
+ controller.error(new WireTransportFault({ afterChunks })),
353
+ TruncateAfterChunks: () => controller.close(),
354
+ HoldAfterChunks: ({ release }) => {
355
+ // Keep the release fiber so cancelling the body interrupts it (and runs its finalizers).
356
+ // `runFork` starts `release` synchronously, so a cancel can land before `held` is set.
357
+ const fiber = Effect.runFork(release)
358
+ held = fiber
359
+
360
+ if (cancelled) {
361
+ held = undefined
362
+
363
+ return Effect.runPromise(Fiber.interrupt(fiber))
364
+ }
365
+
366
+ return Effect.runPromise(Fiber.await(fiber)).then(exit => {
367
+ held = undefined
368
+
369
+ if (cancelled) return
370
+
371
+ if (Exit.isSuccess(exit)) {
372
+ emitNext(controller)
373
+ } else {
374
+ controller.error(Cause.squash(exit.cause))
375
+ }
376
+ })
377
+ }
378
+ })
379
+ },
380
+ cancel: () => {
381
+ cancelled = true
382
+
383
+ if (held === undefined) return
384
+
385
+ return Effect.runPromise(Fiber.interrupt(held))
386
+ }
387
+ },
388
+ { highWaterMark: 0 }
389
+ )
390
+ }
391
+
392
+ const webResponse = (status: number, headers: WireHeaders, body: ReplayBody): Response => {
393
+ const init = { status, headers: { ...headers } }
394
+
395
+ if (isNullBodyStatus(status)) {
396
+ return new Response(null, init)
397
+ }
398
+
399
+ if (body.kind === 'body') {
400
+ return new Response(body.body, init)
401
+ }
402
+
403
+ return new Response(bodyReadable(body.chunks, body.fault), init)
404
+ }
405
+
406
+ const unmatchedError = (request: HttpClientRequest.HttpClientRequest) =>
407
+ new HttpClientError.HttpClientError({
408
+ reason: new HttpClientError.TransportError({
409
+ request,
410
+ description: 'replay has no remaining recorded exchange'
411
+ })
412
+ })
413
+
414
+ const invalidRecordingError = (request: HttpClientRequest.HttpClientRequest, cause: unknown) =>
415
+ new HttpClientError.HttpClientError({
416
+ reason: new HttpClientError.TransportError({
417
+ request,
418
+ cause,
419
+ description: 'recorded response could not be replayed'
420
+ })
421
+ })
422
+
423
+ /**
424
+ * Cause attached to the `HttpClientError` (reason `TransportError`) of a request
425
+ * whose recording could not be replayed. Retrying clients may retry past it;
426
+ * check the ledger for an `invalid` outcome.
427
+ */
428
+ export class WireReplayInvalid extends Data.TaggedError('WireReplayInvalid')<{
429
+ readonly reason: string
430
+ }> {
431
+ override get message(): string {
432
+ return this.reason
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Build a replay `HttpClient` and its ledger over the given fixtures. Each
438
+ * call has independent consumption, attempt, and ledger state.
439
+ */
440
+ export const makeReplayHttpClient = (
441
+ fixtures: ReadonlyArray<WireFixture>,
442
+ options: ReplayHttpClientOptions = {}
443
+ ): Effect.Effect<{ readonly client: HttpClient.HttpClient; readonly ledger: ReplayLedgerApi }> =>
444
+ Effect.gen(function* () {
445
+ const candidates = candidatesFrom(fixtures)
446
+ const faults = options.faults ?? []
447
+
448
+ const state = yield* Ref.make<ReplayState>({
449
+ consumed: new Set(),
450
+ attempts: new Map(),
451
+ entries: []
452
+ })
453
+
454
+ const decide = (
455
+ method: string,
456
+ normalizedUrl: string,
457
+ entry: Omit<LedgerEntryFields, 'attempt' | 'match' | 'fault'>
458
+ ) =>
459
+ Ref.modify(state, (current): [ReplayDecision, ReplayState] => {
460
+ const key = requestKey(method, normalizedUrl)
461
+ const attempt = (current.attempts.get(key) ?? 0) + 1
462
+ const attempts = new Map(current.attempts).set(key, attempt)
463
+ const fields: LedgerEntryFields = { ...entry, attempt, match: { outcome: 'unmatched' } }
464
+
465
+ const candidate = candidates.find(
466
+ item => item.key === key && !current.consumed.has(item.id)
467
+ )
468
+
469
+ // Fail closed first: faults never answer unknown or exhausted requests.
470
+ if (candidate === undefined) {
471
+ return [
472
+ { kind: 'unmatched' },
473
+ { ...current, attempts, entries: [...current.entries, fields] }
474
+ ]
475
+ }
476
+
477
+ const statusFault = faults
478
+ .filter(WireFault.$is('StatusOnAttempt'))
479
+ .find(
480
+ fault => fault.attempt === attempt && faultMatches(fault.match, method, normalizedUrl)
481
+ )
482
+
483
+ if (statusFault !== undefined) {
484
+ fields.match = { outcome: 'injected' }
485
+ fields.fault = statusFault._tag
486
+
487
+ return [
488
+ { kind: 'status', fault: statusFault },
489
+ { ...current, attempts, entries: [...current.entries, fields] }
490
+ ]
491
+ }
492
+
493
+ const response = candidate.exchange.response
494
+
495
+ const chunkFault = faults
496
+ .filter((fault): fault is ChunkFault => !WireFault.$is('StatusOnAttempt')(fault))
497
+ .find(
498
+ fault =>
499
+ (fault.attempt === undefined || fault.attempt === attempt) &&
500
+ faultMatches(fault.match, method, normalizedUrl)
501
+ )
502
+
503
+ const body = replayBody(response, chunkFault)
504
+ const ref = { fixtureId: candidate.fixtureId, exchangeIndex: candidate.exchangeIndex }
505
+
506
+ if (Result.isFailure(body)) {
507
+ // The fault (or recording) did not apply: record the failure, not the
508
+ // fault, and leave the exchange unconsumed.
509
+ fields.match = { outcome: 'invalid', ...ref, reason: body.failure }
510
+
511
+ return [
512
+ { kind: 'invalid', reason: body.failure },
513
+ { ...current, attempts, entries: [...current.entries, fields] }
514
+ ]
515
+ }
516
+
517
+ fields.match = { outcome: 'matched', ...ref }
518
+
519
+ if (chunkFault !== undefined) {
520
+ fields.fault = chunkFault._tag
521
+ }
522
+
523
+ return [
524
+ { kind: 'exchange', response, body: body.success },
525
+ {
526
+ consumed: new Set(current.consumed).add(candidate.id),
527
+ attempts,
528
+ entries: [...current.entries, fields]
529
+ }
530
+ ]
531
+ })
532
+
533
+ const client = HttpClient.make((request, url) =>
534
+ Effect.gen(function* () {
535
+ const normalizedUrl = normalizeWireUrl(url.toString())
536
+ const bodyText = requestBodyText(request)
537
+
538
+ const entry: Omit<LedgerEntryFields, 'attempt' | 'match' | 'fault'> = {
539
+ method: request.method,
540
+ url: normalizedUrl,
541
+ headers: redactHeaders(headerRecord(request.headers))
542
+ }
543
+
544
+ if (bodyText !== undefined) {
545
+ entry.bodyText = bodyText
546
+
547
+ const bodyJson = yield* parseJsonText(bodyText)
548
+
549
+ if (Option.isSome(bodyJson)) {
550
+ entry.bodyJson = bodyJson.value
551
+ }
552
+ }
553
+
554
+ const decision = yield* decide(request.method, normalizedUrl, entry)
555
+
556
+ if (decision.kind === 'unmatched') {
557
+ return yield* Effect.fail(unmatchedError(request))
558
+ }
559
+
560
+ if (decision.kind === 'invalid') {
561
+ return yield* Effect.fail(
562
+ invalidRecordingError(request, new WireReplayInvalid({ reason: decision.reason }))
563
+ )
564
+ }
565
+
566
+ const source = yield* Effect.try({
567
+ try: () =>
568
+ decision.kind === 'status'
569
+ ? webResponse(decision.fault.status, decision.fault.headers ?? {}, {
570
+ kind: 'body',
571
+ body: decision.fault.body ?? ''
572
+ })
573
+ : webResponse(decision.response.status, decision.response.headers, decision.body),
574
+ catch: cause => invalidRecordingError(request, cause)
575
+ })
576
+
577
+ return HttpClientResponse.fromWeb(request, source)
578
+ })
579
+ )
580
+
581
+ const ledger: ReplayLedgerApi = {
582
+ entries: Ref.get(state).pipe(Effect.map(current => current.entries)),
583
+ remaining: Ref.get(state).pipe(
584
+ Effect.map(current =>
585
+ candidates
586
+ .filter(candidate => !current.consumed.has(candidate.id))
587
+ .map(candidate => ({
588
+ fixtureId: candidate.fixtureId,
589
+ exchangeIndex: candidate.exchangeIndex
590
+ }))
591
+ )
592
+ )
593
+ }
594
+
595
+ return { client, ledger }
596
+ })
597
+
598
+ export const ReplayHttpClient = {
599
+ /**
600
+ * Layer providing a replay `HttpClient` and its `ReplayLedger`. Each layer
601
+ * build gets fresh consumption and ledger state.
602
+ */
603
+ layer: (
604
+ fixtures: ReadonlyArray<WireFixture>,
605
+ options: ReplayHttpClientOptions = {}
606
+ ): Layer.Layer<HttpClient.HttpClient | ReplayLedger> =>
607
+ Layer.unwrap(
608
+ makeReplayHttpClient(fixtures, options).pipe(
609
+ Effect.map(({ client, ledger }) =>
610
+ Layer.mergeAll(
611
+ Layer.succeed(HttpClient.HttpClient, client),
612
+ Layer.succeed(ReplayLedger, ledger)
613
+ )
614
+ )
615
+ )
616
+ )
617
+ } as const