host-protocol.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. /** Versioned control messages and framed byte transport for the Desktop Host child. */
  2. /** Protocol version implemented by the Electron shell and installed dsh Host. */
  3. export const DESKTOP_HOST_PROTOCOL_VERSION = 3 as const
  4. /** Child descriptor Electron writes request frames to. */
  5. export const DESKTOP_REQUEST_PIPE_FD = 3
  6. /** Child descriptor Electron reads response frames from. */
  7. export const DESKTOP_RESPONSE_PIPE_FD = 4
  8. /** Child descriptor reserved for Node's lifecycle IPC channel. */
  9. export const DESKTOP_CONTROL_IPC_FD = 5
  10. /** Maximum raw body bytes carried by one data frame. */
  11. export const DESKTOP_PIPE_CHUNK_BYTES = 64 * 1024
  12. const FRAME_MAGIC = 0x44534833
  13. const FRAME_HEADER_BYTES = 13
  14. const MAX_CONTROL_PAYLOAD_BYTES = 1024 * 1024
  15. const REQUEST_FRAME_START = 1
  16. const REQUEST_FRAME_DATA = 2
  17. const REQUEST_FRAME_END = 3
  18. const REQUEST_FRAME_CANCEL = 4
  19. type RequestFrameType = typeof REQUEST_FRAME_START | typeof REQUEST_FRAME_DATA
  20. | typeof REQUEST_FRAME_END | typeof REQUEST_FRAME_CANCEL
  21. const RESPONSE_FRAME_START = 1
  22. const RESPONSE_FRAME_DATA = 2
  23. const RESPONSE_FRAME_END = 3
  24. const RESPONSE_FRAME_ERROR = 4
  25. /** Metadata that precedes one optional request body on the request pipe. */
  26. export interface DesktopHostRequestStart {
  27. readonly url: string
  28. readonly method: string
  29. readonly headers: readonly [string, string][]
  30. readonly hasBody: boolean
  31. }
  32. /** Commands retained on Node IPC because they do not carry Fetch payload bytes. */
  33. export type DesktopHostCommand = {
  34. readonly type: 'shutdown'
  35. }
  36. /** Lifecycle events retained on Node IPC. */
  37. export type DesktopHostEvent = {
  38. readonly type: 'ready'
  39. readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION
  40. readonly dshVersion: string
  41. } | {
  42. readonly type: 'fatal'
  43. readonly message: string
  44. }
  45. /** One decoded response-pipe frame. */
  46. export type DesktopHostResponseFrame = {
  47. readonly type: 'start'
  48. readonly streamId: number
  49. readonly status: number
  50. readonly headers: readonly [string, string][]
  51. readonly hasBody: boolean
  52. } | {
  53. readonly type: 'data'
  54. readonly streamId: number
  55. readonly data: Buffer
  56. } | {
  57. readonly type: 'end'
  58. readonly streamId: number
  59. } | {
  60. readonly type: 'error'
  61. readonly streamId: number
  62. readonly message: string
  63. }
  64. function isRecord(value: unknown): value is Record<string, unknown> {
  65. return typeof value === 'object' && value !== null
  66. }
  67. function isHeaders(value: unknown): value is readonly [string, string][] {
  68. return Array.isArray(value) && value.every(header => Array.isArray(header) && header.length === 2
  69. && typeof header[0] === 'string' && typeof header[1] === 'string')
  70. }
  71. function assertStreamId(streamId: number): void {
  72. if (!Number.isInteger(streamId) || streamId < 1 || streamId > 0xffff_ffff) {
  73. throw new Error(`dsh desktop: invalid pipe stream id ${String(streamId)}`)
  74. }
  75. }
  76. function encodeFrame(type: RequestFrameType, streamId: number, payload: Buffer): Buffer {
  77. assertStreamId(streamId)
  78. const limit = type === REQUEST_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES
  79. if (payload.byteLength > limit) {
  80. throw new Error(`dsh desktop: request pipe frame exceeds the ${String(limit)}-byte limit`)
  81. }
  82. const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + payload.byteLength)
  83. frame.writeUInt32BE(FRAME_MAGIC, 0)
  84. frame.writeUInt8(type, 4)
  85. frame.writeUInt32BE(streamId, 5)
  86. frame.writeUInt32BE(payload.byteLength, 9)
  87. payload.copy(frame, FRAME_HEADER_BYTES)
  88. return frame
  89. }
  90. function encodeJsonFrame(type: RequestFrameType, streamId: number, value: unknown): Buffer {
  91. return encodeFrame(type, streamId, Buffer.from(JSON.stringify(value), 'utf8'))
  92. }
  93. /** Encode the metadata opening one request stream. */
  94. export function encodeDesktopRequestStart(streamId: number, request: DesktopHostRequestStart): Buffer {
  95. return encodeJsonFrame(REQUEST_FRAME_START, streamId, request)
  96. }
  97. /** Encode one bounded raw request-body chunk. */
  98. export function encodeDesktopRequestData(streamId: number, data: Uint8Array): Buffer {
  99. return encodeFrame(REQUEST_FRAME_DATA, streamId, Buffer.from(data))
  100. }
  101. /** Encode normal request-body completion. */
  102. export function encodeDesktopRequestEnd(streamId: number): Buffer {
  103. return encodeFrame(REQUEST_FRAME_END, streamId, Buffer.alloc(0))
  104. }
  105. /** Encode cancellation of one request and its response. */
  106. export function encodeDesktopRequestCancel(streamId: number): Buffer {
  107. return encodeFrame(REQUEST_FRAME_CANCEL, streamId, Buffer.alloc(0))
  108. }
  109. /** Incrementally decode validated response frames from the Host byte pipe. */
  110. export class DesktopHostResponseDecoder {
  111. private buffer: Buffer = Buffer.alloc(0)
  112. /**
  113. * Append bytes and return every complete response frame.
  114. * @param chunk - next bytes read from the Host response pipe.
  115. * @returns complete frames in pipe order.
  116. */
  117. push(chunk: Buffer): DesktopHostResponseFrame[] {
  118. this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk])
  119. const frames: DesktopHostResponseFrame[] = []
  120. for (;;) {
  121. const frame = this.next()
  122. if (frame === undefined) return frames
  123. frames.push(frame)
  124. }
  125. }
  126. /** Reject EOF that splits a frame. */
  127. finish(): void {
  128. if (this.buffer.byteLength !== 0) throw new Error('dsh desktop: Host response pipe ended inside a frame')
  129. }
  130. private next(): DesktopHostResponseFrame | undefined {
  131. if (this.buffer.byteLength < FRAME_HEADER_BYTES) return undefined
  132. if (this.buffer.readUInt32BE(0) !== FRAME_MAGIC) throw new Error('dsh desktop: invalid Host response frame marker')
  133. const rawType = this.buffer.readUInt8(4)
  134. const streamId = this.buffer.readUInt32BE(5)
  135. const payloadLength = this.buffer.readUInt32BE(9)
  136. assertStreamId(streamId)
  137. const limit = rawType === RESPONSE_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES
  138. if (payloadLength > limit) {
  139. throw new Error(`dsh desktop: Host response frame exceeds the ${String(limit)}-byte limit`)
  140. }
  141. const frameLength = FRAME_HEADER_BYTES + payloadLength
  142. if (this.buffer.byteLength < frameLength) return undefined
  143. const payload = this.buffer.subarray(FRAME_HEADER_BYTES, frameLength)
  144. this.buffer = this.buffer.subarray(frameLength)
  145. switch (rawType) {
  146. case RESPONSE_FRAME_START:
  147. return this.parseStart(streamId, payload)
  148. case RESPONSE_FRAME_DATA:
  149. return { type: 'data', streamId, data: payload }
  150. case RESPONSE_FRAME_END:
  151. if (payloadLength !== 0) throw new Error('dsh desktop: Host response end frame carried a payload')
  152. return { type: 'end', streamId }
  153. case RESPONSE_FRAME_ERROR:
  154. return this.parseError(streamId, payload)
  155. default:
  156. throw new Error(`dsh desktop: unknown Host response frame type ${String(rawType)}`)
  157. }
  158. }
  159. private parseStart(streamId: number, payload: Buffer): DesktopHostResponseFrame {
  160. const value = this.parseJson(payload, 'start')
  161. if (!isRecord(value) || !Number.isInteger(value.status) || (value.status as number) < 100
  162. || (value.status as number) > 599 || !isHeaders(value.headers) || typeof value.hasBody !== 'boolean') {
  163. throw new Error('dsh desktop: invalid Host response start payload')
  164. }
  165. return {
  166. type: 'start',
  167. streamId,
  168. status: value.status as number,
  169. headers: value.headers,
  170. hasBody: value.hasBody,
  171. }
  172. }
  173. private parseError(streamId: number, payload: Buffer): DesktopHostResponseFrame {
  174. const value = this.parseJson(payload, 'error')
  175. if (!isRecord(value) || typeof value.message !== 'string') {
  176. throw new Error('dsh desktop: invalid Host response error payload')
  177. }
  178. return { type: 'error', streamId, message: value.message }
  179. }
  180. private parseJson(payload: Buffer, subject: string): unknown {
  181. try {
  182. return JSON.parse(payload.toString('utf8')) as unknown
  183. } catch (error) {
  184. throw new Error(`dsh desktop: Host response ${subject} payload is not JSON: ${error instanceof Error ? error.message : String(error)}`)
  185. }
  186. }
  187. }