@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/LICENSE +21 -0
- package/README.md +316 -0
- package/dist/case.d.mts +89 -0
- package/dist/case.d.mts.map +1 -0
- package/dist/case.mjs +101 -0
- package/dist/case.mjs.map +1 -0
- package/dist/fixture.d.mts +432 -0
- package/dist/fixture.d.mts.map +1 -0
- package/dist/fixture.mjs +391 -0
- package/dist/fixture.mjs.map +1 -0
- package/dist/record.d.mts +96 -0
- package/dist/record.d.mts.map +1 -0
- package/dist/record.mjs +212 -0
- package/dist/record.mjs.map +1 -0
- package/dist/replay.d.mts +316 -0
- package/dist/replay.d.mts.map +1 -0
- package/dist/replay.mjs +272 -0
- package/dist/replay.mjs.map +1 -0
- package/dist/runner.d.mts +163 -0
- package/dist/runner.d.mts.map +1 -0
- package/dist/runner.mjs +264 -0
- package/dist/runner.mjs.map +1 -0
- package/dist/wire-internal.d.mts +90 -0
- package/dist/wire-internal.d.mts.map +1 -0
- package/dist/wire-internal.mjs +212 -0
- package/dist/wire-internal.mjs.map +1 -0
- package/package.json +75 -0
- package/src/case.ts +153 -0
- package/src/fixture.ts +625 -0
- package/src/record.ts +377 -0
- package/src/replay.ts +617 -0
- package/src/runner.ts +559 -0
- package/src/wire-internal.ts +382 -0
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
|
+
})
|