UNPKG

@socketsupply/socket

Version:

A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.

705 lines (605 loc) 16.3 kB
/* global ErrorEvent */ import { AsyncResource } from './async/resource.js' import { EventEmitter } from './events.js' import diagnostics from './diagnostics.js' import { Worker } from './worker_threads.js' import { Buffer } from './buffer.js' import { rand64 } from './crypto.js' import process from './process.js' import signal from './process/signal.js' import ipc from './ipc.js' import gc from './gc.js' import os from './os.js' const dc = diagnostics.channels.group('child_process', [ 'spawn', 'close', 'exit', 'kill' ]) export class Pipe extends AsyncResource { #process = null #reading = true /** * `Pipe` class constructor. * @param {ChildProcess} process * @ignore */ constructor (process) { super('Pipe') this.#process = process if (process.stdout) { const { emit } = process.stdout process.stdout.emit = (...args) => { if (!this.reading) return false return this.runInAsyncScope(() => { return emit.call(process.stdout, ...args) }) } } if (process.stderr) { const { emit } = process.stderr process.stderr.emit = (...args) => { if (!this.reading) return false return this.runInAsyncScope(() => { return emit.call(process.stderr, ...args) }) } } process.once('close', () => this.destroy()) process.once('exit', () => this.destroy()) } /** * `true` if the pipe is still reading, otherwise `false`. * @type {boolean} */ get reading () { return this.#reading } /** * @type {import('./process')} */ get process () { return this.#process } /** * Destroys the pipe */ destroy () { this.#reading = false } } export class ChildProcess extends EventEmitter { #id = rand64() #worker = null #signal = null #timeout = null #resource = null #env = { ...process.env } #pipe = null #state = { killed: false, signalCode: null, exitCode: null, spawnfile: null, spawnargs: [], lifecycle: 'init', pid: 0 } /** * `ChildProcess` class constructor. * @param {{ * env?: object, * stdin?: boolean, * stdout?: boolean, * stderr?: boolean, * signal?: AbortSigal, * }=} [options] */ constructor (options = null) { super() // this does not implement disconnect or message because this is not node // @ts-ignore const workerLocation = new URL('./child_process/worker.js', import.meta.url) // TODO(@jwerle): support environment variable inject if (options?.env && typeof options?.env === 'object') { this.#env = options.env } this.#resource = new AsyncResource('ChildProcess') this.#resource.handle = this this.#resource.runInAsyncScope(() => { this.#worker = new Worker(workerLocation.toString(), { env: options?.env ?? {}, stdin: options?.stdin !== false, stdout: options?.stdout !== false, stderr: options?.stderr !== false, workerData: { id: this.#id } }) this.#pipe = new Pipe(this) }) if (options?.signal) { this.#signal = options.signal this.#signal.addEventListener('abort', () => { this.#resource.runInAsyncScope(() => { this.emit('error', new Error(this.#signal.reason)) this.kill(options?.killSignal ?? 'SIGKILL') }) }) } if (options?.timeout) { this.#timeout = setTimeout(() => { this.#resource.runInAsyncScope(() => { this.emit('error', new Error('Child process timed out')) this.kill(options?.killSignal ?? 'SIGKILL') }) }, options.timeout) this.once('exit', () => { clearTimeout(this.#timeout) }) } this.#worker.on('message', data => { if (data.method === 'kill' && data.args[0] === true) { this.#state.killed = true } if (data.method === 'state') { if (this.#state.pid !== data.args[0].pid) { this.#resource.runInAsyncScope(() => { this.emit('spawn') }) } Object.assign(this.#state, data.args[0]) switch (this.#state.lifecycle) { case 'spawn': { gc.ref(this) dc.channel('spawn').publish({ child_process: this }) break } case 'exit': { this.#resource.runInAsyncScope(() => { this.emit('exit', this.#state.exitCode) }) dc.channel('exit').publish({ child_process: this }) break } case 'close': { this.#resource.runInAsyncScope(() => { this.emit('close', this.#state.exitCode) }) dc.channel('close').publish({ child_process: this }) break } case 'kill': { this.#state.killed = true dc.channel('kill').publish({ child_process: this }) break } } } if (data.method === 'exit') { this.#resource.runInAsyncScope(() => { this.emit('exit', data.args[0]) }) } }) this.#worker.on('error', err => { this.#resource.runInAsyncScope(() => { this.emit('error', err) }) }) } /** * @ignore * @type {Pipe} */ get pipe () { return this.#pipe } /** * `true` if the child process was killed with kill()`, * otherwise `false`. * @type {boolean} */ get killed () { return this.#state.killed } /** * The process identifier for the child process. This value is * `> 0` if the process was spawned successfully, otherwise `0`. * @type {number} */ get pid () { return this.#state.pid } /** * The executable file name of the child process that is launched. This * value is `null` until the child process has successfully been spawned. * @type {string?} */ get spawnfile () { return this.#state.spawnfile ?? null } /** * The full list of command-line arguments the child process was spawned with. * This value is an empty array until the child process has successfully been * spawned. * @type {string[]} */ get spawnargs () { return this.#state.spawnargs } /** * Always `false` as the IPC messaging is not supported. * @type {boolean} */ get connected () { return false } /** * The child process exit code. This value is `null` if the child process * is still running, otherwise it is a positive integer. * @type {number?} */ get exitCode () { return this.#state.exitCode ?? null } /** * If available, the underlying `stdin` writable stream for * the child process. * @type {import('./stream').Writable?} */ get stdin () { return this.#worker.stdin ?? null } /** * If available, the underlying `stdout` readable stream for * the child process. * @type {import('./stream').Readable?} */ get stdout () { return this.#worker.stdout ?? null } /** * If available, the underlying `stderr` readable stream for * the child process. * @type {import('./stream').Readable?} */ get stderr () { return this.#worker.stderr ?? null } /** * The underlying worker thread. * @ignore * @type {import('./worker_threads').Worker} */ get worker () { return this.#worker } /** * This function does nothing, but is present for nodejs compat. */ disconnect () { return false } /** * This function does nothing, but is present for nodejs compat. * @return {boolean} */ send () { return false } /** * This function does nothing, but is present for nodejs compat. */ ref () { return false } /** * This function does nothing, but is present for nodejs compat. */ unref () { return false } /** * Kills the child process. This function throws an error if the child * process has not been spawned or is already killed. * @param {number|string} signal */ kill (...args) { if (!/spawn/.test(this.#state.lifecycle)) { throw new Error('Cannot kill a child process that has not been spawned') } if (this.killed) { throw new Error('Cannot kill an already killed child process') } this.#worker.postMessage({ id: this.#id, method: 'kill', args }) return this } /** * Spawns the child process. This function will thrown an error if the process * is already spawned. * @param {string} command * @param {string[]=} [args] * @return {ChildProcess} */ spawn (...args) { if (/spawning|spawn/.test(this.#state.lifecycle)) { throw new Error('Cannot spawn an already spawned ChildProcess') } if (!args[0] || typeof args[0] !== 'string') { throw new TypeError('Expecting command to be a string.') } this.#state.lifecycle = 'spawning' this.#worker.postMessage({ id: this.#id, env: this.#env, method: 'spawn', args }) return this } /** * `EventTarget` based `addEventListener` method. * @param {string} event * @param {function(Event)} callback * @param {{ once?: false }} [options] */ addEventListener (event, callback, options = null) { callback.listener = (...args) => { if (event === 'error') { callback(new ErrorEvent('error', { // @ts-ignore target: this, error: args[0] })) } else { callback(new Event(event, args[0])) } } if (options?.once === true) { this.once(event, callback.listener) } else { this.on(event, callback.listener) } } /** * `EventTarget` based `removeEventListener` method. * @param {string} event * @param {function(Event)} callback * @param {{ once?: false }} [options] */ removeEventListener (event, callback) { this.off(event, callback.listener ?? callback) } /** * Implements `gc.finalizer` for gc'd resource cleanup. * @return {gc.Finalizer} * @ignore */ [gc.finalizer] () { return { args: [this.#id], async handle (id) { const result = await ipc.send('child_process.kill', { id, signal: 'SIGTERM' }) if (result.err) { console.warn(result.err) } } } } } /** * Spawns a child process exeucting `command` with `args` * @param {string} command * @param {string[]|object=} [args] * @param {object=} [options * @return {ChildProcess} */ export function spawn (command, args = [], options = null) { if (args && typeof args === 'object' && !Array.isArray(args)) { options = args args = [] } if (!command || typeof command !== 'string') { throw new TypeError('Expecting command to be a string.') } if (args && typeof args === 'string') { // @ts-ignore args = args.split(' ') } const child = new ChildProcess(options) child.worker.on('online', () => child.spawn(command, args, options)) return child } export function exec (command, options, callback) { if (typeof options === 'function') { callback = options options = {} } const child = spawn(command, options) const stdout = [] const stderr = [] let closed = false let hasError = false if (child.stdout) { child.stdout.on('data', (data) => { if (hasError || closed) { return } stdout.push(Buffer.from(data)) stdout.push(Buffer.from('\n')) }) } if (child.stderr) { child.stderr.on('data', (data) => { if (hasError || closed) { return } stderr.push(Buffer.from(data)) stderr.push(Buffer.from('\n')) }) } child.once('error', (err) => { hasError = true stdout.splice(0, stdout.length) stderr.splice(0, stderr.length) if (typeof callback === 'function') { callback(err, null, null) } }) if (typeof callback === 'function') { child.once('close', () => { closed = true if (hasError) { return } if (options?.encoding === 'buffer') { callback( null, Buffer.concat(stdout), Buffer.concat(stderr) ) } else { const encoding = options?.encoding ?? 'utf8' callback( null, // @ts-ignore Buffer.concat(stdout).toString(encoding), // @ts-ignore Buffer.concat(stderr).toString(encoding) ) } stdout.splice(0, stdout.length) stderr.splice(0, stderr.length) }) } return Object.assign(child, { then (resolve, reject) { const promise = new Promise((resolve, reject) => { child.once('error', (err) => { hasError = true stdout.splice(0, stdout.length) stderr.splice(0, stderr.length) reject(err) }) child.once('close', () => { closed = true if (options?.encoding === 'buffer') { resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) }) } else { const encoding = options?.encoding ?? 'utf8' resolve({ // @ts-ignore stdout: Buffer.concat(stdout).toString(encoding), // @ts-ignore stderr: Buffer.concat(stderr).toString(encoding) }) } stdout.splice(0, stdout.length) stderr.splice(0, stderr.length) }) }) if (resolve && reject) { return promise.then(resolve, reject) } else if (resolve) { return promise.then(resolve) } return promise }, catch (reject) { return this.then().catch(reject) }, finally (next) { return this.then().finally(next) } }) } export function execSync (command, options) { const result = ipc.sendSync('child_process.exec', { id: rand64(), args: command, cwd: options?.cwd ?? '', stdin: options?.stdin !== false, stdout: options?.stdout !== false, stderr: options?.stderr !== false, timeout: Number.isFinite(options?.timeout) ? options.timeout : 0, killSignal: options?.killSignal ?? signal.SIGTERM }) if (result.err) { // @ts-ignore if (!result.err.code) { throw result.err } // @ts-ignore const { stdout, stderr, signal: errorSignal, code, pid } = result.err const message = code === 'ETIMEDOUT' ? 'execSync ETIMEDOUT' : stderr.join('\n') const error = Object.assign(new Error(message), { pid, stdout, stderr, code: typeof code === 'string' ? code : null, signal: errorSignal || signal.toString(options?.killSignal) || null, status: Number.isFinite(code) ? code : null, output: [null, stdout.join('\n'), stderr.join('\n')] }) // @ts-ignore error.error = error if (typeof code === 'string') { // @ts-ignore error.errno = -os.constants.errno[code] } throw error } const { stdout, stderr, signal: errorSignal, code, pid } = result.data if (code) { const message = code === 'ETIMEDOUT' ? 'execSync ETIMEDOUT' : stderr.join('\n') const error = Object.assign(new Error(message), { pid, stdout, stderr, code: typeof code === 'string' ? code : null, signal: errorSignal || null, status: Number.isFinite(code) ? code : null, output: [null, stdout, stderr] }) // @ts-ignore error.error = error if (typeof code === 'string') { // @ts-ignore error.errno = -os.constants.errno[code] } throw error } const output = stdout && options?.encoding === 'utf8' ? stdout : Buffer.from(stdout) return output } export const execFile = exec exec[Symbol.for('nodejs.util.promisify.custom')] = exec[Symbol.for('socket.runtime.util.promisify.custom')] = async function execPromisify (command, options) { return await new Promise((resolve, reject) => { exec(command, options, (err, stdout, stderr) => { if (err) { reject(err) } else { resolve({ stdout, stderr }) } }) }) } export default { ChildProcess, spawn, execFile, exec }