diff --git a/packages/php-wasm/universal/src/lib/api.ts b/packages/php-wasm/universal/src/lib/api.ts index 4112c2a544..80744e43ea 100644 --- a/packages/php-wasm/universal/src/lib/api.ts +++ b/packages/php-wasm/universal/src/lib/api.ts @@ -374,7 +374,12 @@ function streamToPort(stream: ReadableStream): MessagePort { } } catch (e: any) { try { - port1.postMessage({ t: 'error', m: e?.message || String(e) }); + // @TODO: Find a way to transfer the error object, including any stack trace etc., + // using the error transfer handlers. + port1.postMessage({ + t: 'error', + m: e?.message || JSON.stringify(e), + }); } catch { // Ignore error } @@ -407,10 +412,24 @@ function portToStream(port: MessagePort): ReadableStream { controller.close(); cleanup(); break; - case 'error': - controller.error(new Error(data.m || 'Stream error')); + case 'error': { + let error = ''; + try { + error = JSON.parse(data.m); + } catch { + // Ignore error + } + if (!error) { + error = data.m; + } + if (typeof error === 'string') { + controller.error(new Error(error)); + } else { + controller.error(error); + } cleanup(); break; + } } }; const cleanup = () => { @@ -475,7 +494,7 @@ function promiseToPort(promise: Promise): MessagePort { try { port1.postMessage({ t: 'reject', - m: (err as any)?.message || String(err), + m: (err as any)?.message || JSON.stringify(err), }); } catch { // Ignore error @@ -504,8 +523,20 @@ function portToPromise(port: MessagePort): Promise { cleanup(); resolve(data.v); } else if (data.t === 'reject') { + // @TODO: Find a way to transfer the error object, including any stack trace etc., + // using the error transfer handlers. cleanup(); - reject(new Error(data.m || '')); + let error = ''; + try { + error = JSON.parse(data.m); + } catch { + // Ignore error + } + if (typeof error === 'string') { + reject(new Error(error)); + } else { + reject(error); + } } }; const cleanup = () => { diff --git a/packages/php-wasm/universal/src/lib/php-worker.ts b/packages/php-wasm/universal/src/lib/php-worker.ts index 2744d26949..d209746cf0 100644 --- a/packages/php-wasm/universal/src/lib/php-worker.ts +++ b/packages/php-wasm/universal/src/lib/php-worker.ts @@ -195,11 +195,23 @@ export class PHPWorker implements LimitedPHPApi, AsyncDisposable { const { php, reap } = await _private .get(this)! .requestHandler!.processManager.acquirePHPInstance(); + let response: StreamedPHPResponse; try { - return await php.cli(argv, options); - } finally { + response = await php.cli(argv, options); + } catch (error) { reap(); + throw error; } + /** + * Register the reap() callback to run asynchronously once + * the response is finished. + * + * We don't await for response.finished here. It is a + * `StreamedPHPResponse` instance and the caller may want + * to start processing the streamed data immediately. + */ + response.finished.finally(reap); + return response; } /** @inheritDoc @php-wasm/universal!/PHP.chdir */ diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index 2b7e4f0a66..dd89fd2731 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -9,6 +9,7 @@ import { getLoadedRuntime } from './load-php-runtime'; import type { PHPRequestHandler } from './php-request-handler'; import { PHPResponse, StreamedPHPResponse } from './php-response'; import type { + ChildProcess, MessageListener, PHPEvent, PHPEventListener, @@ -397,6 +398,10 @@ export class PHP implements Disposable { this[__private__dont__use].FS.chdir(path); } + cwd() { + return this[__private__dont__use].FS.cwd(); + } + /** * Changes the permissions of a file or directory. * @param path - The path to the file or directory. @@ -616,6 +621,7 @@ export class PHP implements Disposable { const streamedResponsePromise = this.#executeWithErrorHandling( async () => { if (!this.#phpWasmInitCalled) { + this.#phpWasmInitCalled = true; await this[__private__dont__use].ccall( 'php_wasm_init', null, @@ -625,7 +631,6 @@ export class PHP implements Disposable { isAsync: true, } ); - this.#phpWasmInitCalled = true; } if ( request.scriptPath && @@ -1437,8 +1442,12 @@ export class PHP implements Disposable { */ async cli( argv: string[], - options: { env?: Record } = {} + options: { env?: Record; cwd?: string } = {} ): Promise { + if (argv[0] !== 'php' && !argv[0].endsWith('/php')) { + return this.subProcess(argv, options); + } + if (this.#phpWasmInitCalled) { this.#rotationOptions.needsRotating = true; } @@ -1460,7 +1469,6 @@ export class PHP implements Disposable { [arg] ); } - return this[__private__dont__use].ccall('run_cli', null, [], [], { async: true, }); @@ -1474,6 +1482,83 @@ export class PHP implements Disposable { }); } + /** + * Runs an arbitrary CLI command using the spawn handler associated + * with this PHP instance. + * + * @param argv + * @param options + * @returns StreamedPHPResponse. + */ + private async subProcess( + argv: string[], + options: { env?: Record; cwd?: string } = {} + ): Promise { + const process = this[__private__dont__use].spawnProcess( + argv[0], + argv.slice(1), + { + env: options.env, + cwd: + options.cwd ?? + this.#rotationOptions?.cwd ?? + this.documentRoot ?? + '/', + } + ) as ChildProcess; + + const stderrStream = await createInvertedReadableStream(); + process.on('error', (error) => { + stderrStream.controller.error(error); + }); + process.stderr.on('data', (data) => { + stderrStream.controller.enqueue(data); + }); + + const stdoutStream = await createInvertedReadableStream(); + process.stdout.on('data', (data) => { + stdoutStream.controller.enqueue(data); + }); + + process.on('exit', () => { + // Delay until next tick to ensure we don't close the streams before + // emitting the error event on the stderrStream. + setTimeout(() => { + /** + * Ignore any close() errors, e.g. "stream already closed". We just + * need to try to call close() and forget about this subprocess. + */ + try { + stderrStream.controller.close(); + } catch { + // Ignore error + } + try { + stdoutStream.controller.close(); + } catch { + // Ignore error + } + }, 0); + }); + + return new StreamedPHPResponse( + // Headers stream + new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + stdoutStream.stream, + stderrStream.stream, + // Exit code + new Promise((resolve) => { + process.on('exit', (code) => { + resolve(code); + }); + }) + ); + } + setSkipShebang(shouldSkip: boolean) { this[__private__dont__use].ccall( 'wasm_set_skip_shebang', diff --git a/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts b/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts index 82a78ed6d2..c96aa026d5 100644 --- a/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts +++ b/packages/php-wasm/universal/src/lib/sandboxed-spawn-handler-factory.ts @@ -40,9 +40,11 @@ export function sandboxedSpawnHandlerFactory( // @TODO: Do not hardcode this processApi.stdout(`18 140`); processApi.exit(0); + return; } else if (binaryName === 'tput' && args[1] === 'cols') { processApi.stdout(`140`); processApi.exit(0); + return; } else if (binaryName === 'less') { processApi.on('stdin', (data: Uint8Array) => { processApi.stdout(data); @@ -55,13 +57,21 @@ export function sandboxedSpawnHandlerFactory( }); processApi.exit(0); return; - } else if (binaryName === 'php') { - const { php, reap } = await processManager.acquirePHPInstance({ - considerPrimary: false, - }); + } + + // Binaries requiring PHP to be running. + const { php, reap } = await processManager.acquirePHPInstance({ + considerPrimary: false, + }); - php.chdir(options.cwd as string); - try { + try { + if ('cwd' in options) { + php.chdir((options.cwd as string) ?? '/'); + } + + const cwd = php.cwd(); + + if (binaryName === 'php') { // Figure out more about setting env, putenv(), etc. const result = await php.cli(args, { env: { @@ -89,15 +99,23 @@ export function sandboxedSpawnHandlerFactory( }) ); processApi.exit(await result.exitCode); - } catch (e) { - // An exception here means the PHP runtime has crashed. - processApi.exit(1); - throw e; - } finally { - reap(); + } else if (binaryName === 'ls') { + const files = php.listFiles(args[1] ?? cwd); + files.forEach((file) => { + processApi.stdout(file + '\n'); + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + processApi.exit(0); + } else { + // 127 is the exit code for command not found. + processApi.exit(127); } - } else { + } catch (e) { + // An exception here means the PHP runtime has crashed. processApi.exit(1); + throw e; + } finally { + reap(); } }); } diff --git a/packages/playground/website/public/php-playground.html b/packages/playground/website/public/php-playground.html index e8918d3ce1..3945a1a55e 100644 --- a/packages/playground/website/public/php-playground.html +++ b/packages/playground/website/public/php-playground.html @@ -7,6 +7,10 @@ content="width=device-width, initial-scale=1.0, user-scalable=yes" /> WordPress PHP Playground +