stream-server.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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. /** Own the no-server WebSocket acceptor and every active logical stream. */
  19. export class RemoteStreamMuxServer {
  20. private readonly server = new WebSocketServer({ noServer: true })
  21. private readonly connections = new Set<Promise<void>>()
  22. /**
  23. * @param open - Gateway stream dispatcher.
  24. * @param failure - Gateway error-to-wire mapper.
  25. */
  26. constructor(
  27. private readonly open: RemoteStreamOpener,
  28. private readonly failure: RemoteStreamFailureMapper,
  29. ) {}
  30. /**
  31. * Upgrade one trusted request and begin serving its logical streams.
  32. * @param req - authenticated HTTP upgrade request.
  33. * @param socket - carrier socket transferred to the WebSocket server.
  34. * @param head - bytes already read after the HTTP upgrade headers.
  35. */
  36. handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
  37. this.server.handleUpgrade(req, socket, head, (websocket) => {
  38. const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure)
  39. const done = connection.run()
  40. this.connections.add(done)
  41. void done.then(() => { this.connections.delete(done) })
  42. })
  43. }
  44. /** Terminate all sockets and wait until every iterator has returned. */
  45. async close(): Promise<void> {
  46. for (const socket of this.server.clients) socket.terminate()
  47. const closed = Promise.withResolvers<void>()
  48. this.server.close((error) => {
  49. if (error === undefined) closed.resolve()
  50. else closed.reject(error)
  51. })
  52. await closed.promise
  53. await Promise.all(this.connections)
  54. }
  55. }
  56. interface ActiveStream {
  57. readonly abort: AbortController
  58. done: Promise<void>
  59. }
  60. class RemoteStreamMuxConnection {
  61. private readonly streams = new Map<string, ActiveStream>()
  62. private writes = Promise.resolve()
  63. constructor(
  64. private readonly socket: WebSocket,
  65. private readonly open: RemoteStreamOpener,
  66. private readonly failure: RemoteStreamFailureMapper,
  67. ) {}
  68. async run(): Promise<void> {
  69. const closed = new Promise<void>((resolve) => {
  70. this.socket.once('close', resolve)
  71. this.socket.once('error', () => { this.socket.terminate() })
  72. this.socket.on('message', (data, isBinary) => {
  73. if (isBinary) {
  74. this.socket.close(1003, 'text messages required')
  75. return
  76. }
  77. try {
  78. this.receive(rawText(data))
  79. } catch {
  80. this.socket.close(1008, 'invalid Remote stream request')
  81. }
  82. })
  83. })
  84. await closed
  85. const active = [...this.streams.values()]
  86. for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed'))
  87. await Promise.all(active.map(stream => stream.done))
  88. }
  89. private receive(text: string): void {
  90. const message = parseRemoteStreamClientMessage(text)
  91. if (message.type === 'cancel') {
  92. this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled'))
  93. return
  94. }
  95. if (this.streams.has(message.streamId)) {
  96. throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`)
  97. }
  98. const abort = new AbortController()
  99. const active: ActiveStream = {
  100. abort,
  101. done: Promise.resolve(),
  102. }
  103. this.streams.set(message.streamId, active)
  104. const done = this.pump(message.streamId, message.endpoint, message.payload, active)
  105. active.done = done
  106. const remove = (): void => { this.streams.delete(message.streamId) }
  107. void done.then(remove, remove)
  108. }
  109. private async pump(
  110. streamId: string,
  111. endpoint: string,
  112. payload: unknown,
  113. active: ActiveStream,
  114. ): Promise<void> {
  115. try {
  116. const source = await this.open(endpoint, payload, active.abort.signal)
  117. for await (const value of source) {
  118. await this.send({ type: 'item', streamId, value })
  119. }
  120. if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId })
  121. } catch (error) {
  122. if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) {
  123. try {
  124. await this.send({ type: 'error', streamId, error: this.failure(error) })
  125. } catch {
  126. // A terminal frame that cannot be encoded or written leaves the
  127. // logical stream ambiguous, so fail the physical generation.
  128. this.socket.close(1011, 'Remote stream failure could not be delivered')
  129. }
  130. }
  131. }
  132. }
  133. private send(message: RemoteStreamServerMessage): Promise<void> {
  134. let text: string
  135. try {
  136. text = JSON.stringify(message)
  137. } catch (cause) {
  138. return Promise.reject(new Error('api gateway: Remote stream item is not JSON serializable', { cause }))
  139. }
  140. const delivery = this.writes.then(() => new Promise<void>((resolve, reject) => {
  141. if (this.socket.readyState !== WebSocket.OPEN) {
  142. reject(new Error('api gateway: Remote stream socket is closed'))
  143. return
  144. }
  145. this.socket.send(text, (error) => {
  146. if (error) reject(error)
  147. else resolve()
  148. })
  149. }))
  150. this.writes = delivery.catch(() => undefined)
  151. return delivery
  152. }
  153. }
  154. function rawText(data: RawData): string {
  155. if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
  156. if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
  157. return Buffer.from(data).toString('utf8')
  158. }
  159. /**
  160. * Reject an upgrade without transferring socket ownership to ws.
  161. * @param socket - carrier socket that receives the HTTP rejection.
  162. */
  163. export function rejectRemoteStreamUpgrade(socket: Duplex): void {
  164. socket.end([
  165. 'HTTP/1.1 403 Forbidden',
  166. 'Connection: close',
  167. 'Content-Type: text/plain; charset=utf-8',
  168. 'Content-Length: 9',
  169. '',
  170. 'forbidden',
  171. ].join('\r\n'))
  172. }