import { type ChildProcess, type ChildProcessWithoutNullStreams, execSync, spawn, } from 'node:child_process' import EventEmitter from 'node:events' import { isAbsolute, resolve } from 'node:path' import type { DevService, ResolvedDankConfig } from './config.ts' export type HttpService = NonNullable export type DevServiceEvents = { error: [label: string, cause: string] exit: [label: string, code: number | string] launch: [label: string] stdout: [label: string, output: Array] stderr: [label: string, output: Array] } class ManagedService extends EventEmitter { #label: string #process: ChildProcess | null #spec: DevService constructor(label: string, spec: DevService) { super() this.#label = label this.#spec = spec this.#process = this.#start() } get spec(): DevService { return this.#spec } get httpSpec(): HttpService | undefined { return this.#spec.http } matches(other: DevService): boolean { return matchingConfig(this.#spec, other) } kill() { if (this.#process) killProcess(this.#process) } #start(): ChildProcess { const { path, args } = parseCommand(this.#spec.command) const env = this.#spec.env ? { ...process.env, ...this.#spec.env } : undefined const cwd = !this.#spec.cwd || isAbsolute(this.#spec.cwd) ? this.#spec.cwd : resolve(process.cwd(), this.#spec.cwd) const spawned = spawnProcess(path, args, env, cwd) this.emit('launch', this.#label) spawned.stdout.on('data', chunk => this.emit('stdout', this.#label, parseChunk(chunk)), ) spawned.stderr.on('data', chunk => this.emit('stderr', this.#label, parseChunk(chunk)), ) spawned.on('error', e => { if (e.name === 'AbortError') { return } const cause = 'code' in e && e.code === 'ENOENT' ? 'program not found' : e.message this.emit('error', this.#label, cause) }) spawned.on('exit', (code, signal) => this.emit('exit', this.#label, code || signal!), ) return spawned } } type SpawnProcess = ( program: string, args: Array, env: NodeJS.ProcessEnv | undefined, cwd: string | undefined, ) => ChildProcessWithoutNullStreams type KillProcess = (p: ChildProcess) => void const killProcess: KillProcess = process.platform === 'win32' ? p => execSync(`taskkill /pid ${p.pid} /T /F`) : p => p.kill() const spawnProcess: SpawnProcess = process.platform === 'win32' ? ( path: string, args: Array, env: NodeJS.ProcessEnv | undefined, cwd: string | undefined, ) => spawn('cmd', ['/c', path, ...args], { cwd, env, detached: false, shell: false, windowsHide: true, }) : ( path: string, args: Array, env: NodeJS.ProcessEnv | undefined, cwd: string | undefined, ) => spawn(path, args, { cwd, env, detached: false, shell: false, }) export class DevServices extends EventEmitter { #active: boolean = false #services: Array | null = null get httpServices(): Array { return this.#services?.map(s => s.httpSpec).filter(http => !!http) ?? [] } start(services: ResolvedDankConfig['services']) { if (this.#active) { throw Error() } this.#active = true this.#services = services ? this.#start(services) : [] if (process.platform === 'win32') { process.once('SIGINT', () => process.exit()) } process.once('exit', this.shutdown) } update(services: ResolvedDankConfig['services']) { if (!this.#active) { return } else if (!services?.length) { this.shutdown() } else if ( !matchingConfigs(this.#services?.map(s => s.spec) ?? null, services) ) { this.shutdown() this.#services = this.#start(services) } } shutdown = () => { if (this.#services) { this.#services.forEach(s => { s.kill() s.removeAllListeners() }) this.#services.length = 0 } } #start( services: NonNullable, ): Array { return services.map((spec, i) => { const service = new ManagedService( spec.label ?? `services[${i}]`, spec, ) service.on('error', (label, cause) => this.emit('error', label, cause), ) service.on('exit', (label, code) => this.emit('exit', label, code)) service.on('launch', label => this.emit('launch', label)) service.on('stdout', (label, output) => this.emit('stdout', label, output), ) service.on('stderr', (label, output) => this.emit('stderr', label, output), ) return service }) } } function matchingConfigs( a: Array | null, b: NonNullable, ): boolean { if (a?.length !== b.length) { return false } const crossRef = [...a] for (const toFind of b) { const found = crossRef.findIndex(spec => matchingConfig(spec, toFind)) if (found === -1) { return false } else { crossRef.splice(found, 1) } } return true } function matchingConfig(a: DevService, b: DevService): boolean { if (a.command !== b.command) { return false } if (a.cwd !== b.cwd) { return false } if (!a.env && !b.env) { return true } else if (a.env && !b.env) { return false } else if (!a.env && b.env) { return false } else if (Object.keys(a.env!).length !== Object.keys(b.env!).length) { return false } else { for (const k of Object.keys(a.env!)) { if (!b.env![k]) { return false } else if (a.env![k] !== b.env![k]) { return false } } } return true } function parseChunk(c: Buffer): Array { return c .toString() .replace(/\r?\n$/, '') .split(/\r?\n/) } export function parseCommand(command: string): { path: string args: Array } { command = command.trimStart() const programSplitIndex = command.indexOf(' ') if (programSplitIndex === -1) { return { path: command.trim(), args: [] } } const path = command.substring(0, programSplitIndex) const args: Array = [] let argStart = programSplitIndex + 1 let withinLiteral: false | "'" | '"' = false for (let i = 0; i < command.length; i++) { const c = command[i] if (!withinLiteral) { if (c === "'" || c === '"') { withinLiteral = c continue } if (c === '\\') { i++ continue } } if (withinLiteral) { if (c === withinLiteral) { withinLiteral = false args.push(command.substring(argStart + 1, i)) argStart = i + 1 } continue } if (c === ' ' && i > argStart) { const maybeArg = command.substring(argStart, i).trim() if (maybeArg.length) { args.push(maybeArg) } argStart = i + 1 } } const maybeArg = command.substring(argStart, command.length).trim() if (maybeArg.length) { args.push(maybeArg) } return { path, args } }