stream-server.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. /** Host WebSocket owner for multiplexed Typert Remote streams. */
  2. import type { IncomingMessage } from 'node:http'
  3. import type { Duplex } from 'node:stream'
  4. import WebSocket, { WebSocketServer, type RawData } from 'ws'
  5. import {
  6. parseRemoteStreamClientMessage,
  7. type RemoteStreamFailure,
  8. type RemoteStreamServerMessage,
  9. } from './stream-protocol.ts'
  10. /** Open one validated Remote stream for a decoded wire request. */
  11. export type RemoteStreamOpener = (
  12. endpoint: string,
  13. payload: unknown,
  14. signal: AbortSignal,
  15. ) => Promise<AsyncIterable<unknown>>
  16. /** Convert an invocation or carrier failure to a stable wire value. */
  17. export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure
  18. const MAX_MISSED_HEARTBEATS = 2
  19. /** Own the no-server WebSocket acceptor and every active logical stream. */
  20. export class RemoteStreamMuxServer {
  21. private readonly server = new WebSocketServer({ noServer: true })
  22. private readonly connections = new Set<Promise<void>>()
  23. private readonly missedHeartbeats = new WeakMap<WebSocket, number>()
  24. private heartbeatTimer: NodeJS.Timeout | undefined
  25. /**
  26. * @param open - Gateway stream dispatcher.
  27. * @param failure - Gateway error-to-wire mapper.
  28. * @param heartbeatIntervalMs - interval between WebSocket Ping control frames.
  29. */
  30. constructor(
  31. private readonly open: RemoteStreamOpener,
  32. private readonly failure: RemoteStreamFailureMapper,
  33. private readonly heartbeatIntervalMs: number,
  34. ) {}
  35. /**
  36. * Upgrade one trusted request and begin serving its logical streams.
  37. * @param req - authenticated HTTP upgrade request.
  38. * @param socket - carrier socket transferred to the WebSocket server.
  39. * @param head - bytes already read after the HTTP upgrade headers.
  40. */
  41. handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
  42. this.server.handleUpgrade(req, socket, head, (websocket) => {
  43. this.missedHeartbeats.set(websocket, 0)
  44. websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) })
  45. this.startHeartbeat()
  46. const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure)
  47. const done = connection.run()
  48. this.connections.add(done)
  49. void done.then(() => { this.connections.delete(done) })
  50. })
  51. }
  52. /** Terminate all sockets and wait until every iterator has returned. */
  53. async close(): Promise<void> {
  54. clearInterval(this.heartbeatTimer)
  55. this.heartbeatTimer = undefined
  56. for (const socket of this.server.clients) socket.terminate()
  57. const closed = Promise.withResolvers<void>()
  58. this.server.close((error) => {
  59. if (error === undefined) closed.resolve()
  60. else closed.reject(error)
  61. })
  62. await closed.promise
  63. await Promise.all(this.connections)
  64. }
  65. /** Start one `unref()` timer after the first upgrade; it spans empty-client periods until close(). */
  66. private startHeartbeat(): void {
  67. if (this.heartbeatTimer !== undefined) return
  68. this.heartbeatTimer = setInterval(() => {
  69. for (const socket of this.server.clients) {
  70. if (socket.readyState !== WebSocket.OPEN) continue
  71. const missed = this.missedHeartbeats.get(socket) as number
  72. if (missed >= MAX_MISSED_HEARTBEATS) {
  73. setImmediate(() => {
  74. if ((this.missedHeartbeats.get(socket) as number) >= MAX_MISSED_HEARTBEATS) {
  75. socket.terminate()
  76. }
  77. })
  78. continue
  79. }
  80. this.missedHeartbeats.set(socket, missed + 1)
  81. socket.ping()
  82. }
  83. }, this.heartbeatIntervalMs)
  84. this.heartbeatTimer.unref()
  85. }
  86. }
  87. interface ActiveStream {
  88. readonly abort: AbortController
  89. done: Promise<void>
  90. }
  91. class RemoteStreamMuxConnection {
  92. private readonly streams = new Map<string, ActiveStream>()
  93. private writes = Promise.resolve()
  94. constructor(
  95. private readonly socket: WebSocket,
  96. private readonly open: RemoteStreamOpener,
  97. private readonly failure: RemoteStreamFailureMapper,
  98. ) {}
  99. async run(): Promise<void> {
  100. const closed = new Promise<void>((resolve) => {
  101. this.socket.once('close', resolve)
  102. this.socket.once('error', () => { this.socket.terminate() })
  103. this.socket.on('message', (data, isBinary) => {
  104. if (isBinary) {
  105. this.socket.close(1003, 'text messages required')
  106. return
  107. }
  108. try {
  109. this.receive(rawText(data))
  110. } catch {
  111. this.socket.close(1008, 'invalid Remote stream request')
  112. }
  113. })
  114. })
  115. await closed
  116. const active = [...this.streams.values()]
  117. for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed'))
  118. await Promise.all(active.map(stream => stream.done))
  119. }
  120. private receive(text: string): void {
  121. const message = parseRemoteStreamClientMessage(text)
  122. if (message.type === 'cancel') {
  123. this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled'))
  124. return
  125. }
  126. if (this.streams.has(message.streamId)) {
  127. throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`)
  128. }
  129. const abort = new AbortController()
  130. const active: ActiveStream = {
  131. abort,
  132. done: Promise.resolve(),
  133. }
  134. this.streams.set(message.streamId, active)
  135. const done = this.pump(message.streamId, message.endpoint, message.payload, active)
  136. active.done = done
  137. const remove = (): void => { this.streams.delete(message.streamId) }
  138. void done.then(remove, remove)
  139. }
  140. private async pump(
  141. streamId: string,
  142. endpoint: string,
  143. payload: unknown,
  144. active: ActiveStream,
  145. ): Promise<void> {
  146. try {
  147. const source = await this.open(endpoint, payload, active.abort.signal)
  148. for await (const value of source) {
  149. await this.send({ type: 'item', streamId, value })
  150. }
  151. if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId })
  152. } catch (error) {
  153. if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) {
  154. try {
  155. await this.send({ type: 'error', streamId, error: this.failure(error) })
  156. } catch {
  157. // A terminal frame that cannot be encoded or written leaves the
  158. // logical stream ambiguous, so fail the physical generation.
  159. this.socket.close(1011, 'Remote stream failure could not be delivered')
  160. }
  161. }
  162. }
  163. }
  164. private send(message: RemoteStreamServerMessage): Promise<void> {
  165. let text: string
  166. try {
  167. text = JSON.stringify(message)
  168. } catch (cause) {
  169. return Promise.reject(new Error('api gateway: Remote stream item is not JSON serializable', { cause }))
  170. }
  171. const delivery = this.writes.then(() => new Promise<void>((resolve, reject) => {
  172. if (this.socket.readyState !== WebSocket.OPEN) {
  173. reject(new Error('api gateway: Remote stream socket is closed'))
  174. return
  175. }
  176. this.socket.send(text, (error) => {
  177. if (error) reject(error)
  178. else resolve()
  179. })
  180. }))
  181. this.writes = delivery.catch(() => undefined)
  182. return delivery
  183. }
  184. }
  185. function rawText(data: RawData): string {
  186. if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
  187. if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
  188. return Buffer.from(data).toString('utf8')
  189. }
  190. /**
  191. * Reject an upgrade without transferring socket ownership to ws.
  192. * @param socket - carrier socket that receives the HTTP rejection.
  193. * @param status - authentication or browser-trust rejection status.
  194. */
  195. export function rejectRemoteStreamUpgrade(socket: Duplex, status: 401 | 403): void {
  196. const reason = status === 401 ? 'Unauthorized' : 'Forbidden'
  197. const body = reason.toLowerCase()
  198. socket.end([
  199. `HTTP/1.1 ${String(status)} ${reason}`,
  200. 'Connection: close',
  201. 'Content-Type: text/plain; charset=utf-8',
  202. `Content-Length: ${String(Buffer.byteLength(body))}`,
  203. '',
  204. body,
  205. ].join('\r\n'))
  206. }