sse-transport.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import type { Page } from "@playwright/test"
  2. export type SseConnectionRecord = {
  3. id: number
  4. url: string
  5. path: "/global/event" | "/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 acknowledge = (
  89. connection: Connection,
  90. bytes: number,
  91. chunkCount: number,
  92. eventID?: string,
  93. ): SseDeliveryAcknowledgement => {
  94. const acknowledgement = {
  95. deliveryID: ++nextDeliveryID,
  96. connectionID: connection.id,
  97. bytes,
  98. chunkCount,
  99. deliveredAt: performance.now(),
  100. ...(eventID === undefined ? {} : { eventID }),
  101. }
  102. acknowledgements.push(acknowledgement)
  103. return acknowledgement
  104. }
  105. const end = (mode: "close" | "disconnect" | "error", message?: string) => {
  106. const connection = current()
  107. if (!connection) throw new Error("SSE transport has no active connection")
  108. connection.endedAt = performance.now()
  109. connection.endedBy = mode
  110. if (message) connection.error = message
  111. if (mode === "close") {
  112. connection.controller.close()
  113. return
  114. }
  115. const error = new DOMException(
  116. message ?? "SSE connection disconnected",
  117. mode === "error" ? "Error" : "NetworkError",
  118. )
  119. connection.controller.error(error)
  120. }
  121. const command = (input: BrowserCommand<unknown>) => {
  122. if (input.type === "connections")
  123. return connections.map(({ controller: _controller, ...connection }) => connection)
  124. if (input.type === "acknowledgements") return acknowledgements
  125. if (input.type === "end") return end(input.mode, input.message)
  126. const connection = current()
  127. if (!connection) throw new Error("SSE transport has no active connection")
  128. if (input.type === "raw") {
  129. marker(input.marker)
  130. const output = chunks(new Uint8Array(input.bytes), input.cuts)
  131. output.forEach((chunk) => connection.controller.enqueue(chunk))
  132. return acknowledge(connection, input.bytes.length, output.length)
  133. }
  134. const encoded = input.deliveries.map((delivery) => ({
  135. delivery,
  136. bytes: encoder.encode(frame(delivery.payload, delivery.options)),
  137. }))
  138. encoded.forEach((item) => marker(item.delivery.options?.marker))
  139. if (input.burst) {
  140. const bytes = encoder.encode(
  141. encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
  142. )
  143. connection.controller.enqueue(bytes)
  144. return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
  145. }
  146. const output = chunks(encoded[0]!.bytes, input.cuts)
  147. output.forEach((chunk) => connection.controller.enqueue(chunk))
  148. return acknowledge(connection, encoded[0]!.bytes.byteLength, output.length, encoded[0]!.delivery.options?.id)
  149. }
  150. ;(window as BrowserTransport).__testSseTransport = { command }
  151. const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
  152. const request = new Request(input, init)
  153. const url = new URL(request.url)
  154. if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
  155. return originalFetch(input, init)
  156. const id = ++nextConnectionID
  157. const record = {
  158. id,
  159. url: url.href,
  160. path: url.pathname,
  161. headers: Object.fromEntries(request.headers.entries()),
  162. openedAt: performance.now(),
  163. } as Connection
  164. const stream = new ReadableStream<Uint8Array>({
  165. start(controller) {
  166. record.controller = controller
  167. connections.push(record)
  168. if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
  169. request.signal.addEventListener(
  170. "abort",
  171. () => {
  172. if (record.endedAt !== undefined) return
  173. record.endedAt = performance.now()
  174. record.endedBy = "abort"
  175. controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
  176. },
  177. { once: true },
  178. )
  179. },
  180. cancel() {
  181. if (record.endedAt !== undefined) return
  182. record.endedAt = performance.now()
  183. record.endedBy = "disconnect"
  184. },
  185. })
  186. return Promise.resolve(
  187. new Response(stream, {
  188. status: 200,
  189. headers: {
  190. "cache-control": "no-cache",
  191. "content-type": "text/event-stream",
  192. },
  193. }),
  194. )
  195. }
  196. Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
  197. },
  198. { server, retry: options.retry },
  199. )
  200. const command = <Result>(input: BrowserCommand<T>) =>
  201. page.evaluate((input) => {
  202. const transport = (window as BrowserTransport).__testSseTransport
  203. if (!transport) throw new Error("SSE transport was not installed before page load")
  204. return transport.command(input as BrowserCommand<unknown>)
  205. }, input) as Promise<Result>
  206. return {
  207. server,
  208. async waitForConnection(input = {}) {
  209. await page.waitForFunction(
  210. (after) => {
  211. const transport = (window as BrowserTransport).__testSseTransport
  212. const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
  213. return connections?.some((connection) => connection.id > after)
  214. },
  215. input.after ?? 0,
  216. { timeout: input.timeout },
  217. )
  218. return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
  219. (connection) => connection.id > (input.after ?? 0),
  220. )!
  221. },
  222. send(payload, eventOptions) {
  223. return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
  224. },
  225. burst(payloads, eventOptions = []) {
  226. return command({
  227. type: "send",
  228. deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })),
  229. burst: true,
  230. })
  231. },
  232. split(payload, cuts, eventOptions) {
  233. return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
  234. },
  235. heartbeat(eventOptions) {
  236. return command({
  237. type: "send",
  238. deliveries: [
  239. {
  240. payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
  241. options: eventOptions,
  242. },
  243. ],
  244. burst: false,
  245. })
  246. },
  247. writeRaw(value, cuts, marker) {
  248. return command({
  249. type: "raw",
  250. bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value),
  251. cuts: cuts ? [...cuts] : undefined,
  252. marker,
  253. })
  254. },
  255. close() {
  256. return command({ type: "end", mode: "close" })
  257. },
  258. disconnect(message) {
  259. return command({ type: "end", mode: "disconnect", message })
  260. },
  261. error(message) {
  262. return command({ type: "end", mode: "error", message })
  263. },
  264. connections() {
  265. return command({ type: "connections" })
  266. },
  267. acknowledgements() {
  268. return command({ type: "acknowledgements" })
  269. },
  270. }
  271. }