| 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) |
| }), |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| export const socket = (name: string, options: RecorderOptions = {}): Layer.Layer<Socket.Socket, never, Socket.Socket> => |
| provideCassette(recordingLayer(name, { url: "" }, { ...options, compareClientMessagesAsJson: true }), options) |
|
|
| |
| 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), |
| ) |
|
|