@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/record.ts ADDED
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Record live HTTP exchanges as wire fixtures.
3
+ *
4
+ * This module never constructs a network client: hosts supply the real
5
+ * `HttpClient` (for example `FetchHttpClient.layer`) and this wrapper records
6
+ * what flows through it without changing the bytes the caller receives.
7
+ *
8
+ * @experimental
9
+ */
10
+ import { Context, Data, Effect, Exit, Layer, Option, Ref, Stream } from 'effect'
11
+ import {
12
+ HttpClient,
13
+ HttpClientRequest,
14
+ HttpClientResponse,
15
+ type HttpClientError
16
+ } from 'effect/unstable/http'
17
+ import {
18
+ decodeWireFixture,
19
+ scanFixtureForSecrets,
20
+ type FixtureSecretIssue,
21
+ type WireBodyResponse,
22
+ type WireChunk,
23
+ type WireExchange,
24
+ type WireFixture,
25
+ type WireHeaders,
26
+ type WireRequest,
27
+ type WireResponse
28
+ } from './fixture.ts'
29
+ import {
30
+ headerRecord,
31
+ isCredentialHeaderName,
32
+ isNullBodyStatus,
33
+ mediaType,
34
+ parseJsonText,
35
+ recordBytes,
36
+ requestBodyText
37
+ } from './wire-internal.ts'
38
+
39
+ /** Default allowlisted request headers. Credential headers are always dropped. */
40
+ export const defaultRecordedRequestHeaders: ReadonlyArray<string> = ['content-type', 'accept']
41
+
42
+ /**
43
+ * Default allowlisted response headers. Entries ending in `*` are prefixes.
44
+ * Credential headers (including `set-cookie`) are always dropped.
45
+ */
46
+ export const defaultRecordedResponseHeaders: ReadonlyArray<string> = [
47
+ 'content-type',
48
+ 'retry-after',
49
+ 'retry-after-ms',
50
+ 'x-ratelimit-*',
51
+ 'ratelimit-*',
52
+ 'anthropic-ratelimit-*'
53
+ ]
54
+
55
+ /** Response media types recorded as `chunks` instead of a single `body`. */
56
+ export const defaultStreamMediaTypes: ReadonlyArray<string> = ['text/event-stream']
57
+
58
+ export type WireRecorderOptions = {
59
+ readonly requestHeaders?: ReadonlyArray<string>
60
+ readonly responseHeaders?: ReadonlyArray<string>
61
+ readonly streamMediaTypes?: ReadonlyArray<string>
62
+ }
63
+
64
+ /** Raised by `drain` when a recorded request failed or its body was not read to the end. */
65
+ export class WireRecordingIncomplete extends Data.TaggedError('WireRecordingIncomplete')<{
66
+ /** `METHOD url` (query string removed) of each incomplete exchange. */
67
+ readonly requests: ReadonlyArray<string>
68
+ }> {
69
+ override get message(): string {
70
+ return `Recording incomplete for ${this.requests.length} request(s): ${this.requests.join(', ')}`
71
+ }
72
+ }
73
+
74
+ export type WireRecorderApi = {
75
+ /**
76
+ * Take every recorded exchange, in request order, and clear the recorder.
77
+ * Fails with `WireRecordingIncomplete` (and discards the drained entries)
78
+ * when any request failed or its body was not fully read.
79
+ *
80
+ * A request still pending at drain time is reported in that drain's
81
+ * `WireRecordingIncomplete` and is then dropped: if its body finishes later,
82
+ * the result is ignored and never appears in, or overwrites an entry of, a
83
+ * later drain.
84
+ */
85
+ readonly drain: Effect.Effect<ReadonlyArray<WireExchange>, WireRecordingIncomplete>
86
+ }
87
+
88
+ export class WireRecorder extends Context.Service<WireRecorder, WireRecorderApi>()(
89
+ '@yolk-sdk/conformance/WireRecorder'
90
+ ) {
91
+ /**
92
+ * Wrap the `HttpClient` already in context and provide the recording client
93
+ * plus its `WireRecorder`. Provide the real client below this layer.
94
+ */
95
+ static layer = (
96
+ options: WireRecorderOptions = {}
97
+ ): Layer.Layer<HttpClient.HttpClient | WireRecorder, never, HttpClient.HttpClient> =>
98
+ Layer.unwrap(
99
+ Effect.gen(function* () {
100
+ const upstream = yield* HttpClient.HttpClient
101
+ const { client, recorder } = yield* makeRecordingHttpClient(upstream, options)
102
+
103
+ return Layer.mergeAll(
104
+ Layer.succeed(HttpClient.HttpClient, client),
105
+ Layer.succeed(WireRecorder, recorder)
106
+ )
107
+ })
108
+ )
109
+ }
110
+
111
+ type RecordingEntry =
112
+ | { readonly status: 'pending'; readonly label: string }
113
+ | { readonly status: 'failed'; readonly label: string }
114
+ | { readonly status: 'complete'; readonly label: string; readonly exchange: WireExchange }
115
+
116
+ // `id` is a monotonic reservation id that is never reused, so a finalizer from
117
+ // a request reserved before a `drain` cannot settle an entry reserved after it.
118
+ type RecordingState = {
119
+ readonly nextId: number
120
+ readonly entries: ReadonlyArray<{ readonly id: number; readonly entry: RecordingEntry }>
121
+ }
122
+
123
+ const recordedChunk = (bytes: Uint8Array): WireChunk => {
124
+ const recorded = recordBytes(bytes)
125
+
126
+ return 'text' in recorded ? recorded.text : { base64: recorded.base64 }
127
+ }
128
+
129
+ const recordedBody = (
130
+ status: number,
131
+ headers: WireHeaders,
132
+ bytes: Uint8Array
133
+ ): WireBodyResponse => {
134
+ const recorded = recordBytes(bytes)
135
+
136
+ return 'text' in recorded
137
+ ? { status, headers, body: recorded.text }
138
+ : { status, headers, bodyBase64: recorded.base64 }
139
+ }
140
+
141
+ const headerAllowed = (allowlist: ReadonlyArray<string>, name: string): boolean =>
142
+ !isCredentialHeaderName(name) &&
143
+ allowlist.some(entry => {
144
+ const lower = entry.toLowerCase()
145
+
146
+ return lower.endsWith('*') ? name.startsWith(lower.slice(0, -1)) : name === lower
147
+ })
148
+
149
+ const allowlistHeaders = (
150
+ headers: Readonly<Record<string, string | undefined>>,
151
+ allowlist: ReadonlyArray<string>
152
+ ): WireHeaders => {
153
+ const recorded: Record<string, string> = {}
154
+
155
+ for (const [name, value] of Object.entries(headerRecord(headers))) {
156
+ if (headerAllowed(allowlist, name)) {
157
+ recorded[name] = value
158
+ }
159
+ }
160
+
161
+ return recorded
162
+ }
163
+
164
+ type WireRequestFields = {
165
+ method: string
166
+ url: string
167
+ headers?: WireHeaders
168
+ body?: WireRequest['body']
169
+ }
170
+
171
+ const recordRequest = (
172
+ request: HttpClientRequest.HttpClientRequest,
173
+ allowlist: ReadonlyArray<string>
174
+ ): Effect.Effect<WireRequest> =>
175
+ Effect.gen(function* () {
176
+ const url = Option.match(HttpClientRequest.toUrl(request), {
177
+ onNone: () => request.url,
178
+ onSome: resolved => {
179
+ resolved.hash = ''
180
+
181
+ return resolved.toString()
182
+ }
183
+ })
184
+
185
+ const fields: WireRequestFields = { method: request.method, url }
186
+ const headers = allowlistHeaders(request.headers, allowlist)
187
+
188
+ if (Object.keys(headers).length > 0) {
189
+ fields.headers = headers
190
+ }
191
+
192
+ const text = requestBodyText(request)
193
+
194
+ if (text !== undefined && text.length > 0) {
195
+ const json = yield* parseJsonText(text)
196
+
197
+ fields.body = Option.getOrElse(json, () => text)
198
+ }
199
+
200
+ return fields
201
+ })
202
+
203
+ /**
204
+ * Wrap a host-provided `HttpClient` so every exchange is recorded losslessly.
205
+ * Streamed responses (see `streamMediaTypes`) are teed chunk by chunk with
206
+ * network boundaries preserved: each chunk is stored as text when it is valid
207
+ * UTF-8 on its own (empty chunks as `""`), otherwise as `{ base64 }` of its
208
+ * exact bytes. Other responses are recorded as one `body` (valid UTF-8) or
209
+ * `bodyBase64` (anything else). The caller receives the same status, headers,
210
+ * and bytes. Body read failures still reach the caller as `HttpClientError`s
211
+ * (the upstream error is kept as the cause), matching how `FetchHttpClient`
212
+ * reports them.
213
+ */
214
+ export const makeRecordingHttpClient = (
215
+ upstream: HttpClient.HttpClient,
216
+ options: WireRecorderOptions = {}
217
+ ): Effect.Effect<{ readonly client: HttpClient.HttpClient; readonly recorder: WireRecorderApi }> =>
218
+ Effect.gen(function* () {
219
+ const requestAllowlist = options.requestHeaders ?? defaultRecordedRequestHeaders
220
+ const responseAllowlist = options.responseHeaders ?? defaultRecordedResponseHeaders
221
+
222
+ const streamMediaTypes = (options.streamMediaTypes ?? defaultStreamMediaTypes).map(type =>
223
+ type.toLowerCase()
224
+ )
225
+
226
+ const state = yield* Ref.make<RecordingState>({ nextId: 0, entries: [] })
227
+
228
+ const reserve = (label: string) =>
229
+ Ref.modify(state, (current): [number, RecordingState] => [
230
+ current.nextId,
231
+ {
232
+ nextId: current.nextId + 1,
233
+ entries: [...current.entries, { id: current.nextId, entry: { status: 'pending', label } }]
234
+ }
235
+ ])
236
+
237
+ // Settles only a still-pending entry with this id; an entry already drained
238
+ // (or settled) is left untouched.
239
+ const settle = (id: number, entry: RecordingEntry) =>
240
+ Ref.update(state, current => ({
241
+ ...current,
242
+ entries: current.entries.map(item =>
243
+ item.id === id && item.entry.status === 'pending' ? { id, entry } : item
244
+ )
245
+ }))
246
+
247
+ const client = HttpClient.transform(
248
+ upstream,
249
+ (
250
+ effect: Effect.Effect<
251
+ HttpClientResponse.HttpClientResponse,
252
+ HttpClientError.HttpClientError
253
+ >,
254
+ request
255
+ ) =>
256
+ Effect.gen(function* () {
257
+ const wireRequest = yield* recordRequest(request, requestAllowlist)
258
+ // Query strings may carry credentials: labels keep origin and path only.
259
+ const label = `${wireRequest.method} ${wireRequest.url.split('?', 1)[0]}`
260
+ const id = yield* reserve(label)
261
+ const failed = settle(id, { status: 'failed', label })
262
+ const response = yield* effect.pipe(Effect.tapError(() => failed))
263
+ const headers = headerRecord(response.headers)
264
+ const recordedHeaders = allowlistHeaders(headers, responseAllowlist)
265
+ const init = { status: response.status, headers }
266
+
267
+ const complete = (wireResponse: WireResponse) =>
268
+ settle(id, {
269
+ status: 'complete',
270
+ label,
271
+ exchange: { request: wireRequest, response: wireResponse }
272
+ })
273
+
274
+ const type = mediaType(headers['content-type'])
275
+
276
+ if (type !== undefined && streamMediaTypes.includes(type)) {
277
+ const chunks: Array<WireChunk> = []
278
+
279
+ // Each network chunk is recorded standalone (no decoder carry-over),
280
+ // so replay can reproduce the original bytes and boundaries.
281
+ const teed = response.stream.pipe(
282
+ Stream.tap(bytes => Effect.sync(() => chunks.push(recordedChunk(bytes)))),
283
+ Stream.onExit(exit =>
284
+ Exit.isFailure(exit)
285
+ ? failed
286
+ : complete({ status: response.status, headers: recordedHeaders, chunks })
287
+ )
288
+ )
289
+
290
+ const readable = Stream.toReadableStream(teed, { strategy: { highWaterMark: 0 } })
291
+
292
+ return HttpClientResponse.fromWeb(request, new Response(readable, init))
293
+ }
294
+
295
+ const bytes = yield* response.arrayBuffer.pipe(Effect.tapError(() => failed))
296
+
297
+ yield* complete(recordedBody(response.status, recordedHeaders, new Uint8Array(bytes)))
298
+
299
+ return HttpClientResponse.fromWeb(
300
+ request,
301
+ new Response(isNullBodyStatus(response.status) ? null : bytes, init)
302
+ )
303
+ })
304
+ )
305
+
306
+ const recorder: WireRecorderApi = {
307
+ drain: Ref.modify(state, (current): [RecordingState['entries'], RecordingState] => [
308
+ current.entries,
309
+ { nextId: current.nextId, entries: [] }
310
+ ]).pipe(
311
+ Effect.flatMap(drained => {
312
+ const exchanges: Array<WireExchange> = []
313
+ const incomplete: Array<string> = []
314
+
315
+ for (const { entry } of drained) {
316
+ if (entry.status === 'complete') {
317
+ exchanges.push(entry.exchange)
318
+ } else {
319
+ incomplete.push(entry.label)
320
+ }
321
+ }
322
+
323
+ return incomplete.length > 0
324
+ ? Effect.fail(new WireRecordingIncomplete({ requests: incomplete }))
325
+ : Effect.succeed(exchanges)
326
+ })
327
+ )
328
+ }
329
+
330
+ return { client, recorder }
331
+ })
332
+
333
+ /** A fixture failed schema validation (for example an empty exchange list or bad date). */
334
+ export class WireFixtureInvalid extends Data.TaggedError('WireFixtureInvalid')<{
335
+ readonly fixtureId: string
336
+ readonly cause: unknown
337
+ }> {
338
+ override get message(): string {
339
+ return `Wire fixture ${this.fixtureId} is invalid`
340
+ }
341
+ }
342
+
343
+ /** The secret scan found credentials or credential-like values in a fixture. */
344
+ export class WireFixtureSecretsFound extends Data.TaggedError('WireFixtureSecretsFound')<{
345
+ readonly fixtureId: string
346
+ readonly issues: ReadonlyArray<FixtureSecretIssue>
347
+ }> {
348
+ override get message(): string {
349
+ return `Wire fixture ${this.fixtureId} contains ${this.issues.length} secret-scan issue(s)`
350
+ }
351
+ }
352
+
353
+ export type WireFixtureInput = Omit<WireFixture, 'exchanges'> & {
354
+ readonly exchanges: ReadonlyArray<WireExchange>
355
+ }
356
+
357
+ /**
358
+ * Validate a fixture and run `scanFixtureForSecrets`. Fails with
359
+ * `WireFixtureInvalid` or `WireFixtureSecretsFound`; never returns a fixture
360
+ * with scan issues.
361
+ */
362
+ export const makeWireFixture = (
363
+ input: WireFixtureInput
364
+ ): Effect.Effect<WireFixture, WireFixtureInvalid | WireFixtureSecretsFound> =>
365
+ Effect.gen(function* () {
366
+ const fixture = yield* decodeWireFixture(input).pipe(
367
+ Effect.mapError(cause => new WireFixtureInvalid({ fixtureId: input.id, cause }))
368
+ )
369
+
370
+ const issues = scanFixtureForSecrets(fixture)
371
+
372
+ if (issues.length > 0) {
373
+ return yield* Effect.fail(new WireFixtureSecretsFound({ fixtureId: fixture.id, issues }))
374
+ }
375
+
376
+ return fixture
377
+ })