websocket.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import { Effect, Option, Ref, Scope, Semaphore, Stream, SynchronizedRef } from "effect"
  2. import type { Headers } from "effect/unstable/http"
  3. import * as CassetteService from "./cassette.js"
  4. import { canonicalizeJson, decodeJson, safeText } from "./matching.js"
  5. import { makeReplayState, resolveAutoMode } from "./recorder.js"
  6. import type { RecordReplayMode } from "./internal-effect.js"
  7. import { make, type Redactor } from "./redactor.js"
  8. import { webSocketInteractions, type CassetteMetadata, type WebSocketEvent } from "./schema.js"
  9. export interface WebSocketRequest {
  10. readonly url: string
  11. readonly headers: Headers.Headers
  12. }
  13. export interface WebSocketConnection<E> {
  14. readonly sendText: (message: string) => Effect.Effect<void, E>
  15. readonly messages: Stream.Stream<string | Uint8Array, E>
  16. readonly close: Effect.Effect<void>
  17. }
  18. export interface WebSocketExecutor<E> {
  19. readonly open: (request: WebSocketRequest) => Effect.Effect<WebSocketConnection<E>, E>
  20. }
  21. export interface WebSocketRecordReplayOptions<E> {
  22. readonly name: string
  23. readonly mode?: RecordReplayMode
  24. readonly metadata?: CassetteMetadata
  25. readonly cassette: CassetteService.Interface
  26. readonly live: WebSocketExecutor<E>
  27. readonly redactor?: Redactor
  28. readonly compareClientMessagesAsJson?: boolean
  29. }
  30. const headersRecord = (headers: Headers.Headers): Record<string, string> =>
  31. Object.fromEntries(
  32. Object.entries(headers as Record<string, unknown>).filter(
  33. (entry): entry is [string, string] => typeof entry[1] === "string",
  34. ),
  35. )
  36. const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({
  37. direction,
  38. kind: "text",
  39. body,
  40. })
  41. const decodeEvent = (event: WebSocketEvent) =>
  42. event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
  43. const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
  44. const assertClientEvent = (actual: string, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
  45. Effect.sync(() => {
  46. const matches =
  47. expected?.direction === "client" &&
  48. expected.kind === "text" &&
  49. JSON.stringify(asJson ? jsonOrText(actual) : actual) ===
  50. JSON.stringify(asJson ? jsonOrText(expected.body) : expected.body)
  51. if (matches) return
  52. throw new Error(`WebSocket client frame ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
  53. })
  54. export const makeWebSocketExecutor = <E>(
  55. options: WebSocketRecordReplayOptions<E>,
  56. ): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
  57. Effect.gen(function* () {
  58. const mode = options.mode ?? (yield* resolveAutoMode(options.cassette, options.name))
  59. const redactor = options.redactor ?? make()
  60. const openSnapshot = (request: WebSocketRequest) => {
  61. const snapshot = redactor.request({
  62. method: "GET",
  63. url: request.url,
  64. headers: headersRecord(request.headers),
  65. body: "",
  66. })
  67. return { url: snapshot.url, headers: snapshot.headers }
  68. }
  69. const redactEvent = (event: WebSocketEvent) => {
  70. if (event.kind === "binary") return event
  71. const body =
  72. event.direction === "client"
  73. ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
  74. : redactor.response({ status: 101, headers: {}, body: event.body }).body
  75. return { ...event, body }
  76. }
  77. if (mode === "passthrough") return options.live
  78. if (mode === "record") {
  79. return {
  80. open: (request) =>
  81. Effect.gen(function* () {
  82. const events: WebSocketEvent[] = []
  83. const connection = yield* options.live.open(request)
  84. const closed = yield* Ref.make(false)
  85. const closeLock = yield* Semaphore.make(1)
  86. return {
  87. sendText: (message) =>
  88. Effect.sync(() => events.push(redactEvent(textEvent("client", message)))).pipe(
  89. Effect.andThen(connection.sendText(message)),
  90. ),
  91. messages: connection.messages.pipe(
  92. Stream.tap((message) =>
  93. Effect.sync(() =>
  94. events.push(
  95. typeof message === "string"
  96. ? redactEvent(textEvent("server", message))
  97. : {
  98. direction: "server",
  99. kind: "binary",
  100. body: Buffer.from(message).toString("base64"),
  101. bodyEncoding: "base64",
  102. },
  103. ),
  104. ),
  105. ),
  106. ),
  107. close: closeLock.withPermit(
  108. Effect.gen(function* () {
  109. if (yield* Ref.get(closed)) return
  110. yield* connection.close
  111. yield* options.cassette
  112. .append(
  113. options.name,
  114. { transport: "websocket", open: openSnapshot(request), events },
  115. options.metadata,
  116. )
  117. .pipe(Effect.orDie)
  118. yield* Ref.set(closed, true)
  119. }),
  120. ),
  121. }
  122. }),
  123. }
  124. }
  125. const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
  126. return {
  127. open: (request) =>
  128. Effect.gen(function* () {
  129. const claimed = yield* replay
  130. .claim((interaction, index) =>
  131. Effect.sync(() => {
  132. const incoming = canonicalizeJson(openSnapshot(request))
  133. if (interaction && JSON.stringify(incoming) === JSON.stringify(canonicalizeJson(interaction.open)))
  134. return
  135. throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`)
  136. }),
  137. )
  138. .pipe(Effect.orDie)
  139. const client = claimed.interaction.events.filter((event) => event.direction === "client")
  140. const server = claimed.interaction.events.filter((event) => event.direction === "server")
  141. const position = yield* SynchronizedRef.make(0)
  142. return {
  143. sendText: (message) =>
  144. SynchronizedRef.updateEffect(position, (index) =>
  145. assertClientEvent(message, client[index], index, options.compareClientMessagesAsJson === true).pipe(
  146. Effect.as(index + 1),
  147. ),
  148. ),
  149. messages: Stream.fromIterable(server).pipe(Stream.map(decodeEvent)),
  150. close: Effect.gen(function* () {
  151. const used = yield* SynchronizedRef.get(position)
  152. if (used !== client.length)
  153. return yield* Effect.die(
  154. new Error(`WebSocket client frame count: expected ${client.length}, received ${used}`),
  155. )
  156. }),
  157. }
  158. }),
  159. }
  160. })