socket.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { Deferred, Effect, Exit, FiberSet, Layer, Ref, Scope, Semaphore } from "effect"
  3. import { Socket } from "effect/unstable/socket"
  4. import * as CassetteService from "./cassette.js"
  5. import { canonicalizeJson, decodeJson, safeText } from "./matching.js"
  6. import { makeReplayState, resolveAutoMode } from "./recorder.js"
  7. import { make, type Redactor } from "./redactor.js"
  8. import { webSocketInteractions } from "./schema.js"
  9. import type {
  10. RecorderOptions,
  11. WebSocketEvent,
  12. WebSocketInteraction,
  13. WebSocketRecorderOptions,
  14. WebSocketRequest,
  15. } from "./types.js"
  16. interface ActiveReplay {
  17. readonly interaction: WebSocketInteraction
  18. readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred<void> }>
  19. readonly writeLock: Semaphore.Semaphore
  20. readonly closed: Ref.Ref<boolean>
  21. }
  22. interface ActiveRecording {
  23. readonly events: Array<WebSocketEvent>
  24. readonly eventLock: Semaphore.Semaphore
  25. readonly accepting: Ref.Ref<boolean>
  26. opened: boolean
  27. valid: boolean
  28. }
  29. type Frame = string | Uint8Array
  30. const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent =>
  31. typeof message === "string"
  32. ? { direction, kind: "text", body: message }
  33. : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
  34. const decodeEvent = (event: WebSocketEvent): Frame =>
  35. event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64"))
  36. const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => {
  37. if (event.kind === "binary") return event
  38. const body =
  39. event.direction === "client"
  40. ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body
  41. : redactor.response({ status: 101, headers: {}, body: event.body }).body
  42. return { ...event, body }
  43. }
  44. const comparable = (event: WebSocketEvent, asJson: boolean) => {
  45. if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event))
  46. const decoded = decodeJson(event.body)
  47. return JSON.stringify(
  48. canonicalizeJson({
  49. ...event,
  50. body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value),
  51. }),
  52. )
  53. }
  54. const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) =>
  55. Effect.sync(() => {
  56. if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return
  57. throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`)
  58. })
  59. const runHandler = <A, E, R>(handler: (value: A) => Effect.Effect<unknown, E, R> | void, value: A) =>
  60. Effect.suspend(() => {
  61. const result = handler(value)
  62. return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void
  63. })
  64. const runReplay = <A, E, R>(
  65. state: ActiveReplay,
  66. handler: (value: A) => Effect.Effect<unknown, E, R> | void,
  67. decode: (event: WebSocketEvent) => A,
  68. onOpen: Effect.Effect<void> | undefined,
  69. ) =>
  70. Effect.scoped(
  71. Effect.gen(function* () {
  72. const handlers = yield* FiberSet.make<unknown, E>()
  73. const run = yield* FiberSet.runtime(handlers)<R>()
  74. if (onOpen) yield* onOpen
  75. const drive = Effect.gen(function* () {
  76. while (true) {
  77. const current = yield* Ref.get(state.progress)
  78. const event = state.interaction.events[current.position]
  79. if (!event) return
  80. if (yield* Ref.get(state.closed))
  81. return yield* Effect.die(
  82. new Error(
  83. `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
  84. ),
  85. )
  86. if (event.direction === "server") {
  87. yield* Ref.set(state.progress, {
  88. position: current.position + 1,
  89. changed: yield* Deferred.make<void>(),
  90. })
  91. run(runHandler(handler, decode(event)))
  92. continue
  93. }
  94. yield* Deferred.await(current.changed)
  95. }
  96. })
  97. yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers)))
  98. yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers)))
  99. }),
  100. )
  101. const openSnapshot = (request: WebSocketRequest, redactor: Redactor) => {
  102. const snapshot = redactor.request({ method: "GET", url: request.url, headers: request.headers ?? {}, body: "" })
  103. return { url: snapshot.url, headers: snapshot.headers }
  104. }
  105. const makeRecordingSocket = (
  106. upstream: Socket.Socket,
  107. cassette: CassetteService.Interface,
  108. name: string,
  109. request: WebSocketRequest,
  110. options: WebSocketRecorderOptions,
  111. redactor: Redactor,
  112. ) =>
  113. Effect.gen(function* () {
  114. const active = yield* Ref.make<ActiveRecording | undefined>(undefined)
  115. const writeLock = yield* Semaphore.make(1)
  116. return Socket.make({
  117. runRaw: (handler, runOptions) =>
  118. Effect.gen(function* () {
  119. const state: ActiveRecording = {
  120. events: [],
  121. eventLock: yield* Semaphore.make(1),
  122. accepting: yield* Ref.make(true),
  123. opened: false,
  124. valid: true,
  125. }
  126. const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
  127. if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported")
  128. yield* upstream
  129. .runRaw(
  130. (message) => {
  131. if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing")
  132. state.events.push(redactEvent(encodeEvent("server", message), redactor))
  133. return handler(message)
  134. },
  135. {
  136. ...runOptions,
  137. onOpen: Effect.gen(function* () {
  138. state.opened = true
  139. if (runOptions?.onOpen) yield* runOptions.onOpen
  140. }),
  141. },
  142. )
  143. .pipe(
  144. Effect.onExit((exit) =>
  145. writeLock.withPermit(
  146. state.eventLock.withPermit(
  147. Effect.gen(function* () {
  148. yield* Ref.set(state.accepting, false)
  149. yield* Ref.set(active, undefined)
  150. if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return
  151. yield* cassette
  152. .append(
  153. name,
  154. {
  155. transport: "websocket",
  156. open: openSnapshot(request, redactor),
  157. events: [...state.events],
  158. },
  159. options.metadata,
  160. )
  161. .pipe(Effect.orDie)
  162. }),
  163. ),
  164. ),
  165. ),
  166. )
  167. }),
  168. writer: upstream.writer.pipe(
  169. Effect.map(
  170. (write) => (message) =>
  171. writeLock.withPermit(
  172. Effect.gen(function* () {
  173. if (Socket.isCloseEvent(message)) return yield* write(message)
  174. const state = yield* Ref.get(active)
  175. if (!state || !(yield* Ref.get(state.accepting)))
  176. return yield* Effect.die("WebSocket writer used without an active socket run")
  177. const event = redactEvent(encodeEvent("client", message), redactor)
  178. yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event)))
  179. return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false))))
  180. }),
  181. ),
  182. ),
  183. ),
  184. })
  185. })
  186. const makeReplaySocket = (
  187. cassette: CassetteService.Interface,
  188. name: string,
  189. request: WebSocketRequest,
  190. options: WebSocketRecorderOptions,
  191. redactor: Redactor,
  192. ): Effect.Effect<Socket.Socket, never, Scope.Scope> =>
  193. Effect.gen(function* () {
  194. const replay = yield* makeReplayState(cassette, name, webSocketInteractions)
  195. const active = yield* Ref.make<ActiveReplay | undefined>(undefined)
  196. return Socket.make({
  197. runRaw: (handler, runOptions) =>
  198. Effect.gen(function* () {
  199. const claimed = yield* replay
  200. .claim((interaction, index) =>
  201. Effect.sync(() => {
  202. const incoming = openSnapshot(request, redactor)
  203. if (
  204. interaction &&
  205. JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open))
  206. )
  207. return
  208. throw new Error(
  209. `WebSocket open ${index + 1}: expected ${safeText(interaction?.open)}, received ${safeText(incoming)}`,
  210. )
  211. }),
  212. )
  213. .pipe(Effect.orDie)
  214. const progress = yield* Ref.make({ position: 0, changed: yield* Deferred.make<void>() })
  215. const writeLock = yield* Semaphore.make(1)
  216. const state = {
  217. interaction: claimed.interaction,
  218. progress,
  219. writeLock,
  220. closed: yield* Ref.make(false),
  221. }
  222. const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state])
  223. if (occupied) return yield* Effect.die("Concurrent runs of a replayed WebSocket are not supported")
  224. yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe(
  225. Effect.ensuring(Ref.set(active, undefined)),
  226. )
  227. }),
  228. writer: Effect.succeed((message) => {
  229. return Ref.get(active).pipe(
  230. Effect.flatMap((state) =>
  231. state
  232. ? state.writeLock.withPermit(
  233. Effect.gen(function* () {
  234. const current = yield* Ref.get(state.progress)
  235. if (Socket.isCloseEvent(message)) {
  236. yield* Ref.set(state.closed, true)
  237. yield* Deferred.succeed(current.changed, undefined)
  238. if (current.position === state.interaction.events.length) return
  239. return yield* Effect.die(
  240. new Error(
  241. `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`,
  242. ),
  243. )
  244. }
  245. const actual = redactEvent(encodeEvent("client", message), redactor)
  246. yield* assertEvent(
  247. actual,
  248. state.interaction.events[current.position],
  249. current.position,
  250. options.compareClientMessagesAsJson === true,
  251. )
  252. yield* Ref.set(state.progress, {
  253. position: current.position + 1,
  254. changed: yield* Deferred.make<void>(),
  255. })
  256. yield* Deferred.succeed(current.changed, undefined)
  257. }),
  258. )
  259. : Effect.die("WebSocket writer used without an active socket run"),
  260. ),
  261. )
  262. }),
  263. })
  264. })
  265. const recordingLayer = (
  266. name: string,
  267. request: WebSocketRequest,
  268. options: WebSocketRecorderOptions,
  269. forcedMode?: "record" | "replay",
  270. ): Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service> =>
  271. Layer.effect(
  272. Socket.Socket,
  273. Effect.gen(function* () {
  274. const upstream = yield* Socket.Socket
  275. const cassette = yield* CassetteService.Service
  276. const redactor = make(options.redact)
  277. if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record")
  278. return yield* makeRecordingSocket(upstream, cassette, name, request, options, redactor)
  279. return yield* makeReplaySocket(cassette, name, request, options, redactor)
  280. }),
  281. )
  282. /**
  283. * Wraps a provided `Socket.Socket` with cassette recording and replay.
  284. *
  285. * Supply the ordinary URL-bound Effect socket layer beneath this decorator.
  286. * The cassette name identifies the connection; recorder configuration does not
  287. * duplicate the transport URL.
  288. */
  289. export const socket = (name: string, options: RecorderOptions = {}): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
  290. provideCassette(recordingLayer(name, { url: "" }, { ...options, compareClientMessagesAsJson: true }), options)
  291. /** @internal */
  292. export const socketLayer = (
  293. name: string,
  294. request: WebSocketRequest,
  295. options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" },
  296. ): Layer.Layer<Socket.Socket, never, Socket.Socket> =>
  297. provideCassette(recordingLayer(name, request, options, options.mode), options)
  298. const provideCassette = (
  299. layer: Layer.Layer<Socket.Socket, never, Socket.Socket | CassetteService.Service>,
  300. options: WebSocketRecorderOptions,
  301. ) =>
  302. layer.pipe(
  303. Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
  304. Layer.provide(NodeFileSystem.layer),
  305. )