feat: 品牌替换 + 启动优化 + AGENTS.md 模板定制

- 品牌替换:OpenCode/opencode → AirCoding/aircoding(16+ 文件)
- Logo ASCII art:修复 left/right 行数不匹配导致的启动崩溃
- 启动诊断:添加 OPENCODE_PRINT_TIMING 计时探针
- dev 模式默认 --pure 跳过外部插件加载
- AGENTS.md 模板:追加 AirCoding 多 Agent 专项段落
- architect prompt + plugin:强化 AGENTS.md 产出验证
This commit is contained in:
airlongdian
2026-06-14 09:31:29 +08:00
commit e2fd375a1c
5757 changed files with 1170016 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
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"
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
cassetteName: Schema.String,
}) {
override get message() {
return `Cassette "${this.cassetteName}" not found`
}
}
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
cassetteName: Schema.String,
findings: Schema.Array(SecretFindingSchema),
}) {
override get message() {
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
.map((finding) => `${finding.path} (${finding.reason})`)
.join(", ")}`
}
}
export interface Interface {
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
readonly append: (
name: string,
interaction: Interaction,
metadata?: CassetteMetadata,
) => Effect.Effect<void, UnsafeCassetteError>
readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
const cassettePath = (directory: string, name: string) => {
if (!name || path.isAbsolute(name) || path.win32.isAbsolute(name) || name.split(/[\\/]/).includes(".."))
throw new Error(`Invalid cassette name "${name}"`)
const root = path.resolve(directory)
const target = path.resolve(root, `${name}.json`)
const relative = path.relative(root, target)
if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
throw new Error(`Invalid cassette name "${name}"`)
return target
}
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
const buildCassette = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: { name, recordedAt: new Date().toISOString(), ...metadata },
interactions,
})
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))
const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
export const fileSystem = (
options: { readonly directory?: string } = {},
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.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[])))
const nested = yield* Effect.forEach(entries, (entry) => {
const full = path.join(current, entry)
return fs.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])),
)
})
return nested.flat()
})
return Service.of({
read: (name) =>
fs.readFileString(pathFor(name)).pipe(
Effect.map((raw) => parseCassette(raw).interactions),
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
),
append: (name, interaction, metadata) =>
appendLock.withPermit(
Effect.gen(function* () {
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
const interactions = [...entry.interactions, interaction]
const interactionFindings = [...entry.findings, ...secretFindings(interaction)]
const cassette = buildCassette(name, interactions, metadata)
const findings = [...interactionFindings, ...secretFindings(cassette.metadata ?? {})]
yield* failIfUnsafe(name, findings)
const target = pathFor(name)
yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie)
const temporary = `${target}.${crypto.randomUUID()}.tmp`
yield* fs.writeFileString(temporary, formatCassette(cassette)).pipe(
Effect.flatMap(() => fs.rename(temporary, target)),
Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.catch(() => Effect.void))),
Effect.orDie,
)
recorded.set(name, { interactions, findings: interactionFindings })
}),
),
exists: (name) =>
fs.access(pathFor(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
),
list: () =>
walk(directory).pipe(
Effect.map((files) =>
files
.filter((file) => file.endsWith(".json"))
.map((file) =>
path
.relative(directory, file)
.replace(/\\/g, "/")
.replace(/\.json$/, ""),
)
.toSorted((a, b) => a.localeCompare(b)),
),
),
})
}),
)
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
Layer.sync(Service, () => {
const stored = new Map<string, Interaction[]>(
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
)
const accumulatedFindings = new Map<string, SecretFinding[]>()
const appendLock = Semaphore.makeUnsafe(1)
return Service.of({
read: (name) =>
stored.has(name)
? Effect.succeed(stored.get(name) ?? [])
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
append: (name, interaction, metadata) =>
appendLock.withPermit(
Effect.suspend(() => {
const interactions = [...(stored.get(name) ?? []), interaction]
const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)]
const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings
return failIfUnsafe(name, allFindings).pipe(
Effect.tap(() =>
Effect.sync(() => {
stored.set(name, interactions)
accumulatedFindings.set(name, findings)
}),
),
)
}),
),
exists: (name) => Effect.sync(() => stored.has(name)),
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
})
})

View File

@@ -0,0 +1,25 @@
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),
)

View File

@@ -0,0 +1,18 @@
import { http } from "./effect.js"
import { socket } from "./socket.js"
/** HTTP and WebSocket cassette recording. */
export const HttpRecorder = { http, socket } as const
export namespace HttpRecorder {
/** Additional JSON metadata stored with a cassette. */
export type CassetteMetadata = import("./types.js").CassetteMetadata
/** Recorder configuration. */
export type RecorderOptions = import("./types.js").RecorderOptions
/** Additive redaction and header-preservation policy. */
export type RedactOptions = import("./types.js").RedactOptions
/** Returns whether an incoming HTTP request matches a recorded request. */
export type RequestMatcher = import("./types.js").RequestMatcher
/** The normalized HTTP request representation used for matching. */
export type RequestSnapshot = import("./types.js").RequestSnapshot
}

View File

@@ -0,0 +1,189 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Deferred, Effect, Layer, Option, 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"
export { defaultMatcher }
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
export interface RecordReplayOptions {
readonly mode?: RecordReplayMode
readonly directory?: string
readonly metadata?: CassetteMetadata
readonly redactor?: Redactor
readonly match?: RequestMatcher
}
const TEXT_CONTENT_TYPES = new Set([
"application/graphql",
"application/javascript",
"application/json",
"application/sql",
"application/x-www-form-urlencoded",
"application/xml",
"application/yaml",
"image/svg+xml",
])
const isTextContentType = (contentType: string | undefined) => {
const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase()
if (!mediaType) return false
return (
mediaType.startsWith("text/") ||
mediaType.endsWith("+json") ||
mediaType.endsWith("+xml") ||
TEXT_CONTENT_TYPES.has(mediaType)
)
}
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
response.arrayBuffer.pipe(
Effect.map((bytes) =>
isTextContentType(contentType)
? { body: new TextDecoder().decode(bytes) }
: { 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,
new Response(
request.method === "HEAD" || snapshot.status === 204 || snapshot.status === 205 || snapshot.status === 304
? null
: decodeResponseBody(snapshot),
snapshot,
),
)
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) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
})
export const recordingLayer = (
name: string,
options: Omit<RecordReplayOptions, "directory"> = {},
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> =>
Layer.effect(
HttpClient.HttpClient,
Effect.gen(function* () {
const upstream = yield* HttpClient.HttpClient
const cassetteService = yield* CassetteService.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 snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
return redactor.request({
method: web.method,
url: web.url,
headers: Object.fromEntries(web.headers.entries()),
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)
const tail = yield* Ref.make(initial)
return HttpClient.make((request) =>
Effect.gen(function* () {
const completed = yield* Deferred.make<void>()
const previous = yield* Ref.modify(tail, (current) => [current, completed])
return yield* Effect.gen(function* () {
const incoming = yield* snapshotRequest(request)
const response = yield* upstream.execute(request)
const captured = yield* captureResponseBody(response, response.headers["content-type"])
const responseSnapshot: ResponseSnapshot = {
status: response.status,
headers: response.headers as Record<string, string>,
...captured,
}
const interaction: HttpInteraction = {
transport: "http",
request: incoming,
response: redactor.response(responseSnapshot),
}
yield* Deferred.await(previous)
yield* cassetteService
.append(name, interaction, options.metadata)
.pipe(
Effect.catchTag("UnsafeCassetteError", (error) =>
Effect.fail(transportError(request, error.message)),
),
)
return responseFromSnapshot(request, responseSnapshot)
}).pipe(Effect.ensuring(Deferred.succeed(completed, undefined)))
}),
)
}
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
return HttpClient.make((request) =>
Effect.gen(function* () {
const incoming = yield* snapshotRequest(request)
const claimed = yield* replay
.claim((interaction, index, interactions) => {
const result = selectSequential(interactions, incoming, match, index)
if (result.interaction) return Effect.void
return Effect.fail(
transportError(request, `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,
),
)
return responseFromSnapshot(request, claimed.interaction.response)
}),
)
}),
)
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
recordingLayer(name, options).pipe(
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer),
)

View File

@@ -0,0 +1,15 @@
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"

View File

@@ -0,0 +1,106 @@
import { Option, Schema } from "effect"
import { REDACTED, secretFindings } from "./redaction.js"
import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.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 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,
}),
})
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)) {
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)) {
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)}`]
}
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
if (expected[key] === received[key]) return []
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
})
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
const lines: string[] = []
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}`)
}
const headers = headerDiffs(expected.headers, received.headers)
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
const expectedBody = jsonBody(expected.body)
const receivedBody = jsonBody(received.body)
const body =
expectedBody !== undefined && receivedBody !== undefined
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
: expected.body === received.body
? []
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
if (body.length > 0) lines.push("body:", ...body)
return lines
}
export const selectSequential = (
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: "" }
}

View File

@@ -0,0 +1,62 @@
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
}),
),
),
}
})

View File

@@ -0,0 +1,117 @@
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}` })),
])
}

View File

@@ -0,0 +1,135 @@
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)] : []),
)

View File

@@ -0,0 +1,87 @@
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)

View File

@@ -0,0 +1,326 @@
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),
)

View File

@@ -0,0 +1,108 @@
/** 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>
}

View File

@@ -0,0 +1,173 @@
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}`),
)
}),
}
}),
}
})