websocket-downlink.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. /** Host-side WebSocket carrier for the two server-to-browser event streams. */
  2. import { randomUUID } from 'node:crypto'
  3. import type { IncomingMessage } from 'node:http'
  4. import type { Duplex } from 'node:stream'
  5. import WebSocket, { WebSocketServer } from 'ws'
  6. import type {
  7. ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest,
  8. } from '@deepseek-ai/dsh-host-apiproxy/api'
  9. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
  10. type Frame = MuxFrame | HostFrame
  11. function serverRequest(frame: RpcRequest<Frame>): ServerRequest {
  12. return {
  13. type: 'server-request',
  14. rpcId: frame.rpcId,
  15. method: frame.payload.type,
  16. payload: frame.payload,
  17. }
  18. }
  19. function send(socket: WebSocket, frame: RpcRequest<Frame>): Promise<void> {
  20. return new Promise((resolve, reject) => {
  21. if (socket.readyState !== WebSocket.OPEN) {
  22. reject(new Error('websocket downlink closed before frame delivery'))
  23. return
  24. }
  25. socket.send(JSON.stringify(serverRequest(frame)), (error) => {
  26. if (error) reject(error)
  27. else resolve()
  28. })
  29. })
  30. }
  31. function failureFrame(error: unknown): RpcRequest<Frame> {
  32. return {
  33. rpcId: RpcId(randomUUID()),
  34. payload: {
  35. type: 'stream/error',
  36. error: { code: 'internal', message: String(error), details: {} },
  37. },
  38. }
  39. }
  40. /**
  41. * Owns WebSocket negotiation and frame pumping for the connection plugin's
  42. * two downlinks. Client messages are a protocol violation: upstream traffic
  43. * remains on HTTP.
  44. */
  45. export class WebSocketDownlinks {
  46. private readonly server = new WebSocketServer({ noServer: true })
  47. private readonly pumps = new Set<Promise<void>>()
  48. /** @param api - host API supplying the typed event streams. */
  49. constructor(private readonly api: ApiProxy) {}
  50. /**
  51. * Upgrade one socket and pump the mux stream until either side closes.
  52. * @param req - HTTP upgrade request.
  53. * @param socket - Raw socket transferred by the HTTP server.
  54. * @param head - Bytes already read after the upgrade headers.
  55. */
  56. handleMux(req: IncomingMessage, socket: Duplex, head: Buffer): void {
  57. this.upgrade(req, socket, head, signal => this.api.events.mux({
  58. rpcId: RpcId(randomUUID()),
  59. payload: {},
  60. }, signal))
  61. }
  62. /**
  63. * Upgrade one socket and pump the host stream until either side closes.
  64. * @param req - HTTP upgrade request.
  65. * @param socket - Raw socket transferred by the HTTP server.
  66. * @param head - Bytes already read after the upgrade headers.
  67. */
  68. handleHost(req: IncomingMessage, socket: Duplex, head: Buffer): void {
  69. this.upgrade(req, socket, head, signal => this.api.events.host({
  70. rpcId: RpcId(randomUUID()),
  71. payload: {},
  72. }, signal))
  73. }
  74. /**
  75. * Terminate owned sockets and await the no-server acceptor plus frame pumps.
  76. * @returns A promise resolving after every socket and source iterator stops.
  77. */
  78. async close(): Promise<void> {
  79. for (const socket of this.server.clients) socket.terminate()
  80. await new Promise<void>((resolve, reject) => {
  81. this.server.close((error) => {
  82. if (error === undefined) resolve()
  83. else reject(error)
  84. })
  85. })
  86. await Promise.all(this.pumps)
  87. }
  88. private upgrade<F extends Frame>(
  89. req: IncomingMessage,
  90. socket: Duplex,
  91. head: Buffer,
  92. open: (signal: AbortSignal) => AsyncIterable<RpcRequest<F>>,
  93. ): void {
  94. this.server.handleUpgrade(req, socket, head, (websocket) => {
  95. const abort = new AbortController()
  96. websocket.once('close', () => { abort.abort() })
  97. websocket.once('error', () => { abort.abort() })
  98. websocket.once('message', () => {
  99. websocket.close(1008, 'downlink only')
  100. })
  101. const pump = this.pump(websocket, open(abort.signal), abort)
  102. this.pumps.add(pump)
  103. void pump.then(() => { this.pumps.delete(pump) })
  104. })
  105. }
  106. private async pump<F extends Frame>(
  107. socket: WebSocket,
  108. frames: AsyncIterable<RpcRequest<F>>,
  109. abort: AbortController,
  110. ): Promise<void> {
  111. try {
  112. for await (const frame of frames) await send(socket, frame)
  113. } catch (error) {
  114. if (!abort.signal.aborted) {
  115. try {
  116. await send(socket, failureFrame(error))
  117. } catch {
  118. // Socket loss won the race; no downstream remains to receive the failure frame.
  119. }
  120. }
  121. } finally {
  122. abort.abort()
  123. if (socket.readyState === WebSocket.OPEN) socket.close()
  124. }
  125. }
  126. }
  127. /**
  128. * Reject an untrusted upgrade before protocol negotiation.
  129. * @param socket - Raw HTTP socket that remains owned by the caller.
  130. */
  131. export function rejectWebSocketUpgrade(socket: Duplex): void {
  132. socket.end([
  133. 'HTTP/1.1 403 Forbidden',
  134. 'Connection: close',
  135. 'Content-Type: text/plain; charset=utf-8',
  136. 'Content-Length: 9',
  137. '',
  138. 'forbidden',
  139. ].join('\r\n'))
  140. }