sse-transport.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. import type { Page } from "@playwright/test"
  2. export type SseConnectionRecord = {
  3. id: number
  4. url: string
  5. path: "/global/event" | "/event" | "/api/event"
  6. headers: Record<string, string>
  7. openedAt: number
  8. endedAt?: number
  9. endedBy?: "close" | "disconnect" | "error" | "abort"
  10. error?: string
  11. }
  12. export type SseDeliveryAcknowledgement = {
  13. deliveryID: number
  14. connectionID: number
  15. bytes: number
  16. chunkCount: number
  17. deliveredAt: number
  18. eventID?: string
  19. }
  20. export type SseEventOptions = {
  21. id?: string
  22. event?: string
  23. retry?: number
  24. marker?: string
  25. }
  26. export type SseTransport<T> = {
  27. server: string
  28. waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
  29. send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
  30. burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
  31. split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
  32. heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
  33. writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
  34. close(): Promise<void>
  35. disconnect(message?: string): Promise<void>
  36. error(message?: string): Promise<void>
  37. connections(): Promise<SseConnectionRecord[]>
  38. acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
  39. }
  40. type BrowserCommand<T> =
  41. | { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
  42. | { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
  43. | { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
  44. | { type: "connections" }
  45. | { type: "acknowledgements" }
  46. type BrowserTransport = Window & {
  47. __testSseTransport?: {
  48. command: (command: BrowserCommand<unknown>) => unknown
  49. }
  50. }
  51. export async function installSseTransport<T>(
  52. page: Page,
  53. options: { server: string; retry?: number },
  54. ): Promise<SseTransport<T>> {
  55. const server = new URL(options.server).origin
  56. await page.addInitScript(
  57. ({ server, retry }) => {
  58. type Connection = SseConnectionRecord & { controller: ReadableStreamDefaultController<Uint8Array> }
  59. type ProbeWindow = Window & {
  60. __visualStabilityProbe?: { startedAt: number; markers: { at: number; label: string }[] }
  61. }
  62. const originalFetch = window.fetch.bind(window)
  63. const connections: Connection[] = []
  64. const acknowledgements: SseDeliveryAcknowledgement[] = []
  65. const encoder = new TextEncoder()
  66. let nextConnectionID = 0
  67. let nextDeliveryID = 0
  68. const current = () => connections.findLast((connection) => connection.endedAt === undefined)
  69. const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
  70. const boundaries = [...new Set(cuts ?? [])]
  71. .filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
  72. .sort((a, b) => a - b)
  73. return [0, ...boundaries].map((start, index) => bytes.slice(start, boundaries[index] ?? bytes.byteLength))
  74. }
  75. const marker = (label?: string) => {
  76. if (!label) return
  77. const probe = (window as ProbeWindow).__visualStabilityProbe
  78. if (!probe) return
  79. probe.markers.push({ at: performance.now() - probe.startedAt, label })
  80. }
  81. const frame = (payload: unknown, eventOptions: SseEventOptions = {}) =>
  82. [
  83. eventOptions.event === undefined ? "" : `event: ${eventOptions.event}\n`,
  84. eventOptions.id === undefined ? "" : `id: ${eventOptions.id}\n`,
  85. eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
  86. `data: ${JSON.stringify(payload)}\n\n`,
  87. ].join("")
  88. const currentEvent = (input: unknown) => {
  89. if (!input || typeof input !== "object" || !("payload" in input)) return input
  90. const envelope = input as { directory?: string; payload?: unknown }
  91. if (!envelope.payload || typeof envelope.payload !== "object") return input
  92. const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
  93. if (!payload.type) return input
  94. return {
  95. id: payload.id ?? `evt_mock_${Date.now()}`,
  96. created: Date.now(),
  97. type: payload.type,
  98. data: payload.properties ?? {},
  99. location:
  100. envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
  101. }
  102. }
  103. const acknowledge = (
  104. connection: Connection,
  105. bytes: number,
  106. chunkCount: number,
  107. eventID?: string,
  108. ): SseDeliveryAcknowledgement => {
  109. const acknowledgement = {
  110. deliveryID: ++nextDeliveryID,
  111. connectionID: connection.id,
  112. bytes,
  113. chunkCount,
  114. deliveredAt: performance.now(),
  115. ...(eventID === undefined ? {} : { eventID }),
  116. }
  117. acknowledgements.push(acknowledgement)
  118. return acknowledgement
  119. }
  120. const end = (mode: "close" | "disconnect" | "error", message?: string) => {
  121. const connection = current()
  122. if (!connection) throw new Error("SSE transport has no active connection")
  123. connection.endedAt = performance.now()
  124. connection.endedBy = mode
  125. if (message) connection.error = message
  126. if (mode === "close") {
  127. connection.controller.close()
  128. return
  129. }
  130. const error = new DOMException(
  131. message ?? "SSE connection disconnected",
  132. mode === "error" ? "Error" : "NetworkError",
  133. )
  134. connection.controller.error(error)
  135. }
  136. const command = (input: BrowserCommand<unknown>) => {
  137. if (input.type === "connections")
  138. return connections.map(({ controller: _controller, ...connection }) => connection)
  139. if (input.type === "acknowledgements") return acknowledgements
  140. if (input.type === "end") return end(input.mode, input.message)
  141. const connection = current()
  142. if (!connection) throw new Error("SSE transport has no active connection")
  143. if (input.type === "raw") {
  144. marker(input.marker)
  145. const output = chunks(new Uint8Array(input.bytes), input.cuts)
  146. output.forEach((chunk) => connection.controller.enqueue(chunk))
  147. return acknowledge(connection, input.bytes.length, output.length)
  148. }
  149. const encoded = input.deliveries.map((delivery) => {
  150. const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
  151. return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
  152. })
  153. encoded.forEach((item) => marker(item.delivery.options?.marker))
  154. if (input.burst) {
  155. const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
  156. connection.controller.enqueue(bytes)
  157. return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
  158. }
  159. const output = chunks(encoded[0]!.bytes, input.cuts)
  160. output.forEach((chunk) => connection.controller.enqueue(chunk))
  161. return acknowledge(connection, encoded[0]!.bytes.byteLength, output.length, encoded[0]!.delivery.options?.id)
  162. }
  163. ;(window as BrowserTransport).__testSseTransport = { command }
  164. const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
  165. const request = new Request(input, init)
  166. const url = new URL(request.url)
  167. if (
  168. url.origin !== server ||
  169. (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
  170. )
  171. return originalFetch(request)
  172. const id = ++nextConnectionID
  173. const record = {
  174. id,
  175. url: url.href,
  176. path: url.pathname,
  177. headers: Object.fromEntries(request.headers.entries()),
  178. openedAt: performance.now(),
  179. } as Connection
  180. const stream = new ReadableStream<Uint8Array>({
  181. start(controller) {
  182. record.controller = controller
  183. connections.push(record)
  184. if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
  185. if (url.pathname === "/api/event")
  186. controller.enqueue(
  187. encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
  188. )
  189. if (url.pathname === "/global/event")
  190. controller.enqueue(
  191. encoder.encode(
  192. frame({
  193. payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
  194. }),
  195. ),
  196. )
  197. request.signal.addEventListener(
  198. "abort",
  199. () => {
  200. if (record.endedAt !== undefined) return
  201. record.endedAt = performance.now()
  202. record.endedBy = "abort"
  203. controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
  204. },
  205. { once: true },
  206. )
  207. },
  208. cancel() {
  209. if (record.endedAt !== undefined) return
  210. record.endedAt = performance.now()
  211. record.endedBy = "disconnect"
  212. },
  213. })
  214. return Promise.resolve(
  215. new Response(stream, {
  216. status: 200,
  217. headers: {
  218. "cache-control": "no-cache",
  219. "content-type": "text/event-stream",
  220. },
  221. }),
  222. )
  223. }
  224. Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
  225. },
  226. { server, retry: options.retry },
  227. )
  228. const command = <Result>(input: BrowserCommand<T>) =>
  229. page.evaluate((input) => {
  230. const transport = (window as BrowserTransport).__testSseTransport
  231. if (!transport) throw new Error("SSE transport was not installed before page load")
  232. return transport.command(input as BrowserCommand<unknown>)
  233. }, input) as Promise<Result>
  234. return {
  235. server,
  236. async waitForConnection(input = {}) {
  237. const connection = await page.waitForFunction(
  238. (after) => {
  239. const transport = (window as BrowserTransport).__testSseTransport
  240. const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
  241. return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
  242. },
  243. input.after ?? 0,
  244. { timeout: input.timeout },
  245. )
  246. let result: SseConnectionRecord | undefined
  247. try {
  248. result = await connection.jsonValue()
  249. } finally {
  250. await connection.dispose()
  251. }
  252. if (!result) throw new Error("SSE transport connection disappeared while waiting")
  253. return result
  254. },
  255. send(payload, eventOptions) {
  256. return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
  257. },
  258. burst(payloads, eventOptions = []) {
  259. return command({
  260. type: "send",
  261. deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })),
  262. burst: true,
  263. })
  264. },
  265. split(payload, cuts, eventOptions) {
  266. return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
  267. },
  268. heartbeat(eventOptions) {
  269. return command({
  270. type: "send",
  271. deliveries: [
  272. {
  273. payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
  274. options: eventOptions,
  275. },
  276. ],
  277. burst: false,
  278. })
  279. },
  280. writeRaw(value, cuts, marker) {
  281. return command({
  282. type: "raw",
  283. bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value),
  284. cuts: cuts ? [...cuts] : undefined,
  285. marker,
  286. })
  287. },
  288. close() {
  289. return command({ type: "end", mode: "close" })
  290. },
  291. disconnect(message) {
  292. return command({ type: "end", mode: "disconnect", message })
  293. },
  294. error(message) {
  295. return command({ type: "end", mode: "error", message })
  296. },
  297. connections() {
  298. return command({ type: "connections" })
  299. },
  300. acknowledgements() {
  301. return command({ type: "acknowledgements" })
  302. },
  303. }
  304. }