| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- /** Host WebSocket owner for multiplexed Typert Remote streams. */
- import type { IncomingMessage } from 'node:http'
- import type { Duplex } from 'node:stream'
- import WebSocket, { WebSocketServer, type RawData } from 'ws'
- import {
- parseRemoteStreamClientMessage,
- type RemoteStreamFailure,
- type RemoteStreamServerMessage,
- } from './stream-protocol.ts'
- /** Open one validated Remote stream for a decoded wire request. */
- export type RemoteStreamOpener = (
- endpoint: string,
- payload: unknown,
- signal: AbortSignal,
- ) => Promise<AsyncIterable<unknown>>
- /** Convert an invocation or carrier failure to a stable wire value. */
- export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure
- /** Own the no-server WebSocket acceptor and every active logical stream. */
- export class RemoteStreamMuxServer {
- private readonly server = new WebSocketServer({ noServer: true })
- private readonly connections = new Set<Promise<void>>()
- /**
- * @param open - Gateway stream dispatcher.
- * @param failure - Gateway error-to-wire mapper.
- */
- constructor(
- private readonly open: RemoteStreamOpener,
- private readonly failure: RemoteStreamFailureMapper,
- ) {}
- /**
- * Upgrade one trusted request and begin serving its logical streams.
- * @param req - authenticated HTTP upgrade request.
- * @param socket - carrier socket transferred to the WebSocket server.
- * @param head - bytes already read after the HTTP upgrade headers.
- */
- handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
- this.server.handleUpgrade(req, socket, head, (websocket) => {
- const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure)
- const done = connection.run()
- this.connections.add(done)
- void done.then(() => { this.connections.delete(done) })
- })
- }
- /** Terminate all sockets and wait until every iterator has returned. */
- async close(): Promise<void> {
- for (const socket of this.server.clients) socket.terminate()
- const closed = Promise.withResolvers<void>()
- this.server.close((error) => {
- if (error === undefined) closed.resolve()
- else closed.reject(error)
- })
- await closed.promise
- await Promise.all(this.connections)
- }
- }
- interface ActiveStream {
- readonly abort: AbortController
- done: Promise<void>
- }
- class RemoteStreamMuxConnection {
- private readonly streams = new Map<string, ActiveStream>()
- private writes = Promise.resolve()
- constructor(
- private readonly socket: WebSocket,
- private readonly open: RemoteStreamOpener,
- private readonly failure: RemoteStreamFailureMapper,
- ) {}
- async run(): Promise<void> {
- const closed = new Promise<void>((resolve) => {
- this.socket.once('close', resolve)
- this.socket.once('error', () => { this.socket.terminate() })
- this.socket.on('message', (data, isBinary) => {
- if (isBinary) {
- this.socket.close(1003, 'text messages required')
- return
- }
- try {
- this.receive(rawText(data))
- } catch {
- this.socket.close(1008, 'invalid Remote stream request')
- }
- })
- })
- await closed
- const active = [...this.streams.values()]
- for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed'))
- await Promise.all(active.map(stream => stream.done))
- }
- private receive(text: string): void {
- const message = parseRemoteStreamClientMessage(text)
- if (message.type === 'cancel') {
- this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled'))
- return
- }
- if (this.streams.has(message.streamId)) {
- throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`)
- }
- const abort = new AbortController()
- const active: ActiveStream = {
- abort,
- done: Promise.resolve(),
- }
- this.streams.set(message.streamId, active)
- const done = this.pump(message.streamId, message.endpoint, message.payload, active)
- active.done = done
- const remove = (): void => { this.streams.delete(message.streamId) }
- void done.then(remove, remove)
- }
- private async pump(
- streamId: string,
- endpoint: string,
- payload: unknown,
- active: ActiveStream,
- ): Promise<void> {
- try {
- const source = await this.open(endpoint, payload, active.abort.signal)
- for await (const value of source) {
- await this.send({ type: 'item', streamId, value })
- }
- if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId })
- } catch (error) {
- if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) {
- try {
- await this.send({ type: 'error', streamId, error: this.failure(error) })
- } catch {
- // A terminal frame that cannot be encoded or written leaves the
- // logical stream ambiguous, so fail the physical generation.
- this.socket.close(1011, 'Remote stream failure could not be delivered')
- }
- }
- }
- }
- private send(message: RemoteStreamServerMessage): Promise<void> {
- let text: string
- try {
- text = JSON.stringify(message)
- } catch (cause) {
- return Promise.reject(new Error('api gateway: Remote stream item is not JSON serializable', { cause }))
- }
- const delivery = this.writes.then(() => new Promise<void>((resolve, reject) => {
- if (this.socket.readyState !== WebSocket.OPEN) {
- reject(new Error('api gateway: Remote stream socket is closed'))
- return
- }
- this.socket.send(text, (error) => {
- if (error) reject(error)
- else resolve()
- })
- }))
- this.writes = delivery.catch(() => undefined)
- return delivery
- }
- }
- function rawText(data: RawData): string {
- if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
- if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
- return Buffer.from(data).toString('utf8')
- }
- /**
- * Reject an upgrade without transferring socket ownership to ws.
- * @param socket - carrier socket that receives the HTTP rejection.
- */
- export function rejectRemoteStreamUpgrade(socket: Duplex): void {
- socket.end([
- 'HTTP/1.1 403 Forbidden',
- 'Connection: close',
- 'Content-Type: text/plain; charset=utf-8',
- 'Content-Length: 9',
- '',
- 'forbidden',
- ].join('\r\n'))
- }
|