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/cli.ts b/packages/playground/cli/src/cli.ts index af267ae2d9..b6d48336e4 100644 --- a/packages/playground/cli/src/cli.ts +++ b/packages/playground/cli/src/cli.ts @@ -1,8 +1,13 @@ 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) => { + // eslint-disable-next-line no-console + console.error(e); + process.exit(1); + } +); diff --git a/packages/playground/cli/src/load-balancer.ts b/packages/playground/cli/src/load-balancer.ts index ebcfc00319..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 { PlaygroundCliWorker } from './worker-thread'; +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. @@ -7,7 +7,7 @@ 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; activeRequests: Set>; }; export class LoadBalancer { @@ -19,12 +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 + initialWorker: RemoteAPI ) { this.addWorker(initialWorker); } - addWorker(worker: RemoteAPI) { + addWorker(worker: RemoteAPI) { this.workerLoads.push({ worker, activeRequests: new Set(), diff --git a/packages/playground/cli/src/mounts.ts b/packages/playground/cli/src/mounts.ts index 18c5969fdb..8095772579 100644 --- a/packages/playground/cli/src/mounts.ts +++ b/packages/playground/cli/src/mounts.ts @@ -179,7 +179,7 @@ export function expandAutoMounts(args: RunCLIArgs): RunCLIArgs { // newArgs.mode = 'mount-only'; } - return newArgs as RunCLIArgs; + return newArgs; } export function containsFullWordPressInstallation(path: string): boolean { diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index a10d7af5e9..c6ae982c17 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -1,5 +1,4 @@ import { errorLogPath, logger } from '@php-wasm/logger'; -import { EmscriptenDownloadMonitor, ProgressTracker } from '@php-wasm/progress'; import type { PHPRequest, RemoteAPI, @@ -7,18 +6,17 @@ import type { } from '@php-wasm/universal'; import { PHPResponse, + SupportedPHPVersions, consumeAPI, exposeAPI, exposeSyncAPI, + printDebugDetails, } from '@php-wasm/universal'; -import type { - BlueprintBundle, - BlueprintDeclaration, -} from '@wp-playground/blueprints'; import { compileBlueprint, isBlueprintBundle, - runBlueprintSteps, + type BlueprintBundle, + type BlueprintDeclaration, } from '@wp-playground/blueprints'; import { RecommendedPHPVersion, @@ -26,255 +24,45 @@ 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'; // @ts-ignore -import { resolveWordPressRelease } from '@wp-playground/wordpress'; +import importedWorkerV1UrlString from './worker-thread?worker&url'; +// @ts-ignore +import { + MessageChannel as NodeMessageChannel, + Worker, + 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 type { 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'; -/* 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 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"' - ); - } - } - - if (args.experimentalMultiWorker !== undefined) { - if (args.experimentalMultiWorker <= 1) { - throw new Error( - 'The --experimentalMultiWorker flag must be a positive integer greater than 1.' - ); - } - - 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.' - ); - } - } - 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); - } - - 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; - - try { - return runCLI(cliArgs); - } catch (e) { - const reportableCause = ReportableError.getReportableCause(e); - if (reportableCause) { - console.log(''); - console.log(reportableCause.message); - process.exit(1); - } else { - throw e; - } - } -} +import { startServer } from './server'; +import type { PlaygroundCliBlueprintV1Worker } from './worker-thread'; +/* eslint-disable no-console */ export interface RunCLIArgs { - blueprint?: BlueprintDeclaration | BlueprintBundle; + 'additional-blueprint-steps'?: any[]; + blueprint?: string | BlueprintDeclaration | BlueprintBundle; command: 'server' | 'run-blueprint' | 'build-snapshot'; debug?: boolean; login?: boolean; @@ -284,198 +72,384 @@ export interface RunCLIArgs { 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[]; + 'auto-mount'?: boolean; + + 'experimental-multi-worker'?: number; + 'experimental-trace'?: boolean; + + // 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; } -export interface RunCLIServer extends AsyncDisposable { - playground: RemoteAPI; - server: Server; - [Symbol.asyncDispose](): Promise; -} +export async function parseOptionsAndRunCLI() { + 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, + }) + .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, + }) + + // 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', + // 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.', + 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('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('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', + }) + + // 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; + } -export async function runCLI(args: RunCLIArgs): Promise { - let loadBalancer: LoadBalancer; - let playground: RemoteAPI; + // 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 playgroundsToCleanUp: { - playground: RemoteAPI; - worker: Worker; - }[] = []; + 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); + } - /** - * Expand auto-mounts to include the necessary mounts and steps - * when running in auto-mount mode. - */ - if (args.autoMount) { - args = expandAutoMounts(args); - } + 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); + } - /** - * 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; + 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); + } } - $zip->addFile($file->getPathname(), $file->getPathname()); - } - $zip->close(); + return true; + }); - `, - }); - const zip = await playground.readFileAsBuffer('/tmp/build.zip'); - fs.writeFileSync(outfile, zip); - } + yargsObject.wrap(yargsObject.terminalWidth()); + const args = await yargsObject.argv; - 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, - }); - } + const command = args._[0] as string; - let lastProgressMessage = ''; - function writeProgressUpdate( - writeStream: NodeJS.WriteStream, - message: string, - finalUpdate: boolean - ) { - if (message === lastProgressMessage) { - // Avoid repeating the same message - return; + if (!['run-blueprint', 'server', 'build-snapshot'].includes(command)) { + yargsObject.showHelp(); + process.exit(1); } - 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`); + 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); } - } - /** - * 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); - }); - } - ); + throw e; } +} - function spawnWorkerThreads( - count: number - ): Promise<{ worker: Worker; phpPort: NodeMessagePort }[]> { - const moduleWorkerUrl = new URL( - importedWorkerUrlString, - import.meta.url - ); +export async function runCLI(args: RunCLIArgs) { + let loadBalancer: LoadBalancer | undefined = undefined; - const promises = []; - for (let i = 0; i < count; i++) { - promises.push(spawnPHPWorkerThread(moduleWorkerUrl)); - } - return Promise.all(promises); + const playgroundsToCleanUp: { + playground: { dispose: () => Promise }; + 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); } if (args.quiet) { @@ -483,10 +457,6 @@ export async function runCLI(args: RunCLIArgs): Promise { 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 +469,57 @@ 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); - - wpDetails = await resolveWordPressRelease(args.wp); - logger.log( - `Resolved WordPress release URL: ${wpDetails?.releaseUrl}` + onBind: async (server: Server, port: number) => { + const siteUrl = `http://127.0.0.1:${port}`; + const handler = new BlueprintsV1Handler(args, { + siteUrl, + processIdSpaceLength, + }); + + 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 || []; - - const [initialWorker, ...additionalWorkers] = - await promisedWorkers; + logger.log(`Setting up WordPress ${args.wp}`); - 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,66 +527,42 @@ 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( '/tmp/internal.zip', - internalZip + internalZip! ); await unzipFile( additionalPlayground, @@ -761,17 +573,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 +602,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 +619,386 @@ export async function runCLI(args: RunCLIArgs): Promise { }, }); } + +/** + * 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 siteUrl: string; + private processIdSpaceLength: number; + private args: RunCLIArgs; + + constructor( + args: RunCLIArgs, + options: { + siteUrl: string; + processIdSpaceLength: number; + } + ) { + this.args = args; + this.siteUrl = options.siteUrl; + this.processIdSpaceLength = options.processIdSpaceLength; + } + + getWorkerUrl() { + return importedWorkerV1UrlString; + } + + async bootPrimaryWorker( + phpPort: NodeMessagePort, + fileLockManagerPort: NodeMessagePort + ) { + 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 + // 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['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.phpVersion, + wpVersion: 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 + ); + + await additionalPlayground.isConnected(); + await additionalPlayground.useFileLockManager(fileLockManagerPort); + await additionalPlayground.boot({ + phpVersion: this.phpVersion, + 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['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['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`); + } + } +} + +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..b99330a9e2 100644 --- a/packages/playground/cli/src/server.ts +++ b/packages/playground/cli/src/server.ts @@ -3,17 +3,22 @@ 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..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) { @@ -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: '/', diff --git a/packages/playground/cli/src/v2.ts b/packages/playground/cli/src/v2.ts index a7eb88d23f..29da590d26 100644 --- a/packages/playground/cli/src/v2.ts +++ b/packages/playground/cli/src/v2.ts @@ -107,10 +107,10 @@ export async function getV2Runner(): Promise { } const { readFile } = await import('node:fs/promises'); - data = await readFile(path); + data = (await readFile(path)) as BlobPart; } else { const response = await fetch(v2_runner_url); - data = await response.blob(); + data = (await response.blob()) as BlobPart; } return new File([data], `blueprints.phar`, { type: 'application/zip', diff --git a/packages/playground/cli/src/worker-thread.ts b/packages/playground/cli/src/worker-thread.ts index 344661baae..b7fa484b4b 100644 --- a/packages/playground/cli/src/worker-thread.ts +++ b/packages/playground/cli/src/worker-thread.ts @@ -44,10 +44,13 @@ 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) + ); } } @@ -67,7 +70,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; @@ -183,7 +186,7 @@ export class PlaygroundCliWorker extends PHPWorker { }, hooks: { async beforeWordPressFiles(php) { - mountResources(php, mountsBeforeWpInstall); + await mountResources(php, mountsBeforeWpInstall); }, }, cookieStore: internalCookieStore ? undefined : false, @@ -195,7 +198,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) { @@ -213,7 +216,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/common/src/index.ts b/packages/playground/common/src/index.ts index 0d51c68912..27aabe911d 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; 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..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 @@ -18,6 +18,9 @@ SupportedPHPVersions.forEach((phpVersion: string) => { // Verify response expect(response.httpStatusCode).toBe(200); 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 97517568aa..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 @@ -31,6 +31,9 @@ describe(`PHP ${phpVersion}`, () => { response.text.includes(expectedText), `Response text does not include '${expectedText}'` ); + } catch (e) { + console.error(e); + throw e; } finally { if (cli) { await cli[Symbol.asyncDispose]();