feat(http-recorder): prepare public beta release (#31018)

This commit is contained in:
Kit Langton
2026-06-06 03:01:26 +00:00
committed by GitHub
parent ba57718b05
commit 54f4974546
36 changed files with 2277 additions and 713 deletions
+93 -80
View File
@@ -1,12 +1,11 @@
import { Effect, Option, Ref, Scope, Stream } from "effect"
import { Effect, Option, Ref, Scope, Semaphore, Stream, SynchronizedRef } from "effect"
import type { Headers } from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { canonicalizeJson, decodeJson, safeText } from "./matching"
import { makeReplayState, resolveAutoMode } from "./recorder"
import type { RecordReplayMode } from "./effect"
import { redactUrl } from "./redaction"
import { defaults, type Redactor } from "./redactor"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
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
@@ -40,50 +39,50 @@ const headersRecord = (headers: Headers.Headers): Record<string, string> =>
),
)
const encodeFrame = (message: string | Uint8Array): WebSocketFrame =>
typeof message === "string"
? { kind: "text", body: message }
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({
direction,
kind: "text",
body,
})
const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array =>
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
const decodeFrameText = (frame: WebSocketFrame) =>
frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64"))
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
Effect.sync(() => {
if (JSON.stringify(actual) === JSON.stringify(expected)) return
throw new Error(`${message}: expected ${safeText(expected)}, received ${safeText(actual)}`)
})
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 compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => {
if (!expected)
return Effect.sync(() => {
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${safeText(actual)}`)
})
const expectedText = decodeFrameText(expected)
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
}
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 requested = options.mode ?? "auto"
const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested
const redactor = options.redactor ?? defaults()
const mode = options.mode ?? (yield* resolveAutoMode(options.cassette, options.name))
const redactor = options.redactor ?? make()
const openSnapshot = (request: WebSocketRequest) => {
const redacted = redactor.request({
const snapshot = redactor.request({
method: "GET",
url: request.url,
headers: headersRecord(request.headers),
body: "",
})
return { url: redacted.url, headers: redacted.headers }
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
@@ -92,67 +91,81 @@ export const makeWebSocketExecutor = <E>(
return {
open: (request) =>
Effect.gen(function* () {
const client: WebSocketFrame[] = []
const server: WebSocketFrame[] = []
const events: WebSocketEvent[] = []
const connection = yield* options.live.open(request)
const closed = yield* Ref.make(false)
const closeOnce = Effect.gen(function* () {
if (yield* Ref.getAndSet(closed, true)) return
yield* connection.close
yield* options.cassette
.append(
options.name,
{ transport: "websocket", open: openSnapshot(request), client, server },
options.metadata,
)
.pipe(Effect.orDie)
})
const closeLock = yield* Semaphore.make(1)
return {
sendText: (message) =>
connection
.sendText(message)
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
Effect.sync(() => events.push(redactEvent(textEvent("client", message)))).pipe(
Effect.andThen(connection.sendText(message)),
),
messages: connection.messages.pipe(
Stream.tap((message) => Effect.sync(() => server.push(encodeFrame(message)))),
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)
}),
),
close: closeOnce,
}
}),
}
}
const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
return {
open: (request) =>
Effect.gen(function* () {
const interactions = yield* replay.load.pipe(Effect.orDie)
const index = yield* replay.cursor
const interaction = interactions[index]
if (!interaction)
return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${redactUrl(request.url)}`))
yield* replay.advance
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
const messageIndex = yield* Ref.make(0)
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) =>
Effect.gen(function* () {
const current = yield* Ref.get(messageIndex)
yield* compareClientMessage(
message,
interaction.client[current],
current,
options.compareClientMessagesAsJson === true,
)
yield* Ref.update(messageIndex, (value) => value + 1)
}),
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
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* () {
yield* assertEqual(
`WebSocket client frame count for interaction ${index + 1}`,
yield* Ref.get(messageIndex),
interaction.client.length,
)
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}`),
)
}),
}
}),