sse-transport.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import type { Page } from "@playwright/test"
  2. export type SseConnectionRecord = {
  3. id: number
  4. url: string
  5. path: "/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 (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request)
  168. const id = ++nextConnectionID
  169. const record = {
  170. id,
  171. url: url.href,
  172. path: url.pathname,
  173. headers: Object.fromEntries(request.headers.entries()),
  174. openedAt: performance.now(),
  175. } as Connection
  176. const stream = new ReadableStream<Uint8Array>({
  177. start(controller) {
  178. record.controller = controller
  179. connections.push(record)
  180. if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
  181. controller.enqueue(
  182. encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
  183. )
  184. request.signal.addEventListener(
  185. "abort",
  186. () => {
  187. if (record.endedAt !== undefined) return
  188. record.endedAt = performance.now()
  189. record.endedBy = "abort"
  190. controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
  191. },
  192. { once: true },
  193. )
  194. },
  195. cancel() {
  196. if (record.endedAt !== undefined) return
  197. record.endedAt = performance.now()
  198. record.endedBy = "disconnect"
  199. },
  200. })
  201. return Promise.resolve(
  202. new Response(stream, {
  203. status: 200,
  204. headers: {
  205. "cache-control": "no-cache",
  206. "content-type": "text/event-stream",
  207. },
  208. }),
  209. )
  210. }
  211. Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
  212. },
  213. { server, retry: options.retry },
  214. )
  215. const command = <Result>(input: BrowserCommand<T>) =>
  216. page.evaluate((input) => {
  217. const transport = (window as BrowserTransport).__testSseTransport
  218. if (!transport) throw new Error("SSE transport was not installed before page load")
  219. return transport.command(input as BrowserCommand<unknown>)
  220. }, input) as Promise<Result>
  221. return {
  222. server,
  223. async waitForConnection(input = {}) {
  224. const connection = await page.waitForFunction(
  225. (after) => {
  226. const transport = (window as BrowserTransport).__testSseTransport
  227. const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
  228. return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
  229. },
  230. input.after ?? 0,
  231. { timeout: input.timeout },
  232. )
  233. let result: SseConnectionRecord | undefined
  234. try {
  235. result = await connection.jsonValue()
  236. } finally {
  237. await connection.dispose()
  238. }
  239. if (!result) throw new Error("SSE transport connection disappeared while waiting")
  240. return result
  241. },
  242. send(payload, eventOptions) {
  243. return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
  244. },
  245. burst(payloads, eventOptions = []) {
  246. return command({
  247. type: "send",
  248. deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })),
  249. burst: true,
  250. })
  251. },
  252. split(payload, cuts, eventOptions) {
  253. return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
  254. },
  255. heartbeat(eventOptions) {
  256. return command({
  257. type: "send",
  258. deliveries: [
  259. {
  260. payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
  261. options: eventOptions,
  262. },
  263. ],
  264. burst: false,
  265. })
  266. },
  267. writeRaw(value, cuts, marker) {
  268. return command({
  269. type: "raw",
  270. bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value),
  271. cuts: cuts ? [...cuts] : undefined,
  272. marker,
  273. })
  274. },
  275. close() {
  276. return command({ type: "end", mode: "close" })
  277. },
  278. disconnect(message) {
  279. return command({ type: "end", mode: "disconnect", message })
  280. },
  281. error(message) {
  282. return command({ type: "end", mode: "error", message })
  283. },
  284. connections() {
  285. return command({ type: "connections" })
  286. },
  287. acknowledgements() {
  288. return command({ type: "acknowledgements" })
  289. },
  290. }
  291. }