diff --git a/packages/php-wasm/compile/php/php_wasm.c b/packages/php-wasm/compile/php/php_wasm.c index a1cdcc5997..e3d29f7ca6 100644 --- a/packages/php-wasm/compile/php/php_wasm.c +++ b/packages/php-wasm/compile/php/php_wasm.c @@ -235,6 +235,9 @@ EM_JS(int, wasm_poll_socket, (php_socket_t socketd, int events, int timeout), { while (true) { var mask = POLLNVAL; mask = SYSCALLS.DEFAULT_POLLMASK; + if (FS.isClosed(stream)) { + return ERRNO_CODES.EBADF; + } if (stream.stream_ops?.poll) { mask = stream.stream_ops.poll(stream, -1); } diff --git a/packages/php-wasm/node/asyncify/php_7_2.js b/packages/php-wasm/node/asyncify/php_7_2.js index 93b5b64616..c6c8008abb 100644 --- a/packages/php-wasm/node/asyncify/php_7_2.js +++ b/packages/php-wasm/node/asyncify/php_7_2.js @@ -31943,6 +31943,9 @@ export function init(RuntimeName, PHPLoader) { while (true) { var mask = POLLNVAL; mask = SYSCALLS.DEFAULT_POLLMASK; + if (FS.isClosed(stream)) { + return ERRNO_CODES.EBADF; + } if (stream.stream_ops?.poll) { mask = stream.stream_ops.poll(stream, -1); } diff --git a/packages/php-wasm/node/asyncify/php_7_3.js b/packages/php-wasm/node/asyncify/php_7_3.js index 7276554f94..9b36679963 100644 --- a/packages/php-wasm/node/asyncify/php_7_3.js +++ b/packages/php-wasm/node/asyncify/php_7_3.js @@ -31943,6 +31943,9 @@ export function init(RuntimeName, PHPLoader) { while (true) { var mask = POLLNVAL; mask = SYSCALLS.DEFAULT_POLLMASK; + if (FS.isClosed(stream)) { + return ERRNO_CODES.EBADF; + } if (stream.stream_ops?.poll) { mask = stream.stream_ops.poll(stream, -1); } diff --git a/packages/php-wasm/node/asyncify/php_7_4.js b/packages/php-wasm/node/asyncify/php_7_4.js index 5db4458147..7f1c1f1337 100644 --- a/packages/php-wasm/node/asyncify/php_7_4.js +++ b/packages/php-wasm/node/asyncify/php_7_4.js @@ -31943,6 +31943,9 @@ export function init(RuntimeName, PHPLoader) { while (true) { var mask = POLLNVAL; mask = SYSCALLS.DEFAULT_POLLMASK; + if (FS.isClosed(stream)) { + return ERRNO_CODES.EBADF; + } if (stream.stream_ops?.poll) { mask = stream.stream_ops.poll(stream, -1); } diff --git a/packages/php-wasm/node/asyncify/php_8_0.js b/packages/php-wasm/node/asyncify/php_8_0.js index def51911b6..6cd90e07cb 100644 --- a/packages/php-wasm/node/asyncify/php_8_0.js +++ b/packages/php-wasm/node/asyncify/php_8_0.js @@ -8078,6 +8078,9 @@ export function init(RuntimeName, PHPLoader) { if ((stream.flags & 2097155) === 1) { return 256 | 4; } + if (!pipe.buckets) { + return 0; + } for (var bucket of pipe.buckets) { if (bucket.offset - bucket.roffset > 0) { return 64 | 1; @@ -31943,6 +31946,9 @@ export function init(RuntimeName, PHPLoader) { while (true) { var mask = POLLNVAL; mask = SYSCALLS.DEFAULT_POLLMASK; + if (FS.isClosed(stream)) { + return ERRNO_CODES.EBADF; + } if (stream.stream_ops?.poll) { mask = stream.stream_ops.poll(stream, -1); } @@ -31961,8 +31967,9 @@ export function init(RuntimeName, PHPLoader) { if ( typeof FS == 'undefined' || !(e.name === 'ErrnoError') - ) + ) { throw e; + } return -e.errno; } } diff --git a/packages/php-wasm/node/asyncify/php_8_1.js b/packages/php-wasm/node/asyncify/php_8_1.js index 0a184771f5..cce7f6395b 100644 --- a/packages/php-wasm/node/asyncify/php_8_1.js +++ b/packages/php-wasm/node/asyncify/php_8_1.js @@ -31943,6 +31943,9 @@ export function init(RuntimeName, PHPLoader) { while (true) { var mask = POLLNVAL; mask = SYSCALLS.DEFAULT_POLLMASK; + if (FS.isClosed(stream)) { + return ERRNO_CODES.EBADF; + } if (stream.stream_ops?.poll) { mask = stream.stream_ops.poll(stream, -1); } diff --git a/packages/php-wasm/node/asyncify/php_8_2.js b/packages/php-wasm/node/asyncify/php_8_2.js index 55f4a42113..0ce99df5c2 100644 --- a/packages/php-wasm/node/asyncify/php_8_2.js +++ b/packages/php-wasm/node/asyncify/php_8_2.js @@ -31943,6 +31943,9 @@ export function init(RuntimeName, PHPLoader) { while (true) { var mask = POLLNVAL; mask = SYSCALLS.DEFAULT_POLLMASK; + if (FS.isClosed(stream)) { + return ERRNO_CODES.EBADF; + } if (stream.stream_ops?.poll) { mask = stream.stream_ops.poll(stream, -1); } diff --git a/packages/php-wasm/node/asyncify/php_8_3.js b/packages/php-wasm/node/asyncify/php_8_3.js index 6a482d7a3b..9374482d15 100644 --- a/packages/php-wasm/node/asyncify/php_8_3.js +++ b/packages/php-wasm/node/asyncify/php_8_3.js @@ -31943,6 +31943,9 @@ export function init(RuntimeName, PHPLoader) { while (true) { var mask = POLLNVAL; mask = SYSCALLS.DEFAULT_POLLMASK; + if (FS.isClosed(stream)) { + return ERRNO_CODES.EBADF; + } if (stream.stream_ops?.poll) { mask = stream.stream_ops.poll(stream, -1); } diff --git a/packages/php-wasm/node/asyncify/php_8_4.js b/packages/php-wasm/node/asyncify/php_8_4.js index 1e6be92d96..e8976154f5 100644 --- a/packages/php-wasm/node/asyncify/php_8_4.js +++ b/packages/php-wasm/node/asyncify/php_8_4.js @@ -31943,6 +31943,9 @@ export function init(RuntimeName, PHPLoader) { while (true) { var mask = POLLNVAL; mask = SYSCALLS.DEFAULT_POLLMASK; + if (FS.isClosed(stream)) { + return ERRNO_CODES.EBADF; + } if (stream.stream_ops?.poll) { mask = stream.stream_ops.poll(stream, -1); } 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 c61024337f..8b5be6d6c5 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 @@ -45,7 +45,7 @@ export function sandboxedSpawnHandlerFactory( processApi.exit(0); } else if (binaryName === 'less') { processApi.on('stdin', (data: Uint8Array) => { - processApi.stdout(data.buffer as ArrayBuffer); + processApi.stdout(data); }); processApi.exit(0); } else if (binaryName === 'php') { @@ -70,14 +70,14 @@ export function sandboxedSpawnHandlerFactory( result.stdout.pipeTo( new WritableStream({ write(chunk) { - processApi.stdout(chunk.buffer as ArrayBuffer); + processApi.stdout(chunk); }, }) ); result.stderr.pipeTo( new WritableStream({ write(chunk) { - processApi.stderr(chunk.buffer as ArrayBuffer); + processApi.stderr(chunk); }, }) ); diff --git a/packages/playground/blueprints/public/blueprints.phar b/packages/playground/blueprints/public/blueprints.phar deleted file mode 100755 index 3e11b24952..0000000000 Binary files a/packages/playground/blueprints/public/blueprints.phar and /dev/null differ diff --git a/packages/playground/blueprints/src/lib/v2.spec.ts b/packages/playground/blueprints/src/lib/v2.spec.ts deleted file mode 100644 index b857e88821..0000000000 --- a/packages/playground/blueprints/src/lib/v2.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { loadNodeRuntime } from '@php-wasm/node'; -import type { PHPProcessManager, PHPResponse } from '@php-wasm/universal'; -import { RecommendedPHPVersion } from '@wp-playground/common'; -import type { PHPRequestHandler } from '@php-wasm/universal'; -import { bootRequestHandler } from '@wp-playground/wordpress'; -import { runBlueprintV2 } from './v2'; -import { rootCertificates } from 'node:tls'; -import { createSpawnHandler, phpVar } from '@php-wasm/util'; -import { logger } from '@php-wasm/logger'; - -describe('V2 runner', () => { - let handler: PHPRequestHandler; - - beforeEach(async () => { - handler = await bootRequestHandler({ - createPhpRuntime: async () => - await loadNodeRuntime(RecommendedPHPVersion), - sapiName: 'cli', - siteUrl: 'http://playground-domain/', - phpIniEntries: { - 'openssl.cafile': '/internal/shared/ca-bundle.crt', - }, - createFiles: { - '/internal/shared/ca-bundle.crt': rootCertificates.join('\n'), - }, - spawnHandler: spawnHandlerFactory, - }); - }); - - // @TODO: Unskip this test. It needs the rest of the https://github.com/WordPress/wordpress-playground/pull/2238 to be merged - // before it will pass. - it.skip( - 'should run the runner', - async () => { - const { php } = await handler.processManager.acquirePHPInstance(); - const result = await runBlueprintV2({ - php: php as any, - blueprint: '{"version":2}', - siteUrl: 'http://playground-domain/', - documentRoot: '/wordpress', - hooks: { - afterBlueprintTargetResolved: async () => { - console.log('Blueprint target resolved'); - process.exit(0); - }, - }, - }); - expect(await result?.stdoutText).toBe('Hello, World!'); - }, - { - timeout: 60000, - } - ); -}); - -export function spawnHandlerFactory(processManager: PHPProcessManager) { - return createSpawnHandler(async function (args, processApi, options) { - console.log('Spawn handler called', args); - processApi.notifySpawn(); - if (args[0] === 'exec') { - args.shift(); - } - - if (args[0].endsWith('.php')) { - args.unshift('php'); - } - - // Mock programs required by wp-cli: - if ( - args[0] === '/usr/bin/env' && - args[1] === 'stty' && - args[2] === 'size' - ) { - // These numbers are hardcoded because this - // spawnHandler is transmitted as a string to - // the PHP backend and has no access to local - // scope. It would be nice to find a way to - // transfer / proxy a live object instead. - // @TODO: Do not hardcode this - processApi.stdout(`18 140`); - processApi.exit(0); - } else if (args[0] === 'tput' && args[1] === 'cols') { - processApi.stdout(`140`); - processApi.exit(0); - } else if (args[0] === 'less') { - processApi.on('stdin', (data: Uint8Array) => { - processApi.stdout(data); - }); - - processApi.exit(0); - } else if (args[0] === 'fetch') { - fetch(args[1]).then(async (res) => { - const reader = res.body?.getReader(); - if (!reader) { - processApi.exit(1); - return; - } - while (true) { - const { done, value } = await reader.read(); - if (done) { - processApi.exit(0); - break; - } - processApi.stdout(value); - } - }); - return; - } else if (args[0] === 'php') { - const { php, reap } = await processManager.acquirePHPInstance(); - - let result: PHPResponse | undefined = undefined; - try { - // @TODO: Run the actual PHP CLI SAPI instead of - // interpreting the arguments and emulating - // the CLI constants and globals. - const cliBootstrapScript = ` void | Promise; - beforeWordPressFiles?: (php: UniversalPHP) => void | Promise; - onProgress?: (progress: number, caption: string) => void; - /** - * A hook that is called when an error occurs. It provides succinct - * error messages and structured details. Useful for reporting specific - * errors to the user without displaying the full stack trace. - * - * @param message The error message. - * @param details The error details. - */ - onError?: (message: string, details?: PHPExceptionDetails) => void; - }; -} - -export type PHPExceptionDetails = { - exception: string; - message: string; - file: string; - line: number; - trace: string; -}; - -export async function runBlueprintV2(options: RunV2Options) { - const php = options.php; - const onProgress = options.hooks?.onProgress || (() => {}); - const onError = options.hooks?.onError || (() => {}); - - // beforeWordPressFiles - if (options.hooks?.beforeWordPressFiles) { - await options.hooks.beforeWordPressFiles(php); - } - const file = await getV2Runner(); - php.writeFile( - '/tmp/blueprints.phar', - new Uint8Array(await file.arrayBuffer()) - ); - - const parsedBlueprintDeclaration = parseBlueprintDeclaration( - options.blueprint - ); - let blueprintReference = ''; - switch (parsedBlueprintDeclaration.type) { - case 'inline-file': - php.writeFile( - '/tmp/blueprint.json', - parsedBlueprintDeclaration.contents - ); - blueprintReference = '/tmp/blueprint.json'; - break; - case 'file-reference': - blueprintReference = parsedBlueprintDeclaration.reference; - break; - } - - // @TODO: Unbind this listener after a successful run. - // Maybe propagate messages via addEventListener etc? - await php.onMessage(async (message) => { - try { - const parsed = - typeof message === 'string' ? JSON.parse(message) : message; - if (!parsed) { - return; - } - switch (parsed.type) { - case 'blueprint.target_resolved': - // @TODO: Rethink these debug constants. We shouldn't - // always set them, right? - php.defineConstant('WP_DEBUG', true); - php.defineConstant('WP_DEBUG_LOG', true); - php.defineConstant('WP_DEBUG_DISPLAY', false); - - /* - * Add required constants to "wp-config.php" if they are not already defined. - * This is needed, because some WordPress backups and exports may not include - * definitions for some of the necessary constants. - */ - await ensureWpConfig(php, options.documentRoot); - - if (options.hooks?.afterBlueprintTargetResolved) { - await options.hooks.afterBlueprintTargetResolved(php); - } - break; - case 'blueprint.progress': - onProgress?.( - parsed.progress, - parsed.caption || 'Running the Blueprint' - ); - break; - case 'blueprint.error': - onError?.(parsed.message, parsed.details); - break; - } - } catch (e) { - logger.warn('Failed to parse message as JSON:', message, e); - } - }); - - await php?.writeFile('/tmp/stdout', ''); - await php?.writeFile('/tmp/stderror', ''); - await php?.writeFile( - '/tmp/run-blueprints.php', - `writeJsonMessage([ - 'type' => 'blueprint.progress', - 'progress' => round($progress, 2), - 'caption' => $caption - ]); - } - - public function reportError(string $message, ?Throwable $exception = null): void { - $errorData = [ - 'type' => 'blueprint.error', - 'message' => $message - ]; - - if ($exception) { - $errorData['details'] = [ - 'exception' => get_class($exception), - 'message' => $exception->getMessage(), - 'file' => $exception->getFile(), - 'line' => $exception->getLine(), - 'trace' => $exception->getTraceAsString() - ]; - } - - $this->writeJsonMessage($errorData); - } - - public function reportCompletion(string $message): void { - $this->writeJsonMessage([ - 'type' => 'blueprint.completion', - 'message' => $message - ]); - } - - public function close(): void {} - - private function writeJsonMessage(array $data): void { - post_message_to_js(json_encode($data)); - } -} - return new PlaygroundProgressReporter(); -} -playground_add_filter('blueprint.progress_reporter', 'playground_progress_reporter'); - -require( "/tmp/blueprints.phar" ); -` - ); - - // @TODO: Remove this cast. Add the cli() method to UniversalPHP. - return await (php as any).cli([ - 'php', - '/tmp/run-blueprints.php', - 'exec', - blueprintReference, - '--site-path=/wordpress', - `--site-url=${options.siteUrl}`, - '--db-engine=sqlite', - // '--truncate-new-site-directory=true', - ]); -} - -export type BlueprintV2Declaration = string | BlueprintDeclaration | undefined; -export type ParsedBlueprintV2Declaration = - | { type: 'inline-file'; contents: string } - | { type: 'file-reference'; reference: string }; - -export function parseBlueprintDeclaration( - source: BlueprintV2Declaration | ParsedBlueprintV2Declaration -): ParsedBlueprintV2Declaration { - if ( - typeof source === 'object' && - 'type' in source && - ['inline-file', 'file-reference'].includes(source.type) - ) { - return source; - } - if (!source) { - return { - type: 'inline-file', - contents: '{}', - }; - } - if (typeof source !== 'string') { - // If source is an object, assume it's a Blueprint declaration object and - // convert it to a JSON string. - return { - type: 'inline-file', - contents: JSON.stringify(source), - }; - } - try { - // If source is valid JSON, return it as is. - JSON.parse(source); - return { - type: 'inline-file', - contents: source, - }; - } catch { - return { - type: 'file-reference', - reference: source, - }; - } -} - -export async function getV2Runner(): Promise { - let data = null; - /** - * Only load the v2 runner via node:fs when running in Node.js. - */ - if (typeof process !== 'undefined' && process.versions?.node) { - let path = v2_runner_url; - if (path.startsWith('/@fs/')) { - path = path.slice(4); - } - - const { readFile } = await import('node:fs/promises'); - data = await readFile(path); - } else { - const response = await fetch(v2_runner_url); - data = await response.blob(); - } - return new File([data], `blueprints.phar`, { - type: 'application/zip', - }); -} diff --git a/packages/playground/blueprints/vite.config.ts b/packages/playground/blueprints/vite.config.ts index df12a4f7bb..422e4446ff 100644 --- a/packages/playground/blueprints/vite.config.ts +++ b/packages/playground/blueprints/vite.config.ts @@ -73,6 +73,14 @@ export default defineConfig({ external: getExternalModules(), }, }, + resolve: { + // @ts-ignore + alias: { + // This makes sure Vite doesn't stub it + fs: false, + 'fs/promises': false, + }, + }, test: { globals: true, diff --git a/packages/playground/cli/public/blueprints.phar b/packages/playground/cli/public/blueprints.phar new file mode 100755 index 0000000000..6ac80590b4 Binary files /dev/null and b/packages/playground/cli/public/blueprints.phar differ diff --git a/packages/playground/cli/src/test/v2.spec.ts b/packages/playground/cli/src/test/v2.spec.ts new file mode 100644 index 0000000000..33b5699196 --- /dev/null +++ b/packages/playground/cli/src/test/v2.spec.ts @@ -0,0 +1,47 @@ +import { loadNodeRuntime } from '@php-wasm/node'; +import { type PHPRequestHandler } from '@php-wasm/universal'; +import { bootRequestHandler } from '@wp-playground/wordpress'; +import { rootCertificates } from 'node:tls'; +import { runBlueprintV2 } from '../v2'; +import { RecommendedPHPVersion } from '@wp-playground/common'; + +describe('V2 runner', () => { + let handler: PHPRequestHandler; + + beforeEach(async () => { + handler = await bootRequestHandler({ + createPhpRuntime: async () => + await loadNodeRuntime(RecommendedPHPVersion, { + emscriptenOptions: { + ENV: { + DOCROOT: '/wordpress', + }, + }, + }), + sapiName: 'cli', + siteUrl: 'http://playground-domain/', + phpIniEntries: { + 'openssl.cafile': '/internal/shared/ca-bundle.crt', + }, + createFiles: { + '/internal/shared/ca-bundle.crt': rootCertificates.join('\n'), + }, + }); + }); + + it('should put WordPress in the document root', async () => { + const instance = await handler.processManager.acquirePHPInstance(); + const result = await runBlueprintV2({ + php: instance.php as any, + blueprint: '{"version":2}', + cliArgs: [ + '--site-url=http://playground-domain/', + '--db-engine=sqlite', + ], + }); + await result.finished; + expect(await result.exitCode).toBe(0); + const instance2 = await handler.processManager.acquirePHPInstance(); + expect(instance2.php.listFiles('/wordpress')).toContain('wp-content'); + }, 60000); +}); diff --git a/packages/playground/cli/src/v2.ts b/packages/playground/cli/src/v2.ts new file mode 100644 index 0000000000..a7eb88d23f --- /dev/null +++ b/packages/playground/cli/src/v2.ts @@ -0,0 +1,300 @@ +import { logger } from '@php-wasm/logger'; +import { + type StreamedPHPResponse, + type UniversalPHP, +} from '@php-wasm/universal'; +import { phpVar } from '@php-wasm/util'; +import type { BlueprintDeclaration } from '@wp-playground/blueprints'; + +export type PHPExceptionDetails = { + exception: string; + message: string; + file: string; + line: number; + trace: string; +}; + +export type BlueprintMessage = + | { type: 'blueprint.target_resolved' } + | { type: 'blueprint.progress'; progress: number; caption: string } + | { + type: 'blueprint.error'; + message: string; + details?: PHPExceptionDetails; + } + | { type: 'blueprint.completion'; message: string }; + +interface RunV2Options { + php: UniversalPHP; + cliArgs?: string[]; + blueprint: BlueprintV2Declaration | ParsedBlueprintV2Declaration; + blueprintOverrides?: { + wordpressVersion?: string; + additionalSteps?: any[]; + }; + onMessage?: (message: BlueprintMessage) => void | Promise; +} + +export type BlueprintV2Declaration = string | BlueprintDeclaration | undefined; +export type ParsedBlueprintV2Declaration = + | { type: 'inline-file'; contents: string } + | { type: 'file-reference'; reference: string }; + +export function parseBlueprintDeclaration( + source: BlueprintV2Declaration | ParsedBlueprintV2Declaration +): ParsedBlueprintV2Declaration { + if ( + typeof source === 'object' && + 'type' in source && + ['inline-file', 'file-reference'].includes(source.type) + ) { + return source; + } + if (!source) { + return { + type: 'inline-file', + contents: '{}', + }; + } + if (typeof source !== 'string') { + // If source is an object, assume it's a Blueprint declaration object and + // convert it to a JSON string. + return { + type: 'inline-file', + contents: JSON.stringify(source), + }; + } + try { + // If source is valid JSON, return it as is. + JSON.parse(source); + return { + type: 'inline-file', + contents: source, + }; + } catch { + return { + type: 'file-reference', + reference: source, + }; + } +} + +export async function getV2Runner(): Promise { + let data = null; + + /** + * Avoid a static dependency for now. + * + * Playground.wordpress.net does not need to know about the new runner yet, and + * a static import would force it to download the v2 runner even when it's not needed. + * This breaks the offline mode as the static assets list is not yet updated to accommodate + * for the new .phar file. + */ + // @ts-ignore + const v2_runner_url = (await import('../public/blueprints.phar?url')) + .default; + + /** + * Only load the v2 runner via node:fs when running in Node.js. + */ + if (typeof process !== 'undefined' && process.versions?.node) { + let path = v2_runner_url; + if (path.startsWith('/@fs/')) { + path = path.slice('/@fs'.length); + } + if (path.startsWith('file://')) { + path = path.slice('file://'.length); + } + + const { readFile } = await import('node:fs/promises'); + data = await readFile(path); + } else { + const response = await fetch(v2_runner_url); + data = await response.blob(); + } + return new File([data], `blueprints.phar`, { + type: 'application/zip', + }); +} + +export async function runBlueprintV2( + options: RunV2Options +): Promise { + const cliArgs = options.cliArgs || []; + for (const arg of cliArgs) { + if (arg.startsWith('--site-path=')) { + throw new Error( + 'The --site-path CLI argument must not be provided. In Playground, it is always set to /wordpress.' + ); + } + } + cliArgs.push('--site-path=/wordpress'); + + /** + * Divergence from blueprints.phar – the default database engine is + * SQLite. Why? Because in Playground we'll use SQLite far more often than + * MySQL. + */ + const dbEngine = cliArgs.find((arg) => arg.startsWith('--db-engine=')); + if (!dbEngine) { + cliArgs.push('--db-engine=sqlite'); + } + + const php = options.php; + const onMessage = options?.onMessage || (() => {}); + + const file = await getV2Runner(); + php.writeFile( + '/tmp/blueprints.phar', + new Uint8Array(await file.arrayBuffer()) + ); + + const parsedBlueprintDeclaration = parseBlueprintDeclaration( + options.blueprint + ); + let blueprintReference = ''; + switch (parsedBlueprintDeclaration.type) { + case 'inline-file': + php.writeFile( + '/tmp/blueprint.json', + parsedBlueprintDeclaration.contents + ); + blueprintReference = '/tmp/blueprint.json'; + break; + case 'file-reference': + blueprintReference = parsedBlueprintDeclaration.reference; + break; + } + + const unbindMessageListener = await php.onMessage(async (message) => { + try { + const parsed = + typeof message === 'string' ? JSON.parse(message) : message; + if (!parsed) { + return; + } + + // Make sure stdout and stderr data is emited before the next message is processed. + // Otherwise a code such as `echo "Hello"; post_message_to_js(json_encode([ + // 'type' => 'blueprint.error', + // 'message' => 'Error' + // ]));` + // might emit the message before we process the stdout data. + // + // This is a workaround to ensure that the message is emitted after the stdout data is processed. + // @TODO: Remove this workaround. Find the root cause why stdout data is delayed and address it + // directly. + await new Promise((resolve) => setTimeout(resolve, 0)); + + if (parsed.type.startsWith('blueprint.')) { + await onMessage(parsed); + } + } catch (e) { + logger.warn('Failed to parse message as JSON:', message, e); + } + }); + + /** + * Prepare hooks, filters, and run the Blueprint: + */ + await php?.writeFile( + '/tmp/run-blueprints.php', + ` 'sockets', + ]); +} +playground_add_filter('blueprint.http_client', 'playground_http_client_factory'); + +function playground_on_blueprint_target_resolved() { + post_message_to_js(json_encode([ + 'type' => 'blueprint.target_resolved', + ])); +} +playground_add_filter('blueprint.target_resolved', 'playground_on_blueprint_target_resolved'); + +playground_add_filter('blueprint.resolved', 'playground_on_blueprint_resolved'); +function playground_on_blueprint_resolved($blueprint) { + $additional_blueprint_steps = json_decode(${phpVar( + JSON.stringify(options.blueprintOverrides?.additionalSteps || []) + )}, true); + if(count($additional_blueprint_steps) > 0) { + $blueprint['additionalStepsAfterExecution'] = array_merge( + $blueprint['additionalStepsAfterExecution'] ?? [], + $additional_blueprint_steps + ); + } + + $wp_version_override = json_decode(${phpVar( + JSON.stringify(options.blueprintOverrides?.wordpressVersion || null) + )}, true); + if($wp_version_override) { + $blueprint['wordpressVersion'] = $wp_version_override; + } + return $blueprint; +} + +function playground_progress_reporter() { + class PlaygroundProgressReporter implements ProgressReporter { + + public function reportProgress(float $progress, string $caption): void { + $this->writeJsonMessage([ + 'type' => 'blueprint.progress', + 'progress' => round($progress, 2), + 'caption' => $caption + ]); + } + + public function reportError(string $message, ?Throwable $exception = null): void { + $errorData = [ + 'type' => 'blueprint.error', + 'message' => $message + ]; + + if ($exception) { + $errorData['details'] = [ + 'exception' => get_class($exception), + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTraceAsString() + ]; + } + + $this->writeJsonMessage($errorData); + } + + public function reportCompletion(string $message): void { + $this->writeJsonMessage([ + 'type' => 'blueprint.completion', + 'message' => $message + ]); + } + + public function close(): void {} + + private function writeJsonMessage(array $data): void { + post_message_to_js(json_encode($data)); + } + } + return new PlaygroundProgressReporter(); +} +playground_add_filter('blueprint.progress_reporter', 'playground_progress_reporter'); +require( "/tmp/blueprints.phar" ); +` + ); + const streamedResponse = (await (php as any).cli([ + '/internal/shared/bin/php', + '/tmp/run-blueprints.php', + 'exec', + blueprintReference, + ...cliArgs, + ])) as StreamedPHPResponse; + + streamedResponse.finished.finally(unbindMessageListener); + + return streamedResponse; +} diff --git a/packages/playground/cli/vite.config.ts b/packages/playground/cli/vite.config.ts index 1feb831626..6fa2ed0667 100644 --- a/packages/playground/cli/vite.config.ts +++ b/packages/playground/cli/vite.config.ts @@ -1,11 +1,44 @@ /// import { join } from 'path'; -import { defineConfig } from 'vite'; +import { type PluginOption, defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; // eslint-disable-next-line @nx/enforce-module-boundaries import { viteTsConfigPaths } from '../../vite-extensions/vite-ts-config-paths'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { getExternalModules } from '../../vite-extensions/vite-external-modules'; + +/** + * @TODO: Consider rsbuild for this: + * import { defineConfig } from "@rsbuild/core"; +import { pluginReact } from "@rsbuild/plugin-react"; + +export default defineConfig({ + plugins: [pluginReact()], + source: { + assetsInclude: /\.dat$/, + }, + output: { + dataUriLimit: 0, + chunkFormat: "commonjs", + target, + }, + module: { + rules: [ + { + test: /\.dat/, + use: [ + { + loader: "url-loader", + }, + ], + type: "asset/inline", + }, + ], + }, +}); + */ const plugins = [ dts({ entryRoot: 'src', @@ -16,6 +49,56 @@ const plugins = [ viteTsConfigPaths({ root: '../../../', }), + /** + * In library mode, Vite bundles all `?url` imports as JS modules with a single, + * base64 export. blueprints.phar is too large for that. We need to preserve it + * as an actual file. + * + * ... more comment tbd ... + * + * @see https://github.com/vitejs/vite/issues/3295 + */ + { + name: 'build-phars-as-URL-modules-not-data-imports', + + transform(code, id) { + if (id?.includes('.phar')) { + // @TODO don't hardcode it + // @TODO use URL on the web and path on Node.js + return { + code: ` + import { fileURLToPath } from 'url'; + import { dirname, join } from 'path'; + + let pharPath; + if (typeof __dirname !== 'undefined') { + // CommonJS + pharPath = join(__dirname, "./blueprints.phar"); + } else { + // ESM + pharPath = join(import.meta.dirname, "./blueprints.phar"); + } + + export default pharPath; + `, + map: null, + }; + } + }, + }, +] as PluginOption[]; + +const external = [ + ...getExternalModules(), + '@php-wasm/node', + '@php-wasm/web', + '@php-wasm/universal', + '@php-wasm/logger', + '@php-wasm/progress', + '@php-wasm/util', + '@wp-playground/wordpress', + '@wp-playground/common', + '@wp-playground/blueprints', ]; export default defineConfig({ @@ -29,17 +112,7 @@ export default defineConfig({ format: 'es', plugins: () => plugins, rollupOptions: { - external: [ - '@php-wasm/universal', - '@php-wasm/node', - '@php-wasm/progress', - '@wp-playground/common', - '@wp-playground/wordpress', - '@php-wasm/logger', - 'net', - 'tls', - 'worker_threads', - ], + external, output: { entryFileNames: (/* chunkInfo: any */) => { return '[name]-[hash].js'; @@ -51,39 +124,12 @@ export default defineConfig({ // Configuration for building your library. // See: https://vitejs.dev/guide/build.html#library-mode build: { + assetsDir: '', assetsInlineLimit: 0, target: 'es2020', sourcemap: true, rollupOptions: { - external: [ - '@php-wasm/node', - '@php-wasm/universal', - '@php-wasm/logger', - '@php-wasm/progress', - '@php-wasm/util', - '@wp-playground/wordpress', - '@wp-playground/common', - '@wp-playground/blueprints', - 'yargs', - 'express', - 'crypto', - 'os', - 'net', - 'fs', - 'fs-extra', - 'path', - 'child_process', - 'http', - 'path', - 'tls', - 'util', - 'dns', - 'ws', - 'readline', - 'worker_threads', - 'url', - 'fs-ext', - ], + external, }, lib: { entry: { diff --git a/packages/vite-extensions/vite-external-modules.ts b/packages/vite-extensions/vite-external-modules.ts index 665a838b7d..d700bf48c6 100644 --- a/packages/vite-extensions/vite-external-modules.ts +++ b/packages/vite-extensions/vite-external-modules.ts @@ -3,6 +3,7 @@ import packageJson from '../../package.json'; const deps = [ ...Object.keys(packageJson.dependencies || {}), ...Object.keys(packageJson.devDependencies || {}), + ...Object.keys(packageJson.optionalDependencies || {}), ]; export const getExternalModules = () => { return [