websocket.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  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, safeText } from "./matching"
  5. import { makeReplayState, resolveAutoMode } from "./recorder"
  6. import type { RecordReplayMode } from "./effect"
  7. import { redactUrl } from "./redaction"
  8. import { defaults, type Redactor } from "./redactor"
  9. import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
  10. export interface WebSocketRequest {
  11. readonly url: string
  12. readonly headers: Headers.Headers
  13. }
  14. export interface WebSocketConnection<E> {
  15. readonly sendText: (message: string) => Effect.Effect<void, E>
  16. readonly messages: Stream.Stream<string | Uint8Array, E>
  17. readonly close: Effect.Effect<void>
  18. }
  19. export interface WebSocketExecutor<E> {
  20. readonly open: (request: WebSocketRequest) => Effect.Effect<WebSocketConnection<E>, E>
  21. }
  22. export interface WebSocketRecordReplayOptions<E> {
  23. readonly name: string
  24. readonly mode?: RecordReplayMode
  25. readonly metadata?: CassetteMetadata
  26. readonly cassette: CassetteService.Interface
  27. readonly live: WebSocketExecutor<E>
  28. readonly redactor?: Redactor
  29. readonly compareClientMessagesAsJson?: boolean
  30. }
  31. const headersRecord = (headers: Headers.Headers): Record<string, string> =>
  32. Object.fromEntries(
  33. Object.entries(headers as Record<string, unknown>).filter(
  34. (entry): entry is [string, string] => typeof entry[1] === "string",
  35. ),
  36. )
  37. const encodeFrame = (message: string | Uint8Array): WebSocketFrame =>
  38. typeof message === "string"
  39. ? { kind: "text", body: message }
  40. : { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
  41. const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array =>
  42. frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
  43. const decodeFrameText = (frame: WebSocketFrame) =>
  44. frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64"))
  45. const assertEqual = (message: string, actual: unknown, expected: unknown) =>
  46. Effect.sync(() => {
  47. if (JSON.stringify(actual) === JSON.stringify(expected)) return
  48. throw new Error(`${message}: expected ${safeText(expected)}, received ${safeText(actual)}`)
  49. })
  50. const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
  51. const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => {
  52. if (!expected)
  53. return Effect.sync(() => {
  54. throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${safeText(actual)}`)
  55. })
  56. const expectedText = decodeFrameText(expected)
  57. if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
  58. return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
  59. }
  60. export const makeWebSocketExecutor = <E>(
  61. options: WebSocketRecordReplayOptions<E>,
  62. ): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
  63. Effect.gen(function* () {
  64. const requested = options.mode ?? "auto"
  65. const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested
  66. const redactor = options.redactor ?? defaults()
  67. const openSnapshot = (request: WebSocketRequest) => {
  68. const redacted = redactor.request({
  69. method: "GET",
  70. url: request.url,
  71. headers: headersRecord(request.headers),
  72. body: "",
  73. })
  74. return { url: redacted.url, headers: redacted.headers }
  75. }
  76. if (mode === "passthrough") return options.live
  77. if (mode === "record") {
  78. return {
  79. open: (request) =>
  80. Effect.gen(function* () {
  81. const client: WebSocketFrame[] = []
  82. const server: WebSocketFrame[] = []
  83. const connection = yield* options.live.open(request)
  84. const closed = yield* Ref.make(false)
  85. const closeOnce = Effect.gen(function* () {
  86. if (yield* Ref.getAndSet(closed, true)) return
  87. yield* connection.close
  88. yield* options.cassette
  89. .append(
  90. options.name,
  91. { transport: "websocket", open: openSnapshot(request), client, server },
  92. options.metadata,
  93. )
  94. .pipe(Effect.orDie)
  95. })
  96. return {
  97. sendText: (message) =>
  98. connection
  99. .sendText(message)
  100. .pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
  101. messages: connection.messages.pipe(
  102. Stream.tap((message) => Effect.sync(() => server.push(encodeFrame(message)))),
  103. ),
  104. close: closeOnce,
  105. }
  106. }),
  107. }
  108. }
  109. const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
  110. return {
  111. open: (request) =>
  112. Effect.gen(function* () {
  113. const interactions = yield* replay.load.pipe(Effect.orDie)
  114. const index = yield* replay.cursor
  115. const interaction = interactions[index]
  116. if (!interaction)
  117. return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${redactUrl(request.url)}`))
  118. yield* replay.advance
  119. yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
  120. const messageIndex = yield* Ref.make(0)
  121. return {
  122. sendText: (message) =>
  123. Effect.gen(function* () {
  124. const current = yield* Ref.get(messageIndex)
  125. yield* compareClientMessage(
  126. message,
  127. interaction.client[current],
  128. current,
  129. options.compareClientMessagesAsJson === true,
  130. )
  131. yield* Ref.update(messageIndex, (value) => value + 1)
  132. }),
  133. messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
  134. close: Effect.gen(function* () {
  135. yield* assertEqual(
  136. `WebSocket client frame count for interaction ${index + 1}`,
  137. yield* Ref.get(messageIndex),
  138. interaction.client.length,
  139. )
  140. }),
  141. }
  142. }),
  143. }
  144. })