From 68f0dcbaea62f5639573b0aaab07f7e6740e6002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 14 Jul 2025 16:45:36 +0200 Subject: [PATCH 01/18] Start porting Blueprints v2 --- .../php-wasm/universal/src/lib/php-worker.ts | 11 + packages/playground/cli/src/mounts.ts | 7 +- packages/playground/cli/src/run-cli-v2.ts | 882 ++++++++++++++++++ packages/playground/cli/src/run-cli.ts | 4 +- .../playground/cli/src/worker-thread-v2.ts | 382 ++++++++ packages/playground/common/src/index.ts | 9 +- 6 files changed, 1289 insertions(+), 6 deletions(-) create mode 100644 packages/playground/cli/src/run-cli-v2.ts create mode 100644 packages/playground/cli/src/worker-thread-v2.ts diff --git a/packages/php-wasm/universal/src/lib/php-worker.ts b/packages/php-wasm/universal/src/lib/php-worker.ts index 16e37f5a97..c18b2d25e8 100644 --- a/packages/php-wasm/universal/src/lib/php-worker.ts +++ b/packages/php-wasm/universal/src/lib/php-worker.ts @@ -254,6 +254,17 @@ export class PHPWorker implements LimitedPHPApi, AsyncDisposable { _private.get(this)!.php!.removeEventListener(eventType, listener); } + /** + * @internal + * @deprecated + * Do not use this method directly in the code consuming + * the web API. It will change or even be removed without + * a warning. + */ + protected __internal_getRequestHandler(): PHPRequestHandler { + return _private.get(this)!.requestHandler!; + } + async [Symbol.asyncDispose]() { await _private.get(this)!.requestHandler?.[Symbol.asyncDispose](); } diff --git a/packages/playground/cli/src/mounts.ts b/packages/playground/cli/src/mounts.ts index 18c5969fdb..2edefc6114 100644 --- a/packages/playground/cli/src/mounts.ts +++ b/packages/playground/cli/src/mounts.ts @@ -3,6 +3,7 @@ import type { PHP } from '@php-wasm/universal'; import fs, { existsSync } from 'fs'; import path, { basename, join } from 'path'; import type { RunCLIArgs } from './run-cli'; +import { RunCLIArgsV2 } from './run-cli-v2'; export interface Mount { hostPath: string; @@ -109,7 +110,9 @@ const ACTIVATE_FIRST_THEME_STEP = { /** * Auto-mounts resolution logic: */ -export function expandAutoMounts(args: RunCLIArgs): RunCLIArgs { +export function expandAutoMounts( + args: T +): T { const path = process.cwd(); const mount = [...(args.mount || [])]; @@ -179,7 +182,7 @@ export function expandAutoMounts(args: RunCLIArgs): RunCLIArgs { // newArgs.mode = 'mount-only'; } - return newArgs as RunCLIArgs; + return newArgs as T; } export function containsFullWordPressInstallation(path: string): boolean { diff --git a/packages/playground/cli/src/run-cli-v2.ts b/packages/playground/cli/src/run-cli-v2.ts new file mode 100644 index 0000000000..c53a3e9231 --- /dev/null +++ b/packages/playground/cli/src/run-cli-v2.ts @@ -0,0 +1,882 @@ +/** + * @TODO: + * * Mount a stable system tmp or home/.playground-cli directory to store HTTP Cache. + * Flush stale entries periodically. + * * Find a consistent logging interface. Right now we have a logger for some things and + * output.stdout for other things. In the browser, logger prints information to the + * devtools console which is only needed for debugging. The HTML makes for the UI. + * In CLI, the console and the UI are the same thing. Perhaps we actually need to + * separate what we print for UI reasons from what we print for debugging? + */ + +import { logger } from '@php-wasm/logger'; +import type { + PHPRequest, + RemoteAPI, + StreamedPHPResponse, + SupportedPHPVersion, +} from '@php-wasm/universal'; +import { + PHPResponse, + SupportedPHPVersions, + consumeAPI, + exposeAPI, +} from '@php-wasm/universal'; +import type { BlueprintDeclaration } from '@wp-playground/blueprints'; +import { + RecommendedPHPVersion, + unzipFile, + zipDirectory, +} from '@wp-playground/common'; +import fs, { existsSync } from 'fs'; +import type { Server } from 'http'; +import path from 'path'; +import { Worker } from 'worker_threads'; +import yargs from 'yargs'; +// @ts-ignore +import { expandAutoMounts } from './mounts'; +import { startServer } from './server'; +import { printDebugDetails } from '@php-wasm/universal'; +import type { PlaygroundCliWorker } from './worker-thread-v2'; +// @ts-ignore +import importedWorkerUrlString from './worker-thread-v2?worker&url'; +// @ts-ignore +import { FileLockManagerForNode } from '@php-wasm/node'; +import { LoadBalancer } from './load-balancer'; +import { + parseMountDirArguments, + parseMountWithDelimiterArguments, + type Mount, +} from './mounts'; + +/* eslint-disable no-console */ +import { cpus } from 'os'; +import { jspi } from 'wasm-feature-detect'; +import { + type ParsedBlueprintV2Declaration, + parseBlueprintDeclaration, +} from './v2'; + +export interface RunCLIArgsV2 { + 'additional-blueprint-steps'?: any[]; + blueprint?: string | BlueprintDeclaration; + command: 'server' | 'run-blueprint' | 'build-snapshot'; + debug?: boolean; + login?: boolean; + mount?: Mount[]; + 'mount-before-install'?: Mount[]; + outfile?: string; + php: SupportedPHPVersion; + port?: number; + quiet?: boolean; + wp?: string; + 'auto-mount'?: boolean; + // Blueprint CLI options + mode?: string; + 'db-engine'?: string; + 'db-host'?: string; + 'db-user'?: string; + 'db-pass'?: string; + 'db-name'?: string; + 'db-path'?: string; + 'truncate-new-site-directory'?: boolean; + allow?: string; + 'experimental-multi-worker'?: number; + 'experimental-trace'?: boolean; +} + +export async function parseOptionsAndRunCLI() { + let cliArgs: RunCLIArgsV2 | undefined = undefined; + try { + /** + * @TODO This looks similar to Query API args https://wordpress.github.io/wordpress-playground/developers/apis/query-api/ + * Perhaps the two could be handled by the same code? + */ + const yargsObject = yargs(process.argv.slice(2)) + .usage('Usage: wp-playground [options]') + .positional('command', { + describe: 'Command to run', + choices: ['server', 'run-blueprint', 'build-snapshot'] as const, + demandOption: true, + }) + .option('outfile', { + describe: 'When building, write to this output file.', + type: 'string', + default: 'wordpress.zip', + }) + .option('port', { + describe: 'Port to listen on when serving.', + type: 'number', + default: 9400, + }) + + // Blueprints v2 CLI options + .option('php', { + describe: + 'PHP version to use. If Blueprint is provided, this option overrides the PHP version specified in the Blueprint.', + type: 'string', + choices: SupportedPHPVersions, + }) + + // Modifies the Blueprint: + .option('wp', { + describe: + 'WordPress version to use. If Blueprint is provided, this option overrides the WordPress version specified in the Blueprint.', + type: 'string', + default: 'latest', + hidden: true, + }) + .option('login', { + describe: + 'Should log the user in. If Blueprint is provided, this option overrides the login specified in the Blueprint.', + type: 'boolean', + default: false, + hidden: true, + }) + + // @TODO: Support read-only mounts, e.g. via WORKERFS, a custom + // ReadOnlyNODEFS, or by copying the files into MEMFS + .option('mount', { + describe: + 'Mount a directory to the PHP runtime. You can provide --mount multiple times. Format: /host/path:/vfs/path', + type: 'array', + string: true, + coerce: parseMountWithDelimiterArguments, + }) + .option('mount-before-install', { + describe: + 'Mount a directory to the PHP runtime before installing WordPress. You can provide --mount-before-install multiple times. Format: /host/path:/vfs/path', + type: 'array', + string: true, + coerce: parseMountWithDelimiterArguments, + }) + .option('mount-dir', { + describe: + 'Mount a directory to the PHP runtime. You can provide --mount-dir multiple times. Format: "/host/path" "/vfs/path"', + type: 'array', + nargs: 2, + array: true, + coerce: parseMountDirArguments, + }) + .option('mount-dir-before-install', { + describe: + 'Mount a directory to the PHP runtime before installing WordPress. You can provide --mount-before-install multiple times. Format: "/host/path" "/vfs/path"', + type: 'string', + nargs: 2, + array: true, + coerce: parseMountDirArguments, + }) + .option('blueprint', { + describe: 'Blueprint to execute.', + type: 'string', + }) + .option('quiet', { + describe: 'Do not output logs and progress messages.', + type: 'boolean', + default: false, + }) + .option('debug', { + describe: + 'Print PHP error log content if an error occurs during Playground boot.', + type: 'boolean', + default: false, + }) + .option('auto-mount', { + describe: `Automatically mount the current working directory. You can mount a WordPress directory, a plugin directory, a theme directory, a wp-content directory, or any directory containing PHP and HTML files.`, + type: 'boolean', + default: false, + }) + // Blueprint CLI options + .option('mode', { + describe: 'Execution mode', + type: 'string', + default: 'create-new-site', + choices: [ + 'create-new-site', + 'apply-to-existing-site', + 'mount-only', + ], + }) + .option('db-engine', { + describe: 'Database engine', + type: 'string', + default: 'sqlite', + choices: ['mysql', 'sqlite'], + }) + .option('db-host', { + describe: 'MySQL host', + type: 'string', + }) + .option('db-user', { + describe: 'MySQL user', + type: 'string', + }) + .option('db-pass', { + describe: 'MySQL password', + type: 'string', + }) + .option('db-name', { + describe: 'MySQL database', + type: 'string', + }) + .option('db-path', { + describe: 'SQLite file path', + type: 'string', + }) + .option('truncate-new-site-directory', { + describe: + 'Delete target directory if it exists before execution', + type: 'boolean', + }) + .option('allow', { + describe: 'Allowed permissions (comma-separated)', + type: 'string', + coerce: (value) => value.split(','), + choices: ['bundled-files', 'follow-symlinks'], + }) + .option('follow-symlinks', { + describe: + 'Allow Playground to follow symlinks by automatically mounting symlinked directories and files encountered in mounted directories. \nWarning: Following symlinks will expose files outside mounted directories to Playground and could be a security risk.', + type: 'boolean', + default: false, + }) + .option('experimental-trace', { + describe: + 'Print detailed messages about system behavior to the console. Useful for troubleshooting.', + type: 'boolean', + default: false, + // Hide this option because we want to replace with a more general log-level flag. + hidden: true, + }) + // TODO: Should we make this a hidden flag? + .option('experimental-multi-worker', { + describe: + 'Enable experimental multi-worker support which requires JSPI ' + + 'and a /wordpress directory backed by a real filesystem. ' + + 'Pass a positive number to specify the number of workers to use. ' + + 'Otherwise, default to the number of CPUs minus 1.', + type: 'number', + coerce: (value?: number) => value ?? cpus().length - 1, + }) + .showHelpOnFail(false) + .check(async (args) => { + if (args['experimental-multi-worker'] !== undefined) { + if (args['experimental-multi-worker'] <= 1) { + const message = + 'The --experimentalMultiWorker flag must be a positive integer greater than 1.'; + console.error(message); + throw new Error(message); + } + + if (!(await jspi())) { + const message = + 'JavaScript Promise Integration (JSPI) is not enabled. Please enable JSPI in your JavaScript runtime before using the --experimentalMultiWorker flag. In Node.js, you can use the --experimental-wasm-jspi flag.'; + console.error(message); + throw new Error(message); + } + + const isMountingWordPressDir = (mount: Mount) => + mount.vfsPath === '/wordpress'; + if ( + !args.mount?.some(isMountingWordPressDir) && + !(args['mount-before-install'] as any)?.some( + isMountingWordPressDir + ) + ) { + const message = + 'Please mount a real filesystem directory as the /wordpress directory before using the --experimentalMultiWorker flag.'; + console.error(message); + throw new Error(message); + } + } + return true; + }); + + yargsObject.wrap(yargsObject.terminalWidth()); + const args = await yargsObject.argv; + + const command = args._[0] as string; + + if (!['run-blueprint', 'server', 'build-snapshot'].includes(command)) { + yargsObject.showHelp(); + process.exit(1); + } + + cliArgs = { + ...args, + command, + mount: [...(args.mount || []), ...(args.mountDir || [])], + 'mount-before-install': [ + ...(args['mount-before-install'] || []), + ...(args['mount-dir-before-install'] || []), + ], + } as RunCLIArgsV2; + + return await runCLI(cliArgs); + } catch (e) { + if (cliArgs?.debug) { + await printDebugDetails(e, (e as any)?.streamedResponse); + } + + // If we did not expect this error, print **all** the debug details we can get. + throw e; + } +} + +export interface RunCLIServer extends AsyncDisposable { + playground: RemoteAPI; + server: Server; + [Symbol.asyncDispose](): Promise; +} + +export async function runCLI(args: RunCLIArgsV2): Promise { + let streamedResponse: StreamedPHPResponse | undefined; + let initialWorker: Worker; + + try { + let loadBalancer: LoadBalancer; + let playground: RemoteAPI; + + const playgroundsToCleanUp: { + playground: RemoteAPI; + worker: Worker; + }[] = []; + + /** + * Expand auto-mounts to include the necessary mounts and steps + * when running in auto-mount mode. + */ + if (args['auto-mount']) { + args = expandAutoMounts(args); + } + + const phpVersion = args.php || (await inferPHP(args.blueprint)); + let wordPressReady = false; + let isFirstRequest = true; + + /** + * Spawns a new Worker Thread. + * + * @param workerUrl The absolute URL of the worker script. + * @returns The spawned Worker Thread. + */ + async function spawnPHPWorkerThread( + workerUrl: URL, + onExit: (code: number) => void + ) { + const worker = new Worker(workerUrl); + + return new Promise((resolve, reject) => { + function onMessage(event: string) { + // Let the worker confirm it has initialized. + // We could use the 'online' event to detect start of JS execution, + // but that would miss initialization errors. + if (event === 'worker-script-initialized') { + resolve(worker); + worker.off('message', onMessage); + } + } + function onError(e: Error) { + const error = new Error( + `Worker failed to load at ${workerUrl}. ${ + e.message ? `Original error: ${e.message}` : '' + }` + ); + (error as any).filename = workerUrl; + reject(error); + worker.off('error', onError); + } + worker.on('message', onMessage); + worker.on('error', onError); + worker.on('exit', onExit); + }); + } + + function spawnWorkerThreads(count: number): Promise { + const moduleWorkerUrl = new URL( + importedWorkerUrlString, + import.meta.url + ); + + const promises = []; + for (let i = 0; i < count; i++) { + promises.push( + spawnPHPWorkerThread(moduleWorkerUrl, (code) => { + if (code !== 0) { + process.stderr.write( + `Worker ${i} exited with code ${code}\n` + ); + // If the primary worker crashes, exit the entire process. + if (i === 0) { + process.exit(1); + } + } + }) + ); + } + return Promise.all(promises); + } + + if (args.quiet) { + // @ts-ignore + logger.handlers = []; + } + + // Declare file lock manager outside scope of startServer + // so we can look at it when debugging request handling. + const fileLockManager = new FileLockManagerForNode(); + + logger.log('Starting a PHP server...'); + + return await startServer({ + port: args['port'] as number, + onBind: async ( + server: Server, + port: number + ): Promise => { + const siteUrl = `http://127.0.0.1:${port}`; + + logger.log(`Setting up WordPress ${args.wp}`); + + // Kick off worker threads now to save time later. + // There is no need to wait for other async processes to complete. + const totalWorkerCount = args['experimental-multi-worker'] ?? 1; + const promisedWorkers = spawnWorkerThreads(totalWorkerCount); + + const trace = args['experimental-trace'] === true; + const workers = await promisedWorkers; + initialWorker = workers[0]; + const additionalWorkers = workers.slice(1); + + playground = consumeAPI(initialWorker); + playgroundsToCleanUp.push({ + playground, + worker: initialWorker, + }); + + await playground.isConnected(); + + exposeAPI(fileLockManager, undefined, initialWorker); + + logger.log(`Booting WordPress...`); + + // Each additional worker needs a separate process ID space + // for file locking to work properly because locks are associated + // with individual processes. To accommodate this, we split the safe + // integers into a range for each worker. + const processIdSpaceLength = Math.floor( + Number.MAX_SAFE_INTEGER / totalWorkerCount + ); + + try { + await playground.bootAsPrimaryWorker({ + ...args, + php: phpVersion, + siteUrl, + firstProcessId: 0, + processIdSpaceLength, + trace, + }); + } catch (e) { + await initialWorker.terminate(); + throw e; + } + + if (args.login) { + // @TODO: Do we need this in all the workers? Or just in the primary one? + // Are we sharing constants between workers? + await playground.defineConstant( + 'PLAYGROUND_AUTO_LOGIN_AS_USER', + 'admin' + ); + } + + loadBalancer = new LoadBalancer(playground); + + await playground.isReady(); + wordPressReady = true; + + // Add a newline after the progress bar to avoid the next log message + // from being printed on the same line. + logger.log(''); + logger.log(`Booted!`); + + if (args.command === 'build-snapshot') { + await zipDirectory( + playground, + '/wordpress', + args.outfile as string + ); + logger.log(`WordPress exported to ${args.outfile}`); + process.exit(0); + } else if (args.command === 'run-blueprint') { + logger.log(`Blueprint executed`); + process.exit(0); + } + + if ( + args['experimental-multi-worker'] && + args['experimental-multi-worker'] > 1 + ) { + logger.log(`Preparing additional workers...`); + + // Save /internal directory from initial worker so we can replicate it + // in each additional worker. + const internalZip = await zipDirectory( + playground, + '/internal' + )!; + + // Boot additional workers + const initialWorkerProcessIdSpace = processIdSpaceLength; + await Promise.all( + additionalWorkers.map(async (worker, index) => { + const additionalPlayground = + consumeAPI(worker); + playgroundsToCleanUp.push({ + playground: additionalPlayground, + worker, + }); + + await additionalPlayground.isConnected(); + exposeAPI(fileLockManager, undefined, worker); + + const firstProcessId = + initialWorkerProcessIdSpace + + index * processIdSpaceLength; + + await additionalPlayground.bootAsSecondaryWorker({ + ...args, + php: phpVersion, + siteUrl, + firstProcessId, + processIdSpaceLength, + trace, + }); + await additionalPlayground.isReady(); + + // Replicate the Blueprint-initialized /internal directory + await additionalPlayground.writeFile( + '/tmp/internal.zip', + internalZip! + ); + await unzipFile( + additionalPlayground, + '/tmp/internal.zip', + '/internal' + ); + await additionalPlayground.unlink( + '/tmp/internal.zip' + ); + + loadBalancer.addWorker(additionalPlayground); + }) + ); + + logger.log(`Ready!`); + } + + logger.log(`WordPress is running on ${siteUrl}`); + + return { + playground, + server, + [Symbol.asyncDispose]: async function disposeCLI() { + await Promise.all( + playgroundsToCleanUp.map( + async ({ playground, worker }) => { + await playground.dispose(); + await worker.terminate(); + } + ) + ); + await new Promise((resolve) => server.close(resolve)); + }, + }; + }, + async handleRequest(request: PHPRequest) { + if (!wordPressReady) { + return PHPResponse.forHttpCode( + 502, + 'WordPress is not ready yet' + ); + } + + // Clear the playground_auto_login_already_happened cookie on the first request. + // Otherwise the first Playground CLI server started on the machine will set it, + // all the subsequent runs will get the stale cookie, and the auto-login will + // assume they don't have to auto-login again. + if (isFirstRequest) { + isFirstRequest = false; + if ( + request.headers?.['cookie']?.includes( + 'playground_auto_login_already_happened' + ) + ) { + return new PHPResponse( + 302, + { + 'Set-Cookie': [ + 'playground_auto_login_already_happened=1; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/', + ], + 'Content-Type': ['text/plain'], + 'Content-Length': ['0'], + Location: ['/'], + }, + new Uint8Array() + ); + } + } + + return await loadBalancer.handleRequest(request); + }, + }); + } catch (e) { + if (e) { + (e as any).streamedResponse = streamedResponse; + } + // If we did not expect this error, print **all** the debug details we can get. + throw e; + } +} + +/** + * Infer the PHP version from the Blueprint declaration when the user + * didn't explicitly provide --php. This needs to happen before we boot + * the request handler so that we download / load the correct runtime. + * + * Ideally, we wouldn't need to reason about the Blueprint structure inside + * TypeScript at all. We already have great PHP libraries for handling all that. + * Unfortunately, we did not boot PHP yet. Even worse, we don't know which PHP + * version we need to load yet. + * + * The code below duplicates the data resolution and Blueprint parsing logic + * from the PHP Blueprint runner. We don't need it that much in CLI, since we + * could just load any PHP version to parse the Blueprint and then load the + * correct runtime. However, we will need it in the browser where downloading + * PHP runtimes is expensive. We might as well implement it once and reuse it + * in both places. + * + * ## Limitations + * + * * It can only handle JSON blueprints. Bundles (ZIP, git, etc.) are unsupported. + * The user must provide an explicit `--php=` version when using a bundle. + * + * @param blueprint The Blueprint declaration. + * @returns The PHP version to use. + */ +async function inferPHP(blueprint: string | BlueprintDeclaration | undefined) { + try { + if (!blueprint) { + return RecommendedPHPVersion; + } + /** + * Infer the PHP version from the Blueprint declaration when the user + * didn't explicitly provide --php. This needs to happen before we boot + * the request handler so that we download / load the correct runtime. + */ + const blueprintObject = await resolveBlueprintObject( + parseBlueprintDeclaration(blueprint) + ); + if (!blueprintObject || typeof blueprintObject !== 'object') { + throw new Error('Blueprint is not a valid object'); + } + + let requestedPhp: any | string | undefined = undefined; + /** + * We must, unfortunately, account for all possible versions of the Blueprint + * schema in here. The transpilation to the latest version only happens in the + * PHP code. + */ + requestedPhp = + blueprintObject.phpVersion ?? + blueprintObject.preferredVersions?.php ?? + RecommendedPHPVersion; + + if ( + blueprintObject.phpVersion && + typeof blueprintObject.phpVersion === 'object' + ) { + return ( + blueprintObject.phpVersion.recommended || + blueprintObject.phpVersion.max || + blueprintObject.phpVersion.min + ); + } else if (typeof requestedPhp === 'string') { + return requestedPhp as SupportedPHPVersion; + } else { + throw new Error('phpVersion is not a valid object or string'); + } + } catch (e) { + if (e instanceof NonJsonBlueprintError) { + process.stderr.write( + `Could not determine the PHP version from the Blueprint. ` + + `This usually happens if your Blueprint is not a plain JSON file ` + + `(for example, if it's a ZIP, git repo, or another bundle format). ` + + `Automatic PHP version detection only works for JSON blueprints. ` + + `To continue, please specify the PHP version explicitly using the --php option (e.g. --php=8.2).` + ); + throw e; + } else if (e instanceof BlueprintReferenceError) { + process.stderr.write( + `Failed to load Blueprint: ${e.message}. ` + + `Please check that the Blueprint path or URL is correct.` + ); + throw e; + } else if (e instanceof BlueprintParseError) { + process.stderr.write( + `Blueprint contains invalid JSON: ${e.parseError}. ` + + `Please check the Blueprint syntax and try again.` + ); + throw e; + } + + // Generic inference failure + throw new Error( + `Failed to infer PHP version from Blueprint: ${ + e instanceof Error ? e.message : 'Unknown error' + }. ` + + `Please specify the PHP version explicitly using the --php option.` + ); + } +} + +async function resolveBlueprintObject( + declaration: ParsedBlueprintV2Declaration +): Promise { + if (declaration.type === 'inline-file') { + try { + return JSON.parse(declaration.contents); + } catch (e) { + throw new BlueprintParseError( + `Failed to parse inline Blueprint JSON`, + e instanceof Error ? e.message : 'Unknown JSON parse error' + ); + } + } + if (declaration.type === 'file-reference') { + const filePath = declaration.reference; + const isUrl = + filePath.startsWith('http://') || filePath.startsWith('https://'); + let contents: string; + + try { + if (isUrl) { + // @TODO: Respect HTTP cache in CLI. + const response = await fetch(filePath); + if (!response.ok) { + throw new BlueprintReferenceError( + `Failed to fetch Blueprint from URL (HTTP ${response.status})`, + filePath, + response.status + ); + } + contents = await response.text(); + } else { + const resolvedPath = filePath.startsWith('/') + ? filePath + : path.resolve(process.cwd(), filePath); + + if (!existsSync(resolvedPath)) { + throw new BlueprintReferenceError( + `Blueprint file not found`, + resolvedPath + ); + } + + try { + contents = fs.readFileSync(resolvedPath, 'utf8'); + } catch (e) { + if ((e as any).code === 'ENOENT') { + throw new BlueprintReferenceError( + `Blueprint file not found`, + resolvedPath + ); + } + throw new BlueprintReferenceError( + `Failed to read Blueprint file: ${ + (e as any).message || 'Unknown error' + }`, + resolvedPath + ); + } + } + } catch (e) { + // Re-throw our custom errors + if (e instanceof BlueprintReferenceError) { + throw e; + } + // Handle other network/fetch errors + throw new BlueprintReferenceError( + `Failed to load Blueprint: ${ + e instanceof Error ? e.message : 'Unknown error' + }`, + filePath + ); + } + + try { + return JSON.parse(contents); + } catch (e) { + // Check if this looks like a non-JSON file (ZIP, binary, etc.) + if ( + contents.startsWith('PK') || + contents.includes('\x00') || + !contents.trim().startsWith('{') + ) { + const detectedType = contents.startsWith('PK') + ? 'ZIP archive' + : contents.includes('\x00') + ? 'binary file' + : 'non-JSON text file'; + throw new NonJsonBlueprintError( + `Blueprint appears to be a ${detectedType}, not a JSON file`, + detectedType + ); + } + throw new BlueprintParseError( + `Failed to parse Blueprint JSON from ${isUrl ? 'URL' : 'file'}`, + e instanceof Error ? e.message : 'Unknown JSON parse error' + ); + } + } + throw new NonJsonBlueprintError( + `Unknown blueprint declaration type`, + 'unknown' + ); +} + +/** + * Custom error classes for blueprint resolution failures + */ +class NonJsonBlueprintError extends Error { + public readonly blueprintType: string; + + constructor(message: string, blueprintType: string) { + super(message); + this.name = 'NonJsonBlueprintError'; + this.blueprintType = blueprintType; + } +} + +class BlueprintReferenceError extends Error { + public readonly reference: string; + public readonly statusCode?: number; + + constructor(message: string, reference: string, statusCode?: number) { + super(message); + this.name = 'BlueprintReferenceError'; + this.reference = reference; + this.statusCode = statusCode; + } +} + +class BlueprintParseError extends Error { + public readonly parseError: string; + + constructor(message: string, parseError: string) { + super(message); + this.name = 'BlueprintParseError'; + this.parseError = parseError; + } +} diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index a10d7af5e9..8e34818110 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -665,7 +665,7 @@ export async function runCLI(args: RunCLIArgs): Promise { ); fs.writeFileSync( preinstalledWpContentPath, - await zipDirectory(playground, '/wordpress') + (await zipDirectory(playground, '/wordpress'))! ); logger.log(`Cached!`); } @@ -750,7 +750,7 @@ export async function runCLI(args: RunCLIArgs): Promise { // Replicate the Blueprint-initialized /internal directory await additionalPlayground.writeFile( '/tmp/internal.zip', - internalZip + internalZip! ); await unzipFile( additionalPlayground, diff --git a/packages/playground/cli/src/worker-thread-v2.ts b/packages/playground/cli/src/worker-thread-v2.ts new file mode 100644 index 0000000000..99cd80d69a --- /dev/null +++ b/packages/playground/cli/src/worker-thread-v2.ts @@ -0,0 +1,382 @@ +import { errorLogPath } from '@php-wasm/logger'; +import type { FileLockManager } from '@php-wasm/node'; +import { createNodeFsMountHandler, loadNodeRuntime } from '@php-wasm/node'; +import { EmscriptenDownloadMonitor } from '@php-wasm/progress'; +import type { PHP, SupportedPHPVersion } from '@php-wasm/universal'; +import { + PHPExecutionFailureError, + PHPResponse, + PHPWorker, + consumeAPI, + exposeAPI, + sandboxedSpawnHandlerFactory, +} from '@php-wasm/universal'; +import { sprintf } from '@php-wasm/util'; +import { runBlueprintV2, type BlueprintMessage } from './v2'; +import { bootRequestHandler } from '@wp-playground/wordpress'; +import { existsSync } from 'fs'; +import path from 'path'; +import { rootCertificates } from 'tls'; +import { parentPort } from 'worker_threads'; +import type { Mount } from './mounts'; +import type { RunCLIArgsV2 } from './run-cli-v2'; + +async function mountResources(php: PHP, mounts: Mount[]) { + for (const mount of mounts) { + try { + php.mkdir(mount.vfsPath); + await php.mount( + mount.vfsPath, + createNodeFsMountHandler(mount.hostPath) + ); + } catch { + output.stderr( + `\x1b[31m\x1b[1mError mounting path ${mount.hostPath} at ${mount.vfsPath}\x1b[0m\n` + ); + process.exit(1); + } + } +} + +/** + * Print trace messages from PHP-WASM. + * + * @param {number} processId - The process ID. + * @param {string} format - The format string. + * @param {...any} args - The arguments. + */ +function tracePhpWasm(processId: number, format: string, ...args: any[]) { + // eslint-disable-next-line no-console + console.log( + performance.now().toFixed(6).padStart(15, '0'), + processId.toString().padStart(16, '0'), + sprintf(format, ...args) + ); +} + +/** + * Force TTY status to preserve ANSI control codes in the output. + * + * This script is spawned as `new Worker()` and process.stdout and process.stderr are + * WritableWorkerStdio objects. By default, they strip ANSI control codes from the output + * causing every progress bar update to be printed in a new line instead of updating the + * same line. + */ +Object.defineProperty(process.stdout, 'isTTY', { value: true }); +Object.defineProperty(process.stderr, 'isTTY', { value: true }); + +/** + * Output writer that ensures that progress bars are not printed on the same line as other output. + */ +const output = { + lastWriteWasProgress: false, + progress(data: string) { + if (!process.stdout.isTTY) { + // eslint-disable-next-line no-console + console.log(data); + } else { + if (!output.lastWriteWasProgress) { + process.stdout.write('\n'); + } + process.stdout.write('\r\x1b[K' + data); + output.lastWriteWasProgress = true; + } + }, + stdout(data: string) { + if (output.lastWriteWasProgress) { + process.stdout.write('\n'); + output.lastWriteWasProgress = false; + } + process.stdout.write(data); + }, + stderr(data: string) { + if (output.lastWriteWasProgress) { + process.stdout.write('\n'); + output.lastWriteWasProgress = false; + } + process.stderr.write(data); + }, +}; + +export interface WorkerBootArgs extends RunCLIArgsV2 { + siteUrl: string; + firstProcessId: number; + processIdSpaceLength: number; + trace: boolean; +} + +interface WorkerRunBlueprintArgs extends RunCLIArgsV2 { + siteUrl: string; +} + +interface WorkerBootRequestHandlerOptions { + siteUrl: string; + php: SupportedPHPVersion; + allow?: string; + firstProcessId: number; + processIdSpaceLength: number; + trace: boolean; +} + +export class PlaygroundCliWorker extends PHPWorker { + booted = false; + + constructor(monitor: EmscriptenDownloadMonitor) { + super(undefined, monitor); + } + + async bootAsPrimaryWorker(args: WorkerBootArgs) { + await this.bootRequestHandler(args); + + const primaryPhp = this.__internal_getPHP()!; + await mountResources(primaryPhp, args['mount-before-install'] || []); + + if (args.mode === 'mount-only') { + await mountResources(primaryPhp, args.mount || []); + return; + } + + await this.runBlueprintV2(args); + } + + async bootAsSecondaryWorker(args: WorkerBootArgs) { + await this.bootRequestHandler(args); + const primaryPhp = this.__internal_getPHP()!; + // When secondary workers are spawned, WordPress is already installed. + await mountResources(primaryPhp, args['mount-before-install'] || []); + await mountResources(primaryPhp, args.mount || []); + } + + async runBlueprintV2(args: WorkerRunBlueprintArgs) { + const requestHandler = this.__internal_getRequestHandler()!; + const { php, reap } = + await requestHandler.processManager.acquirePHPInstance({ + considerPrimary: false, + }); + + // Mount the current working directory to the PHP runtime for the purposes of + // Blueprint resolution. + const primaryPhp = this.__internal_getPHP()!; + let unmountCwd = () => {}; + if (typeof args.blueprint === 'string') { + const blueprintPath = path.resolve(process.cwd(), args.blueprint); + if (existsSync(blueprintPath)) { + primaryPhp.mkdir('/internal/shared/cwd'); + unmountCwd = await primaryPhp.mount( + '/internal/shared/cwd', + createNodeFsMountHandler(path.dirname(blueprintPath)) + ); + args.blueprint = path.join( + '/internal/shared/cwd', + path.basename(args.blueprint) + ); + } + } + + try { + const cliArgsToPass: (keyof WorkerRunBlueprintArgs)[] = [ + 'mode', + 'db-engine', + 'db-host', + 'db-user', + 'db-pass', + 'db-name', + 'db-path', + 'truncate-new-site-directory', + 'allow', + ]; + const cliArgs = cliArgsToPass + .filter((arg) => arg in args) + .map((arg) => `--${arg}=${args[arg]}`); + cliArgs.push(`--site-url=${args.siteUrl}`); + + let afterBlueprintTargetResolvedCalled = false; + + const streamedResponse = await runBlueprintV2({ + php, + blueprint: args.blueprint, + blueprintOverrides: { + additionalSteps: args['additional-blueprint-steps'], + wordpressVersion: args.wp, + }, + cliArgs, + onMessage: async (message: BlueprintMessage) => { + switch (message.type) { + case 'blueprint.target_resolved': { + if (!afterBlueprintTargetResolvedCalled) { + await mountResources( + primaryPhp, + args.mount || [] + ); + afterBlueprintTargetResolvedCalled = true; + } + break; + } + case 'blueprint.progress': { + const progressMessage = `${message.caption.trim()} – ${message.progress.toFixed( + 2 + )}%`; + output.progress(progressMessage); + break; + } + case 'blueprint.error': { + const red = '\x1b[31m'; + const bold = '\x1b[1m'; + const reset = '\x1b[0m'; + if (args.debug && message.details) { + output.stderr( + `${red}${bold}Fatal error:${reset} Uncaught ${message.details.exception}: ${message.details.message}\n` + + ` at ${message.details.file}:${message.details.line}\n` + + (message.details.trace + ? message.details.trace + '\n' + : '') + ); + } else { + output.stderr( + `${red}${bold}Error:${reset} ${message.message}\n` + ); + } + break; + } + } + }, + }); + /** + * When we're debugging, every bit of information matters – let's immediately output + * everything we get from the PHP output streams. + */ + if (args.debug) { + streamedResponse!.stdout.pipeTo( + new WritableStream({ + write(chunk) { + process.stdout.write(chunk); + }, + }) + ); + streamedResponse!.stderr.pipeTo( + new WritableStream({ + write(chunk) { + process.stderr.write(chunk); + }, + }) + ); + } + await streamedResponse!.finished; + if ((await streamedResponse!.exitCode) !== 0) { + // exitCode != 1 means the blueprint execution failed. Let's throw an error. + // and clean up. + const syncResponse = await PHPResponse.fromStreamedResponse( + streamedResponse + ); + throw new PHPExecutionFailureError( + `PHP.run() failed with exit code ${syncResponse.exitCode}.`, + syncResponse, + 'request' + ); + } + } catch (error) { + // Capture the PHP error log details to provide more context for debugging. + let phpLogs = ''; + try { + // @TODO: Don't assume errorLogPath starts with /wordpress/ + // ...or maybe we can assume that in Playground CLI? + phpLogs = php.readFileAsText(errorLogPath); + } catch { + // Ignore errors reading the PHP error log. + } + (error as any).phpLogs = phpLogs; + throw error; + } finally { + reap(); + unmountCwd(); + } + } + + async bootRequestHandler({ + siteUrl, + allow, + php, + firstProcessId, + processIdSpaceLength, + trace, + }: WorkerBootRequestHandlerOptions) { + if (this.booted) { + throw new Error('Playground already booted'); + } + this.booted = true; + + let nextProcessId = firstProcessId; + const lastProcessId = firstProcessId + processIdSpaceLength - 1; + const fileLockManager = consumeAPI(parentPort!); + await fileLockManager.isConnected(); + + try { + const constants: Record = + { + WP_DEBUG: true, + WP_DEBUG_LOG: true, + WP_DEBUG_DISPLAY: false, + }; + + const requestHandler = await bootRequestHandler({ + siteUrl, + createPhpRuntime: async () => { + const processId = nextProcessId; + + if (nextProcessId < lastProcessId) { + nextProcessId++; + } else { + // We've reached the end of the process ID space. Start over. + nextProcessId = firstProcessId; + } + + return await loadNodeRuntime(php, { + emscriptenOptions: { + fileLockManager, + processId, + trace: trace ? tracePhpWasm : undefined, + ENV: { + DOCROOT: '/wordpress', + }, + }, + followSymlinks: allow?.includes('follow-symlinks'), + }); + }, + sapiName: 'cli', + createFiles: { + '/internal/shared/ca-bundle.crt': + rootCertificates.join('\n'), + }, + constants, + phpIniEntries: { + 'openssl.cafile': '/internal/shared/ca-bundle.crt', + }, + cookieStore: false, + spawnHandler: sandboxedSpawnHandlerFactory, + }); + this.__internal_setRequestHandler(requestHandler); + + const primaryPhp = await requestHandler.getPrimaryPhp(); + await this.setPrimaryPHP(primaryPhp); + + setApiReady(); + } catch (e) { + setAPIError(e as Error); + throw e; + } + } + + // Provide a named disposal method that can be invoked via comlink. + async dispose() { + await this[Symbol.asyncDispose](); + } +} + +const [setApiReady, setAPIError] = exposeAPI( + new PlaygroundCliWorker(new EmscriptenDownloadMonitor()), + undefined, + parentPort! +); + +// Confirm that the worker script has initialized. +parentPort!.postMessage('worker-script-initialized'); diff --git a/packages/playground/common/src/index.ts b/packages/playground/common/src/index.ts index 48a051181a..6c5407845f 100644 --- a/packages/playground/common/src/index.ts +++ b/packages/playground/common/src/index.ts @@ -75,9 +75,10 @@ export const unzipFile = async ( export const zipDirectory = async ( php: UniversalPHP, - directoryPath: string + directoryPath: string, + zipPath?: string ) => { - const outputPath = `/tmp/file${Math.random()}.zip`; + const outputPath = zipPath || `/tmp/file${Math.random()}.zip`; const js = phpVars({ directoryPath, outputPath, @@ -107,6 +108,10 @@ export const zipDirectory = async ( `, }); + if (zipPath) { + return undefined; + } + const fileBuffer = await php.readFileAsBuffer(outputPath); php.unlink(outputPath); return fileBuffer; From cd98846942e5e0067e5058d2b0a73d1a532c7c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 15 Jul 2025 18:32:17 +0200 Subject: [PATCH 02/18] Extract Blueprint v1-specific parts to a separate handler --- packages/playground/cli/src/load-balancer.ts | 17 +- packages/playground/cli/src/mounts.spec.ts | 18 +- packages/playground/cli/src/mounts.ts | 7 +- packages/playground/cli/src/run-cli-v2.ts | 882 ---------- packages/playground/cli/src/run-cli.ts | 1504 ++++++++++------- packages/playground/cli/src/server.ts | 19 +- .../playground/cli/src/test/cli-run.spec.ts | 6 +- packages/playground/cli/src/v2.spec.ts | 171 ++ packages/playground/cli/src/v2.ts | 300 ++++ .../{worker-thread.ts => worker-thread-v1.ts} | 12 +- .../playground/cli/src/worker-thread-v2.ts | 382 ----- 11 files changed, 1404 insertions(+), 1914 deletions(-) delete mode 100644 packages/playground/cli/src/run-cli-v2.ts create mode 100644 packages/playground/cli/src/v2.spec.ts create mode 100644 packages/playground/cli/src/v2.ts rename packages/playground/cli/src/{worker-thread.ts => worker-thread-v1.ts} (93%) delete mode 100644 packages/playground/cli/src/worker-thread-v2.ts diff --git a/packages/playground/cli/src/load-balancer.ts b/packages/playground/cli/src/load-balancer.ts index ebcfc00319..7529b77223 100644 --- a/packages/playground/cli/src/load-balancer.ts +++ b/packages/playground/cli/src/load-balancer.ts @@ -1,5 +1,6 @@ import type { PHPRequest, PHPResponse, RemoteAPI } from '@php-wasm/universal'; -import type { PlaygroundCliWorker } from './worker-thread'; +import type { PlaygroundCliBlueprintV1Worker } from './worker-thread-v1'; +import type { PlaygroundCliBlueprintV2Worker } from './worker-thread-v2'; // TODO: Let's merge worker management into PHPProcessManager // when we can have multiple workers in both CLI and web. @@ -7,7 +8,9 @@ import type { PlaygroundCliWorker } from './worker-thread'; // TODO: Could we just spawn a worker using the factory function to PHPProcessManager? type WorkerLoad = { - worker: RemoteAPI; + worker: RemoteAPI< + PlaygroundCliBlueprintV1Worker | PlaygroundCliBlueprintV2Worker + >; activeRequests: Set>; }; export class LoadBalancer { @@ -19,12 +22,18 @@ export class LoadBalancer { // Playground CLI initialization, as of 2025-06-11, requires that // an initial worker is booted alone and initialized via Blueprint // before additional workers are created based on the initialized worker. - initialWorker: RemoteAPI + initialWorker: RemoteAPI< + PlaygroundCliBlueprintV1Worker | PlaygroundCliBlueprintV2Worker + > ) { this.addWorker(initialWorker); } - addWorker(worker: RemoteAPI) { + addWorker( + worker: RemoteAPI< + PlaygroundCliBlueprintV1Worker | PlaygroundCliBlueprintV2Worker + > + ) { this.workerLoads.push({ worker, activeRequests: new Set(), diff --git a/packages/playground/cli/src/mounts.spec.ts b/packages/playground/cli/src/mounts.spec.ts index 29adceb12e..5a25708f39 100644 --- a/packages/playground/cli/src/mounts.spec.ts +++ b/packages/playground/cli/src/mounts.spec.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import type { MockInstance } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { expandAutoMounts } from './mounts'; -import type { RunCLIArgs } from './run-cli'; +import type { RunCLIV1Args } from './run-cli-v1'; describe('expandAutoMounts', () => { afterEach(() => { @@ -11,7 +11,7 @@ describe('expandAutoMounts', () => { } }); - const createBasicArgs = (): RunCLIArgs => ({ + const createBasicArgs = (): RunCLIV1Args => ({ command: 'server', php: '8.0', }); @@ -296,7 +296,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIArgs = { + const args: RunCLIV1Args = { ...createBasicArgs(), mount: [ { @@ -327,7 +327,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/wordpress') ); - const args: RunCLIArgs = { + const args: RunCLIV1Args = { ...createBasicArgs(), 'mount-before-install': [ { @@ -357,7 +357,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIArgs = { + const args: RunCLIV1Args = { ...createBasicArgs(), 'additional-blueprint-steps': [ { @@ -387,7 +387,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIArgs = { + const args: RunCLIV1Args = { ...createBasicArgs(), mount: undefined, 'mount-before-install': undefined, @@ -411,7 +411,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIArgs = { + const args: RunCLIV1Args = { ...createBasicArgs(), blueprint: undefined, }; @@ -430,7 +430,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIArgs = { + const args: RunCLIV1Args = { ...createBasicArgs(), blueprint: { plugins: ['gutenberg'] }, }; @@ -445,7 +445,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIArgs = { + const args: RunCLIV1Args = { ...createBasicArgs(), php: '8.1', port: 3000, diff --git a/packages/playground/cli/src/mounts.ts b/packages/playground/cli/src/mounts.ts index 2edefc6114..8095772579 100644 --- a/packages/playground/cli/src/mounts.ts +++ b/packages/playground/cli/src/mounts.ts @@ -3,7 +3,6 @@ import type { PHP } from '@php-wasm/universal'; import fs, { existsSync } from 'fs'; import path, { basename, join } from 'path'; import type { RunCLIArgs } from './run-cli'; -import { RunCLIArgsV2 } from './run-cli-v2'; export interface Mount { hostPath: string; @@ -110,9 +109,7 @@ const ACTIVATE_FIRST_THEME_STEP = { /** * Auto-mounts resolution logic: */ -export function expandAutoMounts( - args: T -): T { +export function expandAutoMounts(args: RunCLIArgs): RunCLIArgs { const path = process.cwd(); const mount = [...(args.mount || [])]; @@ -182,7 +179,7 @@ export function expandAutoMounts( // newArgs.mode = 'mount-only'; } - return newArgs as T; + return newArgs; } export function containsFullWordPressInstallation(path: string): boolean { diff --git a/packages/playground/cli/src/run-cli-v2.ts b/packages/playground/cli/src/run-cli-v2.ts deleted file mode 100644 index c53a3e9231..0000000000 --- a/packages/playground/cli/src/run-cli-v2.ts +++ /dev/null @@ -1,882 +0,0 @@ -/** - * @TODO: - * * Mount a stable system tmp or home/.playground-cli directory to store HTTP Cache. - * Flush stale entries periodically. - * * Find a consistent logging interface. Right now we have a logger for some things and - * output.stdout for other things. In the browser, logger prints information to the - * devtools console which is only needed for debugging. The HTML makes for the UI. - * In CLI, the console and the UI are the same thing. Perhaps we actually need to - * separate what we print for UI reasons from what we print for debugging? - */ - -import { logger } from '@php-wasm/logger'; -import type { - PHPRequest, - RemoteAPI, - StreamedPHPResponse, - SupportedPHPVersion, -} from '@php-wasm/universal'; -import { - PHPResponse, - SupportedPHPVersions, - consumeAPI, - exposeAPI, -} from '@php-wasm/universal'; -import type { BlueprintDeclaration } from '@wp-playground/blueprints'; -import { - RecommendedPHPVersion, - unzipFile, - zipDirectory, -} from '@wp-playground/common'; -import fs, { existsSync } from 'fs'; -import type { Server } from 'http'; -import path from 'path'; -import { Worker } from 'worker_threads'; -import yargs from 'yargs'; -// @ts-ignore -import { expandAutoMounts } from './mounts'; -import { startServer } from './server'; -import { printDebugDetails } from '@php-wasm/universal'; -import type { PlaygroundCliWorker } from './worker-thread-v2'; -// @ts-ignore -import importedWorkerUrlString from './worker-thread-v2?worker&url'; -// @ts-ignore -import { FileLockManagerForNode } from '@php-wasm/node'; -import { LoadBalancer } from './load-balancer'; -import { - parseMountDirArguments, - parseMountWithDelimiterArguments, - type Mount, -} from './mounts'; - -/* eslint-disable no-console */ -import { cpus } from 'os'; -import { jspi } from 'wasm-feature-detect'; -import { - type ParsedBlueprintV2Declaration, - parseBlueprintDeclaration, -} from './v2'; - -export interface RunCLIArgsV2 { - 'additional-blueprint-steps'?: any[]; - blueprint?: string | BlueprintDeclaration; - command: 'server' | 'run-blueprint' | 'build-snapshot'; - debug?: boolean; - login?: boolean; - mount?: Mount[]; - 'mount-before-install'?: Mount[]; - outfile?: string; - php: SupportedPHPVersion; - port?: number; - quiet?: boolean; - wp?: string; - 'auto-mount'?: boolean; - // Blueprint CLI options - mode?: string; - 'db-engine'?: string; - 'db-host'?: string; - 'db-user'?: string; - 'db-pass'?: string; - 'db-name'?: string; - 'db-path'?: string; - 'truncate-new-site-directory'?: boolean; - allow?: string; - 'experimental-multi-worker'?: number; - 'experimental-trace'?: boolean; -} - -export async function parseOptionsAndRunCLI() { - let cliArgs: RunCLIArgsV2 | undefined = undefined; - try { - /** - * @TODO This looks similar to Query API args https://wordpress.github.io/wordpress-playground/developers/apis/query-api/ - * Perhaps the two could be handled by the same code? - */ - const yargsObject = yargs(process.argv.slice(2)) - .usage('Usage: wp-playground [options]') - .positional('command', { - describe: 'Command to run', - choices: ['server', 'run-blueprint', 'build-snapshot'] as const, - demandOption: true, - }) - .option('outfile', { - describe: 'When building, write to this output file.', - type: 'string', - default: 'wordpress.zip', - }) - .option('port', { - describe: 'Port to listen on when serving.', - type: 'number', - default: 9400, - }) - - // Blueprints v2 CLI options - .option('php', { - describe: - 'PHP version to use. If Blueprint is provided, this option overrides the PHP version specified in the Blueprint.', - type: 'string', - choices: SupportedPHPVersions, - }) - - // Modifies the Blueprint: - .option('wp', { - describe: - 'WordPress version to use. If Blueprint is provided, this option overrides the WordPress version specified in the Blueprint.', - type: 'string', - default: 'latest', - hidden: true, - }) - .option('login', { - describe: - 'Should log the user in. If Blueprint is provided, this option overrides the login specified in the Blueprint.', - type: 'boolean', - default: false, - hidden: true, - }) - - // @TODO: Support read-only mounts, e.g. via WORKERFS, a custom - // ReadOnlyNODEFS, or by copying the files into MEMFS - .option('mount', { - describe: - 'Mount a directory to the PHP runtime. You can provide --mount multiple times. Format: /host/path:/vfs/path', - type: 'array', - string: true, - coerce: parseMountWithDelimiterArguments, - }) - .option('mount-before-install', { - describe: - 'Mount a directory to the PHP runtime before installing WordPress. You can provide --mount-before-install multiple times. Format: /host/path:/vfs/path', - type: 'array', - string: true, - coerce: parseMountWithDelimiterArguments, - }) - .option('mount-dir', { - describe: - 'Mount a directory to the PHP runtime. You can provide --mount-dir multiple times. Format: "/host/path" "/vfs/path"', - type: 'array', - nargs: 2, - array: true, - coerce: parseMountDirArguments, - }) - .option('mount-dir-before-install', { - describe: - 'Mount a directory to the PHP runtime before installing WordPress. You can provide --mount-before-install multiple times. Format: "/host/path" "/vfs/path"', - type: 'string', - nargs: 2, - array: true, - coerce: parseMountDirArguments, - }) - .option('blueprint', { - describe: 'Blueprint to execute.', - type: 'string', - }) - .option('quiet', { - describe: 'Do not output logs and progress messages.', - type: 'boolean', - default: false, - }) - .option('debug', { - describe: - 'Print PHP error log content if an error occurs during Playground boot.', - type: 'boolean', - default: false, - }) - .option('auto-mount', { - describe: `Automatically mount the current working directory. You can mount a WordPress directory, a plugin directory, a theme directory, a wp-content directory, or any directory containing PHP and HTML files.`, - type: 'boolean', - default: false, - }) - // Blueprint CLI options - .option('mode', { - describe: 'Execution mode', - type: 'string', - default: 'create-new-site', - choices: [ - 'create-new-site', - 'apply-to-existing-site', - 'mount-only', - ], - }) - .option('db-engine', { - describe: 'Database engine', - type: 'string', - default: 'sqlite', - choices: ['mysql', 'sqlite'], - }) - .option('db-host', { - describe: 'MySQL host', - type: 'string', - }) - .option('db-user', { - describe: 'MySQL user', - type: 'string', - }) - .option('db-pass', { - describe: 'MySQL password', - type: 'string', - }) - .option('db-name', { - describe: 'MySQL database', - type: 'string', - }) - .option('db-path', { - describe: 'SQLite file path', - type: 'string', - }) - .option('truncate-new-site-directory', { - describe: - 'Delete target directory if it exists before execution', - type: 'boolean', - }) - .option('allow', { - describe: 'Allowed permissions (comma-separated)', - type: 'string', - coerce: (value) => value.split(','), - choices: ['bundled-files', 'follow-symlinks'], - }) - .option('follow-symlinks', { - describe: - 'Allow Playground to follow symlinks by automatically mounting symlinked directories and files encountered in mounted directories. \nWarning: Following symlinks will expose files outside mounted directories to Playground and could be a security risk.', - type: 'boolean', - default: false, - }) - .option('experimental-trace', { - describe: - 'Print detailed messages about system behavior to the console. Useful for troubleshooting.', - type: 'boolean', - default: false, - // Hide this option because we want to replace with a more general log-level flag. - hidden: true, - }) - // TODO: Should we make this a hidden flag? - .option('experimental-multi-worker', { - describe: - 'Enable experimental multi-worker support which requires JSPI ' + - 'and a /wordpress directory backed by a real filesystem. ' + - 'Pass a positive number to specify the number of workers to use. ' + - 'Otherwise, default to the number of CPUs minus 1.', - type: 'number', - coerce: (value?: number) => value ?? cpus().length - 1, - }) - .showHelpOnFail(false) - .check(async (args) => { - if (args['experimental-multi-worker'] !== undefined) { - if (args['experimental-multi-worker'] <= 1) { - const message = - 'The --experimentalMultiWorker flag must be a positive integer greater than 1.'; - console.error(message); - throw new Error(message); - } - - if (!(await jspi())) { - const message = - 'JavaScript Promise Integration (JSPI) is not enabled. Please enable JSPI in your JavaScript runtime before using the --experimentalMultiWorker flag. In Node.js, you can use the --experimental-wasm-jspi flag.'; - console.error(message); - throw new Error(message); - } - - const isMountingWordPressDir = (mount: Mount) => - mount.vfsPath === '/wordpress'; - if ( - !args.mount?.some(isMountingWordPressDir) && - !(args['mount-before-install'] as any)?.some( - isMountingWordPressDir - ) - ) { - const message = - 'Please mount a real filesystem directory as the /wordpress directory before using the --experimentalMultiWorker flag.'; - console.error(message); - throw new Error(message); - } - } - return true; - }); - - yargsObject.wrap(yargsObject.terminalWidth()); - const args = await yargsObject.argv; - - const command = args._[0] as string; - - if (!['run-blueprint', 'server', 'build-snapshot'].includes(command)) { - yargsObject.showHelp(); - process.exit(1); - } - - cliArgs = { - ...args, - command, - mount: [...(args.mount || []), ...(args.mountDir || [])], - 'mount-before-install': [ - ...(args['mount-before-install'] || []), - ...(args['mount-dir-before-install'] || []), - ], - } as RunCLIArgsV2; - - return await runCLI(cliArgs); - } catch (e) { - if (cliArgs?.debug) { - await printDebugDetails(e, (e as any)?.streamedResponse); - } - - // If we did not expect this error, print **all** the debug details we can get. - throw e; - } -} - -export interface RunCLIServer extends AsyncDisposable { - playground: RemoteAPI; - server: Server; - [Symbol.asyncDispose](): Promise; -} - -export async function runCLI(args: RunCLIArgsV2): Promise { - let streamedResponse: StreamedPHPResponse | undefined; - let initialWorker: Worker; - - try { - let loadBalancer: LoadBalancer; - let playground: RemoteAPI; - - const playgroundsToCleanUp: { - playground: RemoteAPI; - worker: Worker; - }[] = []; - - /** - * Expand auto-mounts to include the necessary mounts and steps - * when running in auto-mount mode. - */ - if (args['auto-mount']) { - args = expandAutoMounts(args); - } - - const phpVersion = args.php || (await inferPHP(args.blueprint)); - let wordPressReady = false; - let isFirstRequest = true; - - /** - * Spawns a new Worker Thread. - * - * @param workerUrl The absolute URL of the worker script. - * @returns The spawned Worker Thread. - */ - async function spawnPHPWorkerThread( - workerUrl: URL, - onExit: (code: number) => void - ) { - const worker = new Worker(workerUrl); - - return new Promise((resolve, reject) => { - function onMessage(event: string) { - // Let the worker confirm it has initialized. - // We could use the 'online' event to detect start of JS execution, - // but that would miss initialization errors. - if (event === 'worker-script-initialized') { - resolve(worker); - worker.off('message', onMessage); - } - } - function onError(e: Error) { - const error = new Error( - `Worker failed to load at ${workerUrl}. ${ - e.message ? `Original error: ${e.message}` : '' - }` - ); - (error as any).filename = workerUrl; - reject(error); - worker.off('error', onError); - } - worker.on('message', onMessage); - worker.on('error', onError); - worker.on('exit', onExit); - }); - } - - function spawnWorkerThreads(count: number): Promise { - const moduleWorkerUrl = new URL( - importedWorkerUrlString, - import.meta.url - ); - - const promises = []; - for (let i = 0; i < count; i++) { - promises.push( - spawnPHPWorkerThread(moduleWorkerUrl, (code) => { - if (code !== 0) { - process.stderr.write( - `Worker ${i} exited with code ${code}\n` - ); - // If the primary worker crashes, exit the entire process. - if (i === 0) { - process.exit(1); - } - } - }) - ); - } - return Promise.all(promises); - } - - if (args.quiet) { - // @ts-ignore - logger.handlers = []; - } - - // Declare file lock manager outside scope of startServer - // so we can look at it when debugging request handling. - const fileLockManager = new FileLockManagerForNode(); - - logger.log('Starting a PHP server...'); - - return await startServer({ - port: args['port'] as number, - onBind: async ( - server: Server, - port: number - ): Promise => { - const siteUrl = `http://127.0.0.1:${port}`; - - logger.log(`Setting up WordPress ${args.wp}`); - - // Kick off worker threads now to save time later. - // There is no need to wait for other async processes to complete. - const totalWorkerCount = args['experimental-multi-worker'] ?? 1; - const promisedWorkers = spawnWorkerThreads(totalWorkerCount); - - const trace = args['experimental-trace'] === true; - const workers = await promisedWorkers; - initialWorker = workers[0]; - const additionalWorkers = workers.slice(1); - - playground = consumeAPI(initialWorker); - playgroundsToCleanUp.push({ - playground, - worker: initialWorker, - }); - - await playground.isConnected(); - - exposeAPI(fileLockManager, undefined, initialWorker); - - logger.log(`Booting WordPress...`); - - // Each additional worker needs a separate process ID space - // for file locking to work properly because locks are associated - // with individual processes. To accommodate this, we split the safe - // integers into a range for each worker. - const processIdSpaceLength = Math.floor( - Number.MAX_SAFE_INTEGER / totalWorkerCount - ); - - try { - await playground.bootAsPrimaryWorker({ - ...args, - php: phpVersion, - siteUrl, - firstProcessId: 0, - processIdSpaceLength, - trace, - }); - } catch (e) { - await initialWorker.terminate(); - throw e; - } - - if (args.login) { - // @TODO: Do we need this in all the workers? Or just in the primary one? - // Are we sharing constants between workers? - await playground.defineConstant( - 'PLAYGROUND_AUTO_LOGIN_AS_USER', - 'admin' - ); - } - - loadBalancer = new LoadBalancer(playground); - - await playground.isReady(); - wordPressReady = true; - - // Add a newline after the progress bar to avoid the next log message - // from being printed on the same line. - logger.log(''); - logger.log(`Booted!`); - - if (args.command === 'build-snapshot') { - await zipDirectory( - playground, - '/wordpress', - args.outfile as string - ); - logger.log(`WordPress exported to ${args.outfile}`); - process.exit(0); - } else if (args.command === 'run-blueprint') { - logger.log(`Blueprint executed`); - process.exit(0); - } - - if ( - args['experimental-multi-worker'] && - args['experimental-multi-worker'] > 1 - ) { - logger.log(`Preparing additional workers...`); - - // Save /internal directory from initial worker so we can replicate it - // in each additional worker. - const internalZip = await zipDirectory( - playground, - '/internal' - )!; - - // Boot additional workers - const initialWorkerProcessIdSpace = processIdSpaceLength; - await Promise.all( - additionalWorkers.map(async (worker, index) => { - const additionalPlayground = - consumeAPI(worker); - playgroundsToCleanUp.push({ - playground: additionalPlayground, - worker, - }); - - await additionalPlayground.isConnected(); - exposeAPI(fileLockManager, undefined, worker); - - const firstProcessId = - initialWorkerProcessIdSpace + - index * processIdSpaceLength; - - await additionalPlayground.bootAsSecondaryWorker({ - ...args, - php: phpVersion, - siteUrl, - firstProcessId, - processIdSpaceLength, - trace, - }); - await additionalPlayground.isReady(); - - // Replicate the Blueprint-initialized /internal directory - await additionalPlayground.writeFile( - '/tmp/internal.zip', - internalZip! - ); - await unzipFile( - additionalPlayground, - '/tmp/internal.zip', - '/internal' - ); - await additionalPlayground.unlink( - '/tmp/internal.zip' - ); - - loadBalancer.addWorker(additionalPlayground); - }) - ); - - logger.log(`Ready!`); - } - - logger.log(`WordPress is running on ${siteUrl}`); - - return { - playground, - server, - [Symbol.asyncDispose]: async function disposeCLI() { - await Promise.all( - playgroundsToCleanUp.map( - async ({ playground, worker }) => { - await playground.dispose(); - await worker.terminate(); - } - ) - ); - await new Promise((resolve) => server.close(resolve)); - }, - }; - }, - async handleRequest(request: PHPRequest) { - if (!wordPressReady) { - return PHPResponse.forHttpCode( - 502, - 'WordPress is not ready yet' - ); - } - - // Clear the playground_auto_login_already_happened cookie on the first request. - // Otherwise the first Playground CLI server started on the machine will set it, - // all the subsequent runs will get the stale cookie, and the auto-login will - // assume they don't have to auto-login again. - if (isFirstRequest) { - isFirstRequest = false; - if ( - request.headers?.['cookie']?.includes( - 'playground_auto_login_already_happened' - ) - ) { - return new PHPResponse( - 302, - { - 'Set-Cookie': [ - 'playground_auto_login_already_happened=1; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/', - ], - 'Content-Type': ['text/plain'], - 'Content-Length': ['0'], - Location: ['/'], - }, - new Uint8Array() - ); - } - } - - return await loadBalancer.handleRequest(request); - }, - }); - } catch (e) { - if (e) { - (e as any).streamedResponse = streamedResponse; - } - // If we did not expect this error, print **all** the debug details we can get. - throw e; - } -} - -/** - * Infer the PHP version from the Blueprint declaration when the user - * didn't explicitly provide --php. This needs to happen before we boot - * the request handler so that we download / load the correct runtime. - * - * Ideally, we wouldn't need to reason about the Blueprint structure inside - * TypeScript at all. We already have great PHP libraries for handling all that. - * Unfortunately, we did not boot PHP yet. Even worse, we don't know which PHP - * version we need to load yet. - * - * The code below duplicates the data resolution and Blueprint parsing logic - * from the PHP Blueprint runner. We don't need it that much in CLI, since we - * could just load any PHP version to parse the Blueprint and then load the - * correct runtime. However, we will need it in the browser where downloading - * PHP runtimes is expensive. We might as well implement it once and reuse it - * in both places. - * - * ## Limitations - * - * * It can only handle JSON blueprints. Bundles (ZIP, git, etc.) are unsupported. - * The user must provide an explicit `--php=` version when using a bundle. - * - * @param blueprint The Blueprint declaration. - * @returns The PHP version to use. - */ -async function inferPHP(blueprint: string | BlueprintDeclaration | undefined) { - try { - if (!blueprint) { - return RecommendedPHPVersion; - } - /** - * Infer the PHP version from the Blueprint declaration when the user - * didn't explicitly provide --php. This needs to happen before we boot - * the request handler so that we download / load the correct runtime. - */ - const blueprintObject = await resolveBlueprintObject( - parseBlueprintDeclaration(blueprint) - ); - if (!blueprintObject || typeof blueprintObject !== 'object') { - throw new Error('Blueprint is not a valid object'); - } - - let requestedPhp: any | string | undefined = undefined; - /** - * We must, unfortunately, account for all possible versions of the Blueprint - * schema in here. The transpilation to the latest version only happens in the - * PHP code. - */ - requestedPhp = - blueprintObject.phpVersion ?? - blueprintObject.preferredVersions?.php ?? - RecommendedPHPVersion; - - if ( - blueprintObject.phpVersion && - typeof blueprintObject.phpVersion === 'object' - ) { - return ( - blueprintObject.phpVersion.recommended || - blueprintObject.phpVersion.max || - blueprintObject.phpVersion.min - ); - } else if (typeof requestedPhp === 'string') { - return requestedPhp as SupportedPHPVersion; - } else { - throw new Error('phpVersion is not a valid object or string'); - } - } catch (e) { - if (e instanceof NonJsonBlueprintError) { - process.stderr.write( - `Could not determine the PHP version from the Blueprint. ` + - `This usually happens if your Blueprint is not a plain JSON file ` + - `(for example, if it's a ZIP, git repo, or another bundle format). ` + - `Automatic PHP version detection only works for JSON blueprints. ` + - `To continue, please specify the PHP version explicitly using the --php option (e.g. --php=8.2).` - ); - throw e; - } else if (e instanceof BlueprintReferenceError) { - process.stderr.write( - `Failed to load Blueprint: ${e.message}. ` + - `Please check that the Blueprint path or URL is correct.` - ); - throw e; - } else if (e instanceof BlueprintParseError) { - process.stderr.write( - `Blueprint contains invalid JSON: ${e.parseError}. ` + - `Please check the Blueprint syntax and try again.` - ); - throw e; - } - - // Generic inference failure - throw new Error( - `Failed to infer PHP version from Blueprint: ${ - e instanceof Error ? e.message : 'Unknown error' - }. ` + - `Please specify the PHP version explicitly using the --php option.` - ); - } -} - -async function resolveBlueprintObject( - declaration: ParsedBlueprintV2Declaration -): Promise { - if (declaration.type === 'inline-file') { - try { - return JSON.parse(declaration.contents); - } catch (e) { - throw new BlueprintParseError( - `Failed to parse inline Blueprint JSON`, - e instanceof Error ? e.message : 'Unknown JSON parse error' - ); - } - } - if (declaration.type === 'file-reference') { - const filePath = declaration.reference; - const isUrl = - filePath.startsWith('http://') || filePath.startsWith('https://'); - let contents: string; - - try { - if (isUrl) { - // @TODO: Respect HTTP cache in CLI. - const response = await fetch(filePath); - if (!response.ok) { - throw new BlueprintReferenceError( - `Failed to fetch Blueprint from URL (HTTP ${response.status})`, - filePath, - response.status - ); - } - contents = await response.text(); - } else { - const resolvedPath = filePath.startsWith('/') - ? filePath - : path.resolve(process.cwd(), filePath); - - if (!existsSync(resolvedPath)) { - throw new BlueprintReferenceError( - `Blueprint file not found`, - resolvedPath - ); - } - - try { - contents = fs.readFileSync(resolvedPath, 'utf8'); - } catch (e) { - if ((e as any).code === 'ENOENT') { - throw new BlueprintReferenceError( - `Blueprint file not found`, - resolvedPath - ); - } - throw new BlueprintReferenceError( - `Failed to read Blueprint file: ${ - (e as any).message || 'Unknown error' - }`, - resolvedPath - ); - } - } - } catch (e) { - // Re-throw our custom errors - if (e instanceof BlueprintReferenceError) { - throw e; - } - // Handle other network/fetch errors - throw new BlueprintReferenceError( - `Failed to load Blueprint: ${ - e instanceof Error ? e.message : 'Unknown error' - }`, - filePath - ); - } - - try { - return JSON.parse(contents); - } catch (e) { - // Check if this looks like a non-JSON file (ZIP, binary, etc.) - if ( - contents.startsWith('PK') || - contents.includes('\x00') || - !contents.trim().startsWith('{') - ) { - const detectedType = contents.startsWith('PK') - ? 'ZIP archive' - : contents.includes('\x00') - ? 'binary file' - : 'non-JSON text file'; - throw new NonJsonBlueprintError( - `Blueprint appears to be a ${detectedType}, not a JSON file`, - detectedType - ); - } - throw new BlueprintParseError( - `Failed to parse Blueprint JSON from ${isUrl ? 'URL' : 'file'}`, - e instanceof Error ? e.message : 'Unknown JSON parse error' - ); - } - } - throw new NonJsonBlueprintError( - `Unknown blueprint declaration type`, - 'unknown' - ); -} - -/** - * Custom error classes for blueprint resolution failures - */ -class NonJsonBlueprintError extends Error { - public readonly blueprintType: string; - - constructor(message: string, blueprintType: string) { - super(message); - this.name = 'NonJsonBlueprintError'; - this.blueprintType = blueprintType; - } -} - -class BlueprintReferenceError extends Error { - public readonly reference: string; - public readonly statusCode?: number; - - constructor(message: string, reference: string, statusCode?: number) { - super(message); - this.name = 'BlueprintReferenceError'; - this.reference = reference; - this.statusCode = statusCode; - } -} - -class BlueprintParseError extends Error { - public readonly parseError: string; - - constructor(message: string, parseError: string) { - super(message); - this.name = 'BlueprintParseError'; - this.parseError = parseError; - } -} diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 8e34818110..b8c0999228 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -1,24 +1,23 @@ import { errorLogPath, logger } from '@php-wasm/logger'; -import { EmscriptenDownloadMonitor, ProgressTracker } from '@php-wasm/progress'; import type { PHPRequest, RemoteAPI, SupportedPHPVersion, } from '@php-wasm/universal'; import { - PHPResponse, consumeAPI, exposeAPI, exposeSyncAPI, + PHPResponse, + printDebugDetails, + SupportedPHPVersions, } from '@php-wasm/universal'; -import type { - BlueprintBundle, - BlueprintDeclaration, -} from '@wp-playground/blueprints'; import { compileBlueprint, + type CompiledBlueprint, isBlueprintBundle, - runBlueprintSteps, + type BlueprintBundle, + type BlueprintDeclaration, } from '@wp-playground/blueprints'; import { RecommendedPHPVersion, @@ -26,287 +25,476 @@ import { zipDirectory, } from '@wp-playground/common'; import fs from 'fs'; -import type { Server } from 'http'; -import path from 'path'; -import { Worker, MessageChannel } from 'worker_threads'; +import { cpus } from 'os'; +import { jspi } from 'wasm-feature-detect'; +import yargs from 'yargs'; +import { isValidWordPressSlug } from './is-valid-wordpress-slug'; +import { ReportableError } from './reportable-error'; +// @ts-ignore +import importedWorkerV1UrlString from './worker-thread-v1?worker&url'; // @ts-ignore -import { resolveWordPressRelease } from '@wp-playground/wordpress'; +import { + Worker, + MessageChannel as NodeMessageChannel, + type MessagePort as NodeMessagePort, +} from 'worker_threads'; import { expandAutoMounts, parseMountDirArguments, parseMountWithDelimiterArguments, + type Mount, } from './mounts'; +import { resolveBlueprint } from './resolve-blueprint'; + +import { FileLockManagerForNode } from '@php-wasm/node'; +import { EmscriptenDownloadMonitor, ProgressTracker } from '@php-wasm/progress'; +import { resolveWordPressRelease } from '@wp-playground/wordpress'; +import { Server } from 'http'; +import path from 'path'; import { CACHE_FOLDER, cachedDownload, fetchSqliteIntegration, readAsFile, } from './download'; -import { startServer } from './server'; -import type { Mount, PlaygroundCliWorker } from './worker-thread'; -// @ts-ignore -import importedWorkerUrlString from './worker-thread?worker&url'; -// @ts-ignore -import { FileLockManagerForNode } from '@php-wasm/node'; import { LoadBalancer } from './load-balancer'; +import { startServer } from './server'; +import type { PlaygroundCliBlueprintV1Worker } from './worker-thread-v1'; +import type { PlaygroundCliBlueprintV2Worker } from './worker-thread-v2'; + /* eslint-disable no-console */ -import { SupportedPHPVersions } from '@php-wasm/universal'; -import { cpus } from 'os'; -import { jspi } from 'wasm-feature-detect'; -import type { MessagePort as NodeMessagePort } from 'worker_threads'; -import yargs from 'yargs'; -import { isValidWordPressSlug } from './is-valid-wordpress-slug'; -import { ReportableError } from './reportable-error'; -import { resolveBlueprint } from './resolve-blueprint'; +export interface RunCLIArgs { + 'additional-blueprint-steps'?: any[]; + blueprint?: string | BlueprintDeclaration | BlueprintBundle; + command: 'server' | 'run-blueprint' | 'build-snapshot'; + debug?: boolean; + login?: boolean; + mount?: Mount[]; + 'mount-before-install'?: Mount[]; + outfile?: string; + php?: SupportedPHPVersion; + port?: number; + quiet?: boolean; + wp?: string; + 'auto-mount'?: boolean; + + 'experimental-multi-worker'?: number; + 'experimental-trace'?: boolean; + 'blueprint-version'?: 'v1' | 'v2' | 'auto'; + + // v1-specific options (hidden from help but supported for backward compatibility) + 'skip-wordpress-setup'?: boolean; + 'skip-sqlite-setup'?: boolean; + 'internal-cookie-store'?: boolean; + 'blueprint-may-read-adjacent-files'?: boolean; + 'follow-symlinks'?: boolean; + + // v2-specific options + mode?: string; + 'db-engine'?: string; + 'db-host'?: string; + 'db-user'?: string; + 'db-pass'?: string; + 'db-name'?: string; + 'db-path'?: string; + 'truncate-new-site-directory'?: boolean; + allow?: string[]; +} export async function parseOptionsAndRunCLI() { - /** - * @TODO This looks similar to Query API args https://wordpress.github.io/wordpress-playground/developers/apis/query-api/ - * Perhaps the two could be handled by the same code? - */ - const yargsObject = yargs(process.argv.slice(2)) - .usage('Usage: wp-playground [options]') - .positional('command', { - describe: 'Command to run', - choices: ['server', 'run-blueprint', 'build-snapshot'] as const, - demandOption: true, - }) - .option('outfile', { - describe: 'When building, write to this output file.', - type: 'string', - default: 'wordpress.zip', - }) - .option('port', { - describe: 'Port to listen on when serving.', - type: 'number', - default: 9400, - }) - .option('php', { - describe: 'PHP version to use.', - type: 'string', - default: RecommendedPHPVersion, - choices: SupportedPHPVersions, - }) - .option('wp', { - describe: 'WordPress version to use.', - type: 'string', - default: 'latest', - }) - // @TODO: Support read-only mounts, e.g. via WORKERFS, a custom - // ReadOnlyNODEFS, or by copying the files into MEMFS - .option('mount', { - describe: - 'Mount a directory to the PHP runtime (can be used multiple times). Format: /host/path:/vfs/path', - type: 'array', - string: true, - coerce: parseMountWithDelimiterArguments, - }) - .option('mount-before-install', { - describe: - 'Mount a directory to the PHP runtime before WordPress installation (can be used multiple times). Format: /host/path:/vfs/path', - type: 'array', - string: true, - coerce: parseMountWithDelimiterArguments, - }) - .option('mount-dir', { - describe: - 'Mount a directory to the PHP runtime (can be used multiple times). Format: "/host/path" "/vfs/path"', - type: 'array', - nargs: 2, - array: true, - // coerce: parseMountDirArguments, - }) - .option('mount-dir-before-install', { - describe: - 'Mount a directory before WordPress installation (can be used multiple times). Format: "/host/path" "/vfs/path"', - type: 'string', - nargs: 2, - array: true, - coerce: parseMountDirArguments, - }) - .option('login', { - describe: 'Should log the user in', - type: 'boolean', - default: false, - }) - .option('blueprint', { - describe: 'Blueprint to execute.', - type: 'string', - }) - .option('blueprint-may-read-adjacent-files', { - describe: - 'Consent flag: Allow "bundled" resources in a local blueprint to read files in the same directory as the blueprint file.', - type: 'boolean', - default: false, - }) - .option('skip-wordpress-setup', { - describe: - 'Do not download, unzip, and install WordPress. Useful for mounting a pre-configured WordPress directory at /wordpress.', - type: 'boolean', - default: false, - }) - .option('skip-sqlite-setup', { - describe: - 'Skip the SQLite integration plugin setup to allow the WordPress site to use MySQL.', - type: 'boolean', - default: false, - }) - .option('quiet', { - describe: 'Do not output logs and progress messages.', - type: 'boolean', - default: false, - }) - .option('debug', { - describe: - 'Print PHP error log content if an error occurs during Playground boot.', - type: 'boolean', - default: false, - }) - .option('auto-mount', { - describe: `Automatically mount the current working directory. You can mount a WordPress directory, a plugin directory, a theme directory, a wp-content directory, or any directory containing PHP and HTML files.`, - type: 'boolean', - default: false, - }) - .option('follow-symlinks', { - describe: - 'Allow Playground to follow symlinks by automatically mounting symlinked directories and files encountered in mounted directories. \nWarning: Following symlinks will expose files outside mounted directories to Playground and could be a security risk.', - type: 'boolean', - default: false, - }) - .option('experimentalTrace', { - describe: - 'Print detailed messages about system behavior to the console. Useful for troubleshooting.', - type: 'boolean', - default: false, - // Hide this option because we want to replace with a more general log-level flag. - hidden: true, - }) - .option('internal-cookie-store', { - describe: - 'Enable internal cookie handling. When enabled, Playground will manage cookies internally using ' + - 'an HttpCookieStore that persists cookies across requests. When disabled, cookies are handled ' + - 'externally (e.g., by a browser in Node.js environments).', - type: 'boolean', - default: false, - }) - // TODO: Should we make this a hidden flag? - .option('experimentalMultiWorker', { - describe: - 'Enable experimental multi-worker support which requires JSPI ' + - 'and a /wordpress directory backed by a real filesystem. ' + - 'Pass a positive number to specify the number of workers to use. ' + - 'Otherwise, default to the number of CPUs minus 1.', - type: 'number', - coerce: (value?: number) => value ?? cpus().length - 1, - }) - .showHelpOnFail(false) - .check(async (args) => { - if (args.wp !== undefined && !isValidWordPressSlug(args.wp)) { - try { - // Check if is valid URL - new URL(args.wp); - } catch { - throw new Error( - 'Unrecognized WordPress version. Please use "latest", a URL, or a numeric version such as "6.2", "6.0.1", "6.2-beta1", or "6.2-RC1"' - ); + let cliArgs: RunCLIArgs | undefined = undefined; + try { + /** + * @TODO This looks similar to Query API args https://wordpress.github.io/wordpress-playground/developers/apis/query-api/ + * Perhaps the two could be handled by the same code? + */ + const yargsObject = yargs(process.argv.slice(2)) + .usage('Usage: wp-playground [options]') + .positional('command', { + describe: 'Command to run', + choices: ['server', 'run-blueprint', 'build-snapshot'] as const, + demandOption: true, + }) + .option('outfile', { + describe: 'When building, write to this output file.', + type: 'string', + default: 'wordpress.zip', + }) + .option('port', { + describe: 'Port to listen on when serving.', + type: 'number', + default: 9400, + }) + + // Blueprints CLI options + .option('php', { + describe: + 'PHP version to use. If Blueprint is provided, this option overrides the PHP version specified in the Blueprint.', + type: 'string', + choices: SupportedPHPVersions, + }) + + // Modifies the Blueprint: + .option('wp', { + describe: + 'WordPress version to use. If Blueprint is provided, this option overrides the WordPress version specified in the Blueprint.', + type: 'string', + default: 'latest', + }) + .option('login', { + describe: + 'Should log the user in. If Blueprint is provided, this option overrides the login specified in the Blueprint.', + type: 'boolean', + default: false, + }) + + // @TODO: Support read-only mounts, e.g. via WORKERFS, a custom + // ReadOnlyNODEFS, or by copying the files into MEMFS + .option('mount', { + describe: + 'Mount a directory to the PHP runtime. You can provide --mount multiple times. Format: /host/path:/vfs/path', + type: 'array', + string: true, + coerce: parseMountWithDelimiterArguments, + }) + .option('mount-before-install', { + describe: + 'Mount a directory to the PHP runtime before installing WordPress. You can provide --mount-before-install multiple times. Format: /host/path:/vfs/path', + type: 'array', + string: true, + coerce: parseMountWithDelimiterArguments, + }) + .option('mount-dir', { + describe: + 'Mount a directory to the PHP runtime. You can provide --mount-dir multiple times. Format: "/host/path" "/vfs/path"', + type: 'array', + nargs: 2, + array: true, + coerce: parseMountDirArguments, + }) + .option('mount-dir-before-install', { + describe: + 'Mount a directory to the PHP runtime before installing WordPress. You can provide --mount-before-install multiple times. Format: "/host/path" "/vfs/path"', + type: 'string', + nargs: 2, + array: true, + coerce: parseMountDirArguments, + }) + .option('blueprint', { + describe: 'Blueprint to execute.', + type: 'string', + }) + .option('quiet', { + describe: 'Do not output logs and progress messages.', + type: 'boolean', + default: false, + }) + .option('debug', { + describe: + 'Print PHP error log content if an error occurs during Playground boot.', + type: 'boolean', + default: false, + }) + .option('auto-mount', { + describe: `Automatically mount the current working directory. You can mount a WordPress directory, a plugin directory, a theme directory, a wp-content directory, or any directory containing PHP and HTML files.`, + type: 'boolean', + default: false, + }) + .option('internal-cookie-store', { + describe: + 'Enable internal cookie handling. When enabled, Playground will manage cookies internally using ' + + 'an HttpCookieStore that persists cookies across requests. When disabled, cookies are handled ' + + 'externally (e.g., by a browser in Node.js environments).', + type: 'boolean', + default: false, + }) + + // Blueprint version selection + .option('blueprint-version', { + describe: 'Blueprint version to use (auto-detected by default)', + type: 'string', + choices: ['v1', 'v2', 'auto'], + default: 'auto', + }) + + // v2-specific Blueprint CLI options + .option('mode', { + describe: 'Execution mode', + type: 'string', + default: 'create-new-site', + choices: [ + 'create-new-site', + 'apply-to-existing-site', + 'mount-only', + ], + }) + .option('db-engine', { + describe: 'Database engine', + type: 'string', + default: 'sqlite', + choices: ['mysql', 'sqlite'], + }) + .option('db-host', { + describe: 'MySQL host', + type: 'string', + }) + .option('db-user', { + describe: 'MySQL user', + type: 'string', + }) + .option('db-pass', { + describe: 'MySQL password', + type: 'string', + }) + .option('db-name', { + describe: 'MySQL database', + type: 'string', + }) + .option('db-path', { + describe: 'SQLite file path', + type: 'string', + }) + .option('truncate-new-site-directory', { + describe: + 'Delete target directory if it exists before execution', + type: 'boolean', + }) + .option('allow', { + describe: 'Allowed permissions (comma-separated)', + type: 'string', + coerce: (value) => value?.split(','), + choices: ['bundled-files', 'follow-symlinks'], + }) + .option('experimental-trace', { + describe: + 'Print detailed messages about system behavior to the console. Useful for troubleshooting.', + type: 'boolean', + default: false, + // Hide this option because we want to replace with a more general log-level flag. + hidden: true, + }) + // TODO: Should we make this a hidden flag? + .option('experimental-multi-worker', { + describe: + 'Enable experimental multi-worker support which requires JSPI ' + + 'and a /wordpress directory backed by a real filesystem. ' + + 'Pass a positive number to specify the number of workers to use. ' + + 'Otherwise, default to the number of CPUs minus 1.', + type: 'number', + coerce: (value?: number) => value ?? cpus().length - 1, + }) + + // Legacy options, specific to Blueprints v1 (BC reasons only, they're hidden from the help message). + .option('skip-wordpress-setup', { + describe: + 'Do not download, unzip, and install WordPress. Useful for mounting a pre-configured WordPress directory at /wordpress.', + type: 'boolean', + default: false, + hidden: true, + }) + .option('skip-sqlite-setup', { + describe: + 'Skip the SQLite integration plugin setup to allow the WordPress site to use MySQL.', + type: 'boolean', + default: false, + hidden: true, + }) + .option('blueprint-may-read-adjacent-files', { + describe: + 'Consent flag: Allow "bundled" resources in a local blueprint to read files in the same directory as the blueprint file.', + type: 'boolean', + default: false, + hidden: true, + }) + .option('follow-symlinks', { + describe: + 'Allow Playground to follow symlinks by automatically mounting symlinked directories and files encountered in mounted directories. \nWarning: Following symlinks will expose files outside mounted directories to Playground and could be a security risk.', + type: 'boolean', + default: false, + }) + + // Backward compatibility aliases (hidden) + .option('experimentalMultiWorker', { + type: 'number', + hidden: true, + coerce: (value?: number) => value ?? cpus().length - 1, + }) + .option('experimentalTrace', { + type: 'boolean', + hidden: true, + }) + .option('blueprintMayReadAdjacentFiles', { + type: 'boolean', + hidden: true, + }) + .option('skipWordPressSetup', { + type: 'boolean', + hidden: true, + }) + .option('skipSqliteSetup', { + type: 'boolean', + hidden: true, + }) + .option('internalCookieStore', { + type: 'boolean', + hidden: true, + }) + .option('followSymlinks', { + type: 'boolean', + hidden: true, + }) + .option('autoMount', { + type: 'boolean', + hidden: true, + }) + + .showHelpOnFail(false) + .check(async (args) => { + // Normalize camelCase to kebab-case for backward compatibility + if (args.experimentalMultiWorker !== undefined) { + args['experimental-multi-worker'] = + args.experimentalMultiWorker; + } + if (args.experimentalTrace !== undefined) { + args['experimental-trace'] = args.experimentalTrace; + } + if (args.blueprintMayReadAdjacentFiles !== undefined) { + args['blueprint-may-read-adjacent-files'] = + args.blueprintMayReadAdjacentFiles; + } + if (args.skipWordPressSetup !== undefined) { + args['skip-wordpress-setup'] = args.skipWordPressSetup; + } + if (args.skipSqliteSetup !== undefined) { + args['skip-sqlite-setup'] = args.skipSqliteSetup; + } + if (args.internalCookieStore !== undefined) { + args['internal-cookie-store'] = args.internalCookieStore; + } + if (args.followSymlinks !== undefined) { + args['follow-symlinks'] = args.followSymlinks; + } + if (args.autoMount !== undefined) { + args['auto-mount'] = args.autoMount; } - } - if (args.experimentalMultiWorker !== undefined) { - if (args.experimentalMultiWorker <= 1) { - throw new Error( - 'The --experimentalMultiWorker flag must be a positive integer greater than 1.' - ); + // Convert V1 arguments to V2 arguments + if (!args['allow']) { + args['allow'] = []; } - const isMountingWordPressDir = (mount: Mount) => - mount.vfsPath === '/wordpress'; - if ( - !args.mount?.some(isMountingWordPressDir) && - !(args['mount-before-install'] as any)?.some( - isMountingWordPressDir - ) - ) { - throw new Error( - 'Please mount a real filesystem directory as the /wordpress directory before using the --experimentalMultiWorker flag.' - ); + if (args['follow-symlinks']) { + args['allow'].push('follow-symlinks'); + } + + if (args['blueprint-may-read-adjacent-files']) { + args['allow'].push('bundled-files'); } - } - return true; - }); - yargsObject.wrap(yargsObject.terminalWidth()); - const args = await yargsObject.argv; + if (args['skip-sqlite-setup']) { + args['db-engine'] = 'apply-to-existing-site'; + } - const command = args._[0] as string; + if (args['skip-wordpress-setup']) { + args['mode'] = 'mount-only'; + } - if (!['run-blueprint', 'server', 'build-snapshot'].includes(command)) { - yargsObject.showHelp(); - process.exit(1); - } + // Validation + if (args.wp !== undefined && !isValidWordPressSlug(args.wp)) { + try { + // Check if is valid URL + new URL(args.wp); + } catch { + const message = + 'Unrecognized WordPress version. Please use "latest", a URL, or a numeric version such as "6.2", "6.0.1", "6.2-beta1", or "6.2-RC1"'; + console.error(message); + throw new Error(message); + } + } - const cliArgs = { - ...args, - command, - blueprint: await resolveBlueprint({ - sourceString: args.blueprint, - blueprintMayReadAdjacentFiles: args.blueprintMayReadAdjacentFiles, - }), - mount: [...(args.mount || []), ...(args['mount-dir'] || [])], - 'mount-before-install': [ - ...(args['mount-before-install'] || []), - ...(args['mount-dir-before-install'] || []), - ], - } as RunCLIArgs; + if (args['experimental-multi-worker'] !== undefined) { + if (args['experimental-multi-worker'] <= 1) { + const message = + 'The --experimental-multi-worker flag must be a positive integer greater than 1.'; + console.error(message); + throw new Error(message); + } - try { - return runCLI(cliArgs); + if (!(await jspi())) { + const message = + 'JavaScript Promise Integration (JSPI) is not enabled. Please enable JSPI in your JavaScript runtime before using the --experimental-multi-worker flag. In Node.js, you can use the --experimental-wasm-jspi flag.'; + console.error(message); + throw new Error(message); + } + + const isMountingWordPressDir = (mount: Mount) => + mount.vfsPath === '/wordpress'; + if ( + !args.mount?.some(isMountingWordPressDir) && + !(args['mount-before-install'] as any)?.some( + isMountingWordPressDir + ) + ) { + const message = + 'Please mount a real filesystem directory as the /wordpress directory before using the --experimental-multi-worker flag.'; + console.error(message); + throw new Error(message); + } + } + return true; + }); + + yargsObject.wrap(yargsObject.terminalWidth()); + const args = await yargsObject.argv; + + const command = args._[0] as string; + + if (!['run-blueprint', 'server', 'build-snapshot'].includes(command)) { + yargsObject.showHelp(); + process.exit(1); + } + + cliArgs = { + ...args, + command, + mount: [...(args.mount || []), ...(args.mountDir || [])], + 'mount-before-install': [ + ...(args['mount-before-install'] || []), + ...(args['mount-dir-before-install'] || []), + ], + } as RunCLIArgs; + + return await runCLI(cliArgs); } catch (e) { + if (cliArgs?.debug) { + await printDebugDetails(e, (e as any)?.streamedResponse); + } + const reportableCause = ReportableError.getReportableCause(e); if (reportableCause) { console.log(''); console.log(reportableCause.message); process.exit(1); } else { + // If we did not expect this error, print **all** the debug details we can get. throw e; } } } -export interface RunCLIArgs { - blueprint?: BlueprintDeclaration | BlueprintBundle; - command: 'server' | 'run-blueprint' | 'build-snapshot'; - debug?: boolean; - login?: boolean; - mount?: Mount[]; - 'mount-before-install'?: Mount[]; - outfile?: string; - php?: SupportedPHPVersion; - port?: number; - quiet?: boolean; - skipWordPressSetup?: boolean; - skipSqliteSetup?: boolean; - wp?: string; - autoMount?: boolean; - followSymlinks?: boolean; - experimentalMultiWorker?: number; - experimentalTrace?: boolean; - internalCookieStore?: boolean; - 'additional-blueprint-steps'?: any[]; -} - export interface RunCLIServer extends AsyncDisposable { - playground: RemoteAPI; + playground: + | RemoteAPI + | RemoteAPI; server: Server; [Symbol.asyncDispose](): Promise; } export async function runCLI(args: RunCLIArgs): Promise { - let loadBalancer: LoadBalancer; - let playground: RemoteAPI; + let loadBalancer: LoadBalancer | undefined = undefined; const playgroundsToCleanUp: { - playground: RemoteAPI; + playground: { dispose: () => Promise }; worker: Worker; }[] = []; @@ -314,179 +502,15 @@ export async function runCLI(args: RunCLIArgs): Promise { * Expand auto-mounts to include the necessary mounts and steps * when running in auto-mount mode. */ - if (args.autoMount) { + if (args['auto-mount']) { args = expandAutoMounts(args); } - /** - * TODO: This exact feature will be provided in the PHP Blueprints library. - * Let's use it when it ships. Let's also use it in the web Playground - * app. - */ - async function zipSite(outfile: string) { - await playground.run({ - code: `open('/tmp/build.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE)) { - throw new Exception('Failed to create ZIP'); - } - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator('/wordpress') - ); - foreach ($files as $file) { - echo $file . PHP_EOL; - if (!$file->isFile()) { - continue; - } - $zip->addFile($file->getPathname(), $file->getPathname()); - } - $zip->close(); - - `, - }); - const zip = await playground.readFileAsBuffer('/tmp/build.zip'); - fs.writeFileSync(outfile, zip); - } - - async function compileInputBlueprint(additionalBlueprintSteps: any[]) { - /** - * @TODO This looks similar to the resolveBlueprint() call in the website package: - * https://github.com/WordPress/wordpress-playground/blob/ce586059e5885d185376184fdd2f52335cca32b0/packages/playground/website/src/main.tsx#L41 - * - * Also the Blueprint Builder tool does something similar. - * Perhaps all these cases could be handled by the same function? - */ - const blueprint: BlueprintDeclaration | BlueprintBundle = - isBlueprintBundle(args.blueprint) - ? args.blueprint - : { - login: args.login, - ...args.blueprint, - preferredVersions: { - php: - args.php ?? - args?.blueprint?.preferredVersions?.php ?? - RecommendedPHPVersion, - wp: - args.wp ?? - args?.blueprint?.preferredVersions?.wp ?? - 'latest', - ...(args.blueprint?.preferredVersions || {}), - }, - }; - - const tracker = new ProgressTracker(); - let lastCaption = ''; - let progressReached100 = false; - tracker.addEventListener('progress', (e: any) => { - if (progressReached100) { - return; - } - progressReached100 = e.detail.progress === 100; - - // Use floor() so we don't report 100% until truly there. - const progressInteger = Math.floor(e.detail.progress); - lastCaption = - e.detail.caption || lastCaption || 'Running the Blueprint'; - const message = `${lastCaption.trim()} – ${progressInteger}%`; - if (!args.quiet) { - writeProgressUpdate( - process.stdout, - message, - progressReached100 - ); - } - }); - return await compileBlueprint(blueprint as BlueprintDeclaration, { - progress: tracker, - additionalSteps: additionalBlueprintSteps, - }); - } - - let lastProgressMessage = ''; - function writeProgressUpdate( - writeStream: NodeJS.WriteStream, - message: string, - finalUpdate: boolean - ) { - if (message === lastProgressMessage) { - // Avoid repeating the same message - return; - } - lastProgressMessage = message; - - if (writeStream.isTTY) { - // Overwrite previous progress updates in-place for a quieter UX. - writeStream.cursorTo(0); - writeStream.write(message); - writeStream.clearLine(1); - - if (finalUpdate) { - writeStream.write('\n'); - } - } else { - // Fall back to writing one line per progress update - writeStream.write(`${message}\n`); - } - } - - /** - * Spawns a new Worker Thread. - * - * @param workerUrl The absolute URL of the worker script. - * @returns The spawned Worker Thread. - */ - async function spawnPHPWorkerThread(workerUrl: URL) { - const worker = new Worker(workerUrl); - - return new Promise<{ worker: Worker; phpPort: NodeMessagePort }>( - (resolve, reject) => { - worker.once('message', function (message: any) { - // Let the worker confirm it has initialized. - // We could use the 'online' event to detect start of JS execution, - // but that would miss initialization errors. - if (message.command === 'worker-script-initialized') { - resolve({ worker, phpPort: message.phpPort }); - } - }); - worker.once('error', function (e: Error) { - console.error(e); - const error = new Error( - `Worker failed to load at ${workerUrl}. ${ - e.message ? `Original error: ${e.message}` : '' - }` - ); - (error as any).filename = workerUrl; - reject(error); - }); - } - ); - } - - function spawnWorkerThreads( - count: number - ): Promise<{ worker: Worker; phpPort: NodeMessagePort }[]> { - const moduleWorkerUrl = new URL( - importedWorkerUrlString, - import.meta.url - ); - - const promises = []; - for (let i = 0; i < count; i++) { - promises.push(spawnPHPWorkerThread(moduleWorkerUrl)); - } - return Promise.all(promises); - } - if (args.quiet) { // @ts-ignore logger.handlers = []; } - const compiledBlueprint = await compileInputBlueprint( - args['additional-blueprint-steps'] || [] - ); - // Declare file lock manager outside scope of startServer // so we can look at it when debugging request handling. const nativeFlockSync = await import('fs-ext') @@ -499,191 +523,58 @@ export async function runCLI(args: RunCLIArgs): Promise { ); return undefined; }); + const fileLockManager = new FileLockManagerForNode(nativeFlockSync); + const fileLockManagerPort = await exposeFileLockManager(fileLockManager); - /** - * Expose the file lock manager API on a MessagePort and return it. - * - * @see comlink-sync.ts - * @see phpwasm-emscripten-library-file-locking-for-node.js - */ - async function exposeFileLockManager() { - const { port1, port2 } = new MessageChannel(); - if (await jspi()) { - /** - * When JSPI is available, the worker thread expects an asynchronous API. - * - * @see worker-thread.ts - * @see comlink-sync.ts - * @see phpwasm-emscripten-library-file-locking-for-node.js - */ - exposeAPI(fileLockManager, null, port1); - } else { - /** - * When JSPI is not available, the worker thread expects a synchronous API. - * - * @see worker-thread.ts - * @see comlink-sync.ts - * @see phpwasm-emscripten-library-file-locking-for-node.js - */ - await exposeSyncAPI(fileLockManager, port1); - } - return port2; - } + logger.log('Starting a PHP server...'); - let wordPressReady = false; + const totalWorkerCount = args['experimental-multi-worker'] ?? 1; + // Each additional worker needs a separate process ID space + // for file locking to work properly because locks are associated + // with individual processes. To accommodate this, we split the safe + // integers into a range for each worker. + const processIdSpaceLength = Math.floor( + Number.MAX_SAFE_INTEGER / totalWorkerCount + ); - logger.log('Starting a PHP server...'); + let primaryPlayground: + | RemoteAPI + | undefined = undefined; + let wordPressReady = false; return startServer({ port: args['port'] as number, - onBind: async (server: Server, port: number): Promise => { - const absoluteUrl = `http://127.0.0.1:${port}`; - - // Kick off worker threads now to save time later. - // There is no need to wait for other async processes to complete. - const totalWorkerCount = args.experimentalMultiWorker ?? 1; - const promisedWorkers = spawnWorkerThreads(totalWorkerCount); - - logger.log(`Setting up WordPress ${args.wp}`); - let wpDetails: any = undefined; - // @TODO: Rename to FetchProgressMonitor. There's nothing Emscripten - // about that class anymore. - const monitor = new EmscriptenDownloadMonitor(); - if (!args.skipWordPressSetup) { - let progressReached100 = false; - monitor.addEventListener('progress', (( - e: CustomEvent - ) => { - if (progressReached100) { - return; - } - - // @TODO Every progress bar will want percentages. The - // download monitor should just provide that. - const { loaded, total } = e.detail; - // Use floor() so we don't report 100% until truly there. - const percentProgress = Math.floor( - Math.min(100, (100 * loaded) / total) - ); - progressReached100 = percentProgress === 100; - - if (!args.quiet) { - writeProgressUpdate( - process.stdout, - `Downloading WordPress ${percentProgress}%...`, - progressReached100 - ); - } - }) as any); + onBind: async (server: Server, port: number) => { + const siteUrl = `http://127.0.0.1:${port}`; + const handler = new V1Handler(args, { + siteUrl, + totalWorkerCount, + processIdSpaceLength, + }); - wpDetails = await resolveWordPressRelease(args.wp); - logger.log( - `Resolved WordPress release URL: ${wpDetails?.releaseUrl}` + const [initialWorker, ...additionalWorkers] = + await spawnWorkerThreads( + importedWorkerV1UrlString, + totalWorkerCount ); - } - const preinstalledWpContentPath = - wpDetails && - path.join( - CACHE_FOLDER, - `prebuilt-wp-content-for-wp-${wpDetails.version}.zip` - ); - const wordPressZip = !wpDetails - ? undefined - : fs.existsSync(preinstalledWpContentPath) - ? readAsFile(preinstalledWpContentPath) - : await cachedDownload( - wpDetails.releaseUrl, - `${wpDetails.version}.zip`, - monitor - ); - - logger.log(`Fetching SQLite integration plugin...`); - const sqliteIntegrationPluginZip = args.skipSqliteSetup - ? undefined - : await fetchSqliteIntegration(monitor); - - const followSymlinks = args.followSymlinks === true; - const trace = args.experimentalTrace === true; try { - const mountsBeforeWpInstall = - args['mount-before-install'] || []; - const mountsAfterWpInstall = args.mount || []; + logger.log(`Setting up WordPress ${args.wp}`); - const [initialWorker, ...additionalWorkers] = - await promisedWorkers; - - playground = consumeAPI( - initialWorker.phpPort + primaryPlayground = await handler.bootPrimaryWorker( + initialWorker.phpPort, + fileLockManagerPort ); playgroundsToCleanUp.push({ - playground, + playground: primaryPlayground, worker: initialWorker.worker, }); - // Comlink communication proxy - await playground.isConnected(); - - const fileLockManagerPort = await exposeFileLockManager(); - - logger.log(`Booting WordPress...`); - - // Each additional worker needs a separate process ID space - // for file locking to work properly because locks are associated - // with individual processes. To accommodate this, we split the safe - // integers into a range for each worker. - const processIdSpaceLength = Math.floor( - Number.MAX_SAFE_INTEGER / totalWorkerCount - ); - - await playground.useFileLockManager(fileLockManagerPort); - await playground.boot({ - phpVersion: compiledBlueprint.versions.php, - wpVersion: compiledBlueprint.versions.wp, - absoluteUrl, - mountsBeforeWpInstall, - mountsAfterWpInstall, - wordPressZip: - wordPressZip && (await wordPressZip!.arrayBuffer()), - sqliteIntegrationPluginZip: - await sqliteIntegrationPluginZip!.arrayBuffer(), - firstProcessId: 0, - processIdSpaceLength, - followSymlinks, - trace, - internalCookieStore: args.internalCookieStore, - }); - - if ( - wpDetails && - !args['mount-before-install'] && - !fs.existsSync(preinstalledWpContentPath) - ) { - logger.log( - `Caching preinstalled WordPress for the next boot...` - ); - fs.writeFileSync( - preinstalledWpContentPath, - (await zipDirectory(playground, '/wordpress'))! - ); - logger.log(`Cached!`); - } - - loadBalancer = new LoadBalancer(playground); - - await playground.isReady(); - wordPressReady = true; - logger.log(`Booted!`); - - if (compiledBlueprint) { - logger.log(`Running the Blueprint...`); - await runBlueprintSteps(compiledBlueprint, playground); - logger.log(`Finished running the blueprint`); - } + loadBalancer = new LoadBalancer(primaryPlayground); if (args.command === 'build-snapshot') { - await zipSite(args.outfile as string); + await zipSite(primaryPlayground, args.outfile as string); logger.log(`WordPress exported to ${args.outfile}`); process.exit(0); } else if (args.command === 'run-blueprint') { @@ -691,61 +582,37 @@ export async function runCLI(args: RunCLIArgs): Promise { process.exit(0); } - if ( - args.experimentalMultiWorker && - args.experimentalMultiWorker > 1 - ) { + if (totalWorkerCount > 1) { logger.log(`Preparing additional workers...`); // Save /internal directory from initial worker so we can replicate it // in each additional worker. const internalZip = await zipDirectory( - playground, + primaryPlayground, '/internal' ); // Boot additional workers const initialWorkerProcessIdSpace = processIdSpaceLength; await Promise.all( - additionalWorkers.map(async (worker, index) => { - const additionalPlayground = - consumeAPI(worker.phpPort); - playgroundsToCleanUp.push({ - playground: additionalPlayground, - worker: worker.worker, - }); - - await additionalPlayground.isConnected(); - + additionalWorkers.map(async (spawnedWorker, index) => { const firstProcessId = initialWorkerProcessIdSpace + index * processIdSpaceLength; const fileLockManagerPort = - await exposeFileLockManager(); - await additionalPlayground.useFileLockManager( - fileLockManagerPort - ); - await additionalPlayground.boot({ - phpVersion: compiledBlueprint.versions.php, - absoluteUrl, - mountsBeforeWpInstall, - mountsAfterWpInstall, - // Skip WordPress zip because we share the /wordpress directory - // populated by the initial worker. - wordPressZip: undefined, - // Skip SQLite integration plugin for now because we - // will copy it from primary's `/internal` directory. - sqliteIntegrationPluginZip: undefined, - dataSqlPath: - '/wordpress/wp-content/database/.ht.sqlite', - firstProcessId, - processIdSpaceLength, - followSymlinks, - trace, - internalCookieStore: args.internalCookieStore, + await exposeFileLockManager(fileLockManager); + + const additionalPlayground = + await handler.bootSecondaryWorker({ + worker: spawnedWorker, + fileLockManagerPort, + firstProcessId, + }); + playgroundsToCleanUp.push({ + playground: additionalPlayground, + worker: spawnedWorker.worker, }); - await additionalPlayground.isReady(); // Replicate the Blueprint-initialized /internal directory await additionalPlayground.writeFile( @@ -761,17 +628,18 @@ export async function runCLI(args: RunCLIArgs): Promise { '/tmp/internal.zip' ); - loadBalancer.addWorker(additionalPlayground); + loadBalancer!.addWorker(additionalPlayground); }) ); logger.log(`Ready!`); } - logger.log(`WordPress is running on ${absoluteUrl}`); + logger.log(`WordPress is running on ${siteUrl}`); + wordPressReady = true; return { - playground, + playground: primaryPlayground, server, [Symbol.asyncDispose]: async function disposeCLI() { await Promise.all( @@ -789,12 +657,14 @@ export async function runCLI(args: RunCLIArgs): Promise { if (!args.debug) { throw error; } - const phpLogs = await playground.readFileAsText(errorLogPath); + const phpLogs = + (await primaryPlayground?.readFileAsText(errorLogPath)) || + ''; throw new Error(phpLogs, { cause: error }); } }, async handleRequest(request: PHPRequest) { - if (!wordPressReady) { + if (!wordPressReady || !loadBalancer) { return PHPResponse.forHttpCode( 502, 'WordPress is not ready yet' @@ -804,3 +674,405 @@ export async function runCLI(args: RunCLIArgs): Promise { }, }); } + +class V1Handler { + private lastProgressMessage = ''; + + private playgroundsToCleanUp: { + playground: RemoteAPI; + worker: Worker; + }[] = []; + + private compiledBlueprint: CompiledBlueprint | undefined; + + private siteUrl: string; + private totalWorkerCount: number; + private processIdSpaceLength: number; + private args: RunCLIArgs; + + constructor( + args: RunCLIArgs, + options: { + siteUrl: string; + totalWorkerCount: number; + processIdSpaceLength: number; + } + ) { + this.args = args; + this.siteUrl = options.siteUrl; + this.totalWorkerCount = options.totalWorkerCount; + this.processIdSpaceLength = options.processIdSpaceLength; + } + + getWorkerUrl() { + return importedWorkerV1UrlString; + } + + async bootPrimaryWorker( + phpPort: NodeMessagePort, + fileLockManagerPort: NodeMessagePort + ) { + this.compiledBlueprint = await this.compileInputBlueprint( + this.args['additional-blueprint-steps'] || [] + ); + + let wpDetails: any = undefined; + // @TODO: Rename to FetchProgressMonitor. There's nothing Emscripten + // about that class anymore. + const monitor = new EmscriptenDownloadMonitor(); + if (!this.args['skip-wordpress-setup']) { + let progressReached100 = false; + monitor.addEventListener('progress', (( + e: CustomEvent + ) => { + if (progressReached100) { + return; + } + + // @TODO Every progress bar will want percentages. The + // download monitor should just provide that. + const { loaded, total } = e.detail; + // Use floor() so we don't report 100% until truly there. + const percentProgress = Math.floor( + Math.min(100, (100 * loaded) / total) + ); + progressReached100 = percentProgress === 100; + + if (!this.args.quiet) { + this.writeProgressUpdate( + process.stdout, + `Downloading WordPress ${percentProgress}%...`, + progressReached100 + ); + } + }) as any); + + wpDetails = await resolveWordPressRelease(this.args.wp); + logger.log( + `Resolved WordPress release URL: ${wpDetails?.releaseUrl}` + ); + } + + const preinstalledWpContentPath = + wpDetails && + path.join( + CACHE_FOLDER, + `prebuilt-wp-content-for-wp-${wpDetails.version}.zip` + ); + const wordPressZip = !wpDetails + ? undefined + : fs.existsSync(preinstalledWpContentPath) + ? readAsFile(preinstalledWpContentPath) + : await cachedDownload( + wpDetails.releaseUrl, + `${wpDetails.version}.zip`, + monitor + ); + + logger.log(`Fetching SQLite integration plugin...`); + const sqliteIntegrationPluginZip = this.args['skip-sqlite-setup'] + ? undefined + : await fetchSqliteIntegration(monitor); + + const followSymlinks = + this.args.allow?.includes('follow-symlinks') === true; + const trace = this.args['experimental-trace'] === true; + + const mountsBeforeWpInstall = this.args['mount-before-install'] || []; + const mountsAfterWpInstall = this.args.mount || []; + + const playground = consumeAPI(phpPort); + + // Comlink communication proxy + await playground.isConnected(); + + logger.log(`Booting WordPress...`); + + await playground.useFileLockManager(fileLockManagerPort); + await playground.boot({ + phpVersion: this.compiledBlueprint!.versions.php, + wpVersion: this.compiledBlueprint!.versions.wp, + absoluteUrl: this.siteUrl, + mountsBeforeWpInstall, + mountsAfterWpInstall, + wordPressZip: wordPressZip && (await wordPressZip!.arrayBuffer()), + sqliteIntegrationPluginZip: + await sqliteIntegrationPluginZip!.arrayBuffer(), + firstProcessId: 0, + processIdSpaceLength: this.processIdSpaceLength, + followSymlinks, + trace, + internalCookieStore: this.args['internal-cookie-store'], + }); + + if ( + wpDetails && + !this.args['mount-before-install'] && + !fs.existsSync(preinstalledWpContentPath) + ) { + logger.log(`Caching preinstalled WordPress for the next boot...`); + fs.writeFileSync( + preinstalledWpContentPath, + (await zipDirectory(playground, '/wordpress'))! + ); + logger.log(`Cached!`); + } + + return playground; + } + + async bootSecondaryWorker({ + worker, + fileLockManagerPort, + firstProcessId, + }: { + worker: SpawnedWorker; + fileLockManagerPort: NodeMessagePort; + firstProcessId: number; + }) { + const additionalPlayground = consumeAPI( + worker.phpPort + ); + this.playgroundsToCleanUp.push({ + playground: additionalPlayground, + worker: worker.worker, + }); + + await additionalPlayground.isConnected(); + await additionalPlayground.useFileLockManager(fileLockManagerPort); + await additionalPlayground.boot({ + phpVersion: this.compiledBlueprint!.versions.php, + absoluteUrl: this.siteUrl, + mountsBeforeWpInstall: this.args['mount-before-install'] || [], + mountsAfterWpInstall: this.args['mount'] || [], + // Skip WordPress zip because we share the /wordpress directory + // populated by the initial worker. + wordPressZip: undefined, + // Skip SQLite integration plugin for now because we + // will copy it from primary's `/internal` directory. + sqliteIntegrationPluginZip: undefined, + dataSqlPath: '/wordpress/wp-content/database/.ht.sqlite', + firstProcessId, + processIdSpaceLength: this.processIdSpaceLength, + followSymlinks: + this.args['allow']?.includes('follow-symlinks') === true, + trace: this.args['experimental-trace'] === true, + // @TODO: Move this to the request handler or else every worker + // will have a separate cookie store. + internalCookieStore: this.args['internal-cookie-store'], + }); + await additionalPlayground.isReady(); + return additionalPlayground; + } + + async compileInputBlueprint(additionalBlueprintSteps: any[]) { + const args = this.args; + const resolvedBlueprint = + typeof args.blueprint === 'string' + ? await resolveBlueprint({ + sourceString: args.blueprint, + blueprintMayReadAdjacentFiles: + args['allow']?.includes( + 'blueprint-may-read-adjacent-files' + ) === true, + }) + : (args.blueprint as BlueprintDeclaration); + /** + * @TODO This looks similar to the resolveBlueprint() call in the website package: + * https://github.com/WordPress/wordpress-playground/blob/ce586059e5885d185376184fdd2f52335cca32b0/packages/playground/website/src/main.tsx#L41 + * + * Also the Blueprint Builder tool does something similar. + * Perhaps all these cases could be handled by the same function? + */ + const blueprint: BlueprintDeclaration | BlueprintBundle = + isBlueprintBundle(resolvedBlueprint) + ? resolvedBlueprint + : { + login: args.login, + ...(resolvedBlueprint || {}), + preferredVersions: { + php: + args.php ?? + resolvedBlueprint?.preferredVersions?.php ?? + RecommendedPHPVersion, + wp: + args.wp ?? + resolvedBlueprint?.preferredVersions?.wp ?? + 'latest', + ...(resolvedBlueprint?.preferredVersions || {}), + }, + }; + + const tracker = new ProgressTracker(); + let lastCaption = ''; + let progressReached100 = false; + tracker.addEventListener('progress', (e: any) => { + if (progressReached100) { + return; + } + progressReached100 = e.detail.progress === 100; + + // Use floor() so we don't report 100% until truly there. + const progressInteger = Math.floor(e.detail.progress); + lastCaption = + e.detail.caption || lastCaption || 'Running the Blueprint'; + const message = `${lastCaption.trim()} – ${progressInteger}%`; + if (!args.quiet) { + this.writeProgressUpdate( + process.stdout, + message, + progressReached100 + ); + } + }); + return await compileBlueprint(blueprint as BlueprintDeclaration, { + progress: tracker, + additionalSteps: additionalBlueprintSteps, + }); + } + + writeProgressUpdate( + writeStream: NodeJS.WriteStream, + message: string, + finalUpdate: boolean + ) { + if (message === this.lastProgressMessage) { + // Avoid repeating the same message + return; + } + this.lastProgressMessage = message; + + if (writeStream.isTTY) { + // Overwrite previous progress updates in-place for a quieter UX. + writeStream.cursorTo(0); + writeStream.write(message); + writeStream.clearLine(1); + + if (finalUpdate) { + writeStream.write('\n'); + } + } else { + // Fall back to writing one line per progress update + writeStream.write(`${message}\n`); + } + } + + async [Symbol.asyncDispose]() { + await Promise.all( + this.playgroundsToCleanUp.map(async ({ playground, worker }) => { + await playground.dispose(); + await worker.terminate(); + }) + ); + } +} + +type SpawnedWorker = { + worker: Worker; + phpPort: NodeMessagePort; +}; +function spawnWorkerThreads( + workerUrlString: string, + count: number +): Promise { + const moduleWorkerUrl = new URL(workerUrlString, import.meta.url); + + const promises = []; + for (let i = 0; i < count; i++) { + const worker = new Worker(moduleWorkerUrl); + const onExit: (code: number) => void = (code: number) => { + if (code === 0) { + return; + } + process.stderr.write(`Worker ${i} exited with code ${code}\n`); + // If the primary worker crashes, exit the entire process. + if (i === 0) { + process.exit(1); + } + }; + promises.push( + new Promise<{ worker: Worker; phpPort: NodeMessagePort }>( + (resolve, reject) => { + worker.once('message', function (message: any) { + // Let the worker confirm it has initialized. + // We could use the 'online' event to detect start of JS execution, + // but that would miss initialization errors. + if (message.command === 'worker-script-initialized') { + resolve({ worker, phpPort: message.phpPort }); + } + }); + worker.once('error', function (e: Error) { + console.error(e); + const error = new Error( + `Worker failed to load at ${moduleWorkerUrl}. ${ + e.message ? `Original error: ${e.message}` : '' + }` + ); + (error as any).filename = moduleWorkerUrl; + reject(error); + }); + worker.once('exit', onExit); + } + ) + ); + } + return Promise.all(promises); +} + +/** + * Expose the file lock manager API on a MessagePort and return it. + * + * @see comlink-sync.ts + * @see phpwasm-emscripten-library-file-locking-for-node.js + */ +async function exposeFileLockManager(fileLockManager: FileLockManagerForNode) { + const { port1, port2 } = new NodeMessageChannel(); + if (await jspi()) { + /** + * When JSPI is available, the worker thread expects an asynchronous API. + * + * @see worker-thread.ts + * @see comlink-sync.ts + * @see phpwasm-emscripten-library-file-locking-for-node.js + */ + exposeAPI(fileLockManager, null, port1); + } else { + /** + * When JSPI is not available, the worker thread expects a synchronous API. + * + * @see worker-thread.ts + * @see comlink-sync.ts + * @see phpwasm-emscripten-library-file-locking-for-node.js + */ + await exposeSyncAPI(fileLockManager, port1); + } + return port2; +} + +async function zipSite( + playground: RemoteAPI, + outfile: string +) { + await playground.run({ + code: `open('/tmp/build.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE)) { + throw new Exception('Failed to create ZIP'); + } + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator('/wordpress') + ); + foreach ($files as $file) { + echo $file . PHP_EOL; + if (!$file->isFile()) { + continue; + } + $zip->addFile($file->getPathname(), $file->getPathname()); + } + $zip->close(); + + `, + }); + const zip = await playground.readFileAsBuffer('/tmp/build.zip'); + fs.writeFileSync(outfile, zip); +} diff --git a/packages/playground/cli/src/server.ts b/packages/playground/cli/src/server.ts index b9493b4960..acfcaf31e4 100644 --- a/packages/playground/cli/src/server.ts +++ b/packages/playground/cli/src/server.ts @@ -1,19 +1,24 @@ -import type { PHPRequest, PHPResponse } from '@php-wasm/universal'; +import type { PHPRequest, PHPResponse, RemoteAPI } from '@php-wasm/universal'; import type { Request } from 'express'; import express from 'express'; import type { IncomingMessage, Server, ServerResponse } from 'http'; import type { AddressInfo } from 'net'; -import type { RunCLIServer } from './run-cli'; -export interface ServerOptions { +export interface RunCLIServer extends AsyncDisposable { + playground: T; + server: Server; + [Symbol.asyncDispose](): Promise; +} + +export interface ServerOptions { port: number; - onBind: (server: Server, port: number) => Promise; + onBind: (server: Server, port: number) => Promise>; handleRequest: (request: PHPRequest) => Promise; } -export async function startServer( - options: ServerOptions -): Promise { +export async function startServer( + options: ServerOptions +): Promise> { const app = express(); const server = await new Promise< diff --git a/packages/playground/cli/src/test/cli-run.spec.ts b/packages/playground/cli/src/test/cli-run.spec.ts index ea1aba53c5..12606d6b22 100644 --- a/packages/playground/cli/src/test/cli-run.spec.ts +++ b/packages/playground/cli/src/test/cli-run.spec.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { runCLI } from '../run-cli'; -import type { RunCLIServer } from '../run-cli'; +import { runCLI } from '../run-cli-v1'; +import type { RunCLIServerV1 } from '../run-cli-v1'; import type { MockInstance } from 'vitest'; import { vi } from 'vitest'; import { mkdtemp, writeFile } from 'node:fs/promises'; @@ -14,7 +14,7 @@ import { MinifiedWordPressVersionsList } from '@wp-playground/wordpress-builds'; // TODO: Fix or rework these tests because it is difficult to run them now that // runCLI() launches a Worker. describe.skip('cli-run', () => { - let cliServer: RunCLIServer; + let cliServer: RunCLIServerV1; afterEach(async () => { if (cliServer) { diff --git a/packages/playground/cli/src/v2.spec.ts b/packages/playground/cli/src/v2.spec.ts new file mode 100644 index 0000000000..b857e88821 --- /dev/null +++ b/packages/playground/cli/src/v2.spec.ts @@ -0,0 +1,171 @@ +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; +} + +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)) as BlobPart; + } else { + const response = await fetch(v2_runner_url); + data = (await response.blob()) as BlobPart; + } + 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/src/worker-thread.ts b/packages/playground/cli/src/worker-thread-v1.ts similarity index 93% rename from packages/playground/cli/src/worker-thread.ts rename to packages/playground/cli/src/worker-thread-v1.ts index 23ce53df41..67973699e0 100644 --- a/packages/playground/cli/src/worker-thread.ts +++ b/packages/playground/cli/src/worker-thread-v1.ts @@ -43,10 +43,10 @@ export type PrimaryWorkerBootOptions = { internalCookieStore?: boolean; }; -function mountResources(php: PHP, mounts: Mount[]) { +async function mountResources(php: PHP, mounts: Mount[]) { for (const mount of mounts) { php.mkdir(mount.vfsPath); - php.mount(mount.vfsPath, createNodeFsMountHandler(mount.hostPath)); + await php.mount(mount.vfsPath, createNodeFsMountHandler(mount.hostPath)); } } @@ -66,7 +66,7 @@ function tracePhpWasm(processId: number, format: string, ...args: any[]) { ); } -export class PlaygroundCliWorker extends PHPWorker { +export class PlaygroundCliBlueprintV1Worker extends PHPWorker { booted = false; fileLockManager: RemoteAPI | FileLockManager | undefined; @@ -182,7 +182,7 @@ export class PlaygroundCliWorker extends PHPWorker { }, hooks: { async beforeWordPressFiles(php) { - mountResources(php, mountsBeforeWpInstall); + await mountResources(php, mountsBeforeWpInstall); }, }, cookieStore: internalCookieStore ? undefined : false, @@ -194,7 +194,7 @@ export class PlaygroundCliWorker extends PHPWorker { const primaryPhp = await requestHandler.getPrimaryPhp(); await this.setPrimaryPHP(primaryPhp); - mountResources(primaryPhp, mountsAfterWpInstall); + await mountResources(primaryPhp, mountsAfterWpInstall); setApiReady(); } catch (e) { @@ -212,7 +212,7 @@ export class PlaygroundCliWorker extends PHPWorker { const phpChannel = new MessageChannel(); const [setApiReady, setAPIError] = exposeAPI( - new PlaygroundCliWorker(new EmscriptenDownloadMonitor()), + new PlaygroundCliBlueprintV1Worker(new EmscriptenDownloadMonitor()), undefined, phpChannel.port1 ); diff --git a/packages/playground/cli/src/worker-thread-v2.ts b/packages/playground/cli/src/worker-thread-v2.ts deleted file mode 100644 index 99cd80d69a..0000000000 --- a/packages/playground/cli/src/worker-thread-v2.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { errorLogPath } from '@php-wasm/logger'; -import type { FileLockManager } from '@php-wasm/node'; -import { createNodeFsMountHandler, loadNodeRuntime } from '@php-wasm/node'; -import { EmscriptenDownloadMonitor } from '@php-wasm/progress'; -import type { PHP, SupportedPHPVersion } from '@php-wasm/universal'; -import { - PHPExecutionFailureError, - PHPResponse, - PHPWorker, - consumeAPI, - exposeAPI, - sandboxedSpawnHandlerFactory, -} from '@php-wasm/universal'; -import { sprintf } from '@php-wasm/util'; -import { runBlueprintV2, type BlueprintMessage } from './v2'; -import { bootRequestHandler } from '@wp-playground/wordpress'; -import { existsSync } from 'fs'; -import path from 'path'; -import { rootCertificates } from 'tls'; -import { parentPort } from 'worker_threads'; -import type { Mount } from './mounts'; -import type { RunCLIArgsV2 } from './run-cli-v2'; - -async function mountResources(php: PHP, mounts: Mount[]) { - for (const mount of mounts) { - try { - php.mkdir(mount.vfsPath); - await php.mount( - mount.vfsPath, - createNodeFsMountHandler(mount.hostPath) - ); - } catch { - output.stderr( - `\x1b[31m\x1b[1mError mounting path ${mount.hostPath} at ${mount.vfsPath}\x1b[0m\n` - ); - process.exit(1); - } - } -} - -/** - * Print trace messages from PHP-WASM. - * - * @param {number} processId - The process ID. - * @param {string} format - The format string. - * @param {...any} args - The arguments. - */ -function tracePhpWasm(processId: number, format: string, ...args: any[]) { - // eslint-disable-next-line no-console - console.log( - performance.now().toFixed(6).padStart(15, '0'), - processId.toString().padStart(16, '0'), - sprintf(format, ...args) - ); -} - -/** - * Force TTY status to preserve ANSI control codes in the output. - * - * This script is spawned as `new Worker()` and process.stdout and process.stderr are - * WritableWorkerStdio objects. By default, they strip ANSI control codes from the output - * causing every progress bar update to be printed in a new line instead of updating the - * same line. - */ -Object.defineProperty(process.stdout, 'isTTY', { value: true }); -Object.defineProperty(process.stderr, 'isTTY', { value: true }); - -/** - * Output writer that ensures that progress bars are not printed on the same line as other output. - */ -const output = { - lastWriteWasProgress: false, - progress(data: string) { - if (!process.stdout.isTTY) { - // eslint-disable-next-line no-console - console.log(data); - } else { - if (!output.lastWriteWasProgress) { - process.stdout.write('\n'); - } - process.stdout.write('\r\x1b[K' + data); - output.lastWriteWasProgress = true; - } - }, - stdout(data: string) { - if (output.lastWriteWasProgress) { - process.stdout.write('\n'); - output.lastWriteWasProgress = false; - } - process.stdout.write(data); - }, - stderr(data: string) { - if (output.lastWriteWasProgress) { - process.stdout.write('\n'); - output.lastWriteWasProgress = false; - } - process.stderr.write(data); - }, -}; - -export interface WorkerBootArgs extends RunCLIArgsV2 { - siteUrl: string; - firstProcessId: number; - processIdSpaceLength: number; - trace: boolean; -} - -interface WorkerRunBlueprintArgs extends RunCLIArgsV2 { - siteUrl: string; -} - -interface WorkerBootRequestHandlerOptions { - siteUrl: string; - php: SupportedPHPVersion; - allow?: string; - firstProcessId: number; - processIdSpaceLength: number; - trace: boolean; -} - -export class PlaygroundCliWorker extends PHPWorker { - booted = false; - - constructor(monitor: EmscriptenDownloadMonitor) { - super(undefined, monitor); - } - - async bootAsPrimaryWorker(args: WorkerBootArgs) { - await this.bootRequestHandler(args); - - const primaryPhp = this.__internal_getPHP()!; - await mountResources(primaryPhp, args['mount-before-install'] || []); - - if (args.mode === 'mount-only') { - await mountResources(primaryPhp, args.mount || []); - return; - } - - await this.runBlueprintV2(args); - } - - async bootAsSecondaryWorker(args: WorkerBootArgs) { - await this.bootRequestHandler(args); - const primaryPhp = this.__internal_getPHP()!; - // When secondary workers are spawned, WordPress is already installed. - await mountResources(primaryPhp, args['mount-before-install'] || []); - await mountResources(primaryPhp, args.mount || []); - } - - async runBlueprintV2(args: WorkerRunBlueprintArgs) { - const requestHandler = this.__internal_getRequestHandler()!; - const { php, reap } = - await requestHandler.processManager.acquirePHPInstance({ - considerPrimary: false, - }); - - // Mount the current working directory to the PHP runtime for the purposes of - // Blueprint resolution. - const primaryPhp = this.__internal_getPHP()!; - let unmountCwd = () => {}; - if (typeof args.blueprint === 'string') { - const blueprintPath = path.resolve(process.cwd(), args.blueprint); - if (existsSync(blueprintPath)) { - primaryPhp.mkdir('/internal/shared/cwd'); - unmountCwd = await primaryPhp.mount( - '/internal/shared/cwd', - createNodeFsMountHandler(path.dirname(blueprintPath)) - ); - args.blueprint = path.join( - '/internal/shared/cwd', - path.basename(args.blueprint) - ); - } - } - - try { - const cliArgsToPass: (keyof WorkerRunBlueprintArgs)[] = [ - 'mode', - 'db-engine', - 'db-host', - 'db-user', - 'db-pass', - 'db-name', - 'db-path', - 'truncate-new-site-directory', - 'allow', - ]; - const cliArgs = cliArgsToPass - .filter((arg) => arg in args) - .map((arg) => `--${arg}=${args[arg]}`); - cliArgs.push(`--site-url=${args.siteUrl}`); - - let afterBlueprintTargetResolvedCalled = false; - - const streamedResponse = await runBlueprintV2({ - php, - blueprint: args.blueprint, - blueprintOverrides: { - additionalSteps: args['additional-blueprint-steps'], - wordpressVersion: args.wp, - }, - cliArgs, - onMessage: async (message: BlueprintMessage) => { - switch (message.type) { - case 'blueprint.target_resolved': { - if (!afterBlueprintTargetResolvedCalled) { - await mountResources( - primaryPhp, - args.mount || [] - ); - afterBlueprintTargetResolvedCalled = true; - } - break; - } - case 'blueprint.progress': { - const progressMessage = `${message.caption.trim()} – ${message.progress.toFixed( - 2 - )}%`; - output.progress(progressMessage); - break; - } - case 'blueprint.error': { - const red = '\x1b[31m'; - const bold = '\x1b[1m'; - const reset = '\x1b[0m'; - if (args.debug && message.details) { - output.stderr( - `${red}${bold}Fatal error:${reset} Uncaught ${message.details.exception}: ${message.details.message}\n` + - ` at ${message.details.file}:${message.details.line}\n` + - (message.details.trace - ? message.details.trace + '\n' - : '') - ); - } else { - output.stderr( - `${red}${bold}Error:${reset} ${message.message}\n` - ); - } - break; - } - } - }, - }); - /** - * When we're debugging, every bit of information matters – let's immediately output - * everything we get from the PHP output streams. - */ - if (args.debug) { - streamedResponse!.stdout.pipeTo( - new WritableStream({ - write(chunk) { - process.stdout.write(chunk); - }, - }) - ); - streamedResponse!.stderr.pipeTo( - new WritableStream({ - write(chunk) { - process.stderr.write(chunk); - }, - }) - ); - } - await streamedResponse!.finished; - if ((await streamedResponse!.exitCode) !== 0) { - // exitCode != 1 means the blueprint execution failed. Let's throw an error. - // and clean up. - const syncResponse = await PHPResponse.fromStreamedResponse( - streamedResponse - ); - throw new PHPExecutionFailureError( - `PHP.run() failed with exit code ${syncResponse.exitCode}.`, - syncResponse, - 'request' - ); - } - } catch (error) { - // Capture the PHP error log details to provide more context for debugging. - let phpLogs = ''; - try { - // @TODO: Don't assume errorLogPath starts with /wordpress/ - // ...or maybe we can assume that in Playground CLI? - phpLogs = php.readFileAsText(errorLogPath); - } catch { - // Ignore errors reading the PHP error log. - } - (error as any).phpLogs = phpLogs; - throw error; - } finally { - reap(); - unmountCwd(); - } - } - - async bootRequestHandler({ - siteUrl, - allow, - php, - firstProcessId, - processIdSpaceLength, - trace, - }: WorkerBootRequestHandlerOptions) { - if (this.booted) { - throw new Error('Playground already booted'); - } - this.booted = true; - - let nextProcessId = firstProcessId; - const lastProcessId = firstProcessId + processIdSpaceLength - 1; - const fileLockManager = consumeAPI(parentPort!); - await fileLockManager.isConnected(); - - try { - const constants: Record = - { - WP_DEBUG: true, - WP_DEBUG_LOG: true, - WP_DEBUG_DISPLAY: false, - }; - - const requestHandler = await bootRequestHandler({ - siteUrl, - createPhpRuntime: async () => { - const processId = nextProcessId; - - if (nextProcessId < lastProcessId) { - nextProcessId++; - } else { - // We've reached the end of the process ID space. Start over. - nextProcessId = firstProcessId; - } - - return await loadNodeRuntime(php, { - emscriptenOptions: { - fileLockManager, - processId, - trace: trace ? tracePhpWasm : undefined, - ENV: { - DOCROOT: '/wordpress', - }, - }, - followSymlinks: allow?.includes('follow-symlinks'), - }); - }, - sapiName: 'cli', - createFiles: { - '/internal/shared/ca-bundle.crt': - rootCertificates.join('\n'), - }, - constants, - phpIniEntries: { - 'openssl.cafile': '/internal/shared/ca-bundle.crt', - }, - cookieStore: false, - spawnHandler: sandboxedSpawnHandlerFactory, - }); - this.__internal_setRequestHandler(requestHandler); - - const primaryPhp = await requestHandler.getPrimaryPhp(); - await this.setPrimaryPHP(primaryPhp); - - setApiReady(); - } catch (e) { - setAPIError(e as Error); - throw e; - } - } - - // Provide a named disposal method that can be invoked via comlink. - async dispose() { - await this[Symbol.asyncDispose](); - } -} - -const [setApiReady, setAPIError] = exposeAPI( - new PlaygroundCliWorker(new EmscriptenDownloadMonitor()), - undefined, - parentPort! -); - -// Confirm that the worker script has initialized. -parentPort!.postMessage('worker-script-initialized'); From 69975b215d53fbd63d0b4c53d0b8ddc0ba70b2c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 15 Jul 2025 18:33:33 +0200 Subject: [PATCH 03/18] Lint --- packages/playground/cli/src/run-cli.ts | 5 +++-- packages/playground/cli/src/server.ts | 2 +- packages/playground/cli/src/worker-thread-v1.ts | 5 ++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index b8c0999228..e23adc991f 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -49,7 +49,7 @@ import { resolveBlueprint } from './resolve-blueprint'; import { FileLockManagerForNode } from '@php-wasm/node'; import { EmscriptenDownloadMonitor, ProgressTracker } from '@php-wasm/progress'; import { resolveWordPressRelease } from '@wp-playground/wordpress'; -import { Server } from 'http'; +import type { Server } from 'http'; import path from 'path'; import { CACHE_FOLDER, @@ -284,7 +284,8 @@ export async function parseOptionsAndRunCLI() { coerce: (value?: number) => value ?? cpus().length - 1, }) - // Legacy options, specific to Blueprints v1 (BC reasons only, they're hidden from the help message). + // Legacy options, specific to Blueprints v1 (BC reasons only, they're hidden from + // the help message). .option('skip-wordpress-setup', { describe: 'Do not download, unzip, and install WordPress. Useful for mounting a pre-configured WordPress directory at /wordpress.', diff --git a/packages/playground/cli/src/server.ts b/packages/playground/cli/src/server.ts index acfcaf31e4..b99330a9e2 100644 --- a/packages/playground/cli/src/server.ts +++ b/packages/playground/cli/src/server.ts @@ -1,4 +1,4 @@ -import type { PHPRequest, PHPResponse, RemoteAPI } from '@php-wasm/universal'; +import type { PHPRequest, PHPResponse } from '@php-wasm/universal'; import type { Request } from 'express'; import express from 'express'; import type { IncomingMessage, Server, ServerResponse } from 'http'; diff --git a/packages/playground/cli/src/worker-thread-v1.ts b/packages/playground/cli/src/worker-thread-v1.ts index 67973699e0..2ac1ac776f 100644 --- a/packages/playground/cli/src/worker-thread-v1.ts +++ b/packages/playground/cli/src/worker-thread-v1.ts @@ -46,7 +46,10 @@ export type PrimaryWorkerBootOptions = { async function mountResources(php: PHP, mounts: Mount[]) { for (const mount of mounts) { php.mkdir(mount.vfsPath); - await php.mount(mount.vfsPath, createNodeFsMountHandler(mount.hostPath)); + await php.mount( + mount.vfsPath, + createNodeFsMountHandler(mount.hostPath) + ); } } From b8a4e3652f6154fe97e046592e6e46dc6d3ed445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 15 Jul 2025 18:42:49 +0200 Subject: [PATCH 04/18] Lint, cleanup --- packages/playground/cli/src/mounts.spec.ts | 18 ++++---- packages/playground/cli/src/run-cli.ts | 44 ++++++------------- .../playground/cli/src/test/cli-run.spec.ts | 18 ++++---- 3 files changed, 32 insertions(+), 48 deletions(-) diff --git a/packages/playground/cli/src/mounts.spec.ts b/packages/playground/cli/src/mounts.spec.ts index 5a25708f39..29adceb12e 100644 --- a/packages/playground/cli/src/mounts.spec.ts +++ b/packages/playground/cli/src/mounts.spec.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import type { MockInstance } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { expandAutoMounts } from './mounts'; -import type { RunCLIV1Args } from './run-cli-v1'; +import type { RunCLIArgs } from './run-cli'; describe('expandAutoMounts', () => { afterEach(() => { @@ -11,7 +11,7 @@ describe('expandAutoMounts', () => { } }); - const createBasicArgs = (): RunCLIV1Args => ({ + const createBasicArgs = (): RunCLIArgs => ({ command: 'server', php: '8.0', }); @@ -296,7 +296,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIV1Args = { + const args: RunCLIArgs = { ...createBasicArgs(), mount: [ { @@ -327,7 +327,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/wordpress') ); - const args: RunCLIV1Args = { + const args: RunCLIArgs = { ...createBasicArgs(), 'mount-before-install': [ { @@ -357,7 +357,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIV1Args = { + const args: RunCLIArgs = { ...createBasicArgs(), 'additional-blueprint-steps': [ { @@ -387,7 +387,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIV1Args = { + const args: RunCLIArgs = { ...createBasicArgs(), mount: undefined, 'mount-before-install': undefined, @@ -411,7 +411,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIV1Args = { + const args: RunCLIArgs = { ...createBasicArgs(), blueprint: undefined, }; @@ -430,7 +430,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIV1Args = { + const args: RunCLIArgs = { ...createBasicArgs(), blueprint: { plugins: ['gutenberg'] }, }; @@ -445,7 +445,7 @@ describe('expandAutoMounts', () => { path.join(__dirname, 'test/mount-examples/plugin') ); - const args: RunCLIV1Args = { + const args: RunCLIArgs = { ...createBasicArgs(), php: '8.1', port: 3000, diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index e23adc991f..e35a3f0765 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -548,9 +548,8 @@ export async function runCLI(args: RunCLIArgs): Promise { port: args['port'] as number, onBind: async (server: Server, port: number) => { const siteUrl = `http://127.0.0.1:${port}`; - const handler = new V1Handler(args, { + const handler = new BlueprintsV1Handler(args, { siteUrl, - totalWorkerCount, processIdSpaceLength, }); @@ -676,18 +675,17 @@ export async function runCLI(args: RunCLIArgs): Promise { }); } -class V1Handler { +/** + * Boots Playground CLI workers using Blueprint version 1. + * + * Progress tracking, downloads, steps, and all other features are + * implemented in TypeScript and orchestrated by this class. + */ +class BlueprintsV1Handler { + private phpVersion: SupportedPHPVersion | undefined; private lastProgressMessage = ''; - private playgroundsToCleanUp: { - playground: RemoteAPI; - worker: Worker; - }[] = []; - - private compiledBlueprint: CompiledBlueprint | undefined; - private siteUrl: string; - private totalWorkerCount: number; private processIdSpaceLength: number; private args: RunCLIArgs; @@ -695,13 +693,11 @@ class V1Handler { args: RunCLIArgs, options: { siteUrl: string; - totalWorkerCount: number; processIdSpaceLength: number; } ) { this.args = args; this.siteUrl = options.siteUrl; - this.totalWorkerCount = options.totalWorkerCount; this.processIdSpaceLength = options.processIdSpaceLength; } @@ -713,9 +709,10 @@ class V1Handler { phpPort: NodeMessagePort, fileLockManagerPort: NodeMessagePort ) { - this.compiledBlueprint = await this.compileInputBlueprint( + const compiledBlueprint = await this.compileInputBlueprint( this.args['additional-blueprint-steps'] || [] ); + this.phpVersion = compiledBlueprint.versions.php; let wpDetails: any = undefined; // @TODO: Rename to FetchProgressMonitor. There's nothing Emscripten @@ -791,8 +788,8 @@ class V1Handler { await playground.useFileLockManager(fileLockManagerPort); await playground.boot({ - phpVersion: this.compiledBlueprint!.versions.php, - wpVersion: this.compiledBlueprint!.versions.wp, + phpVersion: this.phpVersion, + wpVersion: compiledBlueprint.versions.wp, absoluteUrl: this.siteUrl, mountsBeforeWpInstall, mountsAfterWpInstall, @@ -834,15 +831,11 @@ class V1Handler { const additionalPlayground = consumeAPI( worker.phpPort ); - this.playgroundsToCleanUp.push({ - playground: additionalPlayground, - worker: worker.worker, - }); await additionalPlayground.isConnected(); await additionalPlayground.useFileLockManager(fileLockManagerPort); await additionalPlayground.boot({ - phpVersion: this.compiledBlueprint!.versions.php, + phpVersion: this.phpVersion, absoluteUrl: this.siteUrl, mountsBeforeWpInstall: this.args['mount-before-install'] || [], mountsAfterWpInstall: this.args['mount'] || [], @@ -957,15 +950,6 @@ class V1Handler { writeStream.write(`${message}\n`); } } - - async [Symbol.asyncDispose]() { - await Promise.all( - this.playgroundsToCleanUp.map(async ({ playground, worker }) => { - await playground.dispose(); - await worker.terminate(); - }) - ); - } } type SpawnedWorker = { diff --git a/packages/playground/cli/src/test/cli-run.spec.ts b/packages/playground/cli/src/test/cli-run.spec.ts index 12606d6b22..f628b4a507 100644 --- a/packages/playground/cli/src/test/cli-run.spec.ts +++ b/packages/playground/cli/src/test/cli-run.spec.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { runCLI } from '../run-cli-v1'; -import type { RunCLIServerV1 } from '../run-cli-v1'; +import { runCLI } from '../run-cli'; +import type { RunCLIServer } from '../run-cli'; import type { MockInstance } from 'vitest'; import { vi } from 'vitest'; import { mkdtemp, writeFile } from 'node:fs/promises'; @@ -14,7 +14,7 @@ import { MinifiedWordPressVersionsList } from '@wp-playground/wordpress-builds'; // TODO: Fix or rework these tests because it is difficult to run them now that // runCLI() launches a Worker. describe.skip('cli-run', () => { - let cliServer: RunCLIServerV1; + let cliServer: RunCLIServer; afterEach(async () => { if (cliServer) { @@ -115,7 +115,7 @@ describe.skip('cli-run', () => { ); cliServer = await runCLI({ command: 'server', - autoMount: true, + 'auto-mount': true, }); const phpResponse = await cliServer.playground.run({ code: ` { ); cliServer = await runCLI({ command: 'server', - autoMount: true, + 'auto-mount': true, }); expect(await getActiveTheme()).toBe('Yolo Theme'); @@ -162,7 +162,7 @@ describe.skip('cli-run', () => { ); cliServer = await runCLI({ command: 'server', - autoMount: true, + 'auto-mount': true, }); const response = await cliServer.playground.request({ url: '/wp-login.php', @@ -177,7 +177,7 @@ describe.skip('cli-run', () => { ); cliServer = await runCLI({ command: 'server', - autoMount: true, + 'auto-mount': true, }); const response = await cliServer.playground.request({ url: '/', @@ -193,7 +193,7 @@ describe.skip('cli-run', () => { ); cliServer = await runCLI({ command: 'server', - autoMount: true, + 'auto-mount': true, }); const response = await cliServer.playground.request({ url: '/', @@ -220,7 +220,7 @@ describe.skip('cli-run', () => { cliServer = await runCLI({ command: 'server', - autoMount: true, + 'auto-mount': true, }); const response = await cliServer.playground.request({ url: '/', From 6e7a3fae441abd751bb19343d9da3f4a7be75ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 15 Jul 2025 18:46:59 +0200 Subject: [PATCH 05/18] Lint, cleanup --- packages/playground/cli/src/run-cli.ts | 123 ++++++++---------- .../playground/cli/src/test/cli-run.spec.ts | 4 +- 2 files changed, 54 insertions(+), 73 deletions(-) diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index e35a3f0765..007e884704 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -58,9 +58,8 @@ import { readAsFile, } from './download'; import { LoadBalancer } from './load-balancer'; -import { startServer } from './server'; +import { RunCLIServer, startServer } from './server'; import type { PlaygroundCliBlueprintV1Worker } from './worker-thread-v1'; -import type { PlaygroundCliBlueprintV2Worker } from './worker-thread-v2'; /* eslint-disable no-console */ export interface RunCLIArgs { @@ -80,7 +79,6 @@ export interface RunCLIArgs { 'experimental-multi-worker'?: number; 'experimental-trace'?: boolean; - 'blueprint-version'?: 'v1' | 'v2' | 'auto'; // v1-specific options (hidden from help but supported for backward compatibility) 'skip-wordpress-setup'?: boolean; @@ -208,63 +206,6 @@ export async function parseOptionsAndRunCLI() { type: 'boolean', default: false, }) - - // Blueprint version selection - .option('blueprint-version', { - describe: 'Blueprint version to use (auto-detected by default)', - type: 'string', - choices: ['v1', 'v2', 'auto'], - default: 'auto', - }) - - // v2-specific Blueprint CLI options - .option('mode', { - describe: 'Execution mode', - type: 'string', - default: 'create-new-site', - choices: [ - 'create-new-site', - 'apply-to-existing-site', - 'mount-only', - ], - }) - .option('db-engine', { - describe: 'Database engine', - type: 'string', - default: 'sqlite', - choices: ['mysql', 'sqlite'], - }) - .option('db-host', { - describe: 'MySQL host', - type: 'string', - }) - .option('db-user', { - describe: 'MySQL user', - type: 'string', - }) - .option('db-pass', { - describe: 'MySQL password', - type: 'string', - }) - .option('db-name', { - describe: 'MySQL database', - type: 'string', - }) - .option('db-path', { - describe: 'SQLite file path', - type: 'string', - }) - .option('truncate-new-site-directory', { - describe: - 'Delete target directory if it exists before execution', - type: 'boolean', - }) - .option('allow', { - describe: 'Allowed permissions (comma-separated)', - type: 'string', - coerce: (value) => value?.split(','), - choices: ['bundled-files', 'follow-symlinks'], - }) .option('experimental-trace', { describe: 'Print detailed messages about system behavior to the console. Useful for troubleshooting.', @@ -283,9 +224,57 @@ export async function parseOptionsAndRunCLI() { type: 'number', coerce: (value?: number) => value ?? cpus().length - 1, }) + .option('allow', { + describe: 'Allowed permissions (comma-separated)', + type: 'string', + coerce: (value) => value?.split(','), + choices: ['bundled-files', 'follow-symlinks'], + }) - // Legacy options, specific to Blueprints v1 (BC reasons only, they're hidden from - // the help message). + // v2-specific Blueprint CLI options – commented until a v2 worker is implemented + // .option('mode', { + // describe: 'Execution mode', + // type: 'string', + // default: 'create-new-site', + // choices: [ + // 'create-new-site', + // 'apply-to-existing-site', + // 'mount-only', + // ], + // }) + // .option('db-engine', { + // describe: 'Database engine', + // type: 'string', + // default: 'sqlite', + // choices: ['mysql', 'sqlite'], + // }) + // .option('db-host', { + // describe: 'MySQL host', + // type: 'string', + // }) + // .option('db-user', { + // describe: 'MySQL user', + // type: 'string', + // }) + // .option('db-pass', { + // describe: 'MySQL password', + // type: 'string', + // }) + // .option('db-name', { + // describe: 'MySQL database', + // type: 'string', + // }) + // .option('db-path', { + // describe: 'SQLite file path', + // type: 'string', + // }) + // .option('truncate-new-site-directory', { + // describe: + // 'Delete target directory if it exists before execution', + // type: 'boolean', + // }) + + // Blueprints v1 (legacy) options. Internally, they're migrated to v2 notation. .option('skip-wordpress-setup', { describe: 'Do not download, unzip, and install WordPress. Useful for mounting a pre-configured WordPress directory at /wordpress.', @@ -483,15 +472,7 @@ export async function parseOptionsAndRunCLI() { } } -export interface RunCLIServer extends AsyncDisposable { - playground: - | RemoteAPI - | RemoteAPI; - server: Server; - [Symbol.asyncDispose](): Promise; -} - -export async function runCLI(args: RunCLIArgs): Promise { +export async function runCLI(args: RunCLIArgs) { let loadBalancer: LoadBalancer | undefined = undefined; const playgroundsToCleanUp: { diff --git a/packages/playground/cli/src/test/cli-run.spec.ts b/packages/playground/cli/src/test/cli-run.spec.ts index f628b4a507..3112bcca11 100644 --- a/packages/playground/cli/src/test/cli-run.spec.ts +++ b/packages/playground/cli/src/test/cli-run.spec.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { runCLI } from '../run-cli'; -import type { RunCLIServer } from '../run-cli'; +import type { RunCLIServer } from '../server'; import type { MockInstance } from 'vitest'; import { vi } from 'vitest'; import { mkdtemp, writeFile } from 'node:fs/promises'; @@ -14,7 +14,7 @@ import { MinifiedWordPressVersionsList } from '@wp-playground/wordpress-builds'; // TODO: Fix or rework these tests because it is difficult to run them now that // runCLI() launches a Worker. describe.skip('cli-run', () => { - let cliServer: RunCLIServer; + let cliServer: RunCLIServer; afterEach(async () => { if (cliServer) { From 1f5aa90d9b21072f6775cc08b76077547a082119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 15 Jul 2025 18:47:49 +0200 Subject: [PATCH 06/18] Lint, cleanup --- packages/playground/cli/src/load-balancer.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/playground/cli/src/load-balancer.ts b/packages/playground/cli/src/load-balancer.ts index 7529b77223..7620ea059b 100644 --- a/packages/playground/cli/src/load-balancer.ts +++ b/packages/playground/cli/src/load-balancer.ts @@ -1,6 +1,5 @@ import type { PHPRequest, PHPResponse, RemoteAPI } from '@php-wasm/universal'; import type { PlaygroundCliBlueprintV1Worker } from './worker-thread-v1'; -import type { PlaygroundCliBlueprintV2Worker } from './worker-thread-v2'; // TODO: Let's merge worker management into PHPProcessManager // when we can have multiple workers in both CLI and web. @@ -8,9 +7,7 @@ import type { PlaygroundCliBlueprintV2Worker } from './worker-thread-v2'; // TODO: Could we just spawn a worker using the factory function to PHPProcessManager? type WorkerLoad = { - worker: RemoteAPI< - PlaygroundCliBlueprintV1Worker | PlaygroundCliBlueprintV2Worker - >; + worker: RemoteAPI; activeRequests: Set>; }; export class LoadBalancer { @@ -22,18 +19,12 @@ export class LoadBalancer { // Playground CLI initialization, as of 2025-06-11, requires that // an initial worker is booted alone and initialized via Blueprint // before additional workers are created based on the initialized worker. - initialWorker: RemoteAPI< - PlaygroundCliBlueprintV1Worker | PlaygroundCliBlueprintV2Worker - > + initialWorker: RemoteAPI ) { this.addWorker(initialWorker); } - addWorker( - worker: RemoteAPI< - PlaygroundCliBlueprintV1Worker | PlaygroundCliBlueprintV2Worker - > - ) { + addWorker(worker: RemoteAPI) { this.workerLoads.push({ worker, activeRequests: new Set(), From 5ec5328ad8beaea7ea39b597b01fa0e98b38885d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 15 Jul 2025 18:50:16 +0200 Subject: [PATCH 07/18] Lint, cleanup --- packages/playground/cli/src/run-cli.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 007e884704..072a835547 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -466,7 +466,6 @@ export async function parseOptionsAndRunCLI() { console.log(reportableCause.message); process.exit(1); } else { - // If we did not expect this error, print **all** the debug details we can get. throw e; } } From 79a2297815378c199f888ae365e387842aed498f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 15 Jul 2025 18:51:41 +0200 Subject: [PATCH 08/18] Lint, cleanup --- packages/playground/cli/src/run-cli.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 072a835547..a7b2092eab 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -14,7 +14,6 @@ import { } from '@php-wasm/universal'; import { compileBlueprint, - type CompiledBlueprint, isBlueprintBundle, type BlueprintBundle, type BlueprintDeclaration, @@ -58,7 +57,7 @@ import { readAsFile, } from './download'; import { LoadBalancer } from './load-balancer'; -import { RunCLIServer, startServer } from './server'; +import { startServer } from './server'; import type { PlaygroundCliBlueprintV1Worker } from './worker-thread-v1'; /* eslint-disable no-console */ From 9258c25c59693d274fca17db42871bce19528e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 15 Jul 2025 18:57:36 +0200 Subject: [PATCH 09/18] Remove the 'allow' option --- packages/playground/cli/src/run-cli.ts | 48 +++++--------------------- 1 file changed, 9 insertions(+), 39 deletions(-) diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index a7b2092eab..5d0cd9ce53 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -95,7 +95,6 @@ export interface RunCLIArgs { 'db-name'?: string; 'db-path'?: string; 'truncate-new-site-directory'?: boolean; - allow?: string[]; } export async function parseOptionsAndRunCLI() { @@ -223,14 +222,14 @@ export async function parseOptionsAndRunCLI() { type: 'number', coerce: (value?: number) => value ?? cpus().length - 1, }) - .option('allow', { - describe: 'Allowed permissions (comma-separated)', - type: 'string', - coerce: (value) => value?.split(','), - choices: ['bundled-files', 'follow-symlinks'], - }) // v2-specific Blueprint CLI options – commented until a v2 worker is implemented + // .option('allow', { + // describe: 'Allowed permissions (comma-separated)', + // type: 'string', + // coerce: (value) => value?.split(','), + // choices: ['bundled-files', 'follow-symlinks'], + // }) // .option('mode', { // describe: 'Execution mode', // type: 'string', @@ -279,27 +278,23 @@ export async function parseOptionsAndRunCLI() { 'Do not download, unzip, and install WordPress. Useful for mounting a pre-configured WordPress directory at /wordpress.', type: 'boolean', default: false, - hidden: true, }) .option('skip-sqlite-setup', { describe: 'Skip the SQLite integration plugin setup to allow the WordPress site to use MySQL.', type: 'boolean', default: false, - hidden: true, }) .option('blueprint-may-read-adjacent-files', { describe: 'Consent flag: Allow "bundled" resources in a local blueprint to read files in the same directory as the blueprint file.', type: 'boolean', default: false, - hidden: true, }) .option('follow-symlinks', { describe: 'Allow Playground to follow symlinks by automatically mounting symlinked directories and files encountered in mounted directories. \nWarning: Following symlinks will expose files outside mounted directories to Playground and could be a security risk.', type: 'boolean', - default: false, }) // Backward compatibility aliases (hidden) @@ -367,27 +362,6 @@ export async function parseOptionsAndRunCLI() { args['auto-mount'] = args.autoMount; } - // Convert V1 arguments to V2 arguments - if (!args['allow']) { - args['allow'] = []; - } - - if (args['follow-symlinks']) { - args['allow'].push('follow-symlinks'); - } - - if (args['blueprint-may-read-adjacent-files']) { - args['allow'].push('bundled-files'); - } - - if (args['skip-sqlite-setup']) { - args['db-engine'] = 'apply-to-existing-site'; - } - - if (args['skip-wordpress-setup']) { - args['mode'] = 'mount-only'; - } - // Validation if (args.wp !== undefined && !isValidWordPressSlug(args.wp)) { try { @@ -751,8 +725,7 @@ class BlueprintsV1Handler { ? undefined : await fetchSqliteIntegration(monitor); - const followSymlinks = - this.args.allow?.includes('follow-symlinks') === true; + const followSymlinks = this.args['follow-symlinks'] === true; const trace = this.args['experimental-trace'] === true; const mountsBeforeWpInstall = this.args['mount-before-install'] || []; @@ -827,8 +800,7 @@ class BlueprintsV1Handler { dataSqlPath: '/wordpress/wp-content/database/.ht.sqlite', firstProcessId, processIdSpaceLength: this.processIdSpaceLength, - followSymlinks: - this.args['allow']?.includes('follow-symlinks') === true, + followSymlinks: this.args['follow-symlinks'] === true, trace: this.args['experimental-trace'] === true, // @TODO: Move this to the request handler or else every worker // will have a separate cookie store. @@ -845,9 +817,7 @@ class BlueprintsV1Handler { ? await resolveBlueprint({ sourceString: args.blueprint, blueprintMayReadAdjacentFiles: - args['allow']?.includes( - 'blueprint-may-read-adjacent-files' - ) === true, + args['blueprint-may-read-adjacent-files'] === true, }) : (args.blueprint as BlueprintDeclaration); /** From 741cda0097c6745e6381ec8a79220a503ad24245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 15 Jul 2025 20:00:07 +0200 Subject: [PATCH 10/18] Lint --- packages/playground/cli/src/v2.spec.ts | 171 ------------------------- 1 file changed, 171 deletions(-) delete mode 100644 packages/playground/cli/src/v2.spec.ts diff --git a/packages/playground/cli/src/v2.spec.ts b/packages/playground/cli/src/v2.spec.ts deleted file mode 100644 index b857e88821..0000000000 --- a/packages/playground/cli/src/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 = ` Date: Wed, 16 Jul 2025 00:07:46 +0200 Subject: [PATCH 11/18] Update vite entrypoints --- packages/playground/cli/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playground/cli/vite.config.ts b/packages/playground/cli/vite.config.ts index 6fa2ed0667..f052d3847c 100644 --- a/packages/playground/cli/vite.config.ts +++ b/packages/playground/cli/vite.config.ts @@ -135,7 +135,7 @@ export default defineConfig({ entry: { index: 'src/index.ts', cli: 'src/cli.ts', - 'worker-thread': 'src/worker-thread.ts', + 'worker-thread-v1': 'src/worker-thread-v1.ts', }, name: 'playground-cli', formats: ['es', 'cjs'], From 55fa9c8ffc549f0e6460d312512f142accee63b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 16 Jul 2025 00:45:03 +0200 Subject: [PATCH 12/18] Print errors --- packages/playground/cli/src/cli.ts | 14 +++++++++----- packages/playground/cli/src/run-cli.ts | 9 +-------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/playground/cli/src/cli.ts b/packages/playground/cli/src/cli.ts index af267ae2d9..ac5214e4bf 100644 --- a/packages/playground/cli/src/cli.ts +++ b/packages/playground/cli/src/cli.ts @@ -1,8 +1,12 @@ import { parseOptionsAndRunCLI } from './run-cli'; // Do not await this as top-level await is not supported in all environments. -parseOptionsAndRunCLI().catch(() => { - // process.exit(1); is here and not in parseOptionsAndRunCLI() - // so that we can unit test the failure modes with try/catch. - process.exit(1); -}); +parseOptionsAndRunCLI().then( + () => { + process.exit(0); + }, + (e) => { + console.error(e); + process.exit(1); + } +); diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 5d0cd9ce53..afef091dc4 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -433,14 +433,7 @@ export async function parseOptionsAndRunCLI() { await printDebugDetails(e, (e as any)?.streamedResponse); } - const reportableCause = ReportableError.getReportableCause(e); - if (reportableCause) { - console.log(''); - console.log(reportableCause.message); - process.exit(1); - } else { - throw e; - } + throw e; } } From 083a4dae287d5b079ef074dc27ce76cf20cbf27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 16 Jul 2025 00:45:32 +0200 Subject: [PATCH 13/18] lint --- packages/playground/cli/src/run-cli.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index afef091dc4..052ad07ff9 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -5,12 +5,12 @@ import type { SupportedPHPVersion, } from '@php-wasm/universal'; import { + PHPResponse, + SupportedPHPVersions, consumeAPI, exposeAPI, exposeSyncAPI, - PHPResponse, printDebugDetails, - SupportedPHPVersions, } from '@php-wasm/universal'; import { compileBlueprint, @@ -28,13 +28,12 @@ import { cpus } from 'os'; import { jspi } from 'wasm-feature-detect'; import yargs from 'yargs'; import { isValidWordPressSlug } from './is-valid-wordpress-slug'; -import { ReportableError } from './reportable-error'; // @ts-ignore import importedWorkerV1UrlString from './worker-thread-v1?worker&url'; // @ts-ignore import { - Worker, MessageChannel as NodeMessageChannel, + Worker, type MessagePort as NodeMessagePort, } from 'worker_threads'; import { From 39626c55d98b9d57c66bdf675a56b602e69222ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 16 Jul 2025 00:52:46 +0200 Subject: [PATCH 14/18] print test errors in wp.spec.ts --- .../test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts | 2 ++ .../es-modules-and-vitest/tests/wp.spec.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts b/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts index 5b4fa04e85..78615e0aa4 100644 --- a/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts +++ b/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts @@ -18,6 +18,8 @@ SupportedPHPVersions.forEach((phpVersion: string) => { // Verify response expect(response.httpStatusCode).toBe(200); expect(response.text).toContain('My WordPress Website'); + } catch (e) { + console.error(e); } finally { await cli[Symbol.asyncDispose](); } diff --git a/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts b/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts index 97517568aa..910771162a 100644 --- a/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts +++ b/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts @@ -31,6 +31,8 @@ describe(`PHP ${phpVersion}`, () => { response.text.includes(expectedText), `Response text does not include '${expectedText}'` ); + } catch (e) { + console.error(e); } finally { if (cli) { await cli[Symbol.asyncDispose](); From c2769dee2b206c22bb63d1525eda6cd10eb34a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 16 Jul 2025 01:05:24 +0200 Subject: [PATCH 15/18] lint --- packages/playground/cli/src/cli.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/playground/cli/src/cli.ts b/packages/playground/cli/src/cli.ts index ac5214e4bf..b6d48336e4 100644 --- a/packages/playground/cli/src/cli.ts +++ b/packages/playground/cli/src/cli.ts @@ -6,6 +6,7 @@ parseOptionsAndRunCLI().then( process.exit(0); }, (e) => { + // eslint-disable-next-line no-console console.error(e); process.exit(1); } From 528fc8bf57d3ea0fe3ba093007b72747df45757a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 16 Jul 2025 01:15:33 +0200 Subject: [PATCH 16/18] lint --- .../test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts | 1 + .../es-modules-and-vitest/tests/wp.spec.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts b/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts index 78615e0aa4..9638a5d3c9 100644 --- a/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts +++ b/packages/playground/test-built-npm-packages/commonjs-and-jest/tests/wp.spec.ts @@ -20,6 +20,7 @@ SupportedPHPVersions.forEach((phpVersion: string) => { expect(response.text).toContain('My WordPress Website'); } catch (e) { console.error(e); + throw e; } finally { await cli[Symbol.asyncDispose](); } diff --git a/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts b/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts index 910771162a..6880729335 100644 --- a/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts +++ b/packages/playground/test-built-npm-packages/es-modules-and-vitest/tests/wp.spec.ts @@ -33,6 +33,7 @@ describe(`PHP ${phpVersion}`, () => { ); } catch (e) { console.error(e); + throw e; } finally { if (cli) { await cli[Symbol.asyncDispose](); From 34909c2f4127f980be46ecad98e197139dee6557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 16 Jul 2025 23:58:48 +0200 Subject: [PATCH 17/18] restore worker-thread.ts --- packages/playground/cli/src/load-balancer.ts | 2 +- packages/playground/cli/src/run-cli.ts | 2 +- .../cli/src/{worker-thread-v1.ts => worker-thread.ts} | 0 packages/playground/cli/vite.config.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/playground/cli/src/{worker-thread-v1.ts => worker-thread.ts} (100%) diff --git a/packages/playground/cli/src/load-balancer.ts b/packages/playground/cli/src/load-balancer.ts index 7620ea059b..4879c9b84a 100644 --- a/packages/playground/cli/src/load-balancer.ts +++ b/packages/playground/cli/src/load-balancer.ts @@ -1,5 +1,5 @@ import type { PHPRequest, PHPResponse, RemoteAPI } from '@php-wasm/universal'; -import type { PlaygroundCliBlueprintV1Worker } from './worker-thread-v1'; +import type { PlaygroundCliBlueprintV1Worker } from './worker-thread'; // TODO: Let's merge worker management into PHPProcessManager // when we can have multiple workers in both CLI and web. diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 052ad07ff9..a889cb531f 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -57,7 +57,7 @@ import { } from './download'; import { LoadBalancer } from './load-balancer'; import { startServer } from './server'; -import type { PlaygroundCliBlueprintV1Worker } from './worker-thread-v1'; +import type { PlaygroundCliBlueprintV1Worker } from './worker-thread'; /* eslint-disable no-console */ export interface RunCLIArgs { diff --git a/packages/playground/cli/src/worker-thread-v1.ts b/packages/playground/cli/src/worker-thread.ts similarity index 100% rename from packages/playground/cli/src/worker-thread-v1.ts rename to packages/playground/cli/src/worker-thread.ts diff --git a/packages/playground/cli/vite.config.ts b/packages/playground/cli/vite.config.ts index f052d3847c..6fa2ed0667 100644 --- a/packages/playground/cli/vite.config.ts +++ b/packages/playground/cli/vite.config.ts @@ -135,7 +135,7 @@ export default defineConfig({ entry: { index: 'src/index.ts', cli: 'src/cli.ts', - 'worker-thread-v1': 'src/worker-thread-v1.ts', + 'worker-thread': 'src/worker-thread.ts', }, name: 'playground-cli', formats: ['es', 'cjs'], From a5d65da14e3cfe2ddd3627239ecdb387a9562d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 17 Jul 2025 00:07:38 +0200 Subject: [PATCH 18/18] restore worker-thread.ts --- packages/playground/cli/src/run-cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index a889cb531f..c6ae982c17 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -29,7 +29,7 @@ import { jspi } from 'wasm-feature-detect'; import yargs from 'yargs'; import { isValidWordPressSlug } from './is-valid-wordpress-slug'; // @ts-ignore -import importedWorkerV1UrlString from './worker-thread-v1?worker&url'; +import importedWorkerV1UrlString from './worker-thread?worker&url'; // @ts-ignore import { MessageChannel as NodeMessageChannel,