diff --git a/packages/php-wasm/universal/src/lib/base-php.ts b/packages/php-wasm/universal/src/lib/base-php.ts index a9ea3ba0af0..17ea3189924 100644 --- a/packages/php-wasm/universal/src/lib/base-php.ts +++ b/packages/php-wasm/universal/src/lib/base-php.ts @@ -28,7 +28,12 @@ import { improveWASMErrorReporting, UnhandledRejectionsTarget, } from './wasm-error-reporting'; -import { Semaphore, createSpawnHandler, joinPaths } from '@php-wasm/util'; +import { + Semaphore, + createSpawnHandler, + joinPaths, + phpVars, +} from '@php-wasm/util'; const STRING = 'string'; const NUMBER = 'number'; @@ -267,7 +272,13 @@ export abstract class BasePHP implements IsomorphicLocalPHP { } } if (typeof request.code === 'string') { - this.#setPHPCode(' ?>' + request.code); + const js = phpVars(request.variables || {}); + const phpVariablesDeclarations = Object.entries(js) + .map(([key, value]) => `$${key} = ${value};`) + .join('\n'); + this.#setPHPCode( + ` ${phpVariablesDeclarations} ?>${request.code}` + ); } this.#addServerGlobalEntriesInWasm(); const response = await this.#handleRequest(); diff --git a/packages/php-wasm/universal/src/lib/index.ts b/packages/php-wasm/universal/src/lib/index.ts index c88a05501f3..074ddb5eb34 100644 --- a/packages/php-wasm/universal/src/lib/index.ts +++ b/packages/php-wasm/universal/src/lib/index.ts @@ -41,7 +41,7 @@ export type { SupportedPHPExtensionBundle, } from './supported-php-extensions'; export { BasePHP, __private__dont__use } from './base-php'; -export { loadPHPRuntime } from './load-php-runtime'; +export { currentJsRuntime, loadPHPRuntime } from './load-php-runtime'; export type { DataModule, EmscriptenOptions, diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index 299878dd977..3c6ef962150 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -543,6 +543,11 @@ export interface PHPRunOptions { */ code?: string; + /** + * The variables to pass to the PHP code. + */ + variables?: Record; + /** * Whether to throw an error if the PHP process exits with a non-zero code * or outputs to stderr. diff --git a/packages/php-wasm/universal/src/lib/write-files.ts b/packages/php-wasm/universal/src/lib/write-files.ts index ff0d8a0162c..5810eac3a9f 100644 --- a/packages/php-wasm/universal/src/lib/write-files.ts +++ b/packages/php-wasm/universal/src/lib/write-files.ts @@ -29,7 +29,7 @@ export interface WriteFilesOptions { export async function writeFiles( php: UniversalPHP, root: string, - newFiles: Record, + newFiles: Record, { rmRoot = false }: WriteFilesOptions = {} ) { if (rmRoot) { @@ -37,11 +37,22 @@ export async function writeFiles( await php.rmdir(root, { recursive: true }); } } + await php.mkdir(root); for (const [relativePath, content] of Object.entries(newFiles)) { const filePath = joinPaths(root, relativePath); if (!(await php.fileExists(dirname(filePath)))) { await php.mkdir(dirname(filePath)); } - await php.writeFile(filePath, content); + const finalContent = + content instanceof File + ? new Uint8Array(await content.arrayBuffer()) + : content; + await php.writeFile(filePath, finalContent); } + + const paths: Record = {}; + for (const [relativePath] of Object.entries(newFiles)) { + paths[relativePath] = joinPaths(root, relativePath); + } + return paths; } diff --git a/packages/playground/blueprints/src/index.ts b/packages/playground/blueprints/src/index.ts index 48623ec67cf..a326e1e703a 100644 --- a/packages/playground/blueprints/src/index.ts +++ b/packages/playground/blueprints/src/index.ts @@ -3,6 +3,7 @@ import '@php-wasm/node-polyfills'; export * from './lib/steps'; export * from './lib/steps/handlers'; +export { linkSnapshot, setSnapshot } from './lib/steps/import-wordpress-files'; export { runBlueprintSteps, compileBlueprint } from './lib/compile'; export type { Blueprint } from './lib/blueprint'; export type { diff --git a/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts b/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts index baf8daac7ce..d00f8eab14d 100644 --- a/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts +++ b/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts @@ -1,9 +1,10 @@ import { StepHandler } from '.'; import { unzip } from './unzip'; -import { dirname, joinPaths, phpVar } from '@php-wasm/util'; -import { UniversalPHP } from '@php-wasm/universal'; -import { wpContentFilesExcludedFromExport } from '../utils/wp-content-files-excluded-from-exports'; +import { basename, joinPaths } from '@php-wasm/util'; +import { UniversalPHP, currentJsRuntime } from '@php-wasm/universal'; import { defineSiteUrl } from './define-site-url'; +import { runWpInstallationWizard } from './run-wp-installation-wizard'; +import { defineWpConfigConsts } from './define-wp-config-consts'; /** * @inheritDoc importWordPressFiles @@ -38,94 +39,229 @@ export interface ImportWordPressFilesStep { * `wp-content` and `wp-includes` directories, they will replace * the corresponding directories in Playground's documentRoot. * - * Any files that Playground recognizes as "excluded from the export" + * Any files that Playground recognizes as "exceptd from the export" * will carry over from the existing document root into the imported * directories. For example, the sqlite-database-integration plugin. * - * @param playground Playground client. + * @param php Playground client. * @param wordPressFilesZip Zipped WordPress site. */ export const importWordPressFiles: StepHandler< ImportWordPressFilesStep -> = async (playground, { wordPressFilesZip, pathInZip = '' }) => { - const documentRoot = await playground.documentRoot; - - // Unzip - let importPath = joinPaths('/tmp', 'import'); - await playground.mkdir(importPath); - await unzip(playground, { - zipFile: wordPressFilesZip, - extractToPath: importPath, +> = async (php, { wordPressFilesZip, pathInZip = '' }) => { + await setSnapshot(php, wordPressFilesZip, pathInZip); + await linkSnapshot(php); +}; + +export async function setSnapshot( + php: UniversalPHP, + snapshotZip: File, + pathInZip = '' +) { + const documentRoot = await php.documentRoot; + // Extract the snapshot to a temporary directory. + // Use a subdirectory of the document root to avoid a slow recursive + // copy operation across Emscripten filesystems – in Node.js, + // the /tmp directory is likely kept in MEMFS, while the document + // root is typically a NODEFS mount. + const snapshotPath = joinPaths(documentRoot, '.snapshot'); + await unzipSnapshot(php, snapshotZip, pathInZip, snapshotPath); + await backfillWordPressCore(php, snapshotPath, documentRoot); + await removeContents(php, documentRoot, { except: ['.snapshot'] }); + await moveContents(php, snapshotPath, documentRoot); + await removePath(php, snapshotPath); + await defineWpConfigConsts(php, { + consts: { + CONCATENATE_SCRIPTS: false, + }, }); - importPath = joinPaths(importPath, pathInZip); - - // Carry over any Playground-related files, such as the - // SQLite database plugin, from the current wp-content - // into the one that's about to be imported - const importedWpContentPath = joinPaths(importPath, 'wp-content'); - const wpContentPath = joinPaths(documentRoot, 'wp-content'); - for (const relativePath of wpContentFilesExcludedFromExport) { - // Remove any paths that were supposed to be excluded from the export - // but maybe weren't - const excludedImportPath = joinPaths( - importedWpContentPath, - relativePath - ); - await removePath(playground, excludedImportPath); +} - // Replace them with files sourced from the live wp-content directory - const restoreFromPath = joinPaths(wpContentPath, relativePath); - if (await playground.fileExists(restoreFromPath)) { - await playground.mkdir(dirname(excludedImportPath)); - await playground.mv(restoreFromPath, excludedImportPath); - } +async function unzipSnapshot( + php: UniversalPHP, + snapshotZip: File, + pathInZip = '', + targetPath: string +) { + // await unzip(php, snapshotZip, targetPath); + + // Find WordPress core files in the extracted snapshot. + let importedFilesPath = targetPath; + if (pathInZip) { + // If pathInZip is explicitly provided, use it. + importedFilesPath = joinPaths(targetPath, pathInZip); + } else if ( + (await php.fileExists(joinPaths(targetPath, 'wordpress'))) && + !(await php.fileExists(joinPaths(targetPath, 'wp-content'))) + ) { + // If importing a WordPress.org zip, it may contain a top-level + // directory named "wordpress". If so, use that as the import path. + importedFilesPath = joinPaths(importedFilesPath, 'wordpress'); + } + + // If the imported files were in a nested directory in the zip, + // move them over to the document root. + if (importedFilesPath !== targetPath) { + await moveContents(php, importedFilesPath, targetPath); + } + + // Ensure wp-config.php is present in the extracted snapshot. + if (!(await php.fileExists(joinPaths(targetPath, 'wp-config.php')))) { + const samplePath = joinPaths(targetPath, 'wp-config-sample.php'); + const wpConfig = (await php.fileExists(samplePath)) + ? await php.readFileAsText(samplePath) + : ` { + await php.mkdir(to); + const files = await php.listFiles(from); + for (const file of files) { + if (except.includes(file)) { + continue; + } + await php.mv(joinPaths(from, file), joinPaths(to, file)); + } +} +async function removeContents( + php: UniversalPHP, + from: string, + { except = [] }: { except?: string[] } = {} +): Promise { + const files = await php.listFiles(from); + for (const file of files) { + if (except.includes(file)) { + continue; + } + await removePath(php, joinPaths(from, file)); + } +} + +/** + * Private API. + * + * Turns a static WordPress snapshot present in the document root + * into a configured, runnable WordPress site. + * + * @private + * @param php + */ +export async function linkSnapshot(php: UniversalPHP) { + // Enforce the required Playground and SQLite mu-plugins in + // the browser. + if (currentJsRuntime === 'WEB' || currentJsRuntime === 'WORKER') { + const documentRoot = await php.documentRoot; + // await installSqliteMuPlugin(php, documentRoot); + // await installPlaygroundMuPlugin(php); + + // Run the installation wizard if the database is missing + const dbExists = await php.fileExists( + joinPaths(documentRoot, 'wp-content', 'database', '.ht.sqlite') + ); + if (!dbExists) { + await runWpInstallationWizard(php, {}); + } + } + + // Adjust the site URL + await defineSiteUrl(php, { + siteUrl: await php.absoluteUrl, }); -}; +} async function removePath(playground: UniversalPHP, path: string) { if (await playground.fileExists(path)) { @@ -136,3 +272,12 @@ async function removePath(playground: UniversalPHP, path: string) { } } } + +export function randomString(length: number) { + const chars = + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+=-[]/.,<>?'; + let result = ''; + for (let i = length; i > 0; --i) + result += chars[Math.floor(Math.random() * chars.length)]; + return result; +} diff --git a/packages/playground/blueprints/src/lib/steps/run-wp-installation-wizard.ts b/packages/playground/blueprints/src/lib/steps/run-wp-installation-wizard.ts index fd803b50dbb..4e25c88f2ec 100644 --- a/packages/playground/blueprints/src/lib/steps/run-wp-installation-wizard.ts +++ b/packages/playground/blueprints/src/lib/steps/run-wp-installation-wizard.ts @@ -5,7 +5,7 @@ import { StepHandler } from '.'; */ export interface RunWpInstallationWizardStep { step: 'runWpInstallationWizard'; - options: WordPressInstallationOptions; + options?: WordPressInstallationOptions; } export interface WordPressInstallationOptions { @@ -21,7 +21,7 @@ export interface WordPressInstallationOptions { */ export const runWpInstallationWizard: StepHandler< RunWpInstallationWizardStep -> = async (playground, { options }) => { +> = async (playground, { options = {} }) => { await playground.request({ url: '/wp-admin/install.php?step=2', method: 'POST', diff --git a/packages/playground/blueprints/src/lib/steps/unzip.ts b/packages/playground/blueprints/src/lib/steps/unzip.ts index 0858a2548f1..79d9b8e2f4d 100644 --- a/packages/playground/blueprints/src/lib/steps/unzip.ts +++ b/packages/playground/blueprints/src/lib/steps/unzip.ts @@ -1,6 +1,5 @@ -import { phpVars } from '@php-wasm/util'; import { StepHandler } from '.'; -import { runPhpWithZipFunctions } from '../utils/run-php-with-zip-functions'; +import { unzip as _unzip } from '@wp-playground/php-bridge'; /** * @inheritDoc unzip @@ -30,13 +29,11 @@ export interface UnzipStep { extractToPath: string; } -const tmpPath = '/tmp/file.zip'; - /** * Unzip a zip file. * * @param playground Playground client. - * @param zipPath The zip file to unzip. + * @param zipFile The zip file to unzip. * @param extractTo The directory to extract the zip file to. */ export const unzip: StepHandler> = async ( @@ -44,33 +41,18 @@ export const unzip: StepHandler> = async ( { zipFile, zipPath, extractToPath } ) => { if (zipPath) { - // Compatibility with the old Blueprints API - // @TODO: Remove the zipPath option in the next major version - await playground.writeFile( - tmpPath, - await playground.readFileAsBuffer(zipPath) + zipFile = new File( + [await playground.readFileAsBuffer(zipPath)], + 'file.zip' ); console.warn( `The "zipPath" option of the unzip() Blueprint step is deprecated and will be removed. ` + `Use "zipFile" instead.` ); - } else if (zipFile) { - await playground.writeFile( - tmpPath, - new Uint8Array(await zipFile.arrayBuffer()) - ); - } else { - throw new Error('Either zipPath or zipFile must be provided'); } - const js = phpVars({ - zipPath: tmpPath, - extractToPath, - }); - await runPhpWithZipFunctions( - playground, - `unzip(${js.zipPath}, ${js.extractToPath});` - ); - if (playground.fileExists(tmpPath)) { - await playground.unlink(tmpPath); + + if (!zipFile) { + throw new Error('Either zipPath or zipFile must be provided'); } + return await _unzip(playground, zipFile, extractToPath); }; diff --git a/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts b/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts index 9870ff76d79..1dc0bed3da8 100644 --- a/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts +++ b/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts @@ -1,7 +1,7 @@ import { joinPaths, phpVars } from '@php-wasm/util'; import { UniversalPHP } from '@php-wasm/universal'; import { wpContentFilesExcludedFromExport } from '../utils/wp-content-files-excluded-from-exports'; -import { runPhpWithZipFunctions } from '../utils/run-php-with-zip-functions'; +// import { runPhpWithZipFunctions } from '../utils/run-php-with-zip-functions'; interface ZipWpContentOptions { /** @@ -59,14 +59,14 @@ export const zipWpContent = async ( } : {}, }); - await runPhpWithZipFunctions( - playground, - `zipDir(${js.wpContentPath}, ${js.zipPath}, array( - 'exclude_paths' => ${js.exceptPaths}, - 'zip_root' => ${js.documentRoot}, - 'additional_paths' => ${js.additionalPaths} - ));` - ); + // await runPhpWithZipFunctions( + // playground, + // `zipDir(${js.wpContentPath}, ${js.zipPath}, array( + // 'exclude_paths' => ${js.exceptPaths}, + // 'zip_root' => ${js.documentRoot}, + // 'additional_paths' => ${js.additionalPaths} + // ));` + // ); const fileBuffer = await playground.readFileAsBuffer(zipPath); playground.unlink(zipPath); diff --git a/packages/playground/blueprints/src/lib/utils/run-php-with-zip-functions.ts b/packages/playground/blueprints/src/lib/utils/run-php-with-zip-functions.ts deleted file mode 100644 index 8323a2519f7..00000000000 --- a/packages/playground/blueprints/src/lib/utils/run-php-with-zip-functions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { UniversalPHP } from '@php-wasm/universal'; -// @ts-ignore -import zipFunctions from './zip-functions.php?raw'; -export async function runPhpWithZipFunctions( - playground: UniversalPHP, - code: string -) { - return await playground.run({ - throwOnError: true, - code: zipFunctions + code, - }); -} diff --git a/packages/playground/blueprints/tsconfig.lib.json b/packages/playground/blueprints/tsconfig.lib.json index 829b0bc14c8..c341779b2e0 100644 --- a/packages/playground/blueprints/tsconfig.lib.json +++ b/packages/playground/blueprints/tsconfig.lib.json @@ -5,6 +5,6 @@ "declaration": true, "types": ["node"] }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "src/lib/setup-mu-plugins.ts"], "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] } diff --git a/packages/playground/php-bridge/.eslintrc.json b/packages/playground/php-bridge/.eslintrc.json new file mode 100644 index 00000000000..ee9ffa963c8 --- /dev/null +++ b/packages/playground/php-bridge/.eslintrc.json @@ -0,0 +1,32 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredFiles": [ + "{projectRoot}/vite.config.{js,ts,mjs,mts}" + ] + } + ] + } + } + ] +} diff --git a/packages/playground/php-bridge/README.md b/packages/playground/php-bridge/README.md new file mode 100644 index 00000000000..244c77f179c --- /dev/null +++ b/packages/playground/php-bridge/README.md @@ -0,0 +1,11 @@ +# playground-php-bridge + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build playground-php-bridge` to build the library. + +## Running unit tests + +Run `nx test playground-php-bridge` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/packages/playground/php-bridge/package.json b/packages/playground/php-bridge/package.json new file mode 100644 index 00000000000..a045577b51f --- /dev/null +++ b/packages/playground/php-bridge/package.json @@ -0,0 +1,5 @@ +{ + "version": "0.0.1", + "type": "module", + "private": true +} diff --git a/packages/playground/php-bridge/project.json b/packages/playground/php-bridge/project.json new file mode 100644 index 00000000000..a4c24abc1df --- /dev/null +++ b/packages/playground/php-bridge/project.json @@ -0,0 +1,34 @@ +{ + "name": "playground-php-bridge", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/playground/php-bridge/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/playground/php-bridge" + } + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "passWithNoTests": true, + "reportsDirectory": "../../../coverage/packages/playground/php-bridge" + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "packages/playground/php-bridge/**/*.ts", + "packages/playground/php-bridge/package.json" + ] + } + } + }, + "tags": [] +} diff --git a/packages/playground/php-bridge/src/index.spec.ts b/packages/playground/php-bridge/src/index.spec.ts new file mode 100644 index 00000000000..bc54832550b --- /dev/null +++ b/packages/playground/php-bridge/src/index.spec.ts @@ -0,0 +1,27 @@ +import { NodePHP } from '@php-wasm/node'; +import { withPlaygroundLibrary } from './index'; + +const phpVersion = '8.0'; + +describe('withPlaygroundLibrary()', () => { + let php: NodePHP; + beforeEach(async () => { + php = await NodePHP.load(phpVersion as any, {}); + php.mkdir('/php'); + php.setPhpIniEntry('disable_functions', ''); + }); + it('Should provide the variables', async () => { + const result = await withPlaygroundLibrary( + php as any, + ` = {} +) { + const vars: Record = {}; + const files: Record = {}; + for (const [key, value] of Object.entries(inputs)) { + if (value instanceof File) { + files[key] = value; + } else { + vars[key] = value; + } + } + + const tmpRoot = `/tmp/${randomFilename(8)}`; + const tmpPaths = await writeFiles(php, tmpRoot, files); + + try { + await php.writeFile(`/tmp/playground-library.php`, playgroundLibrary); + return await php.run({ + throwOnError: true, + code: `${code}`, + variables: { ...vars, ...tmpPaths }, + }); + } finally { + await php.rmdir(tmpRoot); + } +} + +/** + * @TODO: Rebase this PH and import this from @php-wasm/utils + */ +export function randomFilename(length: number) { + const chars = + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + let result = ''; + for (let i = length; i > 0; --i) + result += chars[Math.floor(Math.random() * chars.length)]; + return result; +} diff --git a/packages/playground/php-bridge/src/lib/playground-php-bridge.spec.ts b/packages/playground/php-bridge/src/lib/playground-php-bridge.spec.ts new file mode 100644 index 00000000000..d3c3d8de2db --- /dev/null +++ b/packages/playground/php-bridge/src/lib/playground-php-bridge.spec.ts @@ -0,0 +1,7 @@ +import { playgroundPhpBridge } from './playground-php-bridge'; + +describe('playgroundPhpBridge', () => { + it('should work', () => { + expect(playgroundPhpBridge()).toEqual('playground-php-bridge'); + }); +}); diff --git a/packages/playground/php-bridge/src/lib/playground-php-bridge.ts b/packages/playground/php-bridge/src/lib/playground-php-bridge.ts new file mode 100644 index 00000000000..a5e7884c786 --- /dev/null +++ b/packages/playground/php-bridge/src/lib/playground-php-bridge.ts @@ -0,0 +1,3 @@ +export function playgroundPhpBridge(): string { + return 'playground-php-bridge'; +} diff --git a/packages/playground/blueprints/src/lib/utils/zip-functions.php b/packages/playground/php-bridge/src/playground-library.php similarity index 71% rename from packages/playground/blueprints/src/lib/utils/zip-functions.php rename to packages/playground/php-bridge/src/playground-library.php index a3fddee344a..908cc9d9e77 100644 --- a/packages/playground/blueprints/src/lib/utils/zip-functions.php +++ b/packages/playground/php-bridge/src/playground-library.php @@ -58,17 +58,40 @@ function join_paths() return preg_replace('#/+#', '/', join('/', $paths)); } -function unzip($zipPath, $extractTo, $overwrite = true) +function unzip($zipPath, $extractTo, $options = array()) { + $except = array_key_exists('except', $options) ? $options['except'] : array(); if (!is_dir($extractTo)) { mkdir($extractTo, 0777, true); } $zip = new ZipArchive; $res = $zip->open($zipPath); - if ($res === TRUE) { - $zip->extractTo($extractTo); - $zip->close(); + try { + if ($res !== TRUE) { + throw new Exception("Unable to open zip file: $zipPath"); + } + + for ($i = 0; $i < $zip->numFiles; $i++) { + $filename = $zip->getNameIndex($i); + foreach ($except as $e) { + if (preg_match($e, $filename) === 1) { + continue 2; + } + } + echo $filename . "\n"; + $fileInfo = pathinfo($filename); + if (!empty($fileInfo['dirname'])) { + $dir = join_paths($extractTo, $fileInfo['dirname']); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + } + $zip->extractTo($extractTo, $filename); + } + chmod($extractTo, 0777); + } finally { + $zip->close(); } } diff --git a/packages/playground/php-bridge/tsconfig.json b/packages/playground/php-bridge/tsconfig.json new file mode 100644 index 00000000000..421e57804d8 --- /dev/null +++ b/packages/playground/php-bridge/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/playground/php-bridge/tsconfig.lib.json b/packages/playground/php-bridge/tsconfig.lib.json new file mode 100644 index 00000000000..672b0253c97 --- /dev/null +++ b/packages/playground/php-bridge/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node", "vite/client"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/playground/php-bridge/tsconfig.spec.json b/packages/playground/php-bridge/tsconfig.spec.json new file mode 100644 index 00000000000..ee9bf732a8e --- /dev/null +++ b/packages/playground/php-bridge/tsconfig.spec.json @@ -0,0 +1,25 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/packages/playground/php-bridge/vite.config.ts b/packages/playground/php-bridge/vite.config.ts new file mode 100644 index 00000000000..7d508e36fc3 --- /dev/null +++ b/packages/playground/php-bridge/vite.config.ts @@ -0,0 +1,48 @@ +/// +import { defineConfig } from 'vite'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { viteTsConfigPaths } from '../../vite-ts-config-paths'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + cacheDir: '../../../node_modules/.vite/playground-php-bridge', + + plugins: [ + nxViteTsPaths(), + viteTsConfigPaths({ + root: '../../../', + }), + ], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + // Configuration for building your library. + // See: https://vitejs.dev/guide/build.html#library-mode + build: { + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: 'src/index.ts', + name: 'playground-php-bridge', + fileName: 'index', + // Change this to the formats you want to support. + // Don't forget to update your package.json as well. + formats: ['es', 'cjs'], + }, + rollupOptions: { + // External packages that should not be bundled into your library. + external: [], + }, + }, + + test: { + globals: true, + cache: { + dir: '../../../node_modules/.vitest', + }, + environment: 'node', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + }, +}); diff --git a/packages/playground/remote/src/lib/utils.ts b/packages/playground/remote/src/lib/utils.ts deleted file mode 100644 index ec870f6d84b..00000000000 --- a/packages/playground/remote/src/lib/utils.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function randomString(length: number) { - const chars = - '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+=-[]/.,<>?'; - let result = ''; - for (let i = length; i > 0; --i) - result += chars[Math.floor(Math.random() * chars.length)]; - return result; -} diff --git a/packages/playground/remote/src/lib/playground-mu-plugin/0-playground.php b/packages/playground/remote/src/lib/worker-libs/playground-mu-plugin/0-playground.php similarity index 100% rename from packages/playground/remote/src/lib/playground-mu-plugin/0-playground.php rename to packages/playground/remote/src/lib/worker-libs/playground-mu-plugin/0-playground.php diff --git a/packages/playground/remote/src/lib/playground-mu-plugin/playground-includes/wp_http_dummy.php b/packages/playground/remote/src/lib/worker-libs/playground-mu-plugin/playground-includes/wp_http_dummy.php similarity index 100% rename from packages/playground/remote/src/lib/playground-mu-plugin/playground-includes/wp_http_dummy.php rename to packages/playground/remote/src/lib/worker-libs/playground-mu-plugin/playground-includes/wp_http_dummy.php diff --git a/packages/playground/remote/src/lib/playground-mu-plugin/playground-includes/wp_http_fetch.php b/packages/playground/remote/src/lib/worker-libs/playground-mu-plugin/playground-includes/wp_http_fetch.php similarity index 100% rename from packages/playground/remote/src/lib/playground-mu-plugin/playground-includes/wp_http_fetch.php rename to packages/playground/remote/src/lib/worker-libs/playground-mu-plugin/playground-includes/wp_http_fetch.php diff --git a/packages/playground/remote/src/lib/worker-libs/setup-mu-plugins.ts b/packages/playground/remote/src/lib/worker-libs/setup-mu-plugins.ts new file mode 100644 index 00000000000..b3e06efc69d --- /dev/null +++ b/packages/playground/remote/src/lib/worker-libs/setup-mu-plugins.ts @@ -0,0 +1,95 @@ +import { joinPaths } from '@php-wasm/util'; + +/** @ts-ignore */ +import transportFetch from './playground-mu-plugin/playground-includes/wp_http_fetch.php?raw'; +/** @ts-ignore */ +import transportDummy from './playground-mu-plugin/playground-includes/wp_http_dummy.php?raw'; +/** @ts-ignore */ +import playgroundMuPlugin from './playground-mu-plugin/0-playground.php?raw'; +import { UniversalPHP, writeFiles } from '@php-wasm/universal'; +import { unzip } from '@wp-playground/php-bridge'; + +export async function backfillPlaygroundMuPlugin(php: UniversalPHP) { + const muPluginsPath = joinPaths( + await php.documentRoot, + 'wp-content/mu-plugins/' + ); + await writeFiles(php, muPluginsPath, { + '0-playground.php': playgroundMuPlugin, + 'playground-includes/wp_http_dummy.php': transportDummy, + 'playground-includes/wp_http_fetch.php': transportFetch, + }); +} + +export async function backfillSqliteMuPlugin(php: UniversalPHP) { + await ensureSqliteMuPlugin(php); + await activateSqliteMuPlugin(php); +} + +/** + * Ensure the SQLite integration plugin is installed. + * The prebuilt Playground WordPress zips include it, + * but this worker may also be initialized with a custom zip + * or an OPFS directory handle. + * + * The same logic is present in packages/playground/wordpress/build/Dockerfile + * be sure to keep it in sync. + */ +async function ensureSqliteMuPlugin(php: UniversalPHP) { + const documentRoot = await php.documentRoot; + + const sqliteMuPluginPath = 'wp-content/plugins/sqlite-database-integration'; + if (await php.fileExists(joinPaths(documentRoot, sqliteMuPluginPath))) { + // The SQLite plugin is present in the imported snapshot, we're good. + return false; + } + + // Otherwise, let's download and install the SQLite plugin + const muPluginsPath = joinPaths(documentRoot, 'wp-content/mu-plugins/'); + const plugin = await fetch( + 'https://downloads.wordpress.org/plugin/sqlite-database-integration.zip' + ); + await unzip( + php, + new File( + [await plugin.blob()], + 'sqlite-database-integration.latest.zip' + ), + // The zip file contains a directory with the same name as the plugin. + muPluginsPath + ); + + return true; +} + +export async function activateSqliteMuPlugin(php: UniversalPHP) { + const docroot = await php.documentRoot; + const muPluginsPath = joinPaths(docroot, 'wp-content/mu-plugins/'); + const sqlitePluginPath = joinPaths( + muPluginsPath, + 'sqlite-database-integration' + ); + if (!(await php.fileExists(sqlitePluginPath))) { + console.log( + 'sqlite-database-integration not found in mu-plugins, skipping' + ); + return; + } + await php.mkdir(sqlitePluginPath); + await php.writeFile( + joinPaths(muPluginsPath, '0-sqlite.php'), + ' { + await php.mkdir(to); + const files = await php.listFiles(from); + for (const file of files) { + if (except.includes(file)) { + continue; + } + await php.mv(joinPaths(from, file), joinPaths(to, file)); + } +} + +export async function backfillDatabase(php: UniversalPHP) { + // Run the installation wizard if the database is missing + const dbExists = await php.fileExists( + joinPaths( + await php.documentRoot, + 'wp-content', + 'database', + '.ht.sqlite' + ) + ); + if (!dbExists) { + await runWpInstallationWizard(php, {}); + } +} + +async function removePath(playground: UniversalPHP, path: string) { + if (await playground.fileExists(path)) { + if (await playground.isDir(path)) { + await playground.rmdir(path); + } else { + await playground.unlink(path); + } + } +} diff --git a/packages/playground/remote/src/lib/worker-thread.ts b/packages/playground/remote/src/lib/worker-thread.ts index 5740db87800..758effe06fe 100644 --- a/packages/playground/remote/src/lib/worker-thread.ts +++ b/packages/playground/remote/src/lib/worker-thread.ts @@ -13,7 +13,6 @@ import { SupportedPHPVersion, SupportedPHPVersionsList, rotatePHPRuntime, - writeFiles, } from '@php-wasm/universal'; import { createSpawnHandler } from '@php-wasm/util'; import { @@ -26,20 +25,19 @@ import { bindOpfs, playgroundAvailableInOpfs, } from './opfs/bind-opfs'; -import { - defineSiteUrl, - defineWpConfigConsts, - unzip, -} from '@wp-playground/blueprints'; - -/** @ts-ignore */ -import transportFetch from './playground-mu-plugin/playground-includes/wp_http_fetch.php?raw'; -/** @ts-ignore */ -import transportDummy from './playground-mu-plugin/playground-includes/wp_http_dummy.php?raw'; -/** @ts-ignore */ -import playgroundMuPlugin from './playground-mu-plugin/0-playground.php?raw'; + import { joinPaths } from '@php-wasm/util'; -import { randomString } from './utils'; +import { defineWpConfigConsts } from '@wp-playground/blueprints'; +import { + backfillDatabase, + backfillWordPressCore, + backfillWpConfig, + unzipSnapshot, +} from './worker-libs/setup-snapshot'; +import { + backfillPlaygroundMuPlugin, + backfillSqliteMuPlugin, +} from './worker-libs/setup-mu-plugins'; // post message to parent self.postMessage('worker-script-started'); @@ -100,19 +98,20 @@ const scopedSiteUrl = setURLScope(wordPressSiteUrl, scope).toString(); const monitor = new EmscriptenDownloadMonitor(); // Start downloading WordPress if needed -let wordPressRequest = null; +let snapshotRequest = null; +let wpCoreRequest = null; if (!wordPressAvailableInOPFS) { if (requestedWPVersion.startsWith('http')) { // We don't know the size upfront, but we can still monitor the download. // monitorFetch will read the content-length response header when available. - wordPressRequest = monitor.monitorFetch(fetch(requestedWPVersion)); - } else { - const wpDetails = getWordPressModuleDetails(wpVersion); - monitor.expectAssets({ - [wpDetails.url]: wpDetails.size, - }); - wordPressRequest = monitor.monitorFetch(fetch(wpDetails.url)); + snapshotRequest = monitor.monitorFetch(fetch(requestedWPVersion)); } + + const wpDetails = getWordPressModuleDetails(wpVersion); + monitor.expectAssets({ + [wpDetails.url]: wpDetails.size, + }); + wpCoreRequest = monitor.monitorFetch(fetch(wpDetails.url)); } const php = new WebPHP(undefined, { @@ -238,44 +237,29 @@ try { if (startupOptions.sapiName) { await php.setSapiName(startupOptions.sapiName); } - const docroot = php.documentRoot; + php.mkdir(php.documentRoot); // If WordPress isn't already installed, download and extract it from // the zip file. if (!wordPressAvailableInOPFS) { - await unzip(php, { - zipFile: new File( - [await (await wordPressRequest!).blob()], - 'wp.zip' - ), - extractToPath: DOCROOT, - }); - - // Randomize the WordPress secrets - await defineWpConfigConsts(php, { - consts: { - AUTH_KEY: randomString(40), - SECURE_AUTH_KEY: randomString(40), - LOGGED_IN_KEY: randomString(40), - NONCE_KEY: randomString(40), - AUTH_SALT: randomString(40), - SECURE_AUTH_SALT: randomString(40), - LOGGED_IN_SALT: randomString(40), - NONCE_SALT: randomString(40), - }, - }); + if (snapshotRequest) { + const snapshotZip = new File( + [await (await snapshotRequest!).blob()], + 'snapshot.zip' + ); + await unzipSnapshot(php, snapshotZip); + } + const wpCoreZip = new File( + [await (await wpCoreRequest!).blob()], + 'wp.zip' + ); + await backfillWordPressCore(php, wpCoreZip); } - // Always install the playground mu-plugin, even if WordPress is loaded - // from the OPFS. This ensures: - // * The mu-plugin is always there, even when a custom WordPress directory - // is mounted. - // * The mu-plugin is always up to date. - await writeFiles(php, joinPaths(docroot, '/wp-content/mu-plugins'), { - '0-playground.php': playgroundMuPlugin, - 'playground-includes/wp_http_dummy.php': transportDummy, - 'playground-includes/wp_http_fetch.php': transportFetch, - }); + await backfillWpConfig(php); + await backfillSqliteMuPlugin(php); + await backfillPlaygroundMuPlugin(php); + await backfillDatabase(php); if (virtualOpfsDir) { await bindOpfs({ @@ -286,11 +270,17 @@ try { } // Create phpinfo.php + const docroot = php.documentRoot; php.writeFile(joinPaths(docroot, 'phpinfo.php'), ' wp-config.php.new && \ - mv wp-config.php.new wp-config.php +# Remove the wp-config.php file now that WordPress +# is installed and let Playground generate a new one +# on load. +RUN rm wordpress/wp-config.php # Build the final wp.zip file RUN mv wordpress /wordpress && \ diff --git a/packages/playground/wordpress/tsconfig.lib.json b/packages/playground/wordpress/tsconfig.lib.json index 672b0253c97..fc3aed667d6 100644 --- a/packages/playground/wordpress/tsconfig.lib.json +++ b/packages/playground/wordpress/tsconfig.lib.json @@ -5,6 +5,6 @@ "declaration": true, "types": ["node", "vite/client"] }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "../blueprints/src/lib/setup-mu-plugins.ts"], "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 266c10c2adc..958fe1bc026 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -48,6 +48,9 @@ "@wp-playground/nx-extensions": [ "packages/nx-extensions/src/index.ts" ], + "@wp-playground/php-bridge": [ + "packages/playground/php-bridge/src/index.ts" + ], "@wp-playground/remote": [ "packages/playground/remote/src/index.ts" ],