feat(http-recorder): sync recorder v0.3 (#35619)
This commit is contained in:
parent
05e8d5be73
commit
3dd29094f4
47 changed files with 2697 additions and 2207 deletions
46
packages/http-recorder/src/api.ts
Normal file
46
packages/http-recorder/src/api.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/** JSON-compatible cassette metadata value. */
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| ReadonlyArray<JsonValue>
|
||||
| { readonly [key: string]: JsonValue }
|
||||
|
||||
/** Additional JSON metadata stored with a cassette. */
|
||||
export type CassetteMetadata = Readonly<Record<string, JsonValue>>
|
||||
|
||||
/** The normalized HTTP request representation used for matching. */
|
||||
export interface RequestSnapshot {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: Record<string, string>
|
||||
readonly body: string
|
||||
}
|
||||
|
||||
/** Returns whether an incoming HTTP request matches a recorded request. */
|
||||
export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean
|
||||
|
||||
/** Additive redaction and header-preservation policy. */
|
||||
export interface RedactOptions {
|
||||
readonly headers?: ReadonlyArray<string>
|
||||
readonly allowRequestHeaders?: ReadonlyArray<string>
|
||||
readonly allowResponseHeaders?: ReadonlyArray<string>
|
||||
readonly queryParameters?: ReadonlyArray<string>
|
||||
readonly jsonFields?: ReadonlyArray<string>
|
||||
readonly url?: (url: string) => string
|
||||
readonly body?: (body: string) => string
|
||||
}
|
||||
|
||||
/** Options shared by HTTP recorder layers. */
|
||||
export interface RecorderOptions {
|
||||
readonly directory?: string
|
||||
readonly metadata?: CassetteMetadata
|
||||
readonly redact?: RedactOptions
|
||||
readonly match?: RequestMatcher
|
||||
}
|
||||
|
||||
/** Recorder configuration for Effect socket and WebSocket layers. */
|
||||
export type SocketRecorderOptions = Omit<RecorderOptions, "match">
|
||||
|
||||
export * as Api from "./api.js"
|
||||
43
packages/http-recorder/src/cassette/model.ts
Normal file
43
packages/http-recorder/src/cassette/model.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Schema } from "effect"
|
||||
import type { CassetteMetadata, JsonValue } from "../api.js"
|
||||
import { HttpInteractionSchema } from "../http/model.js"
|
||||
import { WebSocketInteractionSchema } from "../websocket/model.js"
|
||||
|
||||
export type { CassetteMetadata, JsonValue } from "../api.js"
|
||||
|
||||
const JsonValueSchema = Schema.suspend(
|
||||
(): Schema.Codec<JsonValue> =>
|
||||
Schema.Union([
|
||||
Schema.Null,
|
||||
Schema.Boolean,
|
||||
Schema.Number,
|
||||
Schema.String,
|
||||
Schema.Array(JsonValueSchema),
|
||||
Schema.Record(Schema.String, JsonValueSchema),
|
||||
]),
|
||||
)
|
||||
|
||||
export const CassetteMetadataSchema = Schema.Record(Schema.String, JsonValueSchema)
|
||||
|
||||
export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe(
|
||||
Schema.toTaggedUnion("transport"),
|
||||
)
|
||||
export type Interaction = Schema.Schema.Type<typeof InteractionSchema>
|
||||
|
||||
export const isHttpInteraction = InteractionSchema.guards.http
|
||||
export const isWebSocketInteraction = InteractionSchema.guards.websocket
|
||||
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
|
||||
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
|
||||
interactions.filter(isWebSocketInteraction)
|
||||
|
||||
export const CassetteSchema = Schema.Struct({
|
||||
version: Schema.Literal(1),
|
||||
metadata: Schema.optional(CassetteMetadataSchema),
|
||||
interactions: Schema.Array(InteractionSchema),
|
||||
})
|
||||
export type Cassette = Schema.Schema.Type<typeof CassetteSchema>
|
||||
|
||||
export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema)
|
||||
export const encodeCassette = Schema.encodeSync(CassetteSchema)
|
||||
|
||||
export * as CassetteModel from "./model.js"
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js"
|
||||
import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js"
|
||||
import { existsSync, rmSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { secretFindings, SecretFindingSchema, type SecretFinding } from "../redaction/secrets.js"
|
||||
import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./model.js"
|
||||
|
||||
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||
|
||||
|
|
@ -14,6 +14,15 @@ export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFo
|
|||
}
|
||||
}
|
||||
|
||||
export class InvalidCassetteError extends Schema.TaggedErrorClass<InvalidCassetteError>()("InvalidCassetteError", {
|
||||
cassetteName: Schema.String,
|
||||
description: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Cassette "${this.cassetteName}" is invalid: ${this.description}`
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
||||
cassetteName: Schema.String,
|
||||
findings: Schema.Array(SecretFindingSchema),
|
||||
|
|
@ -26,7 +35,9 @@ export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteE
|
|||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
||||
readonly read: (
|
||||
name: string,
|
||||
) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError | InvalidCassetteError>
|
||||
readonly append: (
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
|
|
@ -50,7 +61,10 @@ const cassettePath = (directory: string, name: string) => {
|
|||
}
|
||||
|
||||
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
|
||||
fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
|
||||
existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
|
||||
|
||||
export const removeCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
|
||||
rmSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name), { force: true })
|
||||
|
||||
const buildCassette = (
|
||||
name: string,
|
||||
|
|
@ -58,14 +72,16 @@ const buildCassette = (
|
|||
metadata: CassetteMetadata | undefined,
|
||||
): Cassette => ({
|
||||
version: 1,
|
||||
metadata: { name, recordedAt: new Date().toISOString(), ...metadata },
|
||||
metadata: { ...metadata, name, recordedAt: new Date().toISOString() },
|
||||
interactions,
|
||||
})
|
||||
|
||||
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
||||
|
||||
const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))
|
||||
|
||||
const invalidCassette = (name: string, error: unknown) =>
|
||||
new InvalidCassetteError({
|
||||
cassetteName: name,
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
|
||||
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
|
||||
|
||||
|
|
@ -79,9 +95,7 @@ export const fileSystem = (
|
|||
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
||||
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
|
||||
const appendLock = yield* Semaphore.make(1)
|
||||
|
||||
const pathFor = (name: string) => cassettePath(directory, name)
|
||||
|
||||
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
|
|
@ -98,8 +112,17 @@ export const fileSystem = (
|
|||
return Service.of({
|
||||
read: (name) =>
|
||||
fs.readFileString(pathFor(name)).pipe(
|
||||
Effect.map((raw) => parseCassette(raw).interactions),
|
||||
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
|
||||
Effect.mapError((error) =>
|
||||
error.reason._tag === "NotFound"
|
||||
? new CassetteNotFoundError({ cassetteName: name })
|
||||
: invalidCassette(name, error),
|
||||
),
|
||||
Effect.flatMap((raw) =>
|
||||
Effect.try({
|
||||
try: () => parseCassette(raw).interactions,
|
||||
catch: (error) => invalidCassette(name, error),
|
||||
}),
|
||||
),
|
||||
),
|
||||
append: (name, interaction, metadata) =>
|
||||
appendLock.withPermit(
|
||||
|
|
@ -151,7 +174,6 @@ export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {})
|
|||
)
|
||||
const accumulatedFindings = new Map<string, SecretFinding[]>()
|
||||
const appendLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
return Service.of({
|
||||
read: (name) =>
|
||||
stored.has(name)
|
||||
|
|
@ -162,7 +184,7 @@ export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {})
|
|||
Effect.suspend(() => {
|
||||
const interactions = [...(stored.get(name) ?? []), interaction]
|
||||
const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)]
|
||||
const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings
|
||||
const allFindings = metadata ? [...findings, ...secretFindings({ ...metadata, name })] : findings
|
||||
return failIfUnsafe(name, allFindings).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import * as Layer from "effect/Layer"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import type * as HttpClient from "effect/unstable/http/HttpClient"
|
||||
import * as CassetteService from "./cassette.js"
|
||||
import { recordingLayer } from "./internal-effect.js"
|
||||
import { make } from "./redactor.js"
|
||||
import type { RecorderOptions } from "./types.js"
|
||||
|
||||
/**
|
||||
* Provides a fetch-backed `HttpClient` with cassette recording and replay.
|
||||
*
|
||||
* Locally, a missing cassette is recorded from the real service. Existing
|
||||
* cassettes are replayed, and `CI=true` makes a missing cassette fail.
|
||||
*/
|
||||
export const http = (name: string, options: RecorderOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
||||
recordingLayer(name, {
|
||||
metadata: options.metadata,
|
||||
redactor: make(options.redact),
|
||||
match: options.match,
|
||||
}).pipe(
|
||||
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
|
|
@ -1,64 +1,31 @@
|
|||
import { Option, Schema } from "effect"
|
||||
import { REDACTED, secretFindings } from "./redaction.js"
|
||||
import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.js"
|
||||
import { HashSet, Option } from "effect"
|
||||
import type { RequestMatcher, RequestSnapshot } from "../api.js"
|
||||
import { canonicalizeJson, decodeJson, isJsonRecord, jsonBody, safeText } from "../replay/comparison.js"
|
||||
import type { HttpInteraction } from "./model.js"
|
||||
|
||||
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
||||
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
|
||||
export const canonicalizeJson = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(canonicalizeJson)
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.toSorted()
|
||||
.map((key) => [key, canonicalizeJson(value[key])]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export type { RequestMatcher } from "./types.js"
|
||||
export type { RequestMatcher } from "../api.js"
|
||||
|
||||
export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
||||
JSON.stringify({
|
||||
method: snapshot.method,
|
||||
url: snapshot.url,
|
||||
headers: canonicalizeJson(snapshot.headers),
|
||||
body: Option.match(decodeJson(snapshot.body), {
|
||||
onNone: () => snapshot.body,
|
||||
onSome: canonicalizeJson,
|
||||
}),
|
||||
body: Option.match(decodeJson(snapshot.body), { onNone: () => snapshot.body, onSome: canonicalizeJson }),
|
||||
})
|
||||
|
||||
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
||||
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
||||
|
||||
export const safeText = (value: unknown) => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||
const text = JSON.stringify(value)
|
||||
if (!text) return typeof value
|
||||
return text.length > 300 ? `${text.slice(0, 300)}...` : text
|
||||
}
|
||||
|
||||
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
|
||||
|
||||
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
|
||||
if (Object.is(expected, received)) return []
|
||||
if (isRecord(expected) && isRecord(received)) {
|
||||
if (isJsonRecord(expected) && isJsonRecord(received))
|
||||
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
|
||||
.toSorted()
|
||||
.flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
|
||||
.slice(0, limit)
|
||||
}
|
||||
if (Array.isArray(expected) && Array.isArray(received)) {
|
||||
if (Array.isArray(expected) && Array.isArray(received))
|
||||
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
|
||||
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
|
||||
.slice(0, limit)
|
||||
}
|
||||
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
|
||||
}
|
||||
|
||||
|
|
@ -72,12 +39,9 @@ const headerDiffs = (expected: Record<string, string>, received: Record<string,
|
|||
|
||||
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
|
||||
const lines: string[] = []
|
||||
if (expected.method !== received.method) {
|
||||
if (expected.method !== received.method)
|
||||
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
|
||||
}
|
||||
if (expected.url !== received.url) {
|
||||
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
|
||||
}
|
||||
if (expected.url !== received.url) lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
|
||||
const headers = headerDiffs(expected.headers, received.headers)
|
||||
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
|
||||
const expectedBody = jsonBody(expected.body)
|
||||
|
|
@ -92,15 +56,22 @@ export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot
|
|||
return lines
|
||||
}
|
||||
|
||||
export const selectSequential = (
|
||||
export const selectFirstMatching = (
|
||||
interactions: ReadonlyArray<HttpInteraction>,
|
||||
incoming: RequestSnapshot,
|
||||
match: RequestMatcher,
|
||||
index: number,
|
||||
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
|
||||
const interaction = interactions[index]
|
||||
if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
|
||||
if (!match(incoming, interaction.request))
|
||||
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
|
||||
return { interaction, detail: "" }
|
||||
used: HashSet.HashSet<number>,
|
||||
): { readonly _tag: "Matched"; readonly index: number } | { readonly _tag: "Unmatched"; readonly detail: string } => {
|
||||
let firstUnused: HttpInteraction | undefined
|
||||
for (let index = 0; index < interactions.length; index++) {
|
||||
if (HashSet.has(used, index)) continue
|
||||
const interaction = interactions[index]
|
||||
firstUnused ??= interaction
|
||||
if (match(incoming, interaction.request)) return { _tag: "Matched", index }
|
||||
}
|
||||
if (firstUnused === undefined)
|
||||
return { _tag: "Unmatched", detail: `all ${interactions.length} recorded interactions have already been consumed` }
|
||||
return { _tag: "Unmatched", detail: requestDiff(firstUnused.request, incoming).join("\n") }
|
||||
}
|
||||
|
||||
export * as HttpMatching from "./matching.js"
|
||||
30
packages/http-recorder/src/http/model.ts
Normal file
30
packages/http-recorder/src/http/model.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Schema } from "effect"
|
||||
import type { RequestSnapshot } from "../api.js"
|
||||
|
||||
export const RequestSnapshotSchema = Schema.Struct({
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.String,
|
||||
})
|
||||
|
||||
export type { RequestSnapshot } from "../api.js"
|
||||
|
||||
export const ResponseSnapshotSchema = Schema.Struct({
|
||||
status: Schema.Number,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.String,
|
||||
bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
|
||||
})
|
||||
|
||||
export interface ResponseSnapshot extends Schema.Schema.Type<typeof ResponseSnapshotSchema> {}
|
||||
|
||||
export const HttpInteractionSchema = Schema.Struct({
|
||||
transport: Schema.tag("http"),
|
||||
request: RequestSnapshotSchema,
|
||||
response: ResponseSnapshotSchema,
|
||||
})
|
||||
|
||||
export interface HttpInteraction extends Schema.Schema.Type<typeof HttpInteractionSchema> {}
|
||||
|
||||
export * as HttpModel from "./model.js"
|
||||
|
|
@ -1,27 +1,22 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Deferred, Effect, Layer, Option, Ref } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node-shared"
|
||||
import { Deferred, Effect, Layer, Ref } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
Headers,
|
||||
HttpBody,
|
||||
HttpClient,
|
||||
HttpClientError,
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
UrlParams,
|
||||
} from "effect/unstable/http"
|
||||
import * as CassetteService from "./cassette.js"
|
||||
import { defaultMatcher, selectSequential } from "./matching.js"
|
||||
import { makeReplayState, resolveAutoMode } from "./recorder.js"
|
||||
import { make, type Redactor } from "./redactor.js"
|
||||
import { redactUrl } from "./redaction.js"
|
||||
import { httpInteractions } from "./schema.js"
|
||||
import type { CassetteMetadata, HttpInteraction, RequestMatcher, ResponseSnapshot } from "./types.js"
|
||||
import { fileSystem, Service } from "../cassette/store.js"
|
||||
import type { RecorderOptions } from "../options.js"
|
||||
import { make, redactUrl, type Redactor } from "../redaction/redactor.js"
|
||||
import { makeReplayPoolState, resolveAutoMode } from "../replay/state.js"
|
||||
import { httpInteractions, type CassetteMetadata } from "../cassette/model.js"
|
||||
import { defaultMatcher, selectFirstMatching, type RequestMatcher } from "./matching.js"
|
||||
import type { HttpInteraction, ResponseSnapshot } from "./model.js"
|
||||
|
||||
export { defaultMatcher }
|
||||
|
||||
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
|
||||
|
||||
export interface RecordReplayOptions {
|
||||
readonly mode?: RecordReplayMode
|
||||
readonly directory?: string
|
||||
|
|
@ -40,7 +35,6 @@ const TEXT_CONTENT_TYPES = new Set([
|
|||
"application/yaml",
|
||||
"image/svg+xml",
|
||||
])
|
||||
|
||||
const isTextContentType = (contentType: string | undefined) => {
|
||||
const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase()
|
||||
if (!mediaType) return false
|
||||
|
|
@ -51,7 +45,6 @@ const isTextContentType = (contentType: string | undefined) => {
|
|||
TEXT_CONTENT_TYPES.has(mediaType)
|
||||
)
|
||||
}
|
||||
|
||||
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
|
||||
response.arrayBuffer.pipe(
|
||||
Effect.map((bytes) =>
|
||||
|
|
@ -60,10 +53,8 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co
|
|||
: { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const },
|
||||
),
|
||||
)
|
||||
|
||||
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
|
||||
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
||||
|
||||
const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snapshot: ResponseSnapshot) =>
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
|
|
@ -75,35 +66,28 @@ const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snap
|
|||
),
|
||||
)
|
||||
|
||||
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
HttpClientRequest.makeWith(
|
||||
request.method,
|
||||
redactUrl(request.url),
|
||||
UrlParams.empty,
|
||||
Option.none(),
|
||||
Headers.empty,
|
||||
HttpBody.empty,
|
||||
)
|
||||
|
||||
const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
|
||||
export const redactedErrorRequest = (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
redactedUrl = redactUrl(request.url),
|
||||
) => HttpClientRequest.make(request.method)(redactedUrl)
|
||||
const transportError = (request: HttpClientRequest.HttpClientRequest, description: string, redactedUrl?: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
|
||||
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request, redactedUrl), description }),
|
||||
})
|
||||
|
||||
export const recordingLayer = (
|
||||
name: string,
|
||||
options: Omit<RecordReplayOptions, "directory"> = {},
|
||||
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> =>
|
||||
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | Service> =>
|
||||
Layer.effect(
|
||||
HttpClient.HttpClient,
|
||||
Effect.gen(function* () {
|
||||
const upstream = yield* HttpClient.HttpClient
|
||||
const cassetteService = yield* CassetteService.Service
|
||||
const cassette = yield* Service
|
||||
const redactor = options.redactor ?? make()
|
||||
const match = options.match ?? defaultMatcher
|
||||
const requested = options.mode ?? "auto"
|
||||
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
|
||||
|
||||
const mode = requested === "auto" ? yield* resolveAutoMode(cassette, name) : requested
|
||||
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||
|
|
@ -114,9 +98,7 @@ export const recordingLayer = (
|
|||
body: yield* Effect.promise(() => web.text()),
|
||||
})
|
||||
})
|
||||
|
||||
if (mode === "passthrough") return upstream
|
||||
|
||||
if (mode === "record") {
|
||||
const initial = yield* Deferred.make<void>()
|
||||
yield* Deferred.succeed(initial, undefined)
|
||||
|
|
@ -127,6 +109,7 @@ export const recordingLayer = (
|
|||
const previous = yield* Ref.modify(tail, (current) => [current, completed])
|
||||
return yield* Effect.gen(function* () {
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const requestError = (description: string) => transportError(request, description, incoming.url)
|
||||
const response = yield* upstream.execute(request)
|
||||
const captured = yield* captureResponseBody(response, response.headers["content-type"])
|
||||
const responseSnapshot: ResponseSnapshot = {
|
||||
|
|
@ -140,39 +123,32 @@ export const recordingLayer = (
|
|||
response: redactor.response(responseSnapshot),
|
||||
}
|
||||
yield* Deferred.await(previous)
|
||||
yield* cassetteService
|
||||
yield* cassette
|
||||
.append(name, interaction, options.metadata)
|
||||
.pipe(
|
||||
Effect.catchTag("UnsafeCassetteError", (error) =>
|
||||
Effect.fail(transportError(request, error.message)),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(requestError(error.message))))
|
||||
return responseFromSnapshot(request, responseSnapshot)
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(completed, undefined)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
|
||||
const replay = yield* makeReplayPoolState(cassette, name, httpInteractions)
|
||||
return HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const requestError = (description: string) => transportError(request, description, incoming.url)
|
||||
const claimed = yield* replay
|
||||
.claim((interaction, index, interactions) => {
|
||||
const result = selectSequential(interactions, incoming, match, index)
|
||||
if (result.interaction) return Effect.void
|
||||
.claim((interactions, used) => {
|
||||
const result = selectFirstMatching(interactions, incoming, match, used)
|
||||
if (result._tag === "Matched") return Effect.succeed(result.index)
|
||||
return Effect.fail(
|
||||
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
|
||||
requestError(`Fixture "${name}" does not match the current request: ${result.detail}.`),
|
||||
)
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
error._tag === "CassetteNotFoundError"
|
||||
? transportError(
|
||||
request,
|
||||
`Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`,
|
||||
)
|
||||
: error,
|
||||
? requestError(`Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`)
|
||||
: requestError(error.message),
|
||||
),
|
||||
)
|
||||
return responseFromSnapshot(request, claimed.interaction.response)
|
||||
|
|
@ -183,7 +159,18 @@ export const recordingLayer = (
|
|||
|
||||
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
||||
recordingLayer(name, options).pipe(
|
||||
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
||||
Layer.provide(fileSystem({ directory: options.directory })),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
|
||||
export const layer = (
|
||||
name: string,
|
||||
options: RecorderOptions = {},
|
||||
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient> =>
|
||||
recordingLayer(name, { metadata: options.metadata, redactor: make(options.redact), match: options.match }).pipe(
|
||||
Layer.provide(fileSystem({ directory: options.directory })),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
export const layerFetch = (name: string, options: RecorderOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
||||
layer(name, options).pipe(Layer.provide(FetchHttpClient.layer))
|
||||
|
|
@ -1,18 +1,43 @@
|
|||
import { http } from "./effect.js"
|
||||
import { socket } from "./socket.js"
|
||||
import { Layer } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { Api } from "./api.js"
|
||||
import { hasCassetteSync, removeCassetteSync } from "./cassette/store.js"
|
||||
import { layer, layerFetch } from "./http/recorder.js"
|
||||
import { layerSocket, layerWebSocketConstructor } from "./websocket/recorder.js"
|
||||
|
||||
/** HTTP and WebSocket cassette recording. */
|
||||
export const HttpRecorder = { http, socket } as const
|
||||
export const HttpRecorder: {
|
||||
readonly layer: (
|
||||
name: string,
|
||||
options?: Api.RecorderOptions,
|
||||
) => Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient>
|
||||
readonly layerFetch: (name: string, options?: Api.RecorderOptions) => Layer.Layer<HttpClient.HttpClient>
|
||||
readonly layerSocket: (
|
||||
name: string,
|
||||
options?: Api.SocketRecorderOptions,
|
||||
) => Layer.Layer<Socket.Socket, never, Socket.Socket>
|
||||
readonly layerWebSocketConstructor: (
|
||||
name: string,
|
||||
options?: Api.SocketRecorderOptions,
|
||||
) => Layer.Layer<Socket.WebSocketConstructor, never, Socket.WebSocketConstructor>
|
||||
readonly hasCassetteSync: (name: string, options?: { readonly directory?: string }) => boolean
|
||||
readonly removeCassetteSync: (name: string, options?: { readonly directory?: string }) => void
|
||||
} = { hasCassetteSync, layer, layerFetch, layerSocket, layerWebSocketConstructor, removeCassetteSync }
|
||||
|
||||
export namespace HttpRecorder {
|
||||
/** Additional JSON metadata stored with a cassette. */
|
||||
export type CassetteMetadata = import("./types.js").CassetteMetadata
|
||||
export type JsonValue = Api.JsonValue
|
||||
/** Additional JSON metadata stored with a cassette. */
|
||||
export type CassetteMetadata = Api.CassetteMetadata
|
||||
/** Recorder configuration. */
|
||||
export type RecorderOptions = import("./types.js").RecorderOptions
|
||||
export type RecorderOptions = Api.RecorderOptions
|
||||
/** Additive redaction and header-preservation policy. */
|
||||
export type RedactOptions = import("./types.js").RedactOptions
|
||||
export type RedactOptions = Api.RedactOptions
|
||||
/** Returns whether an incoming HTTP request matches a recorded request. */
|
||||
export type RequestMatcher = import("./types.js").RequestMatcher
|
||||
export type RequestMatcher = Api.RequestMatcher
|
||||
/** The normalized HTTP request representation used for matching. */
|
||||
export type RequestSnapshot = import("./types.js").RequestSnapshot
|
||||
export type RequestSnapshot = Api.RequestSnapshot
|
||||
/** Recorder configuration for Effect socket and WebSocket layers. */
|
||||
export type SocketRecorderOptions = Api.SocketRecorderOptions
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette.js"
|
||||
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./internal-effect.js"
|
||||
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction.js"
|
||||
export { socketLayer } from "./socket.js"
|
||||
export {
|
||||
makeWebSocketExecutor,
|
||||
type WebSocketConnection,
|
||||
type WebSocketExecutor,
|
||||
type WebSocketRecordReplayOptions,
|
||||
type WebSocketRequest,
|
||||
} from "./websocket.js"
|
||||
export * as Cassette from "./cassette.js"
|
||||
export * as Redactor from "./redactor.js"
|
||||
|
||||
export * as HttpRecorderInternal from "./internal.js"
|
||||
1
packages/http-recorder/src/options.ts
Normal file
1
packages/http-recorder/src/options.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type { RecorderOptions, RedactOptions, SocketRecorderOptions } from "./api.js"
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { Effect, Scope, SynchronizedRef } from "effect"
|
||||
import type * as CassetteService from "./cassette.js"
|
||||
import type { CassetteNotFoundError } from "./cassette.js"
|
||||
import type { Interaction } from "./schema.js"
|
||||
|
||||
const isCI = () => {
|
||||
const value = process.env.CI
|
||||
return value !== undefined && value !== "" && value !== "false" && value !== "0"
|
||||
}
|
||||
|
||||
export const resolveAutoMode = (
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
): Effect.Effect<"record" | "replay" | "passthrough"> =>
|
||||
Effect.gen(function* () {
|
||||
if (isCI()) return "replay"
|
||||
return (yield* cassette.exists(name)) ? "replay" : "record"
|
||||
})
|
||||
|
||||
export interface ReplayState<T> {
|
||||
readonly claim: <E>(
|
||||
validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray<T>) => Effect.Effect<void, E>,
|
||||
) => Effect.Effect<{ readonly interaction: T; readonly index: number }, CassetteNotFoundError | E>
|
||||
}
|
||||
|
||||
export const makeReplayState = <T>(
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
|
||||
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
|
||||
const position = yield* SynchronizedRef.make(0)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
const used = yield* SynchronizedRef.get(position)
|
||||
if (used === 0) return yield* Effect.void
|
||||
const interactions = yield* load.pipe(Effect.orDie)
|
||||
if (used < interactions.length)
|
||||
return yield* Effect.die(
|
||||
new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`),
|
||||
)
|
||||
return yield* Effect.void
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
claim: (validate) =>
|
||||
Effect.flatMap(load, (interactions) =>
|
||||
SynchronizedRef.modifyEffect(position, (index) =>
|
||||
Effect.gen(function* () {
|
||||
const interaction = interactions[index]
|
||||
yield* validate(interaction, index, interactions)
|
||||
if (interaction === undefined)
|
||||
return yield* Effect.die("Replay validation accepted a missing interaction")
|
||||
return [{ interaction, index }, index + 1] as const
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import { Schema } from "effect"
|
||||
|
||||
export const REDACTED = "[REDACTED]"
|
||||
|
||||
const DEFAULT_REDACT_HEADERS = [
|
||||
"authorization",
|
||||
"cookie",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
"x-api-key",
|
||||
"x-amz-security-token",
|
||||
"x-goog-api-key",
|
||||
]
|
||||
|
||||
const DEFAULT_REDACT_QUERY = [
|
||||
"access_token",
|
||||
"api-key",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"code",
|
||||
"key",
|
||||
"signature",
|
||||
"sig",
|
||||
"token",
|
||||
"x-amz-credential",
|
||||
"x-amz-security-token",
|
||||
"x-amz-signature",
|
||||
]
|
||||
|
||||
const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
|
||||
{ label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
|
||||
{ label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
|
||||
{ label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
|
||||
{ label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
|
||||
{ label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
||||
{ label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
|
||||
{ label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
||||
]
|
||||
|
||||
const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
|
||||
const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
|
||||
|
||||
const envSecrets = () =>
|
||||
Object.entries(process.env).flatMap(([name, value]) => {
|
||||
if (!value) return []
|
||||
if (!ENV_SECRET_NAMES.test(name)) return []
|
||||
if (value.length < 12) return []
|
||||
if (SAFE_ENV_VALUES.has(value.toLowerCase())) return []
|
||||
return [{ name, value }]
|
||||
})
|
||||
|
||||
const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
|
||||
|
||||
const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
|
||||
if (typeof value === "string") return [{ path: base, value }]
|
||||
if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
|
||||
new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
|
||||
|
||||
export type UrlRedactor = (url: string) => string
|
||||
|
||||
export const redactUrl = (
|
||||
raw: string,
|
||||
query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY,
|
||||
urlRedactor?: UrlRedactor,
|
||||
) => {
|
||||
if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw
|
||||
const url = new URL(raw)
|
||||
if (url.username) url.username = REDACTED
|
||||
if (url.password) url.password = REDACTED
|
||||
const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
|
||||
}
|
||||
return urlRedactor?.(url.toString()) ?? url.toString()
|
||||
}
|
||||
|
||||
export const redactHeaders = (
|
||||
headers: Record<string, string>,
|
||||
allow: ReadonlyArray<string>,
|
||||
redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
|
||||
) => {
|
||||
const allowed = new Set(allow.map((name) => name.toLowerCase()))
|
||||
const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers)
|
||||
.map(([name, value]) => [name.toLowerCase(), value] as const)
|
||||
.filter(([name]) => allowed.has(name))
|
||||
.map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
|
||||
.toSorted(([a], [b]) => a.localeCompare(b)),
|
||||
)
|
||||
}
|
||||
|
||||
export const SecretFindingSchema = Schema.Struct({
|
||||
path: Schema.String,
|
||||
reason: Schema.String,
|
||||
})
|
||||
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
|
||||
|
||||
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> => {
|
||||
const environment = envSecrets()
|
||||
return stringEntries(value).flatMap((entry) => [
|
||||
...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
|
||||
path: entry.path,
|
||||
reason: item.label,
|
||||
})),
|
||||
...environment
|
||||
.filter((item) => entry.value.includes(item.value))
|
||||
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
|
||||
])
|
||||
}
|
||||
173
packages/http-recorder/src/redaction/redactor.ts
Normal file
173
packages/http-recorder/src/redaction/redactor.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { Option, Schema } from "effect"
|
||||
import type { RequestSnapshot, ResponseSnapshot } from "../http/model.js"
|
||||
import type { RedactOptions } from "../options.js"
|
||||
|
||||
export type { RedactOptions } from "../options.js"
|
||||
export const REDACTED = "[REDACTED]"
|
||||
|
||||
const DEFAULT_REDACT_HEADERS = [
|
||||
"authorization",
|
||||
"cookie",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
"x-api-key",
|
||||
"x-amz-security-token",
|
||||
"x-goog-api-key",
|
||||
]
|
||||
const DEFAULT_REDACT_QUERY = [
|
||||
"access_token",
|
||||
"api-key",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"code",
|
||||
"key",
|
||||
"signature",
|
||||
"sig",
|
||||
"token",
|
||||
"x-amz-credential",
|
||||
"x-amz-security-token",
|
||||
"x-amz-signature",
|
||||
]
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
|
||||
new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
|
||||
|
||||
export const redactUrl = (
|
||||
raw: string,
|
||||
query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY,
|
||||
transform?: (url: string) => string,
|
||||
) => {
|
||||
if (!URL.canParse(raw)) return transform?.(raw) ?? raw
|
||||
const url = new URL(raw)
|
||||
if (url.username) url.username = REDACTED
|
||||
if (url.password) url.password = REDACTED
|
||||
const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
|
||||
for (const key of url.searchParams.keys()) if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
|
||||
return transform?.(url.toString()) ?? url.toString()
|
||||
}
|
||||
|
||||
export const redactHeaders = (
|
||||
headers: Record<string, string>,
|
||||
allow: ReadonlyArray<string>,
|
||||
redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
|
||||
) => {
|
||||
const allowed = new Set(allow.map((name) => name.toLowerCase()))
|
||||
const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers)
|
||||
.map(([name, value]) => [name.toLowerCase(), value] as const)
|
||||
.filter(([name]) => allowed.has(name))
|
||||
.map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
|
||||
.toSorted(([a], [b]) => a.localeCompare(b)),
|
||||
)
|
||||
}
|
||||
|
||||
const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
||||
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
||||
const identity = <T>(value: T) => value
|
||||
|
||||
export interface Redactor {
|
||||
readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
|
||||
readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
|
||||
}
|
||||
|
||||
export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
|
||||
const requests = redactors
|
||||
.map((redactor) => redactor.request)
|
||||
.filter((fn): fn is Redactor["request"] => fn !== undefined)
|
||||
const responses = redactors
|
||||
.map((redactor) => redactor.response)
|
||||
.filter((fn): fn is Redactor["response"] => fn !== undefined)
|
||||
return {
|
||||
request: requests.length === 0 ? identity : (snapshot) => requests.reduce((value, fn) => fn(value), snapshot),
|
||||
response: responses.length === 0 ? identity : (snapshot) => responses.reduce((value, fn) => fn(value), snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
interface HeaderOptions {
|
||||
readonly allow?: ReadonlyArray<string>
|
||||
readonly redact?: ReadonlyArray<string>
|
||||
}
|
||||
const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({
|
||||
...snapshot,
|
||||
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
|
||||
}),
|
||||
})
|
||||
const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||
response: (snapshot) => ({
|
||||
...snapshot,
|
||||
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
|
||||
}),
|
||||
})
|
||||
|
||||
interface UrlOptions {
|
||||
readonly query?: ReadonlyArray<string>
|
||||
readonly transform?: (url: string) => string
|
||||
}
|
||||
const url = (options: UrlOptions = {}): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
|
||||
})
|
||||
|
||||
const DEFAULT_REDACT_JSON_FIELDS = [
|
||||
"access_token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"client_secret",
|
||||
"password",
|
||||
"refresh_token",
|
||||
"secret",
|
||||
"token",
|
||||
]
|
||||
const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase()
|
||||
interface RedactedJson {
|
||||
readonly value: unknown
|
||||
readonly changed: boolean
|
||||
}
|
||||
const redactJsonFields = (value: unknown, fields: ReadonlySet<string>): RedactedJson => {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map((item) => redactJsonFields(item, fields))
|
||||
return { value: items.map((item) => item.value), changed: items.some((item) => item.changed) }
|
||||
}
|
||||
if (!value || typeof value !== "object") return { value, changed: false }
|
||||
let changed = false
|
||||
const entries = Object.entries(value).map(([key, child]) => {
|
||||
if (fields.has(normalizeField(key))) {
|
||||
if (child !== REDACTED) changed = true
|
||||
return [key, REDACTED] as const
|
||||
}
|
||||
const redacted = redactJsonFields(child, fields)
|
||||
if (redacted.changed) changed = true
|
||||
return [key, redacted.value] as const
|
||||
})
|
||||
return { value: Object.fromEntries(entries), changed }
|
||||
}
|
||||
const redactBody = (value: string, fields: ReadonlySet<string>, transform: ((body: string) => string) | undefined) => {
|
||||
const redacted = Option.match(decodeJson(value), {
|
||||
onNone: () => value,
|
||||
onSome: (parsed) => {
|
||||
const result = redactJsonFields(parsed, fields)
|
||||
return result.changed ? JSON.stringify(result.value) : value
|
||||
},
|
||||
})
|
||||
return transform?.(redacted) ?? redacted
|
||||
}
|
||||
|
||||
export const make = (options: RedactOptions = {}): Redactor => {
|
||||
const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField))
|
||||
return compose(
|
||||
requestHeaders({
|
||||
allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])],
|
||||
redact: options.headers,
|
||||
}),
|
||||
responseHeaders({
|
||||
allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])],
|
||||
redact: options.headers,
|
||||
}),
|
||||
url({ query: options.queryParameters, transform: options.url }),
|
||||
{
|
||||
request: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }),
|
||||
response: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }),
|
||||
},
|
||||
)
|
||||
}
|
||||
47
packages/http-recorder/src/redaction/secrets.ts
Normal file
47
packages/http-recorder/src/redaction/secrets.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Schema } from "effect"
|
||||
|
||||
const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
|
||||
{ label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
|
||||
{ label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
|
||||
{ label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
|
||||
{ label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
|
||||
{ label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
||||
{ label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
|
||||
{ label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
||||
]
|
||||
|
||||
const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
|
||||
const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
|
||||
|
||||
const envSecrets = () =>
|
||||
Object.entries(process.env).flatMap(([name, value]) => {
|
||||
if (!value || !ENV_SECRET_NAMES.test(name) || value.length < 12 || SAFE_ENV_VALUES.has(value.toLowerCase()))
|
||||
return []
|
||||
return [{ name, value }]
|
||||
})
|
||||
|
||||
const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
|
||||
|
||||
const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
|
||||
if (typeof value === "string") return [{ path: base, value }]
|
||||
if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
|
||||
if (value && typeof value === "object")
|
||||
return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
|
||||
return []
|
||||
}
|
||||
|
||||
export const SecretFindingSchema = Schema.Struct({ path: Schema.String, reason: Schema.String })
|
||||
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
|
||||
|
||||
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> => {
|
||||
const environment = envSecrets()
|
||||
return stringEntries(value).flatMap((entry) => [
|
||||
...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
|
||||
path: entry.path,
|
||||
reason: item.label,
|
||||
})),
|
||||
...environment
|
||||
.filter((item) => entry.value.includes(item.value))
|
||||
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
|
||||
])
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import { Option } from "effect"
|
||||
import { decodeJson } from "./matching.js"
|
||||
import { REDACTED, redactHeaders, redactUrl } from "./redaction.js"
|
||||
import type { RedactOptions, RequestSnapshot, ResponseSnapshot } from "./types.js"
|
||||
|
||||
export type { RedactOptions } from "./types.js"
|
||||
|
||||
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
||||
export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
||||
|
||||
const identity = <T>(value: T) => value
|
||||
|
||||
export interface Redactor {
|
||||
readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
|
||||
readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
|
||||
}
|
||||
|
||||
export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
|
||||
const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
|
||||
const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
|
||||
return {
|
||||
request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
|
||||
response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
export interface HeaderOptions {
|
||||
readonly allow?: ReadonlyArray<string>
|
||||
readonly redact?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({
|
||||
...snapshot,
|
||||
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
|
||||
}),
|
||||
})
|
||||
|
||||
export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||
response: (snapshot) => ({
|
||||
...snapshot,
|
||||
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
|
||||
}),
|
||||
})
|
||||
|
||||
export interface UrlOptions {
|
||||
readonly query?: ReadonlyArray<string>
|
||||
readonly transform?: (url: string) => string
|
||||
}
|
||||
|
||||
export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
|
||||
})
|
||||
|
||||
export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({
|
||||
...snapshot,
|
||||
body: Option.match(decodeJson(snapshot.body), {
|
||||
onNone: () => snapshot.body,
|
||||
onSome: (parsed) => JSON.stringify(transform(parsed)),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export interface DefaultRedactorOverrides {
|
||||
readonly requestHeaders?: HeaderOptions
|
||||
readonly responseHeaders?: HeaderOptions
|
||||
readonly url?: UrlOptions
|
||||
readonly body?: (parsed: unknown) => unknown
|
||||
}
|
||||
|
||||
const DEFAULT_REDACT_JSON_FIELDS = [
|
||||
"access_token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"client_secret",
|
||||
"password",
|
||||
"refresh_token",
|
||||
"secret",
|
||||
"token",
|
||||
]
|
||||
|
||||
const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase()
|
||||
|
||||
const redactJsonFields = (value: unknown, fields: ReadonlySet<string>): unknown => {
|
||||
if (Array.isArray(value)) return value.map((item) => redactJsonFields(item, fields))
|
||||
if (!value || typeof value !== "object") return value
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
fields.has(normalizeField(key)) ? REDACTED : redactJsonFields(child, fields),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
const redactBody = (value: string, fields: ReadonlySet<string>, transform: ((body: string) => string) | undefined) => {
|
||||
const redacted = Option.match(decodeJson(value), {
|
||||
onNone: () => value,
|
||||
onSome: (parsed) => JSON.stringify(redactJsonFields(parsed, fields)),
|
||||
})
|
||||
return transform?.(redacted) ?? redacted
|
||||
}
|
||||
|
||||
export const make = (options: RedactOptions = {}): Redactor => {
|
||||
const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField))
|
||||
return compose(
|
||||
requestHeaders({
|
||||
allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])],
|
||||
redact: options.headers,
|
||||
}),
|
||||
responseHeaders({
|
||||
allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])],
|
||||
redact: options.headers,
|
||||
}),
|
||||
url({ query: options.queryParameters, transform: options.url }),
|
||||
{
|
||||
request: (snapshot) => ({
|
||||
...snapshot,
|
||||
body: redactBody(snapshot.body, fields, options.body),
|
||||
}),
|
||||
response: (snapshot) => ({
|
||||
...snapshot,
|
||||
body: redactBody(snapshot.body, fields, options.body),
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
|
||||
compose(
|
||||
requestHeaders(overrides.requestHeaders),
|
||||
responseHeaders(overrides.responseHeaders),
|
||||
url(overrides.url),
|
||||
...(overrides.body ? [body(overrides.body)] : []),
|
||||
)
|
||||
29
packages/http-recorder/src/replay/comparison.ts
Normal file
29
packages/http-recorder/src/replay/comparison.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Option, Schema } from "effect"
|
||||
import { REDACTED } from "../redaction/redactor.js"
|
||||
import { secretFindings } from "../redaction/secrets.js"
|
||||
|
||||
export const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
|
||||
export const canonicalizeJson = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(canonicalizeJson)
|
||||
if (isRecord(value))
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.toSorted()
|
||||
.map((key) => [key, canonicalizeJson(value[key])]),
|
||||
)
|
||||
return value
|
||||
}
|
||||
|
||||
export const safeText = (value: unknown) => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||
const text = JSON.stringify(value)
|
||||
if (!text) return typeof value
|
||||
return text.length > 300 ? `${text.slice(0, 300)}...` : text
|
||||
}
|
||||
|
||||
export const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
|
||||
export const isJsonRecord = isRecord
|
||||
96
packages/http-recorder/src/replay/state.ts
Normal file
96
packages/http-recorder/src/replay/state.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { Effect, Exit, HashSet, Ref, Scope, SynchronizedRef } from "effect"
|
||||
import type { Interaction } from "../cassette/model.js"
|
||||
import type { CassetteNotFoundError, Interface, InvalidCassetteError } from "../cassette/store.js"
|
||||
|
||||
const isCI = () => {
|
||||
const value = process.env.CI
|
||||
return value !== undefined && value !== "" && value !== "false" && value !== "0"
|
||||
}
|
||||
|
||||
export const resolveAutoMode = (
|
||||
cassette: Interface,
|
||||
name: string,
|
||||
): Effect.Effect<"record" | "replay" | "passthrough"> =>
|
||||
Effect.gen(function* () {
|
||||
if (isCI()) return "replay"
|
||||
return (yield* cassette.exists(name)) ? "replay" : "record"
|
||||
})
|
||||
|
||||
export interface ReplayState<T> {
|
||||
readonly claim: <E>(
|
||||
validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray<T>) => Effect.Effect<void, E>,
|
||||
) => Effect.Effect<
|
||||
{ readonly interaction: T; readonly index: number },
|
||||
CassetteNotFoundError | InvalidCassetteError | E
|
||||
>
|
||||
}
|
||||
export interface ReplayPoolState<T> {
|
||||
readonly claim: <E>(
|
||||
select: (interactions: ReadonlyArray<T>, used: HashSet.HashSet<number>) => Effect.Effect<number, E>,
|
||||
) => Effect.Effect<
|
||||
{ readonly interaction: T; readonly index: number },
|
||||
CassetteNotFoundError | InvalidCassetteError | E
|
||||
>
|
||||
}
|
||||
|
||||
export const makeReplayPoolState = <T>(
|
||||
cassette: Interface,
|
||||
name: string,
|
||||
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
|
||||
): Effect.Effect<ReplayPoolState<T>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
|
||||
const claimed = yield* SynchronizedRef.make(HashSet.empty<number>())
|
||||
const attempted = yield* Ref.make(false)
|
||||
yield* Effect.addFinalizer((exit) =>
|
||||
Exit.isFailure(exit)
|
||||
? Effect.void
|
||||
: Effect.gen(function* () {
|
||||
const used = yield* SynchronizedRef.get(claimed)
|
||||
if (HashSet.isEmpty(used) && (yield* Ref.get(attempted))) return yield* Effect.void
|
||||
const interactions = yield* load.pipe(
|
||||
Effect.catchTag("CassetteNotFoundError", () => Effect.succeed([] as ReadonlyArray<T>)),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (HashSet.size(used) < interactions.length)
|
||||
return yield* Effect.die(
|
||||
new Error(
|
||||
`Unused recorded interactions in ${name}: used ${HashSet.size(used)} of ${interactions.length}`,
|
||||
),
|
||||
)
|
||||
return yield* Effect.void
|
||||
}),
|
||||
)
|
||||
return {
|
||||
claim: (select) =>
|
||||
Ref.set(attempted, true).pipe(
|
||||
Effect.andThen(load),
|
||||
Effect.flatMap((interactions) =>
|
||||
SynchronizedRef.modifyEffect(claimed, (used) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* select(interactions, used)
|
||||
const interaction = interactions[index]
|
||||
if (interaction === undefined || HashSet.has(used, index))
|
||||
return yield* Effect.die("Replay selected an unavailable interaction")
|
||||
return [{ interaction, index }, HashSet.add(used, index)] as const
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
export const makeReplayState = <T>(
|
||||
cassette: Interface,
|
||||
name: string,
|
||||
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
|
||||
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
|
||||
makeReplayPoolState(cassette, name, project).pipe(
|
||||
Effect.map((pool) => ({
|
||||
claim: (validate) =>
|
||||
pool.claim((interactions, used) => {
|
||||
const index = HashSet.size(used)
|
||||
return validate(interactions[index], index, interactions).pipe(Effect.as(index))
|
||||
}),
|
||||
})),
|
||||
)
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { Schema } from "effect"
|
||||
import type {
|
||||
CassetteMetadata,
|
||||
HttpInteraction,
|
||||
RequestSnapshot,
|
||||
ResponseSnapshot,
|
||||
WebSocketEvent,
|
||||
WebSocketInteraction,
|
||||
} from "./types.js"
|
||||
|
||||
export type {
|
||||
CassetteMetadata,
|
||||
HttpInteraction,
|
||||
RequestSnapshot,
|
||||
ResponseSnapshot,
|
||||
WebSocketEvent,
|
||||
WebSocketInteraction,
|
||||
} from "./types.js"
|
||||
|
||||
export const RequestSnapshotSchema = Schema.Struct({
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.String,
|
||||
})
|
||||
|
||||
export const ResponseSnapshotSchema = Schema.Struct({
|
||||
status: Schema.Number,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.String,
|
||||
bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
|
||||
})
|
||||
|
||||
export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||
|
||||
export const HttpInteractionSchema = Schema.Struct({
|
||||
transport: Schema.tag("http"),
|
||||
request: RequestSnapshotSchema,
|
||||
response: ResponseSnapshotSchema,
|
||||
})
|
||||
|
||||
export const WebSocketEventSchema = Schema.Union([
|
||||
Schema.Struct({
|
||||
direction: Schema.Literals(["client", "server"]),
|
||||
kind: Schema.tag("text"),
|
||||
body: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
direction: Schema.Literals(["client", "server"]),
|
||||
kind: Schema.tag("binary"),
|
||||
body: Schema.String,
|
||||
bodyEncoding: Schema.Literal("base64"),
|
||||
}),
|
||||
])
|
||||
|
||||
export const WebSocketInteractionSchema = Schema.Struct({
|
||||
transport: Schema.tag("websocket"),
|
||||
open: Schema.Struct({
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
}),
|
||||
events: Schema.Array(WebSocketEventSchema),
|
||||
})
|
||||
|
||||
export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe(
|
||||
Schema.toTaggedUnion("transport"),
|
||||
)
|
||||
export type Interaction = Schema.Schema.Type<typeof InteractionSchema>
|
||||
|
||||
export const isHttpInteraction = InteractionSchema.guards.http
|
||||
|
||||
export const isWebSocketInteraction = InteractionSchema.guards.websocket
|
||||
|
||||
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
|
||||
|
||||
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
|
||||
interactions.filter(isWebSocketInteraction)
|
||||
|
||||
export const CassetteSchema = Schema.Struct({
|
||||
version: Schema.Literal(1),
|
||||
metadata: Schema.optional(CassetteMetadataSchema),
|
||||
interactions: Schema.Array(InteractionSchema),
|
||||
})
|
||||
export type Cassette = Schema.Schema.Type<typeof CassetteSchema>
|
||||
|
||||
export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema)
|
||||
export const encodeCassette = Schema.encodeSync(CassetteSchema)
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Deferred, Effect, Exit, FiberSet, Layer, Ref, Scope, Semaphore } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import * as CassetteService from "./cassette.js"
|
||||
import { canonicalizeJson, decodeJson, safeText } from "./matching.js"
|
||||
import { makeReplayState, resolveAutoMode } from "./recorder.js"
|
||||
import { make, type Redactor } from "./redactor.js"
|
||||
import { webSocketInteractions } from "./schema.js"
|
||||
import type {
|
||||
RecorderOptions,
|
||||
WebSocketEvent,
|
||||
WebSocketInteraction,
|
||||
WebSocketRecorderOptions,
|
||||
WebSocketRequest,
|
||||
} from "./types.js"
|
||||
|
||||
interface ActiveReplay {
|
||||
readonly interaction: WebSocketInteraction
|
||||
readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred<void> }>
|
||||
readonly writeLock: Semaphore.Semaphore
|
||||
readonly closed: Ref.Ref<boolean>
|
||||
}
|
||||
|
||||
interface ActiveRecording {
|
||||
readonly events: Array<WebSocketEvent>
|
||||
readonly eventLock: Semaphore.Semaphore
|
||||
readonly accepting: Ref.Ref<boolean>
|
||||
opened: boolean
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
type Frame = string | Uint8Array
|
||||
|
||||
const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent =>
|
||||
typeof message === "string"
|
||||
? { direction, kind: "text", body: message }
|
||||
: { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
|
||||
|
||||
const decodeEvent = (event: WebSocketEvent): Frame =>
|
||||
event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
|
||||
|
||||
const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => {
|
||||
if (event.kind === "binary") return event
|
||||
const body =
|
||||
event.direction === "client"
|
||||
? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
|
||||
: redactor.response({ status: 101, headers: {}, body: event.body }).body
|
||||
return { ...event, body }
|
||||
}
|
||||
|
||||
const comparable = (event: WebSocketEvent, asJson: boolean) => {
|
||||
if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event))
|
||||
const decoded = decodeJson(event.body)
|
||||
return JSON.stringify(
|
||||
canonicalizeJson({
|
||||
...event,
|
||||
body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
|
||||
Effect.sync(() => {
|
||||
if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return
|
||||
throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
|
||||
})
|
||||
|
||||
const runHandler = <A, E, R>(handler: (value: A) => Effect.Effect<unknown, E, R> | void, value: A) =>
|
||||
Effect.suspend(() => {
|
||||
const result = handler(value)
|
||||
return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void
|
||||
})
|
||||
|
||||
const runReplay = <A, E, R>(
|
||||
state: ActiveReplay,
|
||||
handler: (value: A) => Effect.Effect<unknown, E, R> | void,
|
||||
decode: (event: WebSocketEvent) => A,
|
||||
onOpen: Effect.Effect<void> | undefined,
|
||||
) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handlers = yield* FiberSet.make<unknown, E>()
|
||||
const run = yield* FiberSet.runtime(handlers)<R>()
|
||||
if (onOpen) yield* onOpen
|
||||
|
||||
const drive = Effect.gen(function* () {
|
||||
while (true) {
|
||||
const current = yield* Ref.get(state.progress)
|
||||
const event = state.interaction.events[current.position]
|
||||
if (!event) return
|
||||
if (yield* Ref.get(state.closed))
|
||||
return yield* Effect.die(
|
||||
new Error(
|
||||
`WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
|
||||
),
|
||||
)
|
||||
if (event.direction === "server") {
|
||||
yield* Ref.set(state.progress, {
|
||||
position: current.position + 1,
|
||||
changed: yield* Deferred.make<void>(),
|
||||
})
|
||||
run(runHandler(handler, decode(event)))
|
||||
continue
|
||||
}
|
||||
yield* Deferred.await(current.changed)
|
||||
}
|
||||
})
|
||||
|
||||
yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers)))
|
||||
yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers)))
|
||||
}),
|
||||
)
|
||||
|
||||
const openSnapshot = (request: WebSocketRequest, redactor: Redactor) => {
|
||||
const snapshot = redactor.request({ method: "GET", url: request.url, headers: request.headers ?? {}, body: "" })
|
||||
return { url: snapshot.url, headers: snapshot.headers }
|
||||
}
|
||||
|
||||
const makeRecordingSocket = (
|
||||
upstream: Socket.Socket,
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
request: WebSocketRequest,
|
||||
options: WebSocketRecorderOptions,
|
||||
redactor: Redactor,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const active = yield* Ref.make<ActiveRecording | undefined>(undefined)
|
||||
const writeLock = yield* Semaphore.make(1)
|
||||
|
||||
return Socket.make({
|
||||
runRaw: (handler, runOptions) =>
|
||||
Effect.gen(function* () {
|
||||
const state: ActiveRecording = {
|
||||
events: [],
|
||||
eventLock: yield* Semaphore.make(1),
|
||||
accepting: yield* Ref.make(true),
|
||||
opened: false,
|
||||
valid: true,
|
||||
}
|
||||
const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
|
||||
if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported")
|
||||
yield* upstream
|
||||
.runRaw(
|
||||
(message) => {
|
||||
if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing")
|
||||
state.events.push(redactEvent(encodeEvent("server", message), redactor))
|
||||
return handler(message)
|
||||
},
|
||||
{
|
||||
...runOptions,
|
||||
onOpen: Effect.gen(function* () {
|
||||
state.opened = true
|
||||
if (runOptions?.onOpen) yield* runOptions.onOpen
|
||||
}),
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
Effect.onExit((exit) =>
|
||||
writeLock.withPermit(
|
||||
state.eventLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.set(state.accepting, false)
|
||||
yield* Ref.set(active, undefined)
|
||||
if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return
|
||||
yield* cassette
|
||||
.append(
|
||||
name,
|
||||
{
|
||||
transport: "websocket",
|
||||
open: openSnapshot(request, redactor),
|
||||
events: [...state.events],
|
||||
},
|
||||
options.metadata,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
writer: upstream.writer.pipe(
|
||||
Effect.map(
|
||||
(write) => (message) =>
|
||||
writeLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (Socket.isCloseEvent(message)) return yield* write(message)
|
||||
const state = yield* Ref.get(active)
|
||||
if (!state || !(yield* Ref.get(state.accepting)))
|
||||
return yield* Effect.die("WebSocket writer used without an active socket run")
|
||||
const event = redactEvent(encodeEvent("client", message), redactor)
|
||||
yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event)))
|
||||
return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false))))
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const makeReplaySocket = (
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
request: WebSocketRequest,
|
||||
options: WebSocketRecorderOptions,
|
||||
redactor: Redactor,
|
||||
): Effect.Effect<Socket.Socket, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const replay = yield* makeReplayState(cassette, name, webSocketInteractions)
|
||||
const active = yield* Ref.make<ActiveReplay | undefined>(undefined)
|
||||
|
||||
return Socket.make({
|
||||
runRaw: (handler, runOptions) =>
|
||||
Effect.gen(function* () {
|
||||
const claimed = yield* replay
|
||||
.claim((interaction, index) =>
|
||||
Effect.sync(() => {
|
||||
const incoming = openSnapshot(request, redactor)
|
||||
if (
|
||||
interaction &&
|
||||
JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open))
|
||||
)
|
||||
return
|
||||
throw new Error(
|
||||
`WebSocket open ${index + 1}: expected ${safeText(interaction?.open)}, received ${safeText(incoming)}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const progress = yield* Ref.make({ position: 0, changed: yield* Deferred.make<void>() })
|
||||
const writeLock = yield* Semaphore.make(1)
|
||||
const state = {
|
||||
interaction: claimed.interaction,
|
||||
progress,
|
||||
writeLock,
|
||||
closed: yield* Ref.make(false),
|
||||
}
|
||||
const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
|
||||
if (occupied) return yield* Effect.die("Concurrent runs of a replayed WebSocket are not supported")
|
||||
yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe(
|
||||
Effect.ensuring(Ref.set(active, undefined)),
|
||||
)
|
||||
}),
|
||||
writer: Effect.succeed((message) => {
|
||||
return Ref.get(active).pipe(
|
||||
Effect.flatMap((state) =>
|
||||
state
|
||||
? state.writeLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state.progress)
|
||||
if (Socket.isCloseEvent(message)) {
|
||||
yield* Ref.set(state.closed, true)
|
||||
yield* Deferred.succeed(current.changed, undefined)
|
||||
if (current.position === state.interaction.events.length) return
|
||||
return yield* Effect.die(
|
||||
new Error(
|
||||
`WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const actual = redactEvent(encodeEvent("client", message), redactor)
|
||||
yield* assertEvent(
|
||||
actual,
|
||||
state.interaction.events[current.position],
|
||||
current.position,
|
||||
options.compareClientMessagesAsJson === true,
|
||||
)
|
||||
yield* Ref.set(state.progress, {
|
||||
position: current.position + 1,
|
||||
changed: yield* Deferred.make<void>(),
|
||||
})
|
||||
yield* Deferred.succeed(current.changed, undefined)
|
||||
}),
|
||||
)
|
||||
: Effect.die("WebSocket writer used without an active socket run"),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
const recordingLayer = (
|
||||
name: string,
|
||||
request: WebSocketRequest,
|
||||
options: WebSocketRecorderOptions,
|
||||
forcedMode?: "record" | "replay",
|
||||
): Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service> =>
|
||||
Layer.effect(
|
||||
Socket.Socket,
|
||||
Effect.gen(function* () {
|
||||
const upstream = yield* Socket.Socket
|
||||
const cassette = yield* CassetteService.Service
|
||||
const redactor = make(options.redact)
|
||||
if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record")
|
||||
return yield* makeRecordingSocket(upstream, cassette, name, request, options, redactor)
|
||||
return yield* makeReplaySocket(cassette, name, request, options, redactor)
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Wraps a provided `Socket.Socket` with cassette recording and replay.
|
||||
*
|
||||
* Supply the ordinary URL-bound Effect socket layer beneath this decorator.
|
||||
* The cassette name identifies the connection; recorder configuration does not
|
||||
* duplicate the transport URL.
|
||||
*/
|
||||
export const socket = (name: string, options: RecorderOptions = {}): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
|
||||
provideCassette(recordingLayer(name, { url: "" }, { ...options, compareClientMessagesAsJson: true }), options)
|
||||
|
||||
/** @internal */
|
||||
export const socketLayer = (
|
||||
name: string,
|
||||
request: WebSocketRequest,
|
||||
options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" },
|
||||
): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
|
||||
provideCassette(recordingLayer(name, request, options, options.mode), options)
|
||||
|
||||
const provideCassette = (
|
||||
layer: Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service>,
|
||||
options: WebSocketRecorderOptions,
|
||||
) =>
|
||||
layer.pipe(
|
||||
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
/** Additional JSON metadata stored with a cassette. */
|
||||
export type CassetteMetadata = Record<string, unknown>
|
||||
|
||||
/** The normalized HTTP request representation used for matching. */
|
||||
export interface RequestSnapshot {
|
||||
/** HTTP method. */
|
||||
readonly method: string
|
||||
/** Fully qualified URL after redaction. */
|
||||
readonly url: string
|
||||
/** Allowed and redacted request headers. */
|
||||
readonly headers: Record<string, string>
|
||||
/** Request body after redaction. */
|
||||
readonly body: string
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface ResponseSnapshot {
|
||||
/** HTTP status code. */
|
||||
readonly status: number
|
||||
/** Allowed and redacted response headers. */
|
||||
readonly headers: Record<string, string>
|
||||
/** Text body or base64-encoded binary body. */
|
||||
readonly body: string
|
||||
/** Encoding used by `body`; omitted for ordinary text. */
|
||||
readonly bodyEncoding?: "text" | "base64"
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface HttpInteraction {
|
||||
readonly transport: "http"
|
||||
readonly request: RequestSnapshot
|
||||
readonly response: ResponseSnapshot
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type WebSocketEvent =
|
||||
| { readonly direction: "client" | "server"; readonly kind: "text"; readonly body: string }
|
||||
| {
|
||||
readonly direction: "client" | "server"
|
||||
readonly kind: "binary"
|
||||
readonly body: string
|
||||
readonly bodyEncoding: "base64"
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface WebSocketInteraction {
|
||||
readonly transport: "websocket"
|
||||
readonly open: {
|
||||
readonly url: string
|
||||
readonly headers: Record<string, string>
|
||||
}
|
||||
readonly events: ReadonlyArray<WebSocketEvent>
|
||||
}
|
||||
|
||||
/** Returns whether an incoming HTTP request matches a recorded request. */
|
||||
export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean
|
||||
|
||||
/** Additive redaction and header-preservation policy. */
|
||||
export interface RedactOptions {
|
||||
/** Additional sensitive headers to retain as `[REDACTED]`. */
|
||||
readonly headers?: ReadonlyArray<string>
|
||||
/** Additional non-sensitive request headers to preserve for matching. */
|
||||
readonly allowRequestHeaders?: ReadonlyArray<string>
|
||||
/** Additional non-sensitive response headers to preserve for replay. */
|
||||
readonly allowResponseHeaders?: ReadonlyArray<string>
|
||||
/** Additional sensitive URL query parameter names. */
|
||||
readonly queryParameters?: ReadonlyArray<string>
|
||||
/** Additional JSON field names to redact recursively. */
|
||||
readonly jsonFields?: ReadonlyArray<string>
|
||||
/** Stabilizes a URL after built-in redaction. */
|
||||
readonly url?: (url: string) => string
|
||||
/** Stabilizes a request, response, or text-frame body after built-in redaction. */
|
||||
readonly body?: (body: string) => string
|
||||
}
|
||||
|
||||
/** Options shared by HTTP recorder layers. */
|
||||
export interface RecorderOptions {
|
||||
/** Cassette directory. Defaults to `<cwd>/test/fixtures/recordings`. */
|
||||
readonly directory?: string
|
||||
/** Additional metadata stored in the cassette. */
|
||||
readonly metadata?: CassetteMetadata
|
||||
/** Additive redaction and header-preservation policy. */
|
||||
readonly redact?: RedactOptions
|
||||
/** Custom HTTP request equivalence. */
|
||||
readonly match?: RequestMatcher
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface WebSocketRequest {
|
||||
/** WebSocket URL. */
|
||||
readonly url: string
|
||||
/** Headers used for redacted matching; the recorder does not send them. */
|
||||
readonly headers?: Record<string, string>
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface WebSocketRecorderOptions {
|
||||
/** Cassette directory. Defaults to `<cwd>/test/fixtures/recordings`. */
|
||||
readonly directory?: string
|
||||
/** Additional metadata stored in the cassette. */
|
||||
readonly metadata?: CassetteMetadata
|
||||
/** Additive handshake and text-frame redaction policy. */
|
||||
readonly redact?: RedactOptions
|
||||
/** Compare text client frames as canonical JSON instead of exact strings. */
|
||||
readonly compareClientMessagesAsJson?: boolean
|
||||
/** WebSocket subprotocols used by `layerWebSocket`. */
|
||||
readonly protocols?: string | Array<string>
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
import { Effect, Option, Ref, Scope, Semaphore, Stream, SynchronizedRef } from "effect"
|
||||
import type { Headers } from "effect/unstable/http"
|
||||
import * as CassetteService from "./cassette.js"
|
||||
import { canonicalizeJson, decodeJson, safeText } from "./matching.js"
|
||||
import { makeReplayState, resolveAutoMode } from "./recorder.js"
|
||||
import type { RecordReplayMode } from "./internal-effect.js"
|
||||
import { make, type Redactor } from "./redactor.js"
|
||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketEvent } from "./schema.js"
|
||||
|
||||
export interface WebSocketRequest {
|
||||
readonly url: string
|
||||
readonly headers: Headers.Headers
|
||||
}
|
||||
|
||||
export interface WebSocketConnection<E> {
|
||||
readonly sendText: (message: string) => Effect.Effect<void, E>
|
||||
readonly messages: Stream.Stream<string | Uint8Array, E>
|
||||
readonly close: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface WebSocketExecutor<E> {
|
||||
readonly open: (request: WebSocketRequest) => Effect.Effect<WebSocketConnection<E>, E>
|
||||
}
|
||||
|
||||
export interface WebSocketRecordReplayOptions<E> {
|
||||
readonly name: string
|
||||
readonly mode?: RecordReplayMode
|
||||
readonly metadata?: CassetteMetadata
|
||||
readonly cassette: CassetteService.Interface
|
||||
readonly live: WebSocketExecutor<E>
|
||||
readonly redactor?: Redactor
|
||||
readonly compareClientMessagesAsJson?: boolean
|
||||
}
|
||||
|
||||
const headersRecord = (headers: Headers.Headers): Record<string, string> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(headers as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
)
|
||||
|
||||
const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({
|
||||
direction,
|
||||
kind: "text",
|
||||
body,
|
||||
})
|
||||
|
||||
const decodeEvent = (event: WebSocketEvent) =>
|
||||
event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
|
||||
|
||||
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
|
||||
|
||||
const assertClientEvent = (actual: string, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
|
||||
Effect.sync(() => {
|
||||
const matches =
|
||||
expected?.direction === "client" &&
|
||||
expected.kind === "text" &&
|
||||
JSON.stringify(asJson ? jsonOrText(actual) : actual) ===
|
||||
JSON.stringify(asJson ? jsonOrText(expected.body) : expected.body)
|
||||
if (matches) return
|
||||
throw new Error(`WebSocket client frame ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
|
||||
})
|
||||
|
||||
export const makeWebSocketExecutor = <E>(
|
||||
options: WebSocketRecordReplayOptions<E>,
|
||||
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const mode = options.mode ?? (yield* resolveAutoMode(options.cassette, options.name))
|
||||
const redactor = options.redactor ?? make()
|
||||
const openSnapshot = (request: WebSocketRequest) => {
|
||||
const snapshot = redactor.request({
|
||||
method: "GET",
|
||||
url: request.url,
|
||||
headers: headersRecord(request.headers),
|
||||
body: "",
|
||||
})
|
||||
return { url: snapshot.url, headers: snapshot.headers }
|
||||
}
|
||||
const redactEvent = (event: WebSocketEvent) => {
|
||||
if (event.kind === "binary") return event
|
||||
const body =
|
||||
event.direction === "client"
|
||||
? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
|
||||
: redactor.response({ status: 101, headers: {}, body: event.body }).body
|
||||
return { ...event, body }
|
||||
}
|
||||
|
||||
if (mode === "passthrough") return options.live
|
||||
|
||||
if (mode === "record") {
|
||||
return {
|
||||
open: (request) =>
|
||||
Effect.gen(function* () {
|
||||
const events: WebSocketEvent[] = []
|
||||
const connection = yield* options.live.open(request)
|
||||
const closed = yield* Ref.make(false)
|
||||
const closeLock = yield* Semaphore.make(1)
|
||||
return {
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => events.push(redactEvent(textEvent("client", message)))).pipe(
|
||||
Effect.andThen(connection.sendText(message)),
|
||||
),
|
||||
messages: connection.messages.pipe(
|
||||
Stream.tap((message) =>
|
||||
Effect.sync(() =>
|
||||
events.push(
|
||||
typeof message === "string"
|
||||
? redactEvent(textEvent("server", message))
|
||||
: {
|
||||
direction: "server",
|
||||
kind: "binary",
|
||||
body: Buffer.from(message).toString("base64"),
|
||||
bodyEncoding: "base64",
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
close: closeLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (yield* Ref.get(closed)) return
|
||||
yield* connection.close
|
||||
yield* options.cassette
|
||||
.append(
|
||||
options.name,
|
||||
{ transport: "websocket", open: openSnapshot(request), events },
|
||||
options.metadata,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* Ref.set(closed, true)
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
|
||||
return {
|
||||
open: (request) =>
|
||||
Effect.gen(function* () {
|
||||
const claimed = yield* replay
|
||||
.claim((interaction, index) =>
|
||||
Effect.sync(() => {
|
||||
const incoming = canonicalizeJson(openSnapshot(request))
|
||||
if (interaction && JSON.stringify(incoming) === JSON.stringify(canonicalizeJson(interaction.open)))
|
||||
return
|
||||
throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const client = claimed.interaction.events.filter((event) => event.direction === "client")
|
||||
const server = claimed.interaction.events.filter((event) => event.direction === "server")
|
||||
const position = yield* SynchronizedRef.make(0)
|
||||
return {
|
||||
sendText: (message) =>
|
||||
SynchronizedRef.updateEffect(position, (index) =>
|
||||
assertClientEvent(message, client[index], index, options.compareClientMessagesAsJson === true).pipe(
|
||||
Effect.as(index + 1),
|
||||
),
|
||||
),
|
||||
messages: Stream.fromIterable(server).pipe(Stream.map(decodeEvent)),
|
||||
close: Effect.gen(function* () {
|
||||
const used = yield* SynchronizedRef.get(position)
|
||||
if (used !== client.length)
|
||||
return yield* Effect.die(
|
||||
new Error(`WebSocket client frame count: expected ${client.length}, received ${used}`),
|
||||
)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
35
packages/http-recorder/src/websocket/model.ts
Normal file
35
packages/http-recorder/src/websocket/model.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Schema } from "effect"
|
||||
|
||||
export const WebSocketEventSchema = Schema.Union([
|
||||
Schema.Struct({
|
||||
direction: Schema.Literals(["client", "server"]),
|
||||
kind: Schema.tag("text"),
|
||||
body: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
direction: Schema.Literals(["client", "server"]),
|
||||
kind: Schema.tag("binary"),
|
||||
body: Schema.String,
|
||||
bodyEncoding: Schema.Literal("base64"),
|
||||
}),
|
||||
])
|
||||
|
||||
export type WebSocketEvent = Schema.Schema.Type<typeof WebSocketEventSchema>
|
||||
|
||||
export const WebSocketInteractionSchema = Schema.Struct({
|
||||
transport: Schema.tag("websocket"),
|
||||
connection: Schema.optional(
|
||||
Schema.Struct({
|
||||
sequence: Schema.Number,
|
||||
url: Schema.String,
|
||||
protocols: Schema.Array(Schema.String),
|
||||
close: Schema.Struct({
|
||||
code: Schema.Number,
|
||||
reason: Schema.String,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
events: Schema.Array(WebSocketEventSchema),
|
||||
})
|
||||
|
||||
export interface WebSocketInteraction extends Schema.Schema.Type<typeof WebSocketInteractionSchema> {}
|
||||
584
packages/http-recorder/src/websocket/recorder.ts
Normal file
584
packages/http-recorder/src/websocket/recorder.ts
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node-shared"
|
||||
import { Deferred, Effect, Exit, FiberSet, Layer, Option, Ref, Scope, Semaphore } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { fileSystem, type Interface, Service } from "../cassette/store.js"
|
||||
import type { SocketRecorderOptions } from "../options.js"
|
||||
import { make, type Redactor } from "../redaction/redactor.js"
|
||||
import { canonicalizeJson, decodeJson, safeText } from "../replay/comparison.js"
|
||||
import { makeReplayState, resolveAutoMode } from "../replay/state.js"
|
||||
import { webSocketInteractions, type Interaction } from "../cassette/model.js"
|
||||
import type { WebSocketEvent, WebSocketInteraction } from "./model.js"
|
||||
|
||||
interface WebSocketRecorderOptions extends SocketRecorderOptions {
|
||||
readonly compareClientMessagesAsJson?: boolean
|
||||
}
|
||||
interface ActiveReplay {
|
||||
readonly interaction: WebSocketInteraction
|
||||
readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred<void> }>
|
||||
readonly writeLock: Semaphore.Semaphore
|
||||
readonly closed: Ref.Ref<boolean>
|
||||
}
|
||||
interface ActiveRecording {
|
||||
readonly events: Array<WebSocketEvent>
|
||||
readonly eventLock: Semaphore.Semaphore
|
||||
readonly accepting: Ref.Ref<boolean>
|
||||
opened: boolean
|
||||
valid: boolean
|
||||
}
|
||||
interface PendingRecordings {
|
||||
readonly promises: Set<Promise<void>>
|
||||
readonly errors: Array<unknown>
|
||||
}
|
||||
type Frame = string | Uint8Array
|
||||
|
||||
const normalizeProtocols = (protocols?: string | Array<string>): Array<string> =>
|
||||
protocols === undefined ? [] : typeof protocols === "string" ? [protocols] : [...protocols]
|
||||
const frameFromWebSocketData = async (data: unknown): Promise<Frame> => {
|
||||
if (typeof data === "string") return data
|
||||
if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer())
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice()
|
||||
throw new Error(`Unsupported WebSocket frame: ${Object.prototype.toString.call(data)}`)
|
||||
}
|
||||
const closeEvent = (code: number, reason: string): CloseEvent => {
|
||||
if (typeof globalThis.CloseEvent === "function")
|
||||
return new globalThis.CloseEvent("close", { code, reason, wasClean: code === 1000 })
|
||||
const event = new Event("close")
|
||||
Object.defineProperties(event, {
|
||||
code: { value: code },
|
||||
reason: { value: reason },
|
||||
wasClean: { value: code === 1000 },
|
||||
})
|
||||
return event as CloseEvent
|
||||
}
|
||||
const errorEvent = (error: unknown): ErrorEvent => {
|
||||
if (typeof globalThis.ErrorEvent === "function")
|
||||
return new globalThis.ErrorEvent("error", {
|
||||
error,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
const event = new Event("error")
|
||||
Object.defineProperties(event, {
|
||||
error: { value: error },
|
||||
message: { value: error instanceof Error ? error.message : String(error) },
|
||||
})
|
||||
return event as ErrorEvent
|
||||
}
|
||||
const webSocketFacade = (
|
||||
target: EventTarget,
|
||||
properties: {
|
||||
readonly url: () => string
|
||||
readonly readyState: () => number
|
||||
readonly protocol: () => string
|
||||
readonly extensions: () => string
|
||||
readonly bufferedAmount: () => number
|
||||
readonly send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void
|
||||
readonly close: (code?: number, reason?: string) => void
|
||||
},
|
||||
): globalThis.WebSocket => {
|
||||
Object.defineProperties(target, {
|
||||
url: { get: properties.url },
|
||||
readyState: { get: properties.readyState },
|
||||
protocol: { get: properties.protocol },
|
||||
extensions: { get: properties.extensions },
|
||||
bufferedAmount: { get: properties.bufferedAmount },
|
||||
binaryType: { value: "blob", writable: true },
|
||||
send: { value: properties.send },
|
||||
close: { value: properties.close },
|
||||
CONNECTING: { value: 0 },
|
||||
OPEN: { value: 1 },
|
||||
CLOSING: { value: 2 },
|
||||
CLOSED: { value: 3 },
|
||||
})
|
||||
for (const name of ["open", "message", "error", "close"] as const) {
|
||||
let handler: ((event: Event) => unknown) | null = null
|
||||
Object.defineProperty(target, `on${name}`, {
|
||||
get: () => handler,
|
||||
set: (next) => {
|
||||
if (handler) target.removeEventListener(name, handler)
|
||||
handler = typeof next === "function" ? next : null
|
||||
if (handler) target.addEventListener(name, handler)
|
||||
},
|
||||
})
|
||||
}
|
||||
return target as globalThis.WebSocket
|
||||
}
|
||||
|
||||
const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent =>
|
||||
typeof message === "string"
|
||||
? { direction, kind: "text", body: message }
|
||||
: { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
|
||||
const decodeEvent = (event: WebSocketEvent): Frame =>
|
||||
event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
|
||||
const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => {
|
||||
if (event.kind === "binary") return event
|
||||
const body =
|
||||
event.direction === "client"
|
||||
? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
|
||||
: redactor.response({ status: 101, headers: {}, body: event.body }).body
|
||||
return { ...event, body }
|
||||
}
|
||||
const comparable = (event: WebSocketEvent, asJson: boolean) => {
|
||||
if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event))
|
||||
const decoded = decodeJson(event.body)
|
||||
return JSON.stringify(
|
||||
canonicalizeJson({ ...event, body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value) }),
|
||||
)
|
||||
}
|
||||
const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
|
||||
Effect.sync(() => {
|
||||
if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return
|
||||
throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
|
||||
})
|
||||
const runHandler = <A, E, R>(handler: (value: A) => Effect.Effect<unknown, E, R> | void, value: A) =>
|
||||
Effect.suspend(() => {
|
||||
const result = handler(value)
|
||||
return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void
|
||||
})
|
||||
const runReplay = <A, E, R>(
|
||||
state: ActiveReplay,
|
||||
handler: (value: A) => Effect.Effect<unknown, E, R> | void,
|
||||
decode: (event: WebSocketEvent) => A,
|
||||
onOpen: Effect.Effect<void> | undefined,
|
||||
) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handlers = yield* FiberSet.make<unknown, E>()
|
||||
const run = yield* FiberSet.runtime(handlers)<R>()
|
||||
if (onOpen) yield* onOpen
|
||||
const drive = Effect.gen(function* () {
|
||||
while (true) {
|
||||
const current = yield* Ref.get(state.progress)
|
||||
const event = state.interaction.events[current.position]
|
||||
if (!event) return
|
||||
if (yield* Ref.get(state.closed))
|
||||
return yield* Effect.die(
|
||||
new Error(
|
||||
`WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
|
||||
),
|
||||
)
|
||||
if (event.direction === "server") {
|
||||
yield* Ref.set(state.progress, { position: current.position + 1, changed: yield* Deferred.make<void>() })
|
||||
run(runHandler(handler, decode(event)))
|
||||
continue
|
||||
}
|
||||
yield* Deferred.await(current.changed)
|
||||
}
|
||||
})
|
||||
yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers)))
|
||||
yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers)))
|
||||
}),
|
||||
)
|
||||
|
||||
const makeRecordingSocket = (
|
||||
upstream: Socket.Socket,
|
||||
cassette: Interface,
|
||||
name: string,
|
||||
options: WebSocketRecorderOptions,
|
||||
redactor: Redactor,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const active = yield* Ref.make<ActiveRecording | undefined>(undefined)
|
||||
const writeLock = yield* Semaphore.make(1)
|
||||
return Socket.make({
|
||||
runRaw: (handler, runOptions) =>
|
||||
Effect.gen(function* () {
|
||||
const state: ActiveRecording = {
|
||||
events: [],
|
||||
eventLock: yield* Semaphore.make(1),
|
||||
accepting: yield* Ref.make(true),
|
||||
opened: false,
|
||||
valid: true,
|
||||
}
|
||||
const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
|
||||
if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported")
|
||||
yield* upstream
|
||||
.runRaw(
|
||||
(message) => {
|
||||
if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing")
|
||||
state.events.push(redactEvent(encodeEvent("server", message), redactor))
|
||||
return handler(message)
|
||||
},
|
||||
{
|
||||
...runOptions,
|
||||
onOpen: Effect.gen(function* () {
|
||||
state.opened = true
|
||||
if (runOptions?.onOpen) yield* runOptions.onOpen
|
||||
}),
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
Effect.onExit((exit) =>
|
||||
writeLock.withPermit(
|
||||
state.eventLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.set(state.accepting, false)
|
||||
yield* Ref.set(active, undefined)
|
||||
if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return
|
||||
yield* cassette
|
||||
.append(
|
||||
name,
|
||||
{
|
||||
transport: "websocket",
|
||||
events: [...state.events],
|
||||
},
|
||||
options.metadata,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
writer: upstream.writer.pipe(
|
||||
Effect.map(
|
||||
(write) => (message) =>
|
||||
writeLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (Socket.isCloseEvent(message)) return yield* write(message)
|
||||
const state = yield* Ref.get(active)
|
||||
if (!state || !(yield* Ref.get(state.accepting)))
|
||||
return yield* Effect.die("WebSocket writer used without an active socket run")
|
||||
const event = redactEvent(encodeEvent("client", message), redactor)
|
||||
yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event)))
|
||||
return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false))))
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const makeReplaySocket = (
|
||||
cassette: Interface,
|
||||
name: string,
|
||||
options: WebSocketRecorderOptions,
|
||||
redactor: Redactor,
|
||||
): Effect.Effect<Socket.Socket, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const replay = yield* makeReplayState(cassette, name, webSocketInteractions)
|
||||
const active = yield* Ref.make<ActiveReplay | undefined>(undefined)
|
||||
const runLock = yield* Semaphore.make(1)
|
||||
return Socket.make({
|
||||
runRaw: (handler, runOptions) =>
|
||||
runLock
|
||||
.withPermitsIfAvailable(1)(
|
||||
Effect.gen(function* () {
|
||||
const claimed = yield* replay
|
||||
.claim((interaction) =>
|
||||
interaction ? Effect.void : Effect.die("Missing recorded WebSocket interaction"),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const state = {
|
||||
interaction: claimed.interaction,
|
||||
progress: yield* Ref.make({ position: 0, changed: yield* Deferred.make<void>() }),
|
||||
writeLock: yield* Semaphore.make(1),
|
||||
closed: yield* Ref.make(false),
|
||||
}
|
||||
yield* Ref.set(active, state)
|
||||
yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe(
|
||||
Effect.ensuring(Ref.set(active, undefined)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.flatMap(
|
||||
Option.match({
|
||||
onNone: () => Effect.die("Concurrent runs of a replayed WebSocket are not supported"),
|
||||
onSome: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
),
|
||||
writer: Effect.succeed((message) =>
|
||||
Ref.get(active).pipe(
|
||||
Effect.flatMap((state) =>
|
||||
state
|
||||
? state.writeLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state.progress)
|
||||
if (Socket.isCloseEvent(message)) {
|
||||
yield* Ref.set(state.closed, true)
|
||||
yield* Deferred.succeed(current.changed, undefined)
|
||||
if (current.position === state.interaction.events.length) return
|
||||
return yield* Effect.die(
|
||||
new Error(
|
||||
`WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const actual = redactEvent(encodeEvent("client", message), redactor)
|
||||
yield* assertEvent(
|
||||
actual,
|
||||
state.interaction.events[current.position],
|
||||
current.position,
|
||||
options.compareClientMessagesAsJson === true,
|
||||
)
|
||||
yield* Ref.set(state.progress, {
|
||||
position: current.position + 1,
|
||||
changed: yield* Deferred.make<void>(),
|
||||
})
|
||||
yield* Deferred.succeed(current.changed, undefined)
|
||||
}),
|
||||
)
|
||||
: Effect.die("WebSocket writer used without an active socket run"),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const recordingLayer = (
|
||||
name: string,
|
||||
options: WebSocketRecorderOptions,
|
||||
forcedMode?: "record" | "replay",
|
||||
): Layer.Layer<Socket.Socket, never, Socket.Socket | Service> =>
|
||||
Layer.effect(
|
||||
Socket.Socket,
|
||||
Effect.gen(function* () {
|
||||
const upstream = yield* Socket.Socket
|
||||
const cassette = yield* Service
|
||||
const redactor = make(options.redact)
|
||||
if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record")
|
||||
return yield* makeRecordingSocket(upstream, cassette, name, options, redactor)
|
||||
return yield* makeReplaySocket(cassette, name, options, redactor)
|
||||
}),
|
||||
)
|
||||
|
||||
export const layerSocket = (
|
||||
name: string,
|
||||
options: SocketRecorderOptions = {},
|
||||
): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
|
||||
provideCassette(recordingLayer(name, { ...options, compareClientMessagesAsJson: true }), options)
|
||||
/** @internal */
|
||||
export const layerSocketWithMode = (
|
||||
name: string,
|
||||
options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" },
|
||||
): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
|
||||
provideCassette(recordingLayer(name, options, options.mode), options)
|
||||
const provideCassette = <A, E, R>(layer: Layer.Layer<A, E, R>, options: WebSocketRecorderOptions) =>
|
||||
layer.pipe(Layer.provide(fileSystem({ directory: options.directory })), Layer.provide(NodeFileSystem.layer))
|
||||
|
||||
const makeRecordingWebSocketConstructor = (
|
||||
upstream: Socket.WebSocketConstructor["Service"],
|
||||
cassette: Interface,
|
||||
name: string,
|
||||
metadata: SocketRecorderOptions["metadata"],
|
||||
redactor: Redactor,
|
||||
pending: PendingRecordings,
|
||||
): Socket.WebSocketConstructor["Service"] => {
|
||||
let nextSequence = 0
|
||||
return (url, protocols) => {
|
||||
const sequence = nextSequence++
|
||||
const requestedProtocols = normalizeProtocols(protocols)
|
||||
const native = upstream(url, requestedProtocols)
|
||||
const events: WebSocketEvent[] = []
|
||||
let opened = false
|
||||
let failed = false
|
||||
let closed = false
|
||||
let queue = Promise.resolve()
|
||||
const appendEvent = (direction: "client" | "server", data: unknown) => {
|
||||
queue = queue.then(async () => {
|
||||
if (failed || closed) return
|
||||
try {
|
||||
events.push(redactEvent(encodeEvent(direction, await frameFromWebSocketData(data)), redactor))
|
||||
} catch {
|
||||
failed = true
|
||||
}
|
||||
})
|
||||
}
|
||||
const onOpen = () => {
|
||||
opened = true
|
||||
}
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
appendEvent("server", event.data)
|
||||
}
|
||||
const onError = () => {
|
||||
failed = true
|
||||
}
|
||||
const onClose = (event: CloseEvent) => {
|
||||
native.removeEventListener("open", onOpen)
|
||||
native.removeEventListener("message", onMessage)
|
||||
native.removeEventListener("error", onError)
|
||||
native.removeEventListener("close", onClose)
|
||||
const completion = queue.then(async () => {
|
||||
closed = true
|
||||
if (opened && !failed) {
|
||||
const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" })
|
||||
const interaction: WebSocketInteraction = {
|
||||
transport: "websocket",
|
||||
connection: {
|
||||
sequence,
|
||||
url: request.url,
|
||||
protocols: requestedProtocols,
|
||||
close: { code: event.code, reason: event.reason },
|
||||
},
|
||||
events: [...events],
|
||||
}
|
||||
events.length = 0
|
||||
await Effect.runPromise(cassette.append(name, interaction, metadata).pipe(Effect.orDie))
|
||||
}
|
||||
})
|
||||
pending.promises.add(completion)
|
||||
void completion.then(
|
||||
() => pending.promises.delete(completion),
|
||||
(error) => {
|
||||
pending.promises.delete(completion)
|
||||
pending.errors.push(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
native.addEventListener("open", onOpen)
|
||||
native.addEventListener("message", onMessage)
|
||||
native.addEventListener("error", onError)
|
||||
native.addEventListener("close", onClose)
|
||||
return new Proxy(native, {
|
||||
get: (target, property) => {
|
||||
if (property === "send")
|
||||
return (data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
|
||||
Reflect.apply(target.send, target, [data])
|
||||
appendEvent("client", data)
|
||||
}
|
||||
const value: unknown = Reflect.get(target, property, target)
|
||||
return typeof value === "function" ? value.bind(target) : value
|
||||
},
|
||||
set: (target, property, value) => Reflect.set(target, property, value, target),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const constructorWebSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
|
||||
webSocketInteractions(interactions)
|
||||
.filter((interaction) => interaction.connection !== undefined)
|
||||
.map((interaction, index) => ({ interaction, index }))
|
||||
.toSorted((a, b) => a.interaction.connection!.sequence - b.interaction.connection!.sequence)
|
||||
.map(({ interaction }) => interaction)
|
||||
|
||||
const makeReplayWebSocketConstructor = (
|
||||
cassette: Interface,
|
||||
name: string,
|
||||
redactor: Redactor,
|
||||
): Effect.Effect<Socket.WebSocketConstructor["Service"], never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const replay = yield* makeReplayState(cassette, name, constructorWebSocketInteractions)
|
||||
return (url, protocols) => {
|
||||
const target = new EventTarget()
|
||||
const requestedProtocols = normalizeProtocols(protocols)
|
||||
const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" })
|
||||
let readyState = 0
|
||||
let interaction: WebSocketInteraction | undefined
|
||||
let position = 0
|
||||
let finished = false
|
||||
let closeRequested = false
|
||||
let operations = Promise.resolve()
|
||||
const fail = (error: unknown) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
readyState = 3
|
||||
target.dispatchEvent(errorEvent(error))
|
||||
}
|
||||
const finish = () => {
|
||||
if (finished || !interaction || position !== interaction.events.length) return
|
||||
finished = true
|
||||
readyState = 3
|
||||
const terminal = interaction.connection?.close ?? { code: 1000, reason: "" }
|
||||
target.dispatchEvent(closeEvent(terminal.code, terminal.reason))
|
||||
}
|
||||
const drive = () => {
|
||||
if (!interaction || finished) return
|
||||
while (interaction.events[position]?.direction === "server") {
|
||||
const event = interaction.events[position++]
|
||||
if (!event) break
|
||||
target.dispatchEvent(new MessageEvent("message", { data: decodeEvent(event) }))
|
||||
}
|
||||
if (position === interaction.events.length) setTimeout(finish, 0)
|
||||
}
|
||||
Effect.runPromise(
|
||||
replay
|
||||
.claim((recorded, index) =>
|
||||
Effect.sync(() => {
|
||||
if (!recorded) throw new Error(`Missing recorded WebSocket connection ${index + 1}`)
|
||||
const connection = recorded.connection
|
||||
if (!connection) throw new Error(`WebSocket interaction ${index + 1} has no connection metadata`)
|
||||
if (connection.url !== request.url)
|
||||
throw new Error(
|
||||
`WebSocket connection ${index + 1}: expected URL ${safeText(connection.url)}, received ${safeText(request.url)}`,
|
||||
)
|
||||
if (
|
||||
connection.protocols.length !== requestedProtocols.length ||
|
||||
connection.protocols.some((protocol, protocolIndex) => protocol !== requestedProtocols[protocolIndex])
|
||||
)
|
||||
throw new Error(
|
||||
`WebSocket connection ${index + 1}: expected protocols ${safeText(connection.protocols)}, received ${safeText(requestedProtocols)}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie),
|
||||
).then((claimed) => {
|
||||
if (closeRequested) return fail(new Error("WebSocket closed before it opened"))
|
||||
interaction = claimed.interaction
|
||||
readyState = 1
|
||||
target.dispatchEvent(new Event("open"))
|
||||
drive()
|
||||
}, fail)
|
||||
return webSocketFacade(target, {
|
||||
url: () => url,
|
||||
readyState: () => readyState,
|
||||
protocol: () => requestedProtocols[0] ?? "",
|
||||
extensions: () => "",
|
||||
bufferedAmount: () => 0,
|
||||
send: (data) => {
|
||||
if (!interaction || readyState !== 1 || closeRequested) throw new Error("WebSocket is not open")
|
||||
operations = operations.then(async () => {
|
||||
try {
|
||||
const frame = await frameFromWebSocketData(data)
|
||||
const actual = redactEvent(encodeEvent("client", frame), redactor)
|
||||
Effect.runSync(assertEvent(actual, interaction?.events[position], position, true))
|
||||
position += 1
|
||||
drive()
|
||||
} catch (error) {
|
||||
fail(error)
|
||||
}
|
||||
})
|
||||
},
|
||||
close: () => {
|
||||
if (closeRequested || readyState === 3) return
|
||||
closeRequested = true
|
||||
readyState = 2
|
||||
operations = operations.then(() => {
|
||||
if (!interaction) return
|
||||
if (position !== interaction.events.length)
|
||||
return fail(
|
||||
new Error(`WebSocket closed with unconsumed events: used ${position} of ${interaction.events.length}`),
|
||||
)
|
||||
finish()
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const layerWebSocketConstructor = (
|
||||
name: string,
|
||||
options: SocketRecorderOptions = {},
|
||||
): Layer.Layer<Socket.WebSocketConstructor, never, Socket.WebSocketConstructor> =>
|
||||
provideCassette(
|
||||
Layer.effect(
|
||||
Socket.WebSocketConstructor,
|
||||
Effect.gen(function* () {
|
||||
const upstream = yield* Socket.WebSocketConstructor
|
||||
const cassette = yield* Service
|
||||
const redactor = make(options.redact)
|
||||
if ((yield* resolveAutoMode(cassette, name)) === "replay")
|
||||
return yield* makeReplayWebSocketConstructor(cassette, name, redactor)
|
||||
const pending: PendingRecordings = { promises: new Set(), errors: [] }
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Promise.all(pending.promises)).pipe(
|
||||
Effect.flatMap(() => (pending.errors.length === 0 ? Effect.void : Effect.die(pending.errors[0]))),
|
||||
),
|
||||
)
|
||||
return makeRecordingWebSocketConstructor(upstream, cassette, name, options.metadata, redactor, pending)
|
||||
}),
|
||||
),
|
||||
options,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue