Buckets:
| 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}`), | |
| ) | |
| }), | |
| } | |
| }), | |
| } | |
| }) | |
Xet Storage Details
- Size:
- 6.97 kB
- Xet hash:
- f4868120689210ef3f13905f8312e94b0ad576052da5705b8909f1c8dc3357ec
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.