| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- 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),
- )
|