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 { readonly sendText: (message: string) => Effect.Effect readonly messages: Stream.Stream readonly close: Effect.Effect } export interface WebSocketExecutor { readonly open: (request: WebSocketRequest) => Effect.Effect, E> } export interface WebSocketRecordReplayOptions { readonly name: string readonly mode?: RecordReplayMode readonly metadata?: CassetteMetadata readonly cassette: CassetteService.Interface readonly live: WebSocketExecutor readonly redactor?: Redactor readonly compareClientMessagesAsJson?: boolean } const headersRecord = (headers: Headers.Headers): Record => Object.fromEntries( Object.entries(headers as Record).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 = ( options: WebSocketRecordReplayOptions, ): Effect.Effect, 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}`), ) }), } }), } })