| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430 |
- /** Node-compatible child lifecycle and streaming custom-protocol carrier. */
- import { spawn, type ChildProcess } from 'node:child_process'
- import { once } from 'node:events'
- import { join } from 'node:path'
- import { Readable, Writable } from 'node:stream'
- import {
- DESKTOP_HOST_PROTOCOL_VERSION,
- DESKTOP_PIPE_CHUNK_BYTES,
- DESKTOP_REQUEST_PIPE_FD,
- DESKTOP_RESPONSE_PIPE_FD,
- DesktopHostResponseDecoder,
- encodeDesktopRequestCancel,
- encodeDesktopRequestData,
- encodeDesktopRequestEnd,
- encodeDesktopRequestStart,
- type DesktopHostCommand,
- type DesktopHostEvent,
- type DesktopHostResponseFrame,
- } from './host-protocol.ts'
- interface PendingResponse {
- readonly resolve: (response: Response) => void
- readonly reject: (error: Error) => void
- responseStarted: boolean
- uploadOpen: boolean
- controller?: ReadableStreamDefaultController<Uint8Array>
- requestReader?: ReadableStreamDefaultReader<Uint8Array>
- removeAbort?: () => void
- }
- function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
- if (typeof message !== 'object' || message === null || !('type' in message)) return false
- const candidate = message as Record<string, unknown>
- switch (candidate.type) {
- case 'ready':
- return candidate.protocolVersion === DESKTOP_HOST_PROTOCOL_VERSION && typeof candidate.dshVersion === 'string'
- case 'fatal':
- return typeof candidate.message === 'string'
- default:
- return false
- }
- }
- function errorOf(reason: unknown, fallback: string): Error {
- return reason instanceof Error ? reason : new Error(fallback)
- }
- async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {
- let timer: ReturnType<typeof setTimeout> | undefined
- const timeout = new Promise<false>((resolve) => {
- timer = setTimeout(() => { resolve(false) }, milliseconds)
- timer.unref()
- })
- try {
- return await Promise.race([exit.then(() => true), timeout])
- } finally {
- if (timer !== undefined) clearTimeout(timer)
- }
- }
- /** Ready facts reported by one installed dsh child. */
- export interface DesktopHostReady {
- readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION
- readonly dshVersion: string
- }
- /** One dsh backend running under an owned Node-compatible executable. */
- export class DesktopHostProcess {
- private child: ChildProcess | undefined
- private requestPipe: Writable | undefined
- private responsePipe: Readable | undefined
- private readonly responseDecoder = new DesktopHostResponseDecoder()
- private requestWriteTail: Promise<void> = Promise.resolve()
- private nextStreamId = 1
- private readonly pending = new Map<number, PendingResponse>()
- private readonly blockedResponses = new Set<number>()
- private readyResolve!: (ready: DesktopHostReady) => void
- private readyReject!: (error: Error) => void
- private readonly readyPromise = new Promise<DesktopHostReady>((resolve, reject) => {
- this.readyResolve = resolve
- this.readyReject = reject
- })
- private exitPromise: Promise<void> | undefined
- private stderr = ''
- private failureReported = false
- /**
- * @param executable - absolute upstream Node.js or Electron executable.
- * @param runtimeDir - immutable packages carried by the current application.
- * @param projectDir - active or staged desktop plugin profile.
- * @param inspectPort - optional loopback inspector port for workspace development.
- * @param environment - Child environment; runtime and package-manager overrides are removed.
- * @param onFailure - Receives the first fatal child or transport failure, including after readiness.
- */
- constructor(
- private readonly executable: string,
- private readonly runtimeDir: string,
- private readonly projectDir: string,
- private readonly inspectPort?: number,
- private readonly environment: NodeJS.ProcessEnv = process.env,
- private readonly onFailure?: (error: Error) => void,
- ) {}
- /** Start the child once and resolve only after its complete composition is active. */
- async start(): Promise<DesktopHostReady> {
- if (this.child !== undefined) return this.readyPromise
- const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
- const child = spawn(this.executable, [
- ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
- entry,
- this.runtimeDir,
- this.projectDir,
- ...(this.inspectPort === undefined ? [] : ['--allow-linked-profile']),
- ], {
- cwd: this.projectDir,
- env: {
- ...Object.fromEntries(Object.entries(this.environment).filter(([name]) => (
- name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
- ))),
- ELECTRON_RUN_AS_NODE: '1',
- },
- stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe', 'ipc'],
- })
- const requestPipe = child.stdio[DESKTOP_REQUEST_PIPE_FD]
- const responsePipe = child.stdio[DESKTOP_RESPONSE_PIPE_FD]
- if (!(requestPipe instanceof Writable) || !(responsePipe instanceof Readable)) {
- child.kill('SIGTERM')
- throw new Error('dsh desktop host did not expose the required byte pipes and IPC channel')
- }
- this.child = child
- this.requestPipe = requestPipe
- this.responsePipe = responsePipe
- child.stderr?.setEncoding('utf8')
- child.stderr?.on('data', (chunk: string) => { this.stderr += chunk })
- child.stdout?.pipe(process.stdout)
- responsePipe.on('data', (chunk: Buffer) => { this.acceptResponseBytes(chunk) })
- responsePipe.once('end', () => {
- try {
- this.responseDecoder.finish()
- this.fail(new Error('dsh desktop host response pipe ended'))
- } catch (error) {
- this.fail(errorOf(error, 'dsh desktop host response pipe failed'))
- }
- })
- requestPipe.once('error', (error) => { this.fail(error) })
- responsePipe.once('error', (error) => { this.fail(error) })
- child.on('message', (message: unknown) => {
- if (!isDesktopHostEvent(message)) {
- this.fail(new Error('dsh desktop host sent an invalid IPC event'))
- child.kill('SIGTERM')
- return
- }
- this.handleMessage(message)
- })
- child.once('error', (error) => { this.fail(error) })
- this.exitPromise = new Promise<void>((resolve) => {
- child.once('close', (code) => {
- const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}`
- if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`))
- else this.fail(new Error(`dsh desktop host stopped${suffix}`))
- resolve()
- })
- })
- return this.readyPromise
- }
- /** Forward one `dsh-app://app` request to the child without buffering its body. */
- async fetch(request: Request): Promise<Response> {
- await this.start()
- const child = this.child
- if (child === undefined || !child.connected || this.requestPipe === undefined) {
- throw new Error('dsh desktop host is unavailable')
- }
- if (this.nextStreamId > 0xffff_ffff) throw new Error('dsh desktop host exhausted its request stream ids')
- const streamId = this.nextStreamId++
- const method = request.method.toUpperCase()
- const hasBody = method !== 'GET' && method !== 'HEAD' && request.body !== null
- return new Promise<Response>((resolve, reject) => {
- const pending: PendingResponse = {
- resolve,
- reject,
- responseStarted: false,
- uploadOpen: hasBody,
- }
- const abort = (): void => {
- if (!this.pending.has(streamId)) return
- const error = errorOf(request.signal.reason, 'request aborted')
- pending.uploadOpen = false
- void pending.requestReader?.cancel(error).catch(() => undefined)
- this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError: unknown) => {
- this.fail(errorOf(pipeError, 'dsh desktop request pipe failed'))
- })
- if (pending.controller === undefined) pending.reject(error)
- else pending.controller.error(error)
- this.finishPending(streamId, false)
- }
- if (request.signal.aborted) {
- reject(errorOf(request.signal.reason, 'request aborted'))
- return
- }
- request.signal.addEventListener('abort', abort, { once: true })
- pending.removeAbort = () => { request.signal.removeEventListener('abort', abort) }
- this.pending.set(streamId, pending)
- this.pumpRequest(streamId, request, hasBody).catch((error: unknown) => {
- this.failPending(streamId, errorOf(error, 'dsh desktop request upload failed'))
- })
- })
- }
- /** Request graceful teardown, then wait for child exit. */
- async stop(): Promise<void> {
- const child = this.child
- if (child === undefined) return
- this.blockedResponses.clear()
- this.responsePipe?.resume()
- if (child.connected) this.send({ type: 'shutdown' })
- // Closing the parent-owned write end releases the Host's pending Windows pipe read.
- this.requestPipe?.destroy()
- const exited = this.exitPromise ?? Promise.resolve()
- if (!await exitsWithin(exited, 10_000)) child.kill('SIGTERM')
- if (!await exitsWithin(exited, 5_000)) {
- child.kill('SIGKILL')
- if (!await exitsWithin(exited, 5_000)) {
- throw new Error('dsh desktop host did not exit after SIGKILL')
- }
- }
- this.child = undefined
- this.requestPipe = undefined
- this.responsePipe = undefined
- }
- private async pumpRequest(streamId: number, request: Request, hasBody: boolean): Promise<void> {
- await this.enqueueRequestFrame(encodeDesktopRequestStart(streamId, {
- url: request.url,
- method: request.method.toUpperCase(),
- headers: [...request.headers.entries()],
- hasBody,
- }))
- if (!hasBody) return
- const body = request.body
- if (body === null) throw new Error('dsh desktop request body disappeared before upload')
- const reader = body.getReader()
- const pending = this.pending.get(streamId)
- if (pending === undefined) {
- await reader.cancel()
- return
- }
- pending.requestReader = reader
- try {
- for (;;) {
- const next = await reader.read()
- if (next.done) break
- for (let offset = 0; offset < next.value.byteLength; offset += DESKTOP_PIPE_CHUNK_BYTES) {
- if (!this.pending.has(streamId)) return
- await this.enqueueRequestFrame(encodeDesktopRequestData(
- streamId,
- next.value.subarray(offset, offset + DESKTOP_PIPE_CHUNK_BYTES),
- ))
- }
- }
- const live = this.pending.get(streamId)
- if (live !== undefined) {
- await this.enqueueRequestFrame(encodeDesktopRequestEnd(streamId))
- live.uploadOpen = false
- }
- } finally {
- reader.releaseLock()
- const live = this.pending.get(streamId)
- if (live?.requestReader === reader) delete live.requestReader
- }
- }
- private enqueueRequestFrame(frame: Buffer): Promise<void> {
- const write = this.requestWriteTail.then(async () => {
- const pipe = this.requestPipe
- if (pipe === undefined || pipe.destroyed) throw new Error('dsh desktop host request pipe is unavailable')
- if (!pipe.write(frame)) await once(pipe, 'drain')
- })
- this.requestWriteTail = write.catch(() => undefined)
- return write
- }
- private send(message: DesktopHostCommand): void {
- const child = this.child
- if (child === undefined || !child.connected) throw new Error('dsh desktop host IPC is unavailable')
- child.send(message, (error) => { if (error !== null) this.fail(error) })
- }
- private acceptResponseBytes(chunk: Buffer): void {
- try {
- for (const frame of this.responseDecoder.push(chunk)) this.handleResponseFrame(frame)
- } catch (error) {
- this.fail(errorOf(error, 'dsh desktop host response pipe failed'))
- this.child?.kill('SIGTERM')
- }
- }
- private handleResponseFrame(frame: DesktopHostResponseFrame): void {
- const pending = this.pending.get(frame.streamId)
- if (pending === undefined) {
- if (frame.streamId >= this.nextStreamId) {
- throw new Error(`dsh desktop host responded for unknown stream ${String(frame.streamId)}`)
- }
- return
- }
- switch (frame.type) {
- case 'start': {
- if (pending.responseStarted) throw new Error(`dsh desktop host started stream ${String(frame.streamId)} twice`)
- pending.responseStarted = true
- let body: ReadableStream<Uint8Array> | null = null
- if (frame.hasBody) {
- body = new ReadableStream<Uint8Array>({
- start: (controller) => { pending.controller = controller },
- pull: () => {
- this.blockedResponses.delete(frame.streamId)
- this.resumeResponsePipe()
- },
- cancel: (reason) => { this.cancelResponse(frame.streamId, reason) },
- })
- }
- pending.resolve(new Response(body, {
- status: frame.status,
- headers: new Headers(frame.headers.map(([name, value]) => [name, value] as [string, string])),
- }))
- return
- }
- case 'data': {
- const controller = pending.controller
- if (!pending.responseStarted || controller === undefined) {
- throw new Error(`dsh desktop host sent body data before a body start for stream ${String(frame.streamId)}`)
- }
- controller.enqueue(frame.data)
- if ((controller.desiredSize ?? 0) <= 0) {
- this.blockedResponses.add(frame.streamId)
- this.responsePipe?.pause()
- }
- return
- }
- case 'end':
- if (!pending.responseStarted) {
- throw new Error(`dsh desktop host ended stream ${String(frame.streamId)} before its response start`)
- }
- pending.controller?.close()
- this.finishPending(frame.streamId, true)
- return
- case 'error':
- this.failPending(frame.streamId, new Error(frame.message))
- return
- default:
- frame satisfies never
- }
- }
- private cancelResponse(streamId: number, reason: unknown): void {
- const pending = this.pending.get(streamId)
- if (pending === undefined) return
- pending.uploadOpen = false
- void pending.requestReader?.cancel(reason).catch(() => undefined)
- this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => {
- this.fail(errorOf(error, 'dsh desktop request pipe failed'))
- })
- this.finishPending(streamId, false)
- }
- private failPending(streamId: number, error: Error): void {
- const pending = this.pending.get(streamId)
- if (pending === undefined) return
- pending.uploadOpen = false
- void pending.requestReader?.cancel(error).catch(() => undefined)
- if (pending.controller === undefined) pending.reject(error)
- else pending.controller.error(error)
- this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError: unknown) => {
- this.fail(errorOf(pipeError, 'dsh desktop request pipe failed'))
- })
- this.finishPending(streamId, false)
- }
- private finishPending(streamId: number, cancelOpenUpload: boolean): void {
- const pending = this.pending.get(streamId)
- if (pending === undefined) return
- if (cancelOpenUpload && pending.uploadOpen) {
- pending.uploadOpen = false
- void pending.requestReader?.cancel().catch(() => undefined)
- this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => {
- this.fail(errorOf(error, 'dsh desktop request pipe failed'))
- })
- }
- pending.removeAbort?.()
- this.pending.delete(streamId)
- this.blockedResponses.delete(streamId)
- this.resumeResponsePipe()
- }
- private resumeResponsePipe(): void {
- if (this.blockedResponses.size === 0) this.responsePipe?.resume()
- }
- private handleMessage(message: DesktopHostEvent): void {
- switch (message.type) {
- case 'ready':
- this.readyResolve(message)
- return
- case 'fatal':
- this.fail(new Error(message.message))
- return
- default:
- message satisfies never
- }
- }
- private fail(error: Error): void {
- this.readyReject(error)
- if (!this.failureReported) {
- this.failureReported = true
- try { this.onFailure?.(error) } catch (listenerError) {
- console.error('desktop host failure listener failed', listenerError)
- }
- }
- for (const pending of this.pending.values()) {
- void pending.requestReader?.cancel(error).catch(() => undefined)
- if (pending.controller === undefined) pending.reject(error)
- else pending.controller.error(error)
- pending.removeAbort?.()
- }
- this.pending.clear()
- this.blockedResponses.clear()
- this.responsePipe?.resume()
- }
- }
|