| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- /** Versioned control messages and framed byte transport for the Desktop Host child. */
- /** Protocol version implemented by the Electron shell and installed dsh Host. */
- export const DESKTOP_HOST_PROTOCOL_VERSION = 3 as const
- /** Child descriptor Electron writes request frames to. */
- export const DESKTOP_REQUEST_PIPE_FD = 3
- /** Child descriptor Electron reads response frames from. */
- export const DESKTOP_RESPONSE_PIPE_FD = 4
- /** Child descriptor reserved for Node's lifecycle IPC channel. */
- export const DESKTOP_CONTROL_IPC_FD = 5
- /** Maximum raw body bytes carried by one data frame. */
- export const DESKTOP_PIPE_CHUNK_BYTES = 64 * 1024
- const FRAME_MAGIC = 0x44534833
- const FRAME_HEADER_BYTES = 13
- const MAX_CONTROL_PAYLOAD_BYTES = 1024 * 1024
- const REQUEST_FRAME_START = 1
- const REQUEST_FRAME_DATA = 2
- const REQUEST_FRAME_END = 3
- const REQUEST_FRAME_CANCEL = 4
- type RequestFrameType = typeof REQUEST_FRAME_START | typeof REQUEST_FRAME_DATA
- | typeof REQUEST_FRAME_END | typeof REQUEST_FRAME_CANCEL
- const RESPONSE_FRAME_START = 1
- const RESPONSE_FRAME_DATA = 2
- const RESPONSE_FRAME_END = 3
- const RESPONSE_FRAME_ERROR = 4
- /** Metadata that precedes one optional request body on the request pipe. */
- export interface DesktopHostRequestStart {
- readonly url: string
- readonly method: string
- readonly headers: readonly [string, string][]
- readonly hasBody: boolean
- }
- /** Commands retained on Node IPC because they do not carry Fetch payload bytes. */
- export type DesktopHostCommand = {
- readonly type: 'shutdown'
- }
- /** Lifecycle events retained on Node IPC. */
- export type DesktopHostEvent = {
- readonly type: 'ready'
- readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION
- readonly dshVersion: string
- } | {
- readonly type: 'fatal'
- readonly message: string
- }
- /** One decoded response-pipe frame. */
- export type DesktopHostResponseFrame = {
- readonly type: 'start'
- readonly streamId: number
- readonly status: number
- readonly headers: readonly [string, string][]
- readonly hasBody: boolean
- } | {
- readonly type: 'data'
- readonly streamId: number
- readonly data: Buffer
- } | {
- readonly type: 'end'
- readonly streamId: number
- } | {
- readonly type: 'error'
- readonly streamId: number
- readonly message: string
- }
- function isRecord(value: unknown): value is Record<string, unknown> {
- return typeof value === 'object' && value !== null
- }
- function isHeaders(value: unknown): value is readonly [string, string][] {
- return Array.isArray(value) && value.every(header => Array.isArray(header) && header.length === 2
- && typeof header[0] === 'string' && typeof header[1] === 'string')
- }
- function assertStreamId(streamId: number): void {
- if (!Number.isInteger(streamId) || streamId < 1 || streamId > 0xffff_ffff) {
- throw new Error(`dsh desktop: invalid pipe stream id ${String(streamId)}`)
- }
- }
- function encodeFrame(type: RequestFrameType, streamId: number, payload: Buffer): Buffer {
- assertStreamId(streamId)
- const limit = type === REQUEST_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES
- if (payload.byteLength > limit) {
- throw new Error(`dsh desktop: request pipe frame exceeds the ${String(limit)}-byte limit`)
- }
- const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + payload.byteLength)
- frame.writeUInt32BE(FRAME_MAGIC, 0)
- frame.writeUInt8(type, 4)
- frame.writeUInt32BE(streamId, 5)
- frame.writeUInt32BE(payload.byteLength, 9)
- payload.copy(frame, FRAME_HEADER_BYTES)
- return frame
- }
- function encodeJsonFrame(type: RequestFrameType, streamId: number, value: unknown): Buffer {
- return encodeFrame(type, streamId, Buffer.from(JSON.stringify(value), 'utf8'))
- }
- /** Encode the metadata opening one request stream. */
- export function encodeDesktopRequestStart(streamId: number, request: DesktopHostRequestStart): Buffer {
- return encodeJsonFrame(REQUEST_FRAME_START, streamId, request)
- }
- /** Encode one bounded raw request-body chunk. */
- export function encodeDesktopRequestData(streamId: number, data: Uint8Array): Buffer {
- return encodeFrame(REQUEST_FRAME_DATA, streamId, Buffer.from(data))
- }
- /** Encode normal request-body completion. */
- export function encodeDesktopRequestEnd(streamId: number): Buffer {
- return encodeFrame(REQUEST_FRAME_END, streamId, Buffer.alloc(0))
- }
- /** Encode cancellation of one request and its response. */
- export function encodeDesktopRequestCancel(streamId: number): Buffer {
- return encodeFrame(REQUEST_FRAME_CANCEL, streamId, Buffer.alloc(0))
- }
- /** Incrementally decode validated response frames from the Host byte pipe. */
- export class DesktopHostResponseDecoder {
- private buffer: Buffer = Buffer.alloc(0)
- /**
- * Append bytes and return every complete response frame.
- * @param chunk - next bytes read from the Host response pipe.
- * @returns complete frames in pipe order.
- */
- push(chunk: Buffer): DesktopHostResponseFrame[] {
- this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk])
- const frames: DesktopHostResponseFrame[] = []
- for (;;) {
- const frame = this.next()
- if (frame === undefined) return frames
- frames.push(frame)
- }
- }
- /** Reject EOF that splits a frame. */
- finish(): void {
- if (this.buffer.byteLength !== 0) throw new Error('dsh desktop: Host response pipe ended inside a frame')
- }
- private next(): DesktopHostResponseFrame | undefined {
- if (this.buffer.byteLength < FRAME_HEADER_BYTES) return undefined
- if (this.buffer.readUInt32BE(0) !== FRAME_MAGIC) throw new Error('dsh desktop: invalid Host response frame marker')
- const rawType = this.buffer.readUInt8(4)
- const streamId = this.buffer.readUInt32BE(5)
- const payloadLength = this.buffer.readUInt32BE(9)
- assertStreamId(streamId)
- const limit = rawType === RESPONSE_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES
- if (payloadLength > limit) {
- throw new Error(`dsh desktop: Host response frame exceeds the ${String(limit)}-byte limit`)
- }
- const frameLength = FRAME_HEADER_BYTES + payloadLength
- if (this.buffer.byteLength < frameLength) return undefined
- const payload = this.buffer.subarray(FRAME_HEADER_BYTES, frameLength)
- this.buffer = this.buffer.subarray(frameLength)
- switch (rawType) {
- case RESPONSE_FRAME_START:
- return this.parseStart(streamId, payload)
- case RESPONSE_FRAME_DATA:
- return { type: 'data', streamId, data: payload }
- case RESPONSE_FRAME_END:
- if (payloadLength !== 0) throw new Error('dsh desktop: Host response end frame carried a payload')
- return { type: 'end', streamId }
- case RESPONSE_FRAME_ERROR:
- return this.parseError(streamId, payload)
- default:
- throw new Error(`dsh desktop: unknown Host response frame type ${String(rawType)}`)
- }
- }
- private parseStart(streamId: number, payload: Buffer): DesktopHostResponseFrame {
- const value = this.parseJson(payload, 'start')
- if (!isRecord(value) || !Number.isInteger(value.status) || (value.status as number) < 100
- || (value.status as number) > 599 || !isHeaders(value.headers) || typeof value.hasBody !== 'boolean') {
- throw new Error('dsh desktop: invalid Host response start payload')
- }
- return {
- type: 'start',
- streamId,
- status: value.status as number,
- headers: value.headers,
- hasBody: value.hasBody,
- }
- }
- private parseError(streamId: number, payload: Buffer): DesktopHostResponseFrame {
- const value = this.parseJson(payload, 'error')
- if (!isRecord(value) || typeof value.message !== 'string') {
- throw new Error('dsh desktop: invalid Host response error payload')
- }
- return { type: 'error', streamId, message: value.message }
- }
- private parseJson(payload: Buffer, subject: string): unknown {
- try {
- return JSON.parse(payload.toString('utf8')) as unknown
- } catch (error) {
- throw new Error(`dsh desktop: Host response ${subject} payload is not JSON: ${error instanceof Error ? error.message : String(error)}`)
- }
- }
- }
|