diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000000..d90bf974a2 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1 @@ +preload = ["./packages/bun-extensions/src/import-files-as-text.ts"] diff --git a/lerna.json b/lerna.json index 6e47e6cd20..9d5c8688c2 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useWorkspaces": true, - "version": "0.7.1", + "version": "0.7.2", "useNx": true } diff --git a/package-lock.json b/package-lock.json index 9283499f5c..d82a1b0fcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17795,12 +17795,12 @@ "resolved": "packages/playground/blueprints", "link": true }, - "node_modules/@wp-playground/client": { - "resolved": "packages/playground/client", + "node_modules/@wp-playground/cli": { + "resolved": "packages/playground/cli", "link": true }, - "node_modules/@wp-playground/node": { - "resolved": "packages/playground/node", + "node_modules/@wp-playground/client": { + "resolved": "packages/playground/client", "link": true }, "node_modules/@wp-playground/nx-extensions": { @@ -44913,7 +44913,7 @@ }, "packages/php-wasm/cli": { "name": "@php-wasm/cli", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-2.0-or-later", "bin": { "cli": "php-wasm.js" @@ -44930,7 +44930,7 @@ }, "packages/php-wasm/fs-journal": { "name": "@php-wasm/fs-journal", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -44939,7 +44939,7 @@ }, "packages/php-wasm/logger": { "name": "@php-wasm/logger", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -44948,7 +44948,7 @@ }, "packages/php-wasm/node": { "name": "@php-wasm/node", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -44957,12 +44957,12 @@ }, "packages/php-wasm/node-polyfills": { "name": "@php-wasm/node-polyfills", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-2.0-or-later" }, "packages/php-wasm/progress": { "name": "@php-wasm/progress", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -44971,7 +44971,7 @@ }, "packages/php-wasm/scopes": { "name": "@php-wasm/scopes", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-2.0-or-later", "engines": { "node": ">=16.15.1", @@ -44985,7 +44985,7 @@ }, "packages/php-wasm/universal": { "name": "@php-wasm/universal", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -44994,7 +44994,7 @@ }, "packages/php-wasm/util": { "name": "@php-wasm/util", - "version": "0.7.1", + "version": "0.7.2", "engines": { "node": ">=18.18.0", "npm": ">=8.11.0" @@ -45002,7 +45002,7 @@ }, "packages/php-wasm/web": { "name": "@php-wasm/web", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-2.0-or-later", "engines": { "node": ">=16.15.1", @@ -45011,7 +45011,7 @@ }, "packages/php-wasm/web-service-worker": { "name": "@php-wasm/web-service-worker", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -45020,15 +45020,22 @@ }, "packages/playground/blueprints": { "name": "@wp-playground/blueprints", - "version": "0.7.1", + "version": "0.7.2", "engines": { "node": ">=18.18.0", "npm": ">=8.11.0" } }, + "packages/playground/cli": { + "version": "0.7.2", + "license": "GPL-2.0-or-later", + "bin": { + "cli": "wp-playground.js" + } + }, "packages/playground/client": { "name": "@wp-playground/client", - "version": "0.7.1", + "version": "0.7.2", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -45037,7 +45044,8 @@ }, "packages/playground/node": { "name": "@wp-playground/node", - "version": "0.0.1" + "version": "0.0.1", + "extraneous": true }, "packages/playground/remote": { "name": "@wp-playground/remote", diff --git a/packages/bun-extensions/src/import-files-as-text.ts b/packages/bun-extensions/src/import-files-as-text.ts new file mode 100644 index 0000000000..d28a8b3cbc --- /dev/null +++ b/packages/bun-extensions/src/import-files-as-text.ts @@ -0,0 +1,33 @@ +import { plugin, BunPlugin } from 'bun'; + +/** + * Adds support for `import contents from "file.php?raw"`. + * + * It matches Vite's behavior, which is to read the file as text + * and return its textual content. This allows us to run Bun and + * Vite on the same codebase. + */ +export const ImportFilesAsTextPlugin: BunPlugin = { + name: 'ImportFilesAsTextPlugin', + setup(build) { + build.onLoad( + { + // Looks for paths with a `raw` query parameter. + filter: /[?&]raw(?:[&=]|$)/, + }, + async (args) => { + const path = args.path.split('?')[0]; + // Use Bun.file to read the .php file as text + const text = await Bun.file(path).text(); + + // Return the file content as a module exporting the text + return { + contents: `export default ${JSON.stringify(text)};`, + loader: 'js', // Treat the content as JavaScript + }; + } + ); + }, +}; + +plugin(ImportFilesAsTextPlugin); diff --git a/packages/php-wasm/cli/package.json b/packages/php-wasm/cli/package.json index d52dd12e13..a5b6c65b33 100644 --- a/packages/php-wasm/cli/package.json +++ b/packages/php-wasm/cli/package.json @@ -1,6 +1,6 @@ { "name": "@php-wasm/cli", - "version": "0.7.1", + "version": "0.7.2", "description": "PHP.wasm CLI for node.js", "repository": { "type": "git", diff --git a/packages/php-wasm/compile/php/Dockerfile b/packages/php-wasm/compile/php/Dockerfile index 63ee50f20f..e5a80081e9 100644 --- a/packages/php-wasm/compile/php/Dockerfile +++ b/packages/php-wasm/compile/php/Dockerfile @@ -1023,11 +1023,7 @@ RUN set -euxo pipefail; \ # Turn the php.js file into an ES module # Manually turn the output into a esm module instead of relying on -s MODULARIZE=1. # which pollutes the global namespace and does not play well with import() mechanics. - if [ "$EMSCRIPTEN_ENVIRONMENT" = "node" ]; then \ - echo "const dependencyFilename = __dirname + '/${PHP_VERSION_ESCAPED}/$WASM_FILENAME'; " >> /root/output/php-module.js; \ - else \ - echo "import dependencyFilename from './${PHP_VERSION_ESCAPED}/$WASM_FILENAME'; " >> /root/output/php-module.js; \ - fi; \ + echo "import dependencyFilename from './${PHP_VERSION_ESCAPED}/$WASM_FILENAME'; " >> /root/output/php-module.js; \ echo "export { dependencyFilename }; " >> /root/output/php-module.js && \ echo "export const dependenciesTotalSize = $FILE_SIZE; " >> /root/output/php-module.js && \ cat /root/esm-prefix.js >> /root/output/php-module.js && \ diff --git a/packages/php-wasm/fs-journal/package.json b/packages/php-wasm/fs-journal/package.json index fcc8306f18..0a545c8d04 100644 --- a/packages/php-wasm/fs-journal/package.json +++ b/packages/php-wasm/fs-journal/package.json @@ -1,6 +1,6 @@ { "name": "@php-wasm/fs-journal", - "version": "0.7.1", + "version": "0.7.2", "description": "Bindings to journal the PHP filesystem", "repository": { "type": "git", diff --git a/packages/php-wasm/fs-journal/vite.config.ts b/packages/php-wasm/fs-journal/vite.config.ts index f5eb718ea6..def7c0b87d 100644 --- a/packages/php-wasm/fs-journal/vite.config.ts +++ b/packages/php-wasm/fs-journal/vite.config.ts @@ -7,6 +7,9 @@ import { join } from 'path'; // eslint-disable-next-line @nx/enforce-module-boundaries import { viteTsConfigPaths } from '../../vite-ts-config-paths'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { viteWasmLoader } from '../../vite-wasm-loader'; + export default defineConfig({ cacheDir: '../../../node_modules/.vite/php-wasm-fs-journal', @@ -19,6 +22,8 @@ export default defineConfig({ viteTsConfigPaths({ root: '../../../', }), + + viteWasmLoader, ], // Configuration for building your library. diff --git a/packages/php-wasm/logger/package.json b/packages/php-wasm/logger/package.json index 21030b7118..8d44f95c3a 100644 --- a/packages/php-wasm/logger/package.json +++ b/packages/php-wasm/logger/package.json @@ -1,6 +1,6 @@ { "name": "@php-wasm/logger", - "version": "0.7.1", + "version": "0.7.2", "description": "A logger for PHP-wasm clients like Playground and WP-now.", "repository": { "type": "git", @@ -19,7 +19,8 @@ "directory": "../../../dist/packages/php-wasm/logger" }, "license": "GPL-2.0-or-later", - "main": "index.cjs", + "type": "module", + "main": "index.js", "types": "index.d.ts", "engines": { "node": ">=18.18.0", diff --git a/packages/php-wasm/node-polyfills/package.json b/packages/php-wasm/node-polyfills/package.json index fb7d693626..996851899a 100644 --- a/packages/php-wasm/node-polyfills/package.json +++ b/packages/php-wasm/node-polyfills/package.json @@ -1,6 +1,6 @@ { "name": "@php-wasm/node-polyfills", - "version": "0.7.1", + "version": "0.7.2", "description": "PHP.wasm – polyfills for Node.js", "repository": { "type": "git", diff --git a/packages/php-wasm/node/bin/postprocess-php-modules.cjs b/packages/php-wasm/node/bin/postprocess-php-modules.cjs new file mode 100644 index 0000000000..01a3ac61d5 --- /dev/null +++ b/packages/php-wasm/node/bin/postprocess-php-modules.cjs @@ -0,0 +1,37 @@ +/** + * Replaces `import dependencyFilename from ` with `const dependencyFilename = __dirName + ` + * in all files matching glob `php_*.js` in directory specified by argv[0]. + * + * We need this to: + * + * * Produce a CommonJS and ESM-compliant module using Vite + * * Make the uncompiled source compatible with Bun + */ +const fs = require('fs'); +const glob = require('glob'); + +const directory = process.argv[2]; + +const files = glob.globSync(`${directory}/php_*.js`); +files.forEach((file) => { + fs.readFile(file, 'utf8', (err, data) => { + if (err) { + console.error(err); + return; + } + + const updatedData = data.replace( + 'import dependencyFilename from ', + 'const dependencyFilename = __dirname + ' + ); + + fs.writeFile(file, updatedData, 'utf8', (err) => { + if (err) { + console.error(err); + return; + } + + console.log(`Updated file: ${file}`); + }); + }); +}); diff --git a/packages/php-wasm/node/package.json b/packages/php-wasm/node/package.json index 4aca891e64..4db602814c 100644 --- a/packages/php-wasm/node/package.json +++ b/packages/php-wasm/node/package.json @@ -1,6 +1,6 @@ { "name": "@php-wasm/node", - "version": "0.7.1", + "version": "0.7.2", "description": "PHP.wasm for Node.js", "repository": { "type": "git", diff --git a/packages/php-wasm/node/project.json b/packages/php-wasm/node/project.json index 58a38431fb..c7be65c914 100644 --- a/packages/php-wasm/node/project.json +++ b/packages/php-wasm/node/project.json @@ -22,7 +22,9 @@ "executor": "nx:run-commands", "options": { "commands": [ - "cp -rf packages/php-wasm/node/public/* dist/packages/php-wasm/node" + "cp -rf packages/php-wasm/node/public/* dist/packages/php-wasm/node", + "node packages/php-wasm/node/bin/postprocess-php-modules.cjs dist/packages/php-wasm/node", + "rm -rf dist/packages/php-wasm/node/*.wasm" ], "parallel": false }, diff --git a/packages/php-wasm/node/public/php_7_0.js b/packages/php-wasm/node/public/php_7_0.js index 73584e47ea..6cfc0811d1 100644 --- a/packages/php-wasm/node/public/php_7_0.js +++ b/packages/php-wasm/node/public/php_7_0.js @@ -1,4 +1,4 @@ -const dependencyFilename = __dirname + '/7_0_33/php_7_0.wasm'; +import dependencyFilename from './7_0_33/php_7_0.wasm'; export { dependencyFilename }; export const dependenciesTotalSize = 13118939; export function init(RuntimeName, PHPLoader) { diff --git a/packages/php-wasm/node/public/php_7_1.js b/packages/php-wasm/node/public/php_7_1.js index cfd5ddec91..2ca0ed923f 100644 --- a/packages/php-wasm/node/public/php_7_1.js +++ b/packages/php-wasm/node/public/php_7_1.js @@ -1,4 +1,4 @@ -const dependencyFilename = __dirname + '/7_1_30/php_7_1.wasm'; +import dependencyFilename from './7_1_30/php_7_1.wasm'; export { dependencyFilename }; export const dependenciesTotalSize = 13643181; export function init(RuntimeName, PHPLoader) { diff --git a/packages/php-wasm/node/public/php_7_2.js b/packages/php-wasm/node/public/php_7_2.js index 0740f1405d..5a981759ed 100644 --- a/packages/php-wasm/node/public/php_7_2.js +++ b/packages/php-wasm/node/public/php_7_2.js @@ -1,4 +1,4 @@ -const dependencyFilename = __dirname + '/7_2_34/php_7_2.wasm'; +import dependencyFilename from './7_2_34/php_7_2.wasm'; export { dependencyFilename }; export const dependenciesTotalSize = 14335706; export function init(RuntimeName, PHPLoader) { diff --git a/packages/php-wasm/node/public/php_7_3.js b/packages/php-wasm/node/public/php_7_3.js index 5557fbd3f8..f74a648f33 100644 --- a/packages/php-wasm/node/public/php_7_3.js +++ b/packages/php-wasm/node/public/php_7_3.js @@ -1,4 +1,4 @@ -const dependencyFilename = __dirname + '/7_3_33/php_7_3.wasm'; +import dependencyFilename from './7_3_33/php_7_3.wasm'; export { dependencyFilename }; export const dependenciesTotalSize = 14441796; export function init(RuntimeName, PHPLoader) { diff --git a/packages/php-wasm/node/public/php_7_4.js b/packages/php-wasm/node/public/php_7_4.js index 5fb0c418e4..612e569b56 100644 --- a/packages/php-wasm/node/public/php_7_4.js +++ b/packages/php-wasm/node/public/php_7_4.js @@ -1,4 +1,4 @@ -const dependencyFilename = __dirname + '/7_4_33/php_7_4.wasm'; +import dependencyFilename from './7_4_33/php_7_4.wasm'; export { dependencyFilename }; export const dependenciesTotalSize = 14675209; export function init(RuntimeName, PHPLoader) { diff --git a/packages/php-wasm/node/public/php_8_0.js b/packages/php-wasm/node/public/php_8_0.js index 3668c117aa..642efb0cce 100644 --- a/packages/php-wasm/node/public/php_8_0.js +++ b/packages/php-wasm/node/public/php_8_0.js @@ -1,4 +1,4 @@ -const dependencyFilename = __dirname + '/8_0_30/php_8_0.wasm'; +import dependencyFilename from './8_0_30/php_8_0.wasm'; export { dependencyFilename }; export const dependenciesTotalSize = 13904218; export function init(RuntimeName, PHPLoader) { diff --git a/packages/php-wasm/node/public/php_8_1.js b/packages/php-wasm/node/public/php_8_1.js index 32cf6ed804..951c909bcf 100644 --- a/packages/php-wasm/node/public/php_8_1.js +++ b/packages/php-wasm/node/public/php_8_1.js @@ -1,4 +1,4 @@ -const dependencyFilename = __dirname + '/8_1_23/php_8_1.wasm'; +import dependencyFilename from './8_1_23/php_8_1.wasm'; export { dependencyFilename }; export const dependenciesTotalSize = 13884035; export function init(RuntimeName, PHPLoader) { diff --git a/packages/php-wasm/node/public/php_8_2.js b/packages/php-wasm/node/public/php_8_2.js index 34450b6444..5cc193807b 100644 --- a/packages/php-wasm/node/public/php_8_2.js +++ b/packages/php-wasm/node/public/php_8_2.js @@ -1,4 +1,4 @@ -const dependencyFilename = __dirname + '/8_2_10/php_8_2.wasm'; +import dependencyFilename from './8_2_10/php_8_2.wasm'; export { dependencyFilename }; export const dependenciesTotalSize = 14143340; export function init(RuntimeName, PHPLoader) { diff --git a/packages/php-wasm/node/public/php_8_3.js b/packages/php-wasm/node/public/php_8_3.js index e476bf66cc..1018c3e77d 100644 --- a/packages/php-wasm/node/public/php_8_3.js +++ b/packages/php-wasm/node/public/php_8_3.js @@ -1,4 +1,4 @@ -const dependencyFilename = __dirname + '/8_3_0/php_8_3.wasm'; +import dependencyFilename from './8_3_0/php_8_3.wasm'; export { dependencyFilename }; export const dependenciesTotalSize = 14523141; export function init(RuntimeName, PHPLoader) { diff --git a/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts b/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts index 4f7466b014..c8bb8129f3 100644 --- a/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts +++ b/packages/php-wasm/node/src/lib/networking/outbound-ws-to-tcp-proxy.ts @@ -118,7 +118,7 @@ export function initOutboundWebsocketProxyServer( // Handle new WebSocket client async function onWsConnect(client: any, request: http.IncomingMessage) { - const clientAddr = client._socket.remoteAddress; + const clientAddr = client?._socket?.remoteAddress || client.url; const clientLog = function (...args: any[]) { log(' ' + clientAddr + ': ', ...args); }; diff --git a/packages/php-wasm/node/src/lib/node-php.ts b/packages/php-wasm/node/src/lib/node-php.ts index 61c44b31e8..fff26ccd9d 100644 --- a/packages/php-wasm/node/src/lib/node-php.ts +++ b/packages/php-wasm/node/src/lib/node-php.ts @@ -104,7 +104,12 @@ export class NodePHP extends BasePHP { */ @rethrowFileSystemError('Could not mount {path}') mount(localPath: string | MountSettings, virtualFSPath: string) { - if (!this.fileExists(virtualFSPath)) { + const localRoot = + typeof localPath === 'object' ? localPath.root : localPath; + if ( + !this.fileExists(virtualFSPath) && + lstatSync(localRoot).isDirectory() + ) { this.mkdirTree(virtualFSPath); } this[__private__dont__use].FS.mount( diff --git a/packages/php-wasm/node/vite.config.ts b/packages/php-wasm/node/vite.config.ts index 8079730142..f665077f3d 100644 --- a/packages/php-wasm/node/vite.config.ts +++ b/packages/php-wasm/node/vite.config.ts @@ -5,8 +5,10 @@ /// import { defineConfig } from 'vite'; import viteTsConfigPaths from 'vite-tsconfig-paths'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { viteWasmLoader } from '../../vite-wasm-loader'; -export default defineConfig(() => { +export default defineConfig(function () { return { cacheDir: '../../../node_modules/.vite/php-wasm', @@ -14,14 +16,7 @@ export default defineConfig(() => { viteTsConfigPaths({ root: '../../../', }), - { - name: 'resolve-wasm-path', - load(id): any { - if (id.endsWith('.wasm')) { - return `export default ${JSON.stringify(id)}`; - } - }, - }, + viteWasmLoader, ], // Configuration for building your library. diff --git a/packages/php-wasm/progress/package.json b/packages/php-wasm/progress/package.json index a78d1b7af2..8c42132fc3 100644 --- a/packages/php-wasm/progress/package.json +++ b/packages/php-wasm/progress/package.json @@ -1,6 +1,6 @@ { "name": "@php-wasm/progress", - "version": "0.7.1", + "version": "0.7.2", "description": "PHP.wasm – loading progress monitoring", "repository": { "type": "git", diff --git a/packages/php-wasm/progress/src/lib/emscripten-download-monitor.ts b/packages/php-wasm/progress/src/lib/emscripten-download-monitor.ts index 4a57142d50..a398cdf477 100644 --- a/packages/php-wasm/progress/src/lib/emscripten-download-monitor.ts +++ b/packages/php-wasm/progress/src/lib/emscripten-download-monitor.ts @@ -68,6 +68,9 @@ export class EmscriptenDownloadMonitor extends EventTarget { .pop()!; if (!fileSize) { fileSize = this.#assetsSizes[fileName]; + } else if (!(fileName in this.#assetsSizes)) { + this.#assetsSizes[fileName] = fileSize; + this.#progress[fileName] = loaded; } if (!(fileName in this.#progress)) { logger.warn( diff --git a/packages/php-wasm/scopes/package.json b/packages/php-wasm/scopes/package.json index 741ecdf5f2..d6df514939 100644 --- a/packages/php-wasm/scopes/package.json +++ b/packages/php-wasm/scopes/package.json @@ -1,6 +1,6 @@ { "name": "@php-wasm/scopes", - "version": "0.7.1", + "version": "0.7.2", "description": "PHP.wasm – scoped URLs utils", "repository": { "type": "git", diff --git a/packages/php-wasm/universal/package.json b/packages/php-wasm/universal/package.json index 23dfccaa3f..8ca662d44e 100644 --- a/packages/php-wasm/universal/package.json +++ b/packages/php-wasm/universal/package.json @@ -1,6 +1,6 @@ { "name": "@php-wasm/universal", - "version": "0.7.1", + "version": "0.7.2", "description": "PHP.wasm – emscripten bindings for PHP", "repository": { "type": "git", diff --git a/packages/php-wasm/universal/src/lib/base-php.ts b/packages/php-wasm/universal/src/lib/base-php.ts index f3b58bb67e..51d750fe75 100644 --- a/packages/php-wasm/universal/src/lib/base-php.ts +++ b/packages/php-wasm/universal/src/lib/base-php.ts @@ -225,7 +225,7 @@ export abstract class BasePHP implements IsomorphicLocalPHP, Disposable { * @deprecated */ async request(request: PHPRequest): Promise { - console.warn( + logger.warn( 'PHP.request() is deprecated. Please use new PHPRequestHandler() instead.' ); if (!this.requestHandler) { diff --git a/packages/php-wasm/util/package.json b/packages/php-wasm/util/package.json index 0473b8a908..7c32ce84dc 100644 --- a/packages/php-wasm/util/package.json +++ b/packages/php-wasm/util/package.json @@ -1,6 +1,6 @@ { "name": "@php-wasm/util", - "version": "0.7.1", + "version": "0.7.2", "type": "commonjs", "typedoc": { "entryPoint": "./src/index.ts", diff --git a/packages/php-wasm/web-service-worker/package.json b/packages/php-wasm/web-service-worker/package.json index 96715ec31a..4776e0c5ff 100644 --- a/packages/php-wasm/web-service-worker/package.json +++ b/packages/php-wasm/web-service-worker/package.json @@ -1,6 +1,6 @@ { "name": "@php-wasm/web-service-worker", - "version": "0.7.1", + "version": "0.7.2", "description": "PHP.wasm – service worker utils", "repository": { "type": "git", diff --git a/packages/php-wasm/web/package.json b/packages/php-wasm/web/package.json index ee495fdb9c..9103c86ba0 100644 --- a/packages/php-wasm/web/package.json +++ b/packages/php-wasm/web/package.json @@ -1,6 +1,6 @@ { "name": "@php-wasm/web", - "version": "0.7.1", + "version": "0.7.2", "description": "PHP.wasm for the web", "repository": { "type": "git", diff --git a/packages/playground/blueprints/package.json b/packages/playground/blueprints/package.json index 69ae175009..e5268d3859 100644 --- a/packages/playground/blueprints/package.json +++ b/packages/playground/blueprints/package.json @@ -1,6 +1,6 @@ { "name": "@wp-playground/blueprints", - "version": "0.7.1", + "version": "0.7.2", "exports": { ".": { "import": "./index.js", 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 index 7f3c630343..f70f23ac2b 100644 --- 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 @@ -1,6 +1,90 @@ import { UniversalPHP } from '@php-wasm/universal'; -// @ts-ignore -import zipFunctions from './zip-functions.php?raw'; + +const zipFunctions = `open($output, ZipArchive::CREATE); + if ($res === TRUE) { + $directories = array( + $root . '/' + ); + while (sizeof($directories)) { + $current_dir = array_pop($directories); + + if ($handle = opendir($current_dir)) { + while (false !== ($entry = readdir($handle))) { + if ($entry == '.' || $entry == '..') { + continue; + } + + $entry = join_paths($current_dir, $entry); + if (in_array($entry, $excludePaths)) { + continue; + } + + if (is_dir($entry)) { + $directory_path = $entry . '/'; + array_push($directories, $directory_path); + } else if (is_file($entry)) { + $zip->addFile($entry, substr($entry, strlen($zip_root))); + } + } + closedir($handle); + } + } + foreach ($additionalPaths as $disk_path => $zip_path) { + $zip->addFile($disk_path, $zip_path); + } + $zip->close(); + chmod($output, 0777); + } +} + +function join_paths() +{ + $paths = array(); + + foreach (func_get_args() as $arg) { + if ($arg !== '') { + $paths[] = $arg; + } + } + + return preg_replace('#/+#', '/', join('/', $paths)); +} + +function unzip($zipPath, $extractTo, $overwrite = true) +{ + if (!is_dir($extractTo)) { + mkdir($extractTo, 0777, true); + } + $zip = new ZipArchive; + $res = $zip->open($zipPath); + if ($res === TRUE) { + $zip->extractTo($extractTo); + $zip->close(); + chmod($extractTo, 0777); + } +} + + +function delTree($dir) +{ + $files = array_diff(scandir($dir), array('.', '..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? delTree("$dir/$file") : unlink("$dir/$file"); + } + return rmdir($dir); +} +`; + export async function runPhpWithZipFunctions( playground: UniversalPHP, code: string diff --git a/packages/playground/blueprints/src/lib/utils/zip-functions.php b/packages/playground/blueprints/src/lib/utils/zip-functions.php deleted file mode 100644 index a3fddee344..0000000000 --- a/packages/playground/blueprints/src/lib/utils/zip-functions.php +++ /dev/null @@ -1,83 +0,0 @@ -open($output, ZipArchive::CREATE); - if ($res === TRUE) { - $directories = array( - $root . '/' - ); - while (sizeof($directories)) { - $current_dir = array_pop($directories); - - if ($handle = opendir($current_dir)) { - while (false !== ($entry = readdir($handle))) { - if ($entry == '.' || $entry == '..') { - continue; - } - - $entry = join_paths($current_dir, $entry); - if (in_array($entry, $excludePaths)) { - continue; - } - - if (is_dir($entry)) { - $directory_path = $entry . '/'; - array_push($directories, $directory_path); - } else if (is_file($entry)) { - $zip->addFile($entry, substr($entry, strlen($zip_root))); - } - } - closedir($handle); - } - } - foreach ($additionalPaths as $disk_path => $zip_path) { - $zip->addFile($disk_path, $zip_path); - } - $zip->close(); - chmod($output, 0777); - } -} - -function join_paths() -{ - $paths = array(); - - foreach (func_get_args() as $arg) { - if ($arg !== '') { - $paths[] = $arg; - } - } - - return preg_replace('#/+#', '/', join('/', $paths)); -} - -function unzip($zipPath, $extractTo, $overwrite = true) -{ - if (!is_dir($extractTo)) { - mkdir($extractTo, 0777, true); - } - $zip = new ZipArchive; - $res = $zip->open($zipPath); - if ($res === TRUE) { - $zip->extractTo($extractTo); - $zip->close(); - chmod($extractTo, 0777); - } -} - - -function delTree($dir) -{ - $files = array_diff(scandir($dir), array('.', '..')); - foreach ($files as $file) { - (is_dir("$dir/$file")) ? delTree("$dir/$file") : unlink("$dir/$file"); - } - return rmdir($dir); -} diff --git a/packages/playground/blueprints/vite.config.ts b/packages/playground/blueprints/vite.config.ts index d5912f3a93..c1f407c2d1 100644 --- a/packages/playground/blueprints/vite.config.ts +++ b/packages/playground/blueprints/vite.config.ts @@ -6,6 +6,8 @@ import { join } from 'path'; // eslint-disable-next-line @nx/enforce-module-boundaries import { viteTsConfigPaths } from '../../vite-ts-config-paths'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { viteWasmLoader } from '../../vite-wasm-loader'; export default defineConfig({ cacheDir: '../../../node_modules/.vite/playground-blueprints', @@ -19,6 +21,8 @@ export default defineConfig({ viteTsConfigPaths({ root: '../../../', }), + + viteWasmLoader, ], // Uncomment this if you are using workers. diff --git a/packages/playground/cli/.eslintrc.json b/packages/playground/cli/.eslintrc.json new file mode 100644 index 0000000000..79fd7c1d98 --- /dev/null +++ b/packages/playground/cli/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/playground/cli/README.md b/packages/playground/cli/README.md new file mode 100644 index 0000000000..5619e35b0a --- /dev/null +++ b/packages/playground/cli/README.md @@ -0,0 +1,76 @@ +# WordPress Playground CLI + +A CLI tool for running WordPress playground locally instead of in the browser: + +```shell +$ bun packages/playground/cli/src/cli.ts start --wp=6.5 +WordPress is running on http://127.0.0.1:9400 +``` + +Playground CLI is simple, configurable, and unoppinionated. You can set it up +to your unique WordPress setup. For example, this command would run the documentation +workflow at https://github.com/adamziel/playground-docs-workflow: + +```shell +bun --config=/Users/adam/.bunfig.toml \ + ./packages/playground/cli/src/cli.ts \ + start \ + --mount=./wp-content/plugins/wp-docs-plugin:/wordpress/wp-content/plugins/wp-docs-plugin \ + --mount=./wp-content/html-pages:/wordpress/wp-content/html-pages \ + --mount=./wp-content/uploads:/wordpress/wp-content/uploads \ + --mount=./wp-content/themes/playground-docs:/wordpress/wp-content/themes/playground-docs \ + --blueprint=./wp-content/blueprint-wp-now.json \ + --wp=6.5 +``` + +It is long, sure, but it is also very flexible. If you need a shorter version, you can alias +it or write a bash script. In the future, Blueprints might support relative path mappings, +at which point that command would get much shorter. + +## Philosophy + +The data flow is as follows: + +- Start PHP +- Mount any local directories +- Put a fresh WordPress in the resulting virtual filesystem (unless you're mounting directly at /wordpress). +- Run the Blueprint +- Start a local server, accept requests + +On each run, a fresh WordPress release is unzipped in the virtual filesystem. It is sourced +from a zip file cached at ~/.wordpress-playground/. If you mess up your site, just restart the +server and you'll get a fresh one, again unzipped. The CLI tool never modifies the zip file +so you can always be sure you're starting from a clean slate. + +## Future work + +The CLI tool will have the following commands: + +- `serve` - start a fresh WordPress playground server. +- `build` – run a Blueprint and output a .zip file with the resulting WordPress instance. +- `php` - run the specified PHP file. + +It will also support: + +- Loading Blueprints from URLs. +- Saving the running WordPress site and loading it later. +- Caching all remote resources referenced in Blueprints. Currently, say, plugins are downloaded on each run. + +Conceptually, this isn't too different from Docker containers. There are images (zip files), +containers (running instances), and commands (Blueprints). Playground could support the same +concepts such as: + +- Listing and managing available images and containers. +- Saving a running container and restoring it later +- Starting a container from a specific image (already supported via zip files) +- Running a command in a container (the `php` command) +- Building a new image from a Blueprint (the `build` command) +- Step-by-step cache for Blueprints so that only the changed steps are re-run. + +## Interoperability + +This CLI package is not just a useful tool. It drives interoperability between the in-browser +Playground, CLI packages, and the PHP Blueprints library. Once complete, it will reuse the +same internals as the website at https://playground.wordpress.org whether we're talking about +running PHP code, executing Blueprints, building snapshots, serving requests, or maintaining +multiple PHP instances diff --git a/packages/playground/cli/jest.config.ts b/packages/playground/cli/jest.config.ts new file mode 100644 index 0000000000..1fb6ef5671 --- /dev/null +++ b/packages/playground/cli/jest.config.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +export default { + displayName: 'nx-extensions', + preset: '../../../jest.preset.js', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/packages/nx-extensions', +}; diff --git a/packages/playground/cli/package.json b/packages/playground/cli/package.json new file mode 100644 index 0000000000..9ad5deea20 --- /dev/null +++ b/packages/playground/cli/package.json @@ -0,0 +1,27 @@ +{ + "name": "@wp-playground/cli", + "version": "0.7.2", + "description": "WordPress Playground CLI", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/wordpress-playground" + }, + "homepage": "https://developer.wordpress.org/playground", + "author": "The WordPress contributors", + "contributors": [ + { + "name": "Adam Zielinski", + "email": "adam@adamziel.com", + "url": "https://github.com/adamziel" + } + ], + "publishConfig": { + "access": "public", + "directory": "../../../dist/packages/playground/cli" + }, + "license": "GPL-2.0-or-later", + "type": "module", + "main": "main.js", + "bin": "wp-playground.js", + "gitHead": "2f8d8f3cea548fbd75111e8659a92f601cddc593" +} diff --git a/packages/playground/cli/project.json b/packages/playground/cli/project.json new file mode 100644 index 0000000000..2c860bfe59 --- /dev/null +++ b/packages/playground/cli/project.json @@ -0,0 +1,84 @@ +{ + "name": "wp-playground-cli", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/playground/cli/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@wp-playground/nx-extensions:package-json", + "options": { + "tsConfig": "packages/playground/cli/tsconfig.lib.json", + "outputPath": "dist/packages/playground/cli", + "buildTarget": "wp-playground-cli:build:bundle:production" + } + }, + "build:bundle": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "options": { + "main": "dist/packages/playground/cli/src/cli.js", + "outputPath": "dist/packages/playground/cli" + }, + "defaultConfiguration": "production", + "configurations": { + "development": { + "minify": false + }, + "production": { + "minify": true + } + } + }, + "dev": { + "executor": "nx:run-commands", + "options": { + "command": "bun --watch ./packages/playground/cli/src/cli.ts" + } + }, + "start": { + "executor": "@wp-playground/nx-extensions:built-script", + "options": { + "scriptPath": "dist/packages/playground/cli/wp-playground.js" + }, + "dependsOn": ["build"] + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "node tools/scripts/publish.mjs php-wasm-cli {args.ver} {args.tag}" + }, + "dependsOn": ["build"] + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/playground/cli/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/playground/cli/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "tsc -p packages/playground/cli/tsconfig.lib.json --noEmit", + "tsc -p packages/playground/cli/tsconfig.spec.json --noEmit" + ] + } + } + }, + "tags": ["scope:php-wasm-public"] +} diff --git a/packages/playground/cli/public/wp-playground.js b/packages/playground/cli/public/wp-playground.js new file mode 100644 index 0000000000..cf80c624bb --- /dev/null +++ b/packages/playground/cli/public/wp-playground.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import './cli.js'; diff --git a/packages/playground/cli/src/cli.ts b/packages/playground/cli/src/cli.ts new file mode 100644 index 0000000000..714f792a92 --- /dev/null +++ b/packages/playground/cli/src/cli.ts @@ -0,0 +1,315 @@ +import fs from 'fs'; +import path from 'path'; +import yargs from 'yargs'; +import { startServer } from './server'; +import { + PHPRequest, + PHPRequestHandler, + PHPResponse, + SupportedPHPVersion, + SupportedPHPVersions, +} from '@php-wasm/universal'; +import { logger } from '@php-wasm/logger'; +import { createPhp } from './setup-php'; +import { setupWordPress } from './setup-wp'; +import { + Blueprint, + compileBlueprint, + defineSiteUrl, + runBlueprintSteps, +} from '@wp-playground/blueprints'; +import { RecommendedPHPVersion } from '@wp-playground/wordpress'; +import { NodePHP } from '@php-wasm/node'; +import { isValidWordPressSlug } from './is-valid-wordpress-slug'; +import { EmscriptenDownloadMonitor, ProgressTracker } from '@php-wasm/progress'; + +export interface Mount { + hostPath: string; + vfsPath: string; +} + +async function run() { + /** + * @TODO This looks similar to Query API args https://wordpress.github.io/wordpress-playground/query-api + * Perhaps the two could be handled by the same code? + */ + const yargsObject = await yargs(process.argv.slice(2)) + .usage('Usage: wp-playground [options]') + .positional('command', { + describe: 'Command to run', + type: 'string', + choices: ['server', 'run-blueprint', 'build-snapshot'], + }) + .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. You can provide --mount multiple times. Format: /host/path:/vfs/path', + type: 'array', + string: true, + }) + .option('login', { + describe: 'Should log the user in', + type: 'boolean', + default: false, + }) + .option('blueprint', { + describe: 'Blueprint to execute.', + type: 'string', + }) + .option('skipWordPressSetup', { + describe: + 'Do not download, unzip, and install WordPress. Useful for mounting a pre-configured WordPress directory at /wordpress.', + type: 'boolean', + default: false, + }) + .option('quiet', { + describe: 'Do not output logs and progress messages.', + type: 'boolean', + default: false, + }) + .check((args) => { + if (args.wp !== undefined && !isValidWordPressSlug(args.wp)) { + throw new Error( + 'Unrecognized WordPress version. Please use "latest" or numeric versions such as "6.2", "6.0.1", "6.2-beta1", or "6.2-RC1"' + ); + } + if (args.blueprint !== undefined) { + const blueprintPath = path.resolve( + process.cwd(), + args.blueprint + ); + if (!fs.existsSync(blueprintPath)) { + throw new Error('Blueprint file does not exist'); + } + + const content = fs.readFileSync(blueprintPath, 'utf-8'); + try { + args.blueprint = JSON.parse(content); + } catch (e) { + throw new Error('Blueprint file is not a valid JSON file'); + } + } + return true; + }); + + yargsObject.wrap(yargsObject.terminalWidth()); + const args = await yargsObject.argv; + + if (args.quiet) { + // @ts-ignore + logger.handlers = []; + } + + /** + * 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) { + // Fake URL for the build + const { php, reap } = + await requestHandler.processManager.acquirePHPInstance(); + try { + await php.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 = php.readFileAsBuffer('/tmp/build.zip'); + fs.writeFileSync(outfile, zip); + } finally { + reap(); + } + } + + async function prepareSite( + php: NodePHP, + wpVersion: string, + siteUrl: string + ) { + // No need to unzip WordPress if it's already mounted at /wordpress + if (!args.skipWordPressSetup) { + logger.log(`Setting up WordPress ${wpVersion}`); + // @TODO: Rename to FetchProgressMonitor. There's nothing Emscripten about that class anymore. + const monitor = new EmscriptenDownloadMonitor(); + monitor.addEventListener('progress', (( + e: CustomEvent + ) => { + // @TODO Every progres bar will want percentages. The + // download monitor should just provide that. + const percentProgress = Math.round( + Math.min(100, (100 * e.detail.loaded) / e.detail.total) + ); + if (!args.quiet) { + process.stdout.write( + `\rDownloading WordPress ${percentProgress}%... ` + ); + } + }) as any); + await setupWordPress(php, wpVersion, monitor); + } + + const mounts: Mount[] = (args.mount || []).map((mount) => { + const [source, vfsPath] = mount.split(':'); + return { + hostPath: path.resolve(process.cwd(), source), + vfsPath, + }; + }); + for (const mount of mounts) { + php.mount(mount.hostPath, mount.vfsPath); + } + + await defineSiteUrl(php, { + siteUrl, + }); + } + + function compileInputBlueprint() { + /** + * @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? + */ + let blueprint: Blueprint | undefined; + if (args.blueprint) { + blueprint = args.blueprint as Blueprint; + } else { + blueprint = { + preferredVersions: { + php: args.php as SupportedPHPVersion, + wp: args.wp, + }, + login: args.login, + }; + } + + const tracker = new ProgressTracker(); + let lastCaption = ''; + let progress100 = false; + tracker.addEventListener('progress', (e: any) => { + if (progress100) { + return; + } else if (e.detail.progress === 100) { + progress100 = true; + } + lastCaption = + e.detail.caption || lastCaption || 'Running the Blueprint'; + process.stdout.write( + '\r\x1b[K' + `${lastCaption.trim()} – ${e.detail.progress}%` + ); + if (progress100) { + process.stdout.write('\n'); + } + }); + return compileBlueprint(blueprint as Blueprint, { + progress: tracker, + }); + } + + const command = args._[0] as string; + if (!['run-blueprint', 'server', 'build-snapshot'].includes(command)) { + yargsObject.showHelp(); + process.exit(1); + } + + const compiledBlueprint = compileInputBlueprint(); + + let requestHandler: PHPRequestHandler; + let wordPressReady = false; + + logger.log('Starting a PHP server...'); + + startServer({ + port: args['port'] as number, + onBind: async (port: number) => { + const absoluteUrl = `http://127.0.0.1:${port}`; + requestHandler = new PHPRequestHandler({ + phpFactory: async ({ isPrimary }) => + createPhp( + requestHandler, + compiledBlueprint.versions.php, + isPrimary + ), + documentRoot: '/wordpress', + absoluteUrl, + }); + + const php = await requestHandler.getPrimaryPhp(); + await prepareSite(php, compiledBlueprint.versions.wp, absoluteUrl); + + wordPressReady = true; + + if (compiledBlueprint) { + const { php, reap } = + await requestHandler.processManager.acquirePHPInstance(); + try { + logger.log(`Running the Blueprint...`); + await runBlueprintSteps(compiledBlueprint, php); + logger.log(`Finished running the blueprint`); + } finally { + reap(); + } + } + + if (command === 'build-snapshot') { + zipSite(args.outfile as string); + logger.log(`WordPress exported to ${args.outfile}`); + process.exit(0); + } else if (command === 'run-blueprint') { + logger.log(`Blueprint executed`); + process.exit(0); + } else { + logger.log(`WordPress is running on ${absoluteUrl}`); + } + }, + async handleRequest(request: PHPRequest) { + if (!wordPressReady) { + return PHPResponse.forHttpCode( + 502, + 'WordPress is not ready yet' + ); + } + return await requestHandler.request(request); + }, + }); +} + +run(); diff --git a/packages/playground/cli/src/download.ts b/packages/playground/cli/src/download.ts new file mode 100644 index 0000000000..31bad56a8b --- /dev/null +++ b/packages/playground/cli/src/download.ts @@ -0,0 +1,84 @@ +import { EmscriptenDownloadMonitor } from '@php-wasm/progress'; +import fs from 'fs-extra'; +import os from 'os'; +import path, { basename } from 'path'; + +export const CACHE_FOLDER = path.join(os.homedir(), '.wordpress-playground'); + +// @TODO: Support HTTP cache, invalidate the local file if the remote file has changed +export async function cachedDownload( + remoteUrl: string, + cacheKey: string, + monitor: EmscriptenDownloadMonitor +) { + const artifactPath = path.join(CACHE_FOLDER, cacheKey); + if (!fs.existsSync(artifactPath)) { + fs.ensureDirSync(CACHE_FOLDER); + await downloadTo(remoteUrl, artifactPath, monitor); + } + return readAsFile(artifactPath); +} + +async function downloadTo( + remoteUrl: string, + localPath: string, + monitor: EmscriptenDownloadMonitor +) { + const response = await monitor.monitorFetch(fetch(remoteUrl)); + const reader = response.body!.getReader(); + const writer = fs.createWriteStream(localPath); + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + writer.write(Buffer.from(value)); + } +} + +export function readAsFile(path: string, fileName?: string): File { + return new File([fs.readFileSync(path)], fileName ?? basename(path)); +} + +export async function resolveWPRelease(version = 'latest') { + if (version === 'trunk' || version === 'nightly') { + return { + url: 'https://wordpress.org/nightly-builds/wordpress-latest.zip', + version: 'nightly-' + new Date().toISOString().split('T')[0], + }; + } + + let latestVersions = await fetch( + 'https://api.wordpress.org/core/version-check/1.7/?channel=beta' + ).then((res) => res.json()); + + latestVersions = latestVersions.offers.filter( + (v: any) => v.response === 'autoupdate' + ); + + for (const apiVersion of latestVersions) { + if (version === 'beta' && apiVersion.version.includes('beta')) { + return { + url: apiVersion.download, + version: apiVersion.version, + }; + } else if (version === 'latest') { + return { + url: apiVersion.download, + version: apiVersion.version, + }; + } else if ( + apiVersion.version.substring(0, version.length) === version + ) { + return { + url: apiVersion.download, + version: apiVersion.version, + }; + } + } + + return { + url: `https://wordpress.org/wordpress-${version}.zip`, + version: version, + }; +} diff --git a/packages/playground/cli/src/is-valid-wordpress-slug.ts b/packages/playground/cli/src/is-valid-wordpress-slug.ts new file mode 100644 index 0000000000..4fdd957961 --- /dev/null +++ b/packages/playground/cli/src/is-valid-wordpress-slug.ts @@ -0,0 +1,21 @@ +/** + * Checks if the given version string is a valid WordPress version. + * + * The Regex is based on the releases on https://wordpress.org/download/releases/#betas + * The version string can be one of the following formats: + * - "latest" + * - "trunk" + * - "nightly" + * - "x.y" (x and y are integers) e.g. "6.2" + * - "x.y.z" (x, y and z are integers) e.g. "6.2.1" + * - "x.y.z-betaN" (N is an integer) e.g. "6.2.1-beta1" + * - "x.y.z-RCN" (N is an integer) e.g. "6.2-RC1" + * + * @param version The version string to check. + * @returns A boolean value indicating whether the version string is a valid WordPress version. + */ +export function isValidWordPressSlug(version: string): boolean { + const versionPattern = + /^latest$|^trunk$|^nightly$|^(?:(\d+)\.(\d+)(?:\.(\d+))?)((?:-beta(?:\d+)?)|(?:-RC(?:\d+)?))?$/; + return versionPattern.test(version); +} diff --git a/packages/playground/cli/src/server.ts b/packages/playground/cli/src/server.ts new file mode 100644 index 0000000000..55e3b99376 --- /dev/null +++ b/packages/playground/cli/src/server.ts @@ -0,0 +1,68 @@ +import express, { Request } from 'express'; +import { PHPRequest, PHPResponse } from '@php-wasm/universal'; +import { IncomingMessage, Server, ServerResponse } from 'http'; +import { AddressInfo } from 'net'; + +export interface ServerOptions { + port: number; + onBind: (port: number) => Promise; + handleRequest: (request: PHPRequest) => Promise; +} + +export async function startServer(options: ServerOptions) { + const app = express(); + + const server = await new Promise< + Server + >((resolve, reject) => { + const server = app.listen(options.port, () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('Server address is not available')); + } else { + resolve(server); + } + }); + }); + + app.use('/', async (req, res) => { + const phpResponse = await options.handleRequest({ + url: req.url, + headers: parseHeaders(req), + method: req.method as any, + body: await bufferRequestBody(req), + }); + + res.statusCode = phpResponse.httpStatusCode; + for (const key in phpResponse.headers) { + res.setHeader(key, phpResponse.headers[key]); + } + res.end(phpResponse.bytes); + }); + + const address = server.address(); + const port = (address! as AddressInfo).port; + await options.onBind(port); +} + +const bufferRequestBody = async (req: Request): Promise => + await new Promise((resolve) => { + const body: Uint8Array[] = []; + req.on('data', (chunk) => { + body.push(chunk); + }); + req.on('end', () => { + resolve(Buffer.concat(body)); + }); + }); + +const parseHeaders = (req: Request): Record => { + const requestHeaders: Record = {}; + if (req.rawHeaders && req.rawHeaders.length) { + for (let i = 0; i < req.rawHeaders.length; i += 2) { + requestHeaders[req.rawHeaders[i].toLowerCase()] = + req.rawHeaders[i + 1]; + } + } + return requestHeaders; +}; diff --git a/packages/playground/cli/src/setup-php.ts b/packages/playground/cli/src/setup-php.ts new file mode 100644 index 0000000000..4c23e34fd3 --- /dev/null +++ b/packages/playground/cli/src/setup-php.ts @@ -0,0 +1,140 @@ +import { NodePHP } from '@php-wasm/node'; +import { + BasePHP, + PHPRequestHandler, + SupportedPHPVersion, + __private__dont__use, + rotatePHPRuntime, +} from '@php-wasm/universal'; +import { rootCertificates } from 'tls'; +import { dirname } from '@php-wasm/util'; + +export async function createPhp( + requestHandler: PHPRequestHandler, + phpVersion: SupportedPHPVersion, + isPrimary: boolean +) { + const createPhpRuntime = async () => await NodePHP.loadRuntime(phpVersion); + const php = new NodePHP(); + php.requestHandler = requestHandler; + /** + * @TODO: Consider an API like + * + * await php.useRuntimeFactory(runtimeFactory, { rotateAfterRequests: 400 }); + * + * or + * + * const php = await NodePHP.create({ + * runtimeFactory, + * rotateAfterRequests: 400, + * }) + */ + php.initializeRuntime(await createPhpRuntime()); + php.setSapiName('cli'); + php.setPhpIniPath('/tmp/php.ini'); + php.writeFile('/tmp/php.ini', ''); + php.setPhpIniEntry('memory_limit', '256M'); + php.setPhpIniEntry('allow_url_fopen', '1'); + php.setPhpIniEntry('disable_functions', ''); + + // Write the ca-bundle.crt file to disk so that PHP can find it. + php.setPhpIniEntry('openssl.cafile', '/tmp/ca-bundle.crt'); + php.writeFile('/tmp/ca-bundle.crt', rootCertificates.join('\n')); + + if (!isPrimary) { + /** + * @TODO: Consider an API similar to + * + * php.mount('/wordpress', primaryPHP.getMountPoint('/wordpress')); + */ + proxyFileSystem( + await requestHandler.getPrimaryPhp(), + php, + '/wordpress' + ); + } + + // php.setSpawnHandler(spawnHandlerFactory(processManager)); + // Rotate the PHP runtime periodically to avoid memory leak-related crashes. + // @see https://github.com/WordPress/wordpress-playground/pull/990 for more context + rotatePHPRuntime({ + php, + cwd: '/wordpress', + recreateRuntime: createPhpRuntime, + maxRequests: 400, + }); + return php; +} + +/** + * Share the parent's MEMFS instance with the child process. + * Only mount the document root and the /tmp directory, + * the rest of the filesystem (like the devices) should be + * private to each PHP instance. + * + * @TODO: Ship this feature in the php-wasm library. It + * will be commonly used in multi-instance Playground + * applications. The website app does something similar, + * and so will wp-now, VSCode, etc. + */ +export function proxyFileSystem( + sourceOfTruth: BasePHP, + replica: BasePHP, + documentRoot: string +) { + for (const path of [documentRoot, '/tmp']) { + if (!replica.fileExists(path)) { + replica.mkdir(path); + } + if (!sourceOfTruth.fileExists(path)) { + sourceOfTruth.mkdir(path); + } + replica[__private__dont__use].FS.mount( + replica[__private__dont__use].PROXYFS, + { + root: path, + fs: sourceOfTruth[__private__dont__use].FS, + }, + path + ); + } +} + +/** + * @TODO: Ship this feature in the php-wasm library. + * + * Perhaps change the implementation of the setPhpIniValue()? + * We could ensure there's always a valid php.ini file, + * even if empty. php_wasm.c wouldn't provide any defaults. + * BasePHP would, and it would write them to the default php.ini + * file. Then we'd be able to use setPhpIniValue() at any time, not + * just before the first run() call. + */ +export async function withPHPIniValues( + php: NodePHP, + phpIniValues: Record, + callback: () => Promise +) { + const phpIniPath = ( + await php.run({ + code: ' `${key} = ${value}`) + .join('\n'); + php.writeFile(phpIniPath, [originalPhpIni, newPhpIni].join('\n')); + try { + await callback(); + } finally { + php.writeFile(phpIniPath, originalPhpIni); + } +} diff --git a/packages/playground/cli/src/setup-wp.ts b/packages/playground/cli/src/setup-wp.ts new file mode 100644 index 0000000000..e6bfab8825 --- /dev/null +++ b/packages/playground/cli/src/setup-wp.ts @@ -0,0 +1,187 @@ +import fs from 'fs'; + +import { NodePHP } from '@php-wasm/node'; +import { EmscriptenDownloadMonitor } from '@php-wasm/progress'; +import { + defineSiteUrl, + runWpInstallationWizard, + unzip, + zipWpContent, +} from '@wp-playground/blueprints'; +import path from 'path'; +import { + resolveWPRelease, + cachedDownload, + CACHE_FOLDER, + readAsFile, +} from './download'; +import { withPHPIniValues } from './setup-php'; + +/** + * Ensures a functional WordPress installation in php document root. + * + * This is a TypeScript function for now, just to get something off the + * ground, but it will be superseded by the PHP Blueprints library developed + * at https://github.com/WordPress/blueprints-library/ + * + * That PHP library will come with a set of functions and a CLI tool to + * turn a Blueprint into a WordPress directory structure or a zip Snapshot. + * Let's **not** invest in the TypeScript implementation of this function, + * accept the limitation, and switch to the PHP implementation as soon + * as that's viable. + */ +export async function setupWordPress( + php: NodePHP, + wpVersion = 'latest', + monitor: EmscriptenDownloadMonitor +) { + /** + * @TODO: This looks similar to what the website does to setup WordPress. + * Perhaps there's a common function that could be shared? + */ + const wpDetails = await resolveWPRelease(wpVersion); + const [wpZip, sqliteZip] = await Promise.all([ + cachedDownload(wpDetails.url, `${wpDetails.version}.zip`, monitor), + cachedDownload( + 'https://github.com/WordPress/sqlite-database-integration/archive/refs/heads/main.zip', + 'sqlite.zip', + monitor + ), + ]); + await prepareWordPress(php, wpZip, sqliteZip); + + const preinstalledWpContentPath = path.join( + CACHE_FOLDER, + `prebuilt-wp-content-for-wp-${wpDetails.version}.zip` + ); + if (fs.existsSync(preinstalledWpContentPath)) { + /** + * @TODO: This caching mechanism will be made generic and provided as a + * handler for the PHP Blueprints library. + */ + await unzip(php, { + zipFile: readAsFile(preinstalledWpContentPath), + extractToPath: '/wordpress', + }); + } else { + // Define a fake URL for the installation wizard. + await defineSiteUrl(php, { + siteUrl: 'http://playground.internal', + }); + + // Disable networking for the installation wizard + // to avoid loopback requests and also speed it up. + // @TODO: Expose withPHPIniValues as a function from the + // php-wasm library. + await withPHPIniValues( + php, + { + disable_functions: 'fsockopen', + allow_url_fopen: '0', + }, + async () => + await runWpInstallationWizard(php, { + options: {}, + }) + ); + + const wpContent = await zipWpContent(php); + fs.writeFileSync(preinstalledWpContentPath, wpContent); + } +} + +/** + * Prepare the WordPress document root given a WordPress zip file and + * the sqlite-database-integration zip file. + * + * This is a TypeScript function for now, just to get something off the + * ground, but it will be superseded by the PHP Blueprints library developed + * at https://github.com/WordPress/blueprints-library/ + * + * That PHP library will come with a set of functions and a CLI tool to + * turn a Blueprint into a WordPress directory structure or a zip Snapshot. + * Let's **not** invest in the TypeScript implementation of this function, + * accept the limitation, and switch to the PHP implementation as soon + * as that's viable. + */ +async function prepareWordPress(php: NodePHP, wpZip: File, sqliteZip: File) { + php.mkdir('/tmp/unzipped-wordpress'); + await unzip(php, { + zipFile: wpZip, + extractToPath: '/tmp/unzipped-wordpress', + }); + // The zip file may contain a subdirectory, or not. + const wpPath = php.fileExists('/tmp/unzipped-wordpress/wordpress') + ? '/tmp/unzipped-wordpress/wordpress' + : '/tmp/unzipped-wordpress'; + php.mv(wpPath, '/wordpress'); + + php.mkdir('/tmp/sqlite-database-integration'); + await unzip(php, { + zipFile: sqliteZip, + extractToPath: '/tmp/sqlite-database-integration', + }); + + php.mv( + '/tmp/sqlite-database-integration/sqlite-database-integration-main', + '/wordpress/sqlite-database-integration' + ); + + const db = php.readFileAsText( + '/wordpress/sqlite-database-integration/db.copy' + ); + const updatedDb = db + .replace( + "'{SQLITE_IMPLEMENTATION_FOLDER_PATH}'", + "__DIR__.'/../sqlite-database-integration/'" + ) + .replace( + "'{SQLITE_PLUGIN}'", + "__DIR__.'/../sqlite-database-integration/load.php'" + ); + php.writeFile('/wordpress/wp-content/db.php', updatedDb); + + /** + * This should be a mu-plugin, but since the user may have + * provided custom mounts, we avoid writing to the mu-plugins + * directory + * + * @TODO: Either document this hack, or find a better way to + * handle this. + * @TODO: The web version also uses an mu-plugin and it has the + * same filters as this one. Also wp-now and VS Code. + * There seems to be an opportunity to share the code between + * the two. + */ + + php.writeFile( + '/wordpress/wp-includes/default-filters.php', + php.readFileAsText('/wordpress/wp-includes/default-filters.php') + + ` + // Redirect /wp-admin to /wp-admin/ + add_filter( 'redirect_canonical', function( $redirect_url ) { + if ( '/wp-admin' === $redirect_url ) { + return $redirect_url . '/'; + } + return $redirect_url; + } ); + + // Needed because gethostbyname( 'wordpress.org' ) returns + // a private network IP address for some reason. + add_filter( 'allowed_redirect_hosts', function( $deprecated = '' ) { + return array( + 'wordpress.org', + 'api.wordpress.org', + 'downloads.wordpress.org', + ); + } ); + + // Support permalinks without "index.php" + add_filter( 'got_url_rewrite', '__return_true' ); + ` + ); + php.writeFile( + '/wordpress/wp-config.php', + php.readFileAsText('/wordpress/wp-config-sample.php') + ); +} diff --git a/packages/playground/cli/tsconfig.json b/packages/playground/cli/tsconfig.json new file mode 100644 index 0000000000..e4e0c46e69 --- /dev/null +++ b/packages/playground/cli/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "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/cli/tsconfig.lib.json b/packages/playground/cli/tsconfig.lib.json new file mode 100644 index 0000000000..829b0bc14c --- /dev/null +++ b/packages/playground/cli/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/playground/cli/tsconfig.spec.json b/packages/playground/cli/tsconfig.spec.json new file mode 100644 index 0000000000..231650b3da --- /dev/null +++ b/packages/playground/cli/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/packages/playground/cli/vite.config.ts b/packages/playground/cli/vite.config.ts new file mode 100644 index 0000000000..ca7e3aa652 --- /dev/null +++ b/packages/playground/cli/vite.config.ts @@ -0,0 +1,56 @@ +/// +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + assetsInclude: ['**/*.ini'], + cacheDir: '../../../node_modules/.vite/php-cli', + + plugins: [nxViteTsPaths()], + + // Configuration for building your library. + // See: https://vitejs.dev/guide/build.html#library-mode + build: { + assetsInlineLimit: 0, + target: 'es2020', + rollupOptions: { + external: [ + '@php-wasm/node', + '@php-wasm/universal', + '@php-wasm/logger', + '@php-wasm/progress', + '@php-wasm/util', + '@wp-playground/wordpress', + '@wp-playground/blueprints', + 'yargs', + 'express', + 'os', + 'net', + 'fs', + 'fs-extra', + 'path', + 'child_process', + 'http', + 'path', + 'tls', + 'util', + 'dns', + 'ws', + ], + input: 'packages/playground/cli/src/cli.ts', + output: { + format: 'esm', + entryFileNames: '[name].js', + }, + }, + }, + + test: { + globals: true, + cache: { + dir: '../../../node_modules/.vitest', + }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + }, +}); diff --git a/packages/playground/client/package.json b/packages/playground/client/package.json index a906d9ddba..7c03025ddd 100644 --- a/packages/playground/client/package.json +++ b/packages/playground/client/package.json @@ -1,6 +1,6 @@ { "name": "@wp-playground/client", - "version": "0.7.1", + "version": "0.7.2", "description": "WordPress Playground client", "repository": { "type": "git", diff --git a/packages/playground/sync/vite.config.ts b/packages/playground/sync/vite.config.ts index cc65b73ecf..c5ab5cae07 100644 --- a/packages/playground/sync/vite.config.ts +++ b/packages/playground/sync/vite.config.ts @@ -1,8 +1,9 @@ /// // eslint-disable-next-line @nx/enforce-module-boundaries import { viteTsConfigPaths } from '../../vite-ts-config-paths'; + // eslint-disable-next-line @nx/enforce-module-boundaries -import ignoreWasmImports from '../ignore-wasm-imports'; +import { viteWasmLoader } from '../../vite-wasm-loader'; export default { base: '/', @@ -19,7 +20,7 @@ export default { viteTsConfigPaths({ root: '../../../', }), - ignoreWasmImports(), + viteWasmLoader, ], // Configuration for building your library. diff --git a/packages/playground/wordpress/package.json b/packages/playground/wordpress/package.json index a26098e0ac..b899762efc 100644 --- a/packages/playground/wordpress/package.json +++ b/packages/playground/wordpress/package.json @@ -16,7 +16,6 @@ } ], "main": "./index.js", - "module": "./index.mjs", "typings": "./index.d.ts", "license": "GPL-2.0-or-later", "type": "module", diff --git a/packages/vite-wasm-loader.ts b/packages/vite-wasm-loader.ts new file mode 100644 index 0000000000..28a2975ba4 --- /dev/null +++ b/packages/vite-wasm-loader.ts @@ -0,0 +1,7 @@ +export const viteWasmLoader = { + load(id: string): any { + if (id.endsWith('.wasm')) { + return `export default ${JSON.stringify(id)}`; + } + }, +} as any; diff --git a/tsconfig.base.json b/tsconfig.base.json index 2ba54cf630..a3bfe1f160 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -42,6 +42,7 @@ "@wp-playground/blueprints": [ "packages/playground/blueprints/src/index.ts" ], + "@wp-playground/cli": ["packages/playground/cli/src/index.ts"], "@wp-playground/client": [ "packages/playground/client/src/index.ts" ],