websocket.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import { Effect, Option, Ref, Scope, Stream } from "effect"
  2. import type { Headers } from "effect/unstable/http"
  3. import * as CassetteService from "./cassette"
  4. import { canonicalizeJson, decodeJson } from "./matching"
  5. import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
  6. import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema"
  7. export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
  8. export interface WebSocketRequest {
  9. readonly url: string
  10. readonly headers: Headers.Headers
  11. }
  12. export interface WebSocketConnection<E> {
  13. readonly sendText: (message: string) => Effect.Effect<void, E>
  14. readonly messages: Stream.Stream<string | Uint8Array, E>
  15. readonly close: Effect.Effect<void>
  16. }
  17. export interface WebSocketExecutor<E> {
  18. readonly open: (request: WebSocketRequest) => Effect.Effect<WebSocketConnection<E>, E>
  19. }
  20. export interface WebSocketRecordReplayOptions<E> {
  21. readonly name: string
  22. readonly mode?: "record" | "replay" | "passthrough"
  23. readonly metadata?: CassetteMetadata
  24. readonly cassette: CassetteService.Interface
  25. readonly live: WebSocketExecutor<E>
  26. readonly redact?: {
  27. readonly headers?: ReadonlyArray<string>
  28. readonly query?: ReadonlyArray<string>
  29. readonly url?: (url: string) => string
  30. }
  31. readonly requestHeaders?: ReadonlyArray<string>
  32. readonly compareClientMessagesAsJson?: boolean
  33. }
  34. const headersRecord = (headers: Headers.Headers) =>
  35. Object.fromEntries(
  36. Object.entries(headers as Record<string, unknown>)
  37. .filter((entry): entry is [string, string] => typeof entry[1] === "string")
  38. .toSorted(([a], [b]) => a.localeCompare(b)),
  39. )
  40. const openSnapshot = (
  41. request: WebSocketRequest,
  42. options: Pick<WebSocketRecordReplayOptions<never>, "redact" | "requestHeaders"> = {},
  43. ) => ({
  44. url: redactUrl(request.url, options.redact?.query, options.redact?.url),
  45. headers: redactHeaders(
  46. headersRecord(request.headers),
  47. options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS,
  48. options.redact?.headers,
  49. ),
  50. })
  51. const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body })
  52. const frameText = (frame: WebSocketFrame) => {
  53. if (frame.kind === "text") return frame.body
  54. return new TextDecoder().decode(Buffer.from(frame.body, "base64"))
  55. }
  56. const frameMessage = (frame: WebSocketFrame) =>
  57. frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
  58. const receivedFrame = (message: string | Uint8Array): WebSocketFrame =>
  59. typeof message === "string"
  60. ? textFrame(message)
  61. : { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
  62. const unsafeCassette = (name: string, findings: ReadonlyArray<SecretFinding>) =>
  63. new Error(
  64. `Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings
  65. .map((item) => `${item.path} (${item.reason})`)
  66. .join(", ")}`,
  67. )
  68. const mismatch = (message: string, actual: unknown, expected: unknown) =>
  69. new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
  70. const assertEqual = (message: string, actual: unknown, expected: unknown) =>
  71. Effect.sync(() => {
  72. if (JSON.stringify(actual) === JSON.stringify(expected)) return
  73. throw mismatch(message, actual, expected)
  74. })
  75. const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
  76. const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => {
  77. if (!expected)
  78. return Effect.sync(() => {
  79. throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
  80. })
  81. const expectedText = frameText(expected)
  82. if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
  83. return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
  84. }
  85. export const makeWebSocketExecutor = <E>(
  86. options: WebSocketRecordReplayOptions<E>,
  87. ): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
  88. Effect.gen(function* () {
  89. const mode = options.mode ?? "replay"
  90. if (mode === "passthrough") return options.live
  91. if (mode === "record") {
  92. return {
  93. open: (request) =>
  94. Effect.gen(function* () {
  95. const client: WebSocketFrame[] = []
  96. const server: WebSocketFrame[] = []
  97. const connection = yield* options.live.open(request)
  98. const closed = yield* Ref.make(false)
  99. const closeOnce = Effect.gen(function* () {
  100. if (yield* Ref.getAndSet(closed, true)) return
  101. yield* connection.close
  102. const result = yield* options.cassette
  103. .append(
  104. options.name,
  105. { transport: "websocket", open: openSnapshot(request, options), client, server },
  106. options.metadata,
  107. )
  108. .pipe(Effect.orDie)
  109. if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings))
  110. })
  111. return {
  112. sendText: (message: string) =>
  113. connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
  114. messages: connection.messages.pipe(
  115. Stream.map((message) => {
  116. server.push(receivedFrame(message))
  117. return message
  118. }),
  119. ),
  120. close: closeOnce,
  121. }
  122. }),
  123. }
  124. }
  125. const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray<WebSocketInteraction> } | undefined>(
  126. undefined,
  127. )
  128. const cursor = yield* Ref.make(0)
  129. yield* Effect.addFinalizer(() =>
  130. Effect.gen(function* () {
  131. const input = yield* Ref.get(replay)
  132. if (!input) return
  133. yield* assertEqual(
  134. `Unused recorded WebSocket interactions in ${options.name}`,
  135. yield* Ref.get(cursor),
  136. input.interactions.length,
  137. )
  138. }),
  139. )
  140. const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () {
  141. const cached = yield* Ref.get(replay)
  142. if (cached) return cached
  143. const input = {
  144. interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)),
  145. }
  146. yield* Ref.set(replay, input)
  147. return input
  148. })
  149. return {
  150. open: (request) => {
  151. return Effect.gen(function* () {
  152. const input = yield* loadReplay()
  153. const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
  154. const interaction = input.interactions[index]
  155. if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
  156. yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open)
  157. const messageIndex = yield* Ref.make(0)
  158. return {
  159. sendText: (message: string) =>
  160. Effect.gen(function* () {
  161. const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
  162. yield* compareClientMessage(
  163. message,
  164. interaction.client[current],
  165. current,
  166. options.compareClientMessagesAsJson === true,
  167. )
  168. }),
  169. messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)),
  170. close: Effect.gen(function* () {
  171. yield* assertEqual(
  172. `WebSocket client frame count for interaction ${index + 1}`,
  173. yield* Ref.get(messageIndex),
  174. interaction.client.length,
  175. )
  176. }),
  177. }
  178. })
  179. },
  180. }
  181. })