From bc3daa0608d7c78783dad5c17f2cf294f380f4f1 Mon Sep 17 00:00:00 2001 From: Adam Zielinski Date: Tue, 14 Mar 2023 12:16:46 +0100 Subject: [PATCH] Playground Public API (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description With this PR, Playground exposes a consistent communication layer for its consumers: ```ts const playground = connect( iframe.contentWindow ); playground.writeFile( '/wordpress/test.php', 'Hello, world!' ); playground.goTo( '/test.php' ); ``` It means **your app can have the same powers as the official demo**. Just note this PR does not expose any public packages yet. That will be the next step. ## Under the hood Technically, this PR: 1. Formalizes `php-wasm` public API 2. Formalizes Playground public API as an extension of the above 3. Uses the [comlink](https://github.com/GoogleChromeLabs/comlink) library to expose Playground's public API There are a few layers to this: * Playground worker initializes the PHP and WordPress and exposes an internal API using comlink * Playground "connector" HTML page consumes the worker API, extends it, and re-exposes it publicly using comlink * Playground UI is now a separate app that connects to the "connector" page and consumes its API using comlink All the public-facing features, like plugin pre-installation or import/export, are now implemented by consuming the public API. Once I [publish these features in npm](https://github.com/WordPress/wordpress-playground/issues/147), consuming Playground will be as simple as importing a package. This PR also refactors the Playground website to use React – this process was indispensable in shaping the public API. ## Public API Here's the raw interface – the documentation will be shipped with the npm package and provided in this repo. ```ts interface PlaygroundAPI { absoluteUrl: string; goTo(requestedPath: string): Promise; getCurrentURL(): Promise; setIframeSandboxFlags(flags: string[]): Promise; onNavigation(callback: (newURL: string) => void): Promise; onDownloadProgress( callback: (progress: CustomEvent) => void ): Promise; getWordPressModuleDetails(): Promise<{ staticAssetsDirectory: string; defaultTheme: string; }>; pathToInternalUrl(path: string): Promise; internalUrlToPath(internalUrl: string): Promise; request( request: PHPServerRequest, redirects?: number ): Promise; run(request?: PHPRequest | undefined): Promise; setPhpIniPath(path: string): Promise; setPhpIniEntry(key: string, value: string): Promise; mkdirTree(path: string): Promise; readFileAsText(path: string): Promise; readFileAsBuffer(path: string): Promise; writeFile(path: string, data: string | Uint8Array): Promise; unlink(path: string): Promise; listFiles(path: string): Promise; isDir(path: string): Promise; fileExists(path: string): Promise; } ``` --- .eslintrc.js | 1 + package.json | 9 +- packages/php-wasm/package.json | 27 +- packages/php-wasm/rollup.config.mjs | 13 +- .../php-wasm/src/php-library/comlink-utils.ts | 96 +++ .../php-wasm/src/php-library/index-node.ts | 3 +- packages/php-wasm/src/php-library/index.ts | 34 +- .../src/{web => php-library}/messaging.ts | 7 +- .../php-wasm/src/php-library/php-browser.ts | 39 +- .../php-wasm/src/php-library/php-server.ts | 101 ++- packages/php-wasm/src/php-library/php.ts | 338 +++++--- .../src/{web => php-library}/scope.ts | 0 packages/php-wasm/src/php-library/urls.ts | 4 +- packages/php-wasm/src/web.ts | 4 +- packages/php-wasm/src/web/index.ts | 32 +- packages/php-wasm/src/web/php-public-api.ts | 108 +++ .../emscripten-download-monitor.ts | 102 ++- .../progress-monitoring}/progress-observer.ts | 53 +- ...-library.ts => register-service-worker.ts} | 37 +- .../php-wasm/src/web/remote-php-server.ts | 96 --- .../php-wasm/src/web/service-worker/index.ts | 353 +++++++- .../src/web/service-worker/worker-library.ts | 350 -------- .../php-wasm/src/web/spawn-worker-thread.ts | 54 ++ .../php-wasm/src/web/worker-thread/index.ts | 1 - .../src/web/worker-thread/window-library.ts | 290 ------- .../src/web/worker-thread/worker-library.ts | 267 ------ packages/php-wasm/tsconfig.build.json | 15 + packages/php-wasm/tsconfig.json | 18 +- packages/php-wasm/tsconfig.node.json | 8 - packages/php-wasm/tsconfig.worker.json | 24 - packages/playground-client/package.json | 54 ++ packages/playground-client/rollup.config.mjs | 74 ++ packages/playground-client/src/index.ts | 14 + .../playground-client/tsconfig.build.json | 12 + packages/playground-client/tsconfig.json | 3 + packages/wordpress-playground/package.json | 12 +- .../src/__tests__/migration-tests.ts | 8 +- packages/wordpress-playground/src/app.tsx | 566 ------------- .../src/boot-playground.tsx | 122 +++ packages/wordpress-playground/src/boot.ts | 88 -- packages/wordpress-playground/src/client.ts | 30 - .../src/components/address-bar/index.tsx | 49 ++ .../components/address-bar/style.module.css | 38 + .../src/components/browser-chrome/index.tsx | 62 ++ .../browser-chrome/style.module.css | 137 +++ .../src/components/export-button/index.tsx | 33 + .../components/export-button/style.module.css | 10 + .../src/components/import-button/index.tsx | 82 ++ .../components/import-button/style.module.css | 10 + .../src/components/import-form/index.tsx | 141 ++++ .../components/import-form/style.module.css | 71 ++ .../components/playground-viewport/index.tsx | 92 +++ .../playground-viewport/style.module.css | 8 + .../src/components/plugin-ide/setup.ts | 31 + .../src/components/progress-bar/index.tsx | 55 ++ .../components/progress-bar/style.module.css | 89 ++ .../wordpress-playground/src/examples.html | 40 + .../wordpress-playground/src/examples.tsx | 94 +++ .../src/features/common.ts | 30 + .../src/features/import-export.ts | 140 ++++ .../src/features/install-plugin.ts | 101 +++ .../install-plugins-from-directory.ts | 117 +++ .../features/install-theme-from-directory.ts | 51 ++ .../src/features/install-theme.ts | 65 ++ .../src/features/login.ts | 21 + .../src/{ => features}/migration.php | 0 packages/wordpress-playground/src/hooks.ts | 67 ++ packages/wordpress-playground/src/index.html | 33 + ...rker-utils.ts => is-uploaded-file-path.ts} | 0 .../wordpress-playground/src/playground.html | 146 ---- .../wordpress-playground/src/promise-queue.ts | 37 - .../src/service-worker.ts | 18 +- .../wordpress-playground/src/wordpress.html | 550 ------------- .../wordpress-playground/src/worker-thread.ts | 240 ++---- .../wordpress-playground/src/wp-client.ts | 10 + .../wordpress-playground/src/wp-macros.ts | 212 ----- .../src/wp-modules-urls.ts | 13 - packages/wordpress-playground/src/wp-patch.ts | 116 +++ .../wordpress-playground/tsconfig.base.json | 39 - .../wordpress-playground/tsconfig.build.json | 14 + packages/wordpress-playground/tsconfig.json | 18 +- .../wordpress-playground/tsconfig.worker.json | 15 - packages/wordpress-playground/vite.config.ts | 10 +- packages/wordpress-plugin-ide/package.json | 4 +- .../wordpress-plugin-ide/src/style.module.css | 20 + packages/wordpress-plugin-ide/tsconfig.json | 8 +- .../tsconfig.base.json => tsconfig.build.json | 27 +- tsconfig.json | 12 + yarn.lock | 779 ++++++++++++++++-- 89 files changed, 4148 insertions(+), 3274 deletions(-) create mode 100644 packages/php-wasm/src/php-library/comlink-utils.ts rename packages/php-wasm/src/{web => php-library}/messaging.ts (94%) rename packages/php-wasm/src/{web => php-library}/scope.ts (100%) create mode 100644 packages/php-wasm/src/web/php-public-api.ts rename packages/php-wasm/src/web/{ => progress-monitoring}/emscripten-download-monitor.ts (63%) rename packages/{wordpress-playground/src => php-wasm/src/web/progress-monitoring}/progress-observer.ts (55%) rename packages/php-wasm/src/web/{service-worker/window-library.ts => register-service-worker.ts} (67%) delete mode 100644 packages/php-wasm/src/web/remote-php-server.ts delete mode 100644 packages/php-wasm/src/web/service-worker/worker-library.ts create mode 100644 packages/php-wasm/src/web/spawn-worker-thread.ts delete mode 100644 packages/php-wasm/src/web/worker-thread/index.ts delete mode 100644 packages/php-wasm/src/web/worker-thread/window-library.ts delete mode 100644 packages/php-wasm/src/web/worker-thread/worker-library.ts create mode 100644 packages/php-wasm/tsconfig.build.json delete mode 100644 packages/php-wasm/tsconfig.node.json delete mode 100644 packages/php-wasm/tsconfig.worker.json create mode 100644 packages/playground-client/package.json create mode 100644 packages/playground-client/rollup.config.mjs create mode 100644 packages/playground-client/src/index.ts create mode 100644 packages/playground-client/tsconfig.build.json create mode 100644 packages/playground-client/tsconfig.json delete mode 100644 packages/wordpress-playground/src/app.tsx create mode 100644 packages/wordpress-playground/src/boot-playground.tsx delete mode 100644 packages/wordpress-playground/src/boot.ts delete mode 100644 packages/wordpress-playground/src/client.ts create mode 100644 packages/wordpress-playground/src/components/address-bar/index.tsx create mode 100644 packages/wordpress-playground/src/components/address-bar/style.module.css create mode 100644 packages/wordpress-playground/src/components/browser-chrome/index.tsx create mode 100644 packages/wordpress-playground/src/components/browser-chrome/style.module.css create mode 100644 packages/wordpress-playground/src/components/export-button/index.tsx create mode 100644 packages/wordpress-playground/src/components/export-button/style.module.css create mode 100644 packages/wordpress-playground/src/components/import-button/index.tsx create mode 100644 packages/wordpress-playground/src/components/import-button/style.module.css create mode 100644 packages/wordpress-playground/src/components/import-form/index.tsx create mode 100644 packages/wordpress-playground/src/components/import-form/style.module.css create mode 100644 packages/wordpress-playground/src/components/playground-viewport/index.tsx create mode 100644 packages/wordpress-playground/src/components/playground-viewport/style.module.css create mode 100644 packages/wordpress-playground/src/components/plugin-ide/setup.ts create mode 100644 packages/wordpress-playground/src/components/progress-bar/index.tsx create mode 100644 packages/wordpress-playground/src/components/progress-bar/style.module.css create mode 100644 packages/wordpress-playground/src/examples.html create mode 100644 packages/wordpress-playground/src/examples.tsx create mode 100644 packages/wordpress-playground/src/features/common.ts create mode 100644 packages/wordpress-playground/src/features/import-export.ts create mode 100644 packages/wordpress-playground/src/features/install-plugin.ts create mode 100644 packages/wordpress-playground/src/features/install-plugins-from-directory.ts create mode 100644 packages/wordpress-playground/src/features/install-theme-from-directory.ts create mode 100644 packages/wordpress-playground/src/features/install-theme.ts create mode 100644 packages/wordpress-playground/src/features/login.ts rename packages/wordpress-playground/src/{ => features}/migration.php (100%) create mode 100644 packages/wordpress-playground/src/hooks.ts create mode 100644 packages/wordpress-playground/src/index.html rename packages/wordpress-playground/src/{worker-utils.ts => is-uploaded-file-path.ts} (100%) delete mode 100644 packages/wordpress-playground/src/playground.html delete mode 100644 packages/wordpress-playground/src/promise-queue.ts delete mode 100644 packages/wordpress-playground/src/wordpress.html create mode 100644 packages/wordpress-playground/src/wp-client.ts delete mode 100644 packages/wordpress-playground/src/wp-macros.ts delete mode 100644 packages/wordpress-playground/src/wp-modules-urls.ts create mode 100644 packages/wordpress-playground/src/wp-patch.ts delete mode 100644 packages/wordpress-playground/tsconfig.base.json create mode 100644 packages/wordpress-playground/tsconfig.build.json delete mode 100644 packages/wordpress-playground/tsconfig.worker.json create mode 100644 packages/wordpress-plugin-ide/src/style.module.css rename packages/php-wasm/tsconfig.base.json => tsconfig.build.json (61%) create mode 100644 tsconfig.json diff --git a/.eslintrc.js b/.eslintrc.js index 7a2e4f1e..17326f48 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -40,6 +40,7 @@ module.exports = { 'jsx-a11y/click-events-have-key-events': 0, 'jsx-a11y/no-static-element-interactions': 0, '@typescript-eslint/ban-ts-comment': 0, + '@typescript-eslint/ban-types': 0, '@typescript-eslint/no-non-null-assertion': 0, }, }; diff --git a/package.json b/package.json index 873eb16f..c7212c72 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,10 @@ "yarn": ">=3.4.0", "node": ">=16.0.0" }, + "resolutions": { + "react": "18.2.0", + "react-dom": "18.2.0" + }, "author": "The WordPress contributors", "license": "Apache-2.0", "dependencies": { @@ -58,10 +62,11 @@ "magic-string": "^0.26.7", "npm-run-all": "^4.1.5", "prettier": "^2.7.1", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", "react-refresh": "^0.14.0", "request": "^2.88.2", + "typescript-plugin-css-modules": "^4.2.3", "vitepress": "^1.0.0-alpha.26", "vue": "^3.2.41", "ws": "^8.12.0", diff --git a/packages/php-wasm/package.json b/packages/php-wasm/package.json index 399e4aeb..6f6df655 100644 --- a/packages/php-wasm/package.json +++ b/packages/php-wasm/package.json @@ -7,16 +7,26 @@ "build" ], "exports": { - ".": "./build/web/index.js", - "./*": "./build/*", - "./web": "./build/web/index.js", - "./web/*": "./build/web/*", - "./node": "./build/node/index.js", - "./node/*": "./build/node/*", - "./package.json": "./package.json" + ".": { + "import": "./build/web/index.js", + "default": "./build/web/index.js", + "types": "./build/web/dts/index.d.ts" + }, + "./web/service-worker": { + "import": "./build/web/service-worker.js", + "types": "./build/web/dts/web/service-worker/index.d.ts" + }, + "./node": { + "import": "./build/node/index.js", + "types": "./build/node/dts/index.d.ts" + }, + "./node/*": { + "import": "./build/node/*", + "types": "./build/node/dts/*" + } }, "type": "module", - "types": "build/web/types", + "types": "build/web/dts/index.d.ts", "scripts": { "dev": "rollup -c rollup.config.mjs -w", "build": "rollup -c rollup.config.mjs", @@ -47,6 +57,7 @@ "author": "The WordPress contributors", "license": "Apache-2.0", "dependencies": { + "comlink": "^4.4.1", "glob": "^9.2.1", "rollup-plugin-copy": "^3.4.0" }, diff --git a/packages/php-wasm/rollup.config.mjs b/packages/php-wasm/rollup.config.mjs index 0296345c..9d1d5d18 100644 --- a/packages/php-wasm/rollup.config.mjs +++ b/packages/php-wasm/rollup.config.mjs @@ -9,9 +9,7 @@ export default [ { input: { 'service-worker': - 'src/web/service-worker/worker-library.ts', - 'worker-thread': - 'src/web/worker-thread/worker-library.ts', + 'src/web/service-worker/index.ts', index: 'src/web.ts', }, external: ['pnpapi'], @@ -27,10 +25,10 @@ export default [ ], }), typescript({ - tsconfig: './tsconfig.json', + tsconfig: './tsconfig.build.json', compilerOptions: { declarationDir: 'build/web/types', - outDir: 'build/web/types', + outDir: 'build/web/types' } }), url({ @@ -45,14 +43,15 @@ export default [ external: ['pnpapi', 'util'], output: { dir: 'build/node', - format: 'esm', + format: 'esm' }, plugins: [ typescript({ - tsconfig: './tsconfig.node.json', + tsconfig: './tsconfig.build.json', compilerOptions: { declarationDir: 'build/node/types', outDir: 'build/node/types', + lib: [] } }), url({ diff --git a/packages/php-wasm/src/php-library/comlink-utils.ts b/packages/php-wasm/src/php-library/comlink-utils.ts new file mode 100644 index 00000000..8a57cf2b --- /dev/null +++ b/packages/php-wasm/src/php-library/comlink-utils.ts @@ -0,0 +1,96 @@ +import Comlink from 'comlink'; + +export function consumeAPI(remote: Worker | Window) { + setupTransferHandlers(); + + const endpoint = + remote instanceof Worker ? remote : Comlink.windowEndpoint(remote); + + return Comlink.wrap(endpoint); +} + +type PublicAPI = Methods & PipedAPI & { isReady: () => Promise }; +export function exposeAPI(apiMethods?: Methods, pipedApi?: PipedAPI): + [() => void, PublicAPI] +{ + setupTransferHandlers(); + + let setReady; + const ready = new Promise((resolve) => { + setReady = resolve; + }); + + const methods = proxyClone(apiMethods); + const exposedApi = new Proxy(methods, { + get: (target, prop) => { + if (prop === 'isReady') { + return () => ready; + } + if (prop in target) { + return target[prop]; + } + return pipedApi?.[prop]; + }, + }) as unknown as PublicAPI; + + Comlink.expose( + exposedApi, + typeof window !== 'undefined' + ? Comlink.windowEndpoint(self.parent) + : undefined + ); + return [ + setReady, + exposedApi, + ]; +} + +function setupTransferHandlers() { + Comlink.transferHandlers.set('EVENT', { + canHandle: (obj): obj is CustomEvent => obj instanceof CustomEvent, + serialize: (ev: CustomEvent) => { + return [ + { + detail: ev.detail, + }, + [], + ]; + }, + deserialize: (obj) => obj, + }); + Comlink.transferHandlers.set('FUNCTION', { + canHandle: (obj: unknown): obj is Function => typeof obj === 'function', + serialize(obj: Function) { + console.debug('[Comlink][Performance] Proxying a function'); + const { port1, port2 } = new MessageChannel(); + Comlink.expose(obj, port1); + return [port2, [port2]]; + }, + deserialize(port: any) { + port.start(); + return Comlink.wrap(port); + }, + }); +} + +function proxyClone(object: any) { + return new Proxy(object, { + get(target, prop) { + switch (typeof target[prop]) { + case 'function': + return (...args) => target[prop](...args); + case 'object': + if (target[prop] === null) { + return target[prop]; + } + return proxyClone(target[prop]); + case 'undefined': + case 'number': + case 'string': + return target[prop]; + default: + return Comlink.proxy(target[prop]); + } + }, + }); +} diff --git a/packages/php-wasm/src/php-library/index-node.ts b/packages/php-wasm/src/php-library/index-node.ts index cdb426d4..f2ba76c6 100644 --- a/packages/php-wasm/src/php-library/index-node.ts +++ b/packages/php-wasm/src/php-library/index-node.ts @@ -3,13 +3,14 @@ * and re-exports everything from the main PHP module. */ +import type { PHPLoaderModule } from './php'; import { TextEncoder, TextDecoder } from 'util'; global.TextEncoder = TextEncoder as any; global.TextDecoder = TextDecoder as any; export * from './php'; -export async function getPHPLoaderModule(version = '8.2') { +export async function getPHPLoaderModule(version = '8.2'): Promise { switch (version) { case '8.2': // @ts-ignore diff --git a/packages/php-wasm/src/php-library/index.ts b/packages/php-wasm/src/php-library/index.ts index fcf9db16..7f4c908d 100644 --- a/packages/php-wasm/src/php-library/index.ts +++ b/packages/php-wasm/src/php-library/index.ts @@ -1,20 +1,32 @@ -export { PHP, startPHP } from './php'; +export { PHP, loadPHPRuntime } from './php'; export type { PHPOutput, PHPRequest, PHPResponse, JavascriptRuntime, - ErrnoError, + ErrnoError, + DataModule, + PHPLoaderModule, + PHPRuntime, + PHPRuntimeId, + EmscriptenOptions, + MountSettings } from './php'; +export { toRelativeUrl } from './urls'; + +export * from './comlink-utils'; +export type * from './comlink-utils'; + import PHPServer from './php-server'; export { PHPServer }; export type { PHPServerConfigation, PHPServerRequest } from './php-server'; import PHPBrowser from './php-browser'; +import { PHPLoaderModule } from './php'; export { PHPBrowser }; -export async function getPHPLoaderModule(version = '8.2') { +export async function getPHPLoaderModule(version = '8.2'): Promise { switch (version) { case '8.2': // @ts-ignore @@ -47,3 +59,19 @@ export async function getPHPLoaderModule(version = '8.2') { throw new Error(`Unsupported PHP version ${version}`); } +export type StartupOptions = Record; +export function parseWorkerStartupOptions(): StartupOptions { + // Read the query string startup options + if (typeof self?.location?.href !== 'undefined') { + // Web + const startupOptions: StartupOptions = {}; + const params = new URL(self.location.href).searchParams; + params.forEach((value, key) => { + startupOptions[key] = value; + }); + return startupOptions; + } else { + // Node.js + return JSON.parse(process.env.WORKER_OPTIONS || '{}'); + } +} diff --git a/packages/php-wasm/src/web/messaging.ts b/packages/php-wasm/src/php-library/messaging.ts similarity index 94% rename from packages/php-wasm/src/web/messaging.ts rename to packages/php-wasm/src/php-library/messaging.ts index 4ceaf1bf..bb0c2068 100644 --- a/packages/php-wasm/src/web/messaging.ts +++ b/packages/php-wasm/src/php-library/messaging.ts @@ -81,7 +81,7 @@ export function getNextRequestId() { * @returns The reply from the messageTarget. */ export function awaitReply( - messageTarget: EventTarget, + messageTarget: IsomorphicEventTarget, requestId: number, timeout: number = DEFAULT_RESPONSE_TIMEOUT ): Promise { @@ -135,3 +135,8 @@ export interface MessageResponse { interface PostMessageTarget { postMessage(message: any, ...args: any[]): void; } + +interface IsomorphicEventTarget { + addEventListener(type: string, listener: (event: any) => void): void; + removeEventListener(type: string, listener: (event: any) => void): void; +} diff --git a/packages/php-wasm/src/php-library/php-browser.ts b/packages/php-wasm/src/php-library/php-browser.ts index f9897c08..ffa92ebb 100644 --- a/packages/php-wasm/src/php-library/php-browser.ts +++ b/packages/php-wasm/src/php-library/php-browser.ts @@ -2,13 +2,34 @@ import type PHPServer from './php-server'; import type { PHPServerRequest } from './php-server'; import type { PHPResponse } from './php'; +export interface HandlesRequest { + /** + * Sends the request to the server. + * + * When cookies are present in the response, this method stores + * them and sends them with any subsequent requests. + * + * When a redirection is present in the response, this method + * follows it by discarding a response and sending a subsequent + * request. + * + * @param request - The request. + * @param redirects - Internal. The number of redirects handled so far. + * @returns PHPServer response. + */ + request( + request: PHPServerRequest, + redirects?: number + ): Promise; +} + /** * A fake web browser that handles PHPServer's cookies and redirects * internally without exposing them to the consumer. * * @public */ -export class PHPBrowser { +export class PHPBrowser implements HandlesRequest { #cookies; #config; @@ -28,23 +49,9 @@ export class PHPBrowser { }; } - /** - * Sends the request to the server. - * - * When cookies are present in the response, this method stores - * them and sends them with any subsequent requests. - * - * When a redirection is present in the response, this method - * follows it by discarding a response and sending a subsequent - * request. - * - * @param request - The request. - * @param redirects - Internal. The number of redirects handled so far. - * @returns PHPServer response. - */ async request( request: PHPServerRequest, - redirects: number = 0 + redirects = 0 ): Promise { const response = await this.server.request({ ...request, diff --git a/packages/php-wasm/src/php-library/php-server.ts b/packages/php-wasm/src/php-library/php-server.ts index 77746a6b..b5c402eb 100644 --- a/packages/php-wasm/src/php-library/php-server.ts +++ b/packages/php-wasm/src/php-library/php-server.ts @@ -1,17 +1,21 @@ import { ensurePathPrefix, - getPathQueryFragment, + toRelativeUrl, removePathPrefix, + DEFAULT_BASE_URL, } from './urls'; import type { FileInfo, PHP, PHPRequest, PHPResponse } from './php'; export type PHPServerRequest = Pick< PHPRequest, - 'method' | 'headers' | 'body' -> & { - absoluteUrl: string; - files?: Record; -}; + 'method' | 'headers' +> & { files?: Record } & ( + | { absoluteUrl: string; relativeUrl?: never } + | { absoluteUrl?: never; relativeUrl: string } + ) & ( + | Pick & { formData?: never } + | { body?: never, formData: Record } + ); /** * A fake PHP server that handles HTTP requests but does not @@ -63,10 +67,10 @@ export class PHPServer { * @param php - The PHP instance. * @param config - Server configuration. */ - constructor(php: PHP, config: PHPServerConfigation) { + constructor(php: PHP, config: PHPServerConfigation = {}) { const { - documentRoot = '/var/www/', - absoluteUrl, + documentRoot = '/www/', + absoluteUrl = location.origin, isStaticFilePath = () => false, } = config; this.php = php; @@ -94,6 +98,32 @@ export class PHPServer { ].join(''); } + /** + * Converts a path to an absolute URL based at the PHPServer + * root. + * + * @param path The server path to convert to an absolute URL. + * @returns The absolute URL. + */ + pathToInternalUrl(path: string): string { + return `${this.absoluteUrl}${path}`; + } + + /** + * Converts an absolute URL based at the PHPServer to a relative path + * without the server pathname and scope. + * + * @param internalUrl An absolute URL based at the PHPServer root. + * @returns The relative path. + */ + internalUrlToPath(internalUrl: string): string { + const url = new URL(internalUrl); + if (url.pathname.startsWith(this.#PATHNAME)) { + url.pathname = url.pathname.slice(this.#PATHNAME.length); + } + return toRelativeUrl(url); + } + /** * The absolute URL of this PHPServer instance. */ @@ -109,14 +139,24 @@ export class PHPServer { * @returns The response. */ async request(request: PHPServerRequest): Promise { - const serverPath = removePathPrefix( - new URL(request.absoluteUrl).pathname, + let requestedUrl; + if (request.relativeUrl !== undefined) { + requestedUrl = new URL( + request.relativeUrl, + DEFAULT_BASE_URL + ); + } else { + requestedUrl = new URL(request.absoluteUrl); + } + + const normalizedRelativeUrl = removePathPrefix( + requestedUrl.pathname, this.#PATHNAME ); - if (this.#isStaticFilePath(serverPath)) { - return this.#serveStaticFile(serverPath); + if (this.#isStaticFilePath(normalizedRelativeUrl)) { + return this.#serveStaticFile(normalizedRelativeUrl); } - return await this.#dispatchToPHP(request); + return await this.#dispatchToPHP(request, requestedUrl); } /** @@ -162,15 +202,18 @@ export class PHPServer { * @param request - The request. * @returns The response. */ - async #dispatchToPHP(request: PHPServerRequest): Promise { + async #dispatchToPHP(request: PHPServerRequest, requestedUrl: URL): Promise { this.php.addServerGlobalEntry('DOCUMENT_ROOT', this.#DOCROOT); this.php.addServerGlobalEntry( 'HTTPS', this.#ABSOLUTE_URL.startsWith('https://') ? 'on' : '' ); + let preferredMethod: PHPRequest['method'] = 'GET'; + const fileInfos: FileInfo[] = []; if (request.files) { + preferredMethod = 'POST'; for (const key in request.files) { const file: File = request.files[key]; fileInfos.push({ @@ -182,20 +225,32 @@ export class PHPServer { } } - const requestedUrl = new URL(request.absoluteUrl); + const defaultHeaders = { + host: this.#HOST, + }; + + let body; + if (request.formData !== undefined) { + preferredMethod = 'POST'; + defaultHeaders['content-type'] = 'application/x-www-form-urlencoded'; + body = new URLSearchParams(request.formData as Record).toString(); + } else { + body = request.body; + } + return this.php.run({ relativeUri: ensurePathPrefix( - getPathQueryFragment(requestedUrl), + toRelativeUrl(requestedUrl), this.#PATHNAME ), protocol: this.#PROTOCOL, - method: request.method, - body: request.body, + method: request.method || preferredMethod, + body, fileInfos, scriptPath: this.#resolvePHPFilePath(requestedUrl.pathname), headers: { - ...(request.headers || {}), - host: this.#HOST, + ...defaultHeaders, + ...(request.headers || {}) }, }); } @@ -289,11 +344,11 @@ export interface PHPServerConfigation { * The directory in the PHP filesystem where the server will look * for the files to serve. Default: `/var/www`. */ - documentRoot: string; + documentRoot?: string; /** * Server URL. Used to populate $_SERVER details like HTTP_HOST. */ - absoluteUrl: string; + absoluteUrl?: string; /** * Callback used by the PHPServer to decide whether * the requested path refers to a PHP file or a static file. diff --git a/packages/php-wasm/src/php-library/php.ts b/packages/php-wasm/src/php-library/php.ts index a198e380..fe5dcfd1 100644 --- a/packages/php-wasm/src/php-library/php.ts +++ b/packages/php-wasm/src/php-library/php.ts @@ -1,7 +1,10 @@ const STR = 'string'; const NUM = 'number'; -export type JavascriptRuntime = 'NODE' | 'WEB' | 'WEBWORKER'; +export type JavascriptRuntime = 'NODE' | 'WEB' | 'WORKER'; + +declare const self: WindowOrWorkerGlobalScope; +declare const WorkerGlobalScope: object | undefined; type PHPHeaders = Record; export interface FileInfo { @@ -77,8 +80,11 @@ export interface PHPResponse { httpStatusCode: number; } +export type PHPRuntimeId = number; +const loadedRuntimes: PHPRuntime[] = []; + /** - * Initializes the PHP runtime with the given arguments and data dependencies. + * Loads the PHP runtime with the given arguments and data dependencies. * * This function handles the entire PHP initialization pipeline. In particular, it: * @@ -193,14 +199,13 @@ export interface PHPResponse { * @param runtime - The current JavaScript environment. One of: NODE, WEB, or WEBWORKER. * @param phpModuleArgs - The Emscripten module arguments, see https://emscripten.org/docs/api_reference/module.html#affecting-execution. * @param dataDependenciesModules - A list of the ESM-wrapped Emscripten data dependency modules. - * @returns PHP instance. + * @returns Loaded runtime id. */ -export async function startPHP( - phpLoaderModule: any, - runtime: JavascriptRuntime, - phpModuleArgs: any = {}, - dataDependenciesModules: any[] = [] -): Promise { +export async function loadPHPRuntime( + phpLoaderModule: PHPLoaderModule, + phpModuleArgs: EmscriptenOptions = {}, + dataDependenciesModules: DataModule[] = [] +): Promise { let resolvePhpReady, resolveDepsReady; const depsReady = new Promise((resolve) => { resolveDepsReady = resolve; @@ -209,13 +214,16 @@ export async function startPHP( resolvePhpReady = resolve; }); - const loadPHPRuntime = phpLoaderModule.default; - const PHPRuntime = loadPHPRuntime(runtime, { + const PHPRuntime = phpLoaderModule.default(currentJsRuntime, { onAbort(reason) { console.error('WASM aborted: '); console.error(reason); }, ENV: {}, + // Emscripten sometimes prepends a '/' to the path, which + // breaks vite dev mode. An identity `locateFile` function + // fixes it. + locateFile: (path) => path, ...phpModuleArgs, noInitialRun: true, onRuntimeInitialized() { @@ -240,9 +248,184 @@ export async function startPHP( await depsReady; await phpReady; - return new PHP(PHPRuntime); + + loadedRuntimes.push(PHPRuntime); + return loadedRuntimes.length - 1; +} + +const currentJsRuntime = (function () { + if (typeof window !== 'undefined') { + return 'WEB'; + } else if ( + typeof WorkerGlobalScope !== 'undefined' && + self instanceof (WorkerGlobalScope as any) + ) { + return 'WORKER'; + } else { + return 'NODE'; + } +})(); + +export interface PHPIni { + + setPhpIniPath(path: string): void; + setPhpIniEntry(key: string, value: string): void; + +} + +export interface CLIHandler { + /** + * Starts a PHP CLI session with given arguments. + * + * Can only be used when PHP was compiled with the CLI SAPI. + * Cannot be used in conjunction with `run()`. + * + * @param argv - The arguments to pass to the CLI. + * @returns The exit code of the CLI session. + */ + cli(argv: string[]): Promise; +} + +export interface NodeFilesystem { + + /** + * Mounts a Node.js filesystem to a given path in the PHP filesystem. + * + * @param settings - The Node.js filesystem settings. + * @param path - The path to mount the filesystem to. + * @see {@link https://emscripten.org/docs/api_reference/Filesystem-API.html#FS.mount} + */ + mount(settings: any, path: string); } +export interface Filesystem { + /** + * Recursively creates a directory with the given path in the PHP filesystem. + * For example, if the path is `/root/php/data`, and `/root` already exists, + * it will create the directories `/root/php` and `/root/php/data`. + * + * @param path - The directory path to create. + */ + mkdirTree(path: string): void; + + /** + * Reads a file from the PHP filesystem and returns it as a string. + * + * @throws {@link ErrnoError} – If the file doesn't exist. + * @param path - The file path to read. + * @returns The file contents. + */ + readFileAsText(path: string): string; + + /** + * Reads a file from the PHP filesystem and returns it as an array buffer. + * + * @throws {@link ErrnoError} – If the file doesn't exist. + * @param path - The file path to read. + * @returns The file contents. + */ + readFileAsBuffer(path: string): Uint8Array; + + /** + * Overwrites data in a file in the PHP filesystem. + * Creates a new file if one doesn't exist yet. + * + * @param path - The file path to write to. + * @param data - The data to write to the file. + */ + writeFile(path: string, data: string | Uint8Array): void; + + /** + * Removes a file from the PHP filesystem. + * + * @throws {@link ErrnoError} – If the file doesn't exist. + * @param path - The file path to remove. + */ + unlink(path: string): void; + + /** + * Lists the files and directories in the given directory. + * + * @param path - The directory path to list. + * @returns The list of files and directories in the given directory. + */ + listFiles(path: string): string[]; + + /** + * Checks if a directory exists in the PHP filesystem. + * + * @param path – The path to check. + * @returns True if the path is a directory, false otherwise. + */ + isDir(path: string): boolean; + + /** + * Checks if a file (or a directory) exists in the PHP filesystem. + * + * @param path - The file path to check. + * @returns True if the file exists, false otherwise. + */ + fileExists(path: string): boolean; +} + +export interface HandlesRun { + + /** + * Dispatches a PHP request. + * Cannot be used in conjunction with `cli()`. + * + * @example + * ```js + * const output = php.run(' PHPRuntime +} + +export type DataModule = { + dependencyFilename: string, + dependenciesTotalSize: number, + default: (phpRuntime: PHPRuntime) => void +}; + +export type EmscriptenOptions = { + onAbort?: (message: string) => void; + ENV?: Record; + locateFile?: (path: string) => string; + noInitialRun?: boolean; + dataFileDownloads?: Record; + print?: (message: string) => void; + printErr?: (message: string) => void; + onRuntimeInitialized?: () => void; + monitorRunDependencies?: (left: number) => void; +} & Record; + +export type MountSettings = { + root: string; + mountpoint: string; +}; + /** * An environment-agnostic wrapper around the Emscripten PHP runtime * that abstracts the super low-level API and provides a more convenient @@ -254,7 +437,7 @@ export async function startPHP( * @public * @see {startPHP} This class is not meant to be used directly. Use `startPHP` instead. */ -export class PHP { +export class PHP implements PHPIni, Filesystem, NodeFilesystem, CLIHandler, HandlesRun { #Runtime; #phpIniOverrides: [string, string][] = []; #webSapiInitialized = false; @@ -263,10 +446,22 @@ export class PHP { * Initializes a PHP runtime. * * @internal - * @param PHPRuntime - PHP Runtime as initialized by startPHP. + * @param PHPRuntime - Optional. PHP Runtime ID as initialized by loadPHPRuntime. */ - constructor(PHPRuntime: any) { - this.#Runtime = PHPRuntime; + constructor(PHPRuntimeId?: PHPRuntimeId) { + if (PHPRuntimeId !== undefined) { + this.initializeRuntime(PHPRuntimeId); + } + } + + initializeRuntime(runtimeId: PHPRuntimeId) { + if (this.#Runtime) { + throw new Error('PHP runtime already initialized.'); + } + if (!loadedRuntimes[runtimeId]) { + throw new Error('Invalid PHP runtime id.'); + } + this.#Runtime = loadedRuntimes[runtimeId]; } setPhpIniPath(path: string) { @@ -283,27 +478,6 @@ export class PHP { this.#phpIniOverrides.push([key, value]); } - /** - * Dispatches a PHP request. - * Cannot be used in conjunction with `cli()`. - * - * @example - * ```js - * const output = php.run(' { for (const arg of argv) { this.#Runtime.ccall('wasm_add_cli_arg', null, [STR], [arg]); @@ -535,81 +700,26 @@ export class PHP { }; } - /** - * Recursively creates a directory with the given path in the PHP filesystem. - * For example, if the path is `/root/php/data`, and `/root` already exists, - * it will create the directories `/root/php` and `/root/php/data`. - * - * @param path - The directory path to create. - */ mkdirTree(path: string) { this.#Runtime.FS.mkdirTree(path); } - /** - * Mounts a Node.js filesystem to a given path in the PHP filesystem. - * - * @param settings - The Node.js filesystem settings. - * @param path - The path to mount the filesystem to. - * @see {@link https://emscripten.org/docs/api_reference/Filesystem-API.html#FS.mount} - */ - mount(settings: any, path: string) { - this.#Runtime.FS.mount( - this.#Runtime.FS.filesystems.NODEFS, - settings, - path - ); - } - - /** - * Reads a file from the PHP filesystem and returns it as a string. - * - * @throws {@link ErrnoError} – If the file doesn't exist. - * @param path - The file path to read. - * @returns The file contents. - */ - readFileAsText(path: string): string { + readFileAsText(path: string) { return new TextDecoder().decode(this.readFileAsBuffer(path)); } - /** - * Reads a file from the PHP filesystem and returns it as an array buffer. - * - * @throws {@link ErrnoError} – If the file doesn't exist. - * @param path - The file path to read. - * @returns The file contents. - */ readFileAsBuffer(path: string): Uint8Array { return this.#Runtime.FS.readFile(path); } - /** - * Overwrites data in a file in the PHP filesystem. - * Creates a new file if one doesn't exist yet. - * - * @param path - The file path to write to. - * @param data - The data to write to the file. - */ writeFile(path: string, data: string | Uint8Array) { this.#Runtime.FS.writeFile(path, data); } - /** - * Removes a file from the PHP filesystem. - * - * @throws {@link ErrnoError} – If the file doesn't exist. - * @param path - The file path to remove. - */ unlink(path: string) { this.#Runtime.FS.unlink(path); } - /** - * Lists the files and directories in the given directory. - * - * @param path - The directory path to list. - * @returns The list of files and directories in the given directory. - */ listFiles(path: string): string[] { if (!this.fileExists(path)) { return []; @@ -623,13 +733,7 @@ export class PHP { return []; } } - - /** - * Checks if a directory exists in the PHP filesystem. - * - * @param path – The path to check. - * @returns True if the path is a directory, false otherwise. - */ + isDir(path: string): boolean { if (!this.fileExists(path)) { return false; @@ -639,12 +743,6 @@ export class PHP { ); } - /** - * Checks if a file (or a directory) exists in the PHP filesystem. - * - * @param path - The file path to check. - * @returns True if the file exists, false otherwise. - */ fileExists(path: string): boolean { try { this.#Runtime.FS.lookupPath(path); @@ -653,6 +751,14 @@ export class PHP { return false; } } + + mount(settings: MountSettings, path: string) { + this.#Runtime.FS.mount( + this.#Runtime.FS.filesystems.NODEFS, + settings, + path + ); + } } function normalizeHeaders(headers: PHPHeaders): PHPHeaders { @@ -683,4 +789,4 @@ export interface PHPOutput { * @see https://emscripten.org/docs/api_reference/Filesystem-API.html * @see https://github.com/emscripten-core/emscripten/blob/main/system/lib/libc/musl/arch/emscripten/bits/errno.h */ -export interface ErrnoError extends Error {} +export type ErrnoError = Error diff --git a/packages/php-wasm/src/web/scope.ts b/packages/php-wasm/src/php-library/scope.ts similarity index 100% rename from packages/php-wasm/src/web/scope.ts rename to packages/php-wasm/src/php-library/scope.ts diff --git a/packages/php-wasm/src/php-library/urls.ts b/packages/php-wasm/src/php-library/urls.ts index f36d38b4..9d495920 100644 --- a/packages/php-wasm/src/php-library/urls.ts +++ b/packages/php-wasm/src/php-library/urls.ts @@ -10,13 +10,13 @@ export const DEFAULT_BASE_URL = 'http://example.com'; * @example * ```js * const url = new URL('http://example.com/foo/bar?baz=qux#quux'); - * getPathQueryFragment(url); // '/foo/bar?baz=qux#quux' + * toRelativeUrl(url); // '/foo/bar?baz=qux#quux' * ``` * * @param url The URL. * @returns The path, query, and fragment. */ -export function getPathQueryFragment(url: URL): string { +export function toRelativeUrl(url: URL): string { return url.toString().substring(url.origin.length); } diff --git a/packages/php-wasm/src/web.ts b/packages/php-wasm/src/web.ts index 5d45ddaa..58f0da50 100644 --- a/packages/php-wasm/src/web.ts +++ b/packages/php-wasm/src/web.ts @@ -1,2 +1,2 @@ -export * from './php-library/index'; -export * from './web/index'; +export * from './php-library/index.ts'; +export * from './web/index.ts'; diff --git a/packages/php-wasm/src/web/index.ts b/packages/php-wasm/src/web/index.ts index 2b3daeb6..e284b77f 100644 --- a/packages/php-wasm/src/web/index.ts +++ b/packages/php-wasm/src/web/index.ts @@ -1,13 +1,29 @@ -export { setURLScope, getURLScope, isURLScoped, removeURLScope } from './scope'; +export { + setURLScope, + getURLScope, + isURLScoped, + removeURLScope, +} from '../php-library/scope'; export { spawnPHPWorkerThread, - SpawnedWorkerThread, -} from './worker-thread/window-library'; -export { registerServiceWorker } from './service-worker/window-library'; -export { postMessageExpectReply, awaitReply, responseTo } from './messaging'; -export { cloneResponseMonitorProgress } from './emscripten-download-monitor'; + recommendedWorkerBackend, +} from './spawn-worker-thread'; +export { registerServiceWorker } from './register-service-worker'; +export { + postMessageExpectReply, + awaitReply, + responseTo, +} from '../php-library/messaging'; + +export { + EmscriptenDownloadMonitor, + cloneResponseMonitorProgress, +} from './progress-monitoring/emscripten-download-monitor'; export type { - DownloadProgressEvent, + DownloadProgress, DownloadProgressCallback, -} from './emscripten-download-monitor'; +} from './progress-monitoring/emscripten-download-monitor'; +export { ProgressObserver } from './progress-monitoring/progress-observer'; +export type { ProgressMode, ProgressObserverEvent } from './progress-monitoring/progress-observer'; +export { PHPPublicAPI } from './php-public-api'; diff --git a/packages/php-wasm/src/web/php-public-api.ts b/packages/php-wasm/src/web/php-public-api.ts new file mode 100644 index 00000000..ed7857be --- /dev/null +++ b/packages/php-wasm/src/web/php-public-api.ts @@ -0,0 +1,108 @@ +import type { + Filesystem, + PHPIni, + PHPRequest, + PHPResponse, + HandlesRun, +} from '../php-library/php'; +import type { HandlesRequest } from '../php-library/php-browser'; +import type { + PHP, + PHPBrowser, + PHPServer, + PHPServerRequest, +} from '../php-library/index'; +import { EmscriptenDownloadMonitor } from '.'; + +type Promisify = { + [P in keyof T]: T[P] extends (...args: infer A) => infer R + ? R extends void | Promise + ? T[P] + : (...args: A) => Promise> + : Promise; +}; + +type PublicAPI = Promisify; + +export class PHPPublicAPI implements PublicAPI +{ + #php: PHP; + #phpServer: PHPServer; + #phpBrowser: PHPBrowser; + #monitor?: EmscriptenDownloadMonitor; + + absoluteUrl: string; + + constructor(browser: PHPBrowser, monitor?: EmscriptenDownloadMonitor) { + this.#phpBrowser = browser; + this.#phpServer = browser.server; + this.#php = browser.server.php; + this.absoluteUrl = this.#phpServer.absoluteUrl; + this.#monitor = monitor; + } + + pathToInternalUrl(path: string): string { + return this.#phpServer.pathToInternalUrl(path); + } + + internalUrlToPath(internalUrl: string): string { + return this.#phpServer.internalUrlToPath(internalUrl); + } + + onDownloadProgress( + callback: (progress: CustomEvent) => void + ) { + this.#monitor?.addEventListener('progress', callback as any); + } + + request( + request: PHPServerRequest, + redirects?: number + ): Promise { + return this.#phpBrowser.request(request, redirects); + } + + async run(request?: PHPRequest | undefined): Promise { + return this.#php.run(request); + } + + setPhpIniPath(path: string): void { + return this.#php.setPhpIniPath(path); + } + + setPhpIniEntry(key: string, value: string): void { + this.#php.setPhpIniEntry(key, value); + } + + mkdirTree(path: string): void { + this.#php.mkdirTree(path); + } + + async readFileAsText(path: string): Promise { + return this.#php.readFileAsText(path); + } + + async readFileAsBuffer(path: string): Promise { + return this.#php.readFileAsBuffer(path); + } + + writeFile(path: string, data: string | Uint8Array): void { + this.#php.writeFile(path, data); + } + + unlink(path: string): void { + this.#php.unlink(path); + } + + async listFiles(path: string): Promise { + return this.#php.listFiles(path); + } + + async isDir(path: string): Promise { + return this.#php.isDir(path); + } + + async fileExists(path: string): Promise { + return this.#php.fileExists(path); + } +} diff --git a/packages/php-wasm/src/web/emscripten-download-monitor.ts b/packages/php-wasm/src/web/progress-monitoring/emscripten-download-monitor.ts similarity index 63% rename from packages/php-wasm/src/web/emscripten-download-monitor.ts rename to packages/php-wasm/src/web/progress-monitoring/emscripten-download-monitor.ts index b5b96bd8..a330bea5 100644 --- a/packages/php-wasm/src/web/emscripten-download-monitor.ts +++ b/packages/php-wasm/src/web/progress-monitoring/emscripten-download-monitor.ts @@ -1,4 +1,4 @@ -import { DEFAULT_BASE_URL } from '../php-library/urls'; +import { DEFAULT_BASE_URL } from '../../php-library/urls'; /* * An approximate total file size to use when the actual @@ -9,12 +9,17 @@ import { DEFAULT_BASE_URL } from '../php-library/urls'; * * The approximation isn't accurate, but it's better than nothing. * It's not about being exact but about giving the user a rough sense - * of progress. + * of #progress. */ const FALLBACK_FILE_SIZE = 5 * 1024 * 1024; +interface MonitoredModule { + dependencyFilename: string; + dependenciesTotalSize: number; +} + /** - * Monitors the download progress of Emscripten modules + * Monitors the download #progress of Emscripten modules * * Usage: * ```js @@ -24,35 +29,53 @@ const FALLBACK_FILE_SIZE = 5 * 1024 * 1024; * 'web', * downloadMonitor.phpArgs * ); - * downloadMonitor.addEventListener('progress', (e) => { - * console.log( e.detail.progress); + * downloadMonitor.addEventListener('#progress', (e) => { + * console.log( e.detail.#progress); * }) * ``` */ export class EmscriptenDownloadMonitor extends EventTarget { - assetsSizes: Record; - progress: Record; - phpArgs: any; + #assetsSizes: Record = {}; + #progress: Record = {}; - constructor(assetsSizes: Record) { + constructor(modules: MonitoredModule[] = []) { super(); - this.assetsSizes = assetsSizes; - this.progress = Object.fromEntries( - Object.entries(assetsSizes).map(([name]) => [name, 0]) - ); + this.setModules(modules); this.#monitorWebAssemblyStreaming(); - this.phpArgs = { + } + + getEmscriptenArgs() { + return { dataFileDownloads: this.#createDataFileDownloadsProxy(), }; } + setModules(modules: MonitoredModule[]) { + this.#assetsSizes = modules.reduce((acc, module) => { + if (module.dependenciesTotalSize > 0) { + const url = new URL( + module.dependencyFilename, + 'http://example.com' + ).pathname; + const filename = url.split('/').pop()!; + acc[filename] = Math.max( + filename in acc ? acc[filename] : 0, + module.dependenciesTotalSize + ); + } + return acc; + }, {} as Record); + this.#progress = Object.fromEntries( + Object.entries(this.#assetsSizes).map(([name]) => [name, 0]) + ); + } + /** * Replaces the default WebAssembly.instantiateStreaming with a version - * that monitors the download progress. + * that monitors the download #progress. */ #monitorWebAssemblyStreaming() { - const self = this; const instantiateStreaming = WebAssembly.instantiateStreaming; WebAssembly.instantiateStreaming = async ( responseOrPromise, @@ -65,7 +88,7 @@ export class EmscriptenDownloadMonitor extends EventTarget { const reportingResponse = cloneResponseMonitorProgress( response, - ({ loaded, total }) => self.#notify(file, loaded, total) + ({ detail: { loaded, total } }) => this.#notify(file, loaded, total) ); return instantiateStreaming(reportingResponse, ...args); @@ -74,13 +97,13 @@ export class EmscriptenDownloadMonitor extends EventTarget { /** * Creates a `dataFileDownloads` Proxy object that can be passed - * to `startPHP` to monitor the download progress of the data + * to `startPHP` to monitor the download #progress of the data * dependencies. */ #createDataFileDownloadsProxy() { const self = this; const dataFileDownloads = {}; - // Monitor assignments like dataFileDownloads[file] = progress + // Monitor assignments like dataFileDownloads[file] = #progress return new Proxy(dataFileDownloads, { set(obj, file: string, progress) { self.#notify(file, progress.loaded, progress.total); @@ -99,7 +122,7 @@ export class EmscriptenDownloadMonitor extends EventTarget { } /** - * Notifies about the download progress of a file. + * Notifies about the download #progress of a file. * * @param file The file name. * @param loaded The number of bytes of that file loaded so far. @@ -110,22 +133,22 @@ export class EmscriptenDownloadMonitor extends EventTarget { .split('/') .pop()!; if (!fileSize) { - fileSize = this.assetsSizes[fileName]; + fileSize = this.#assetsSizes[fileName]; } - if (!(fileName in this.progress)) { + if (!(fileName in this.#progress)) { console.warn( - `Registered a download progress of an unregistered file "${fileName}". ` + - `This may cause a sudden **decrease** in the progress percentage as the ` + + `Registered a download #progress of an unregistered file "${fileName}". ` + + `This may cause a sudden **decrease** in the #progress percentage as the ` + `total number of bytes increases during the download.` ); } - this.progress[file] = loaded; + this.#progress[file] = loaded; this.dispatchEvent( new CustomEvent('progress', { detail: { - loaded: sumValues(this.progress), - total: sumValues(this.assetsSizes), + loaded: sumValues(this.#progress), + total: sumValues(this.#assetsSizes), }, }) ); @@ -138,7 +161,7 @@ function sumValues(obj: Record) { export default EmscriptenDownloadMonitor; -export interface DownloadProgressEvent { +export interface DownloadProgress { /** * The number of bytes loaded so far. */ @@ -151,20 +174,31 @@ export interface DownloadProgressEvent { /** * Clones a fetch Response object and returns a version - * that calls the `onProgress` callback as the progress + * that calls the `onProgress` callback as the #progress * changes. * * @param response The fetch Response object to clone. - * @param onProgress The callback to call when the download progress changes. + * @param onProgress The callback to call when the download #progress changes. * @returns The cloned response */ export function cloneResponseMonitorProgress( response: Response, - onProgress: DownloadProgressCallback + onProgress: (event: CustomEvent) => void ): Response { const contentLength = response.headers.get('content-length') || ''; const total = parseInt(contentLength, 10) || FALLBACK_FILE_SIZE; + function notify(loaded, total) { + onProgress( + new CustomEvent('progress', { + detail: { + loaded, + total, + } + }) + ); + } + return new Response( new ReadableStream({ async start(controller) { @@ -181,11 +215,11 @@ export function cloneResponseMonitorProgress( loaded += value.byteLength; } if (done) { - onProgress({ loaded, total: loaded }); + notify(loaded, loaded); controller.close(); break; } else { - onProgress({ loaded, total }); + notify(loaded, total); controller.enqueue(value); } } catch (e) { @@ -204,4 +238,4 @@ export function cloneResponseMonitorProgress( ); } -export type DownloadProgressCallback = (event: DownloadProgressEvent) => void; +export type DownloadProgressCallback = (progress: DownloadProgress) => void; diff --git a/packages/wordpress-playground/src/progress-observer.ts b/packages/php-wasm/src/web/progress-monitoring/progress-observer.ts similarity index 55% rename from packages/wordpress-playground/src/progress-observer.ts rename to packages/php-wasm/src/web/progress-monitoring/progress-observer.ts index b9e987fd..01687507 100644 --- a/packages/wordpress-playground/src/progress-observer.ts +++ b/packages/php-wasm/src/web/progress-monitoring/progress-observer.ts @@ -1,38 +1,42 @@ -export const enum ProgressType { +import { DownloadProgress } from "./emscripten-download-monitor"; + +export type ProgressMode = /** * Real-time progress is used when we get real-time reports * about the progress. */ - REAL_TIME = 'REAL_TIME', + | 'REAL_TIME' + /** * Slowly increment progress is used when we don't know how long * an operation will take and just want to keep slowly incrementing * the progress bar. */ - SLOWLY_INCREMENT = 'SLOWLY_INCREMENT', -} + | 'SLOWLY_INCREMENT'; + +export type ProgressObserverEvent = { + progress: number; + mode: ProgressMode; + caption: string; +}; -export class ProgressObserver { +export class ProgressObserver extends EventTarget { #observedProgresses: Record = {}; #lastObserverId = 0; - #onProgress: ( - progress: number, - mode: ProgressType, - caption?: string - ) => void; - constructor(onProgress) { - this.#onProgress = onProgress; - } + progress = 0; + mode: ProgressMode = 'REAL_TIME'; + caption = ""; partialObserver(progressBudget, caption = '') { const id = ++this.#lastObserverId; this.#observedProgresses[id] = 0; - return ({ loaded, total }) => { + return (progress: CustomEvent) => { + const { loaded, total } = progress?.detail || {}; this.#observedProgresses[id] = (loaded / total) * progressBudget; this.#onProgress( this.totalProgress, - ProgressType.REAL_TIME, + 'REAL_TIME', caption ); }; @@ -41,7 +45,7 @@ export class ProgressObserver { slowlyIncrementBy(progress) { const id = ++this.#lastObserverId; this.#observedProgresses[id] = progress; - this.#onProgress(this.totalProgress, ProgressType.SLOWLY_INCREMENT); + this.#onProgress(this.totalProgress, 'SLOWLY_INCREMENT'); } get totalProgress() { @@ -50,4 +54,21 @@ export class ProgressObserver { 0 ); } + + #onProgress( + progress: number, + mode: ProgressMode, + caption?: string + ) { + this.dispatchEvent( + new CustomEvent('progress', { + detail: { + progress, + mode, + caption, + }, + }) + ); + } + } diff --git a/packages/php-wasm/src/web/service-worker/window-library.ts b/packages/php-wasm/src/web/register-service-worker.ts similarity index 67% rename from packages/php-wasm/src/web/service-worker/window-library.ts rename to packages/php-wasm/src/web/register-service-worker.ts index b2845bfc..8a22aa04 100644 --- a/packages/php-wasm/src/web/service-worker/window-library.ts +++ b/packages/php-wasm/src/web/register-service-worker.ts @@ -1,4 +1,7 @@ -/// +import type { Remote } from "comlink"; +import { PHPPublicAPI } from "./php-public-api"; +import { responseTo } from "../php-library/messaging"; + /** * Run this in the main application to register the service worker or * reload the registered worker if the app expects a different version @@ -9,7 +12,12 @@ * mismatched with the actual version, the service worker * will be re-registered. */ -export async function registerServiceWorker(scriptUrl, expectedVersion) { +export async function registerServiceWorker( + phpApi: Remote, + scope: string, + scriptUrl, + expectedVersion +) { const sw = (navigator as any).serviceWorker; if (!sw) { throw new Error('Service workers are not supported in this browser.'); @@ -51,6 +59,31 @@ export async function registerServiceWorker(scriptUrl, expectedVersion) { }); } + // Proxy the service worker messages to the worker thread: + navigator.serviceWorker.addEventListener( + 'message', + async function onMessage(event) { + console.debug('Message from ServiceWorker', event); + /** + * Ignore events meant for other PHP instances to + * avoid handling the same event twice. + * + * This is important because the service worker posts the + * same message to all application instances across all browser tabs. + */ + if (scope && event.data.scope !== scope) { + return; + } + + const args = event.data.args || []; + + const result = await phpApi[event.data.method](...args); + event.source!.postMessage( + responseTo(event.data.requestId, result) + ); + } + ); + sw.startMessages(); } diff --git a/packages/php-wasm/src/web/remote-php-server.ts b/packages/php-wasm/src/web/remote-php-server.ts deleted file mode 100644 index 1c249450..00000000 --- a/packages/php-wasm/src/web/remote-php-server.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { PHPRequest, PHPResponse } from '../php-library/php'; - -/** - * A base class for controlling the PHP instance running in another - * contexts. Implement the `rpc` method to call the methods of the - * remote PHP instance. - */ -export abstract class PHPBackend { - /** - * @param code - * @see {PHP.run} - */ - async run(code: string): Promise { - return await this.rpc('run', { code }); - } - - /** - * @param request - * @see {PHP.request} - */ - async HTTPRequest( - request: PHPRequest - ): Promise { - const response = (await this.rpc('HTTPRequest', { - request, - })) as PHPResponse; - return { - ...response, - get text() { - return new TextDecoder().decode(response.body); - }, - }; - } - - /** - * @param path - * @see {PHP.readFile} - */ - async readFile(path: string): Promise { - return await this.rpc('readFile', { path }); - } - - /** - * @param path - * @param contents - * @see {PHP.writeFile} - */ - async writeFile(path: string, contents: string): Promise { - return await this.rpc('writeFile', { path, contents }); - } - - /** - * @param path - * @see {PHP.unlink} - */ - async unlink(path: string): Promise { - return await this.rpc('unlink', { path }); - } - - /** - * @param path - * @see {PHP.mkdirTree} - */ - async mkdirTree(path: string): Promise { - return await this.rpc('mkdirTree', { path }); - } - - /** - * @param path - * @see {PHP.listFiles} - */ - async listFiles(path: string): Promise { - return await this.rpc('listFiles', { path }); - } - - /** - * @param path - * @see {PHP.isDir} - */ - async isDir(path: string): Promise { - return await this.rpc('isDir', { path }); - } - - /** - * @param path - * @see {PHP.fileExists} - */ - async fileExists(path: string): Promise { - return await this.rpc('fileExists', { path }); - } - - protected abstract rpc( - type: string, - args?: Record - ): Promise; -} diff --git a/packages/php-wasm/src/web/service-worker/index.ts b/packages/php-wasm/src/web/service-worker/index.ts index 0b334905..a2bbbe29 100644 --- a/packages/php-wasm/src/web/service-worker/index.ts +++ b/packages/php-wasm/src/web/service-worker/index.ts @@ -1 +1,352 @@ -export * from "./worker-library"; \ No newline at end of file +/// +/// +/// + +declare const self: ServiceWorkerGlobalScope; + +import { awaitReply, getNextRequestId } from '../../php-library/messaging'; +import { + getURLScope, + isURLScoped, + removeURLScope, + setURLScope, +} from '../../php-library/scope'; +import { toRelativeUrl } from '../../php-library/urls'; + +/** + * Run this function in the service worker to install the required event + * handlers. + * + * @param config + */ +export function initializeServiceWorker(config: ServiceWorkerConfiguration) { + const { version, handleRequest = defaultRequestHandler } = config; + /** + * Enable the client app to force-update the service worker + * registration. + */ + self.addEventListener('message', (event) => { + if (!event.data) { + return; + } + + if (event.data === 'skip-waiting') { + self.skipWaiting(); + } + }); + + /** + * Ensure the client gets claimed by this service worker right after the registration. + * + * Only requests from the "controlled" pages are resolved via the fetch listener below. + * However, simply registering the worker is not enough to make it the "controller" of + * the current page. The user still has to reload the page. If they don't an iframe + * pointing to /index.php will show a 404 message instead of a homepage. + * + * This activation handles saves the user reloading the page after the initial confusion. + * It immediately makes this worker the controller of any client that registers it. + */ + self.addEventListener('activate', (event) => { + // eslint-disable-next-line no-undef + event.waitUntil(self.clients.claim()); + }); + + /** + * The main method. It captures the requests and loop them back to the + * Worker Thread using the Loopback request + */ + self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Provide a custom JSON response in the special /version endpoint + // so the frontend app can know whether it's time to update the + // service worker registration. + if (url.pathname === '/version') { + event.preventDefault(); + const currentVersion = + typeof version === 'function' ? version() : version; + event.respondWith( + new Response(JSON.stringify({ version: currentVersion }), { + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }) + ); + return; + } + + // Don't handle requests to the service worker script itself. + if (url.pathname.startsWith(self.location.pathname)) { + return; + } + + // Only handle requests from scoped sites. + // So – bale out if the request URL is not scoped and the + // referrer URL is not scoped. + if (!isURLScoped(url)) { + let referrerUrl; + try { + referrerUrl = new URL(event.request.referrer); + } catch (e) { + return; + } + if (!isURLScoped(referrerUrl)) { + // Let the browser handle uncoped requests as is. + return; + } + } + + console.debug( + `[ServiceWorker] Serving request: ${toRelativeUrl( + removeURLScope(url) + )}` + ); + const responsePromise = handleRequest(event); + if (responsePromise) { + event.respondWith(responsePromise); + } + }); +} + +async function defaultRequestHandler(event) { + event.preventDefault(); + const url = new URL(event.request.url); + const unscopedUrl = removeURLScope(url); + if (!seemsLikeAPHPServerPath(unscopedUrl.pathname)) { + return fetch( + await cloneRequest(event.request, { + url, + }) + ); + } + return convertFetchEventToPHPRequest(event); +} + +export async function convertFetchEventToPHPRequest(event) { + let url = new URL(event.request.url); + + if (!isURLScoped(url)) { + try { + const referrerUrl = new URL(event.request.referrer); + url = setURLScope(url, getURLScope(referrerUrl)!); + } catch (e) {} + } + + const { body, files, contentType } = await rewritePost(event.request); + const requestHeaders = {}; + for (const pair of (event.request.headers as any).entries()) { + requestHeaders[pair[0]] = pair[1]; + } + + let phpResponse; + try { + const message = { + method: 'request', + args: [ + { + body, + files, + absoluteUrl: url.toString(), + method: event.request.method, + headers: { + ...requestHeaders, + Host: url.host, + 'Content-type': contentType, + }, + }, + ] + }; + console.debug( + '[ServiceWorker] Forwarding a request to the Worker Thread', + { message } + ); + const requestId = await broadcastMessageExpectReply( + message, + getURLScope(url) + ); + phpResponse = await awaitReply(self, requestId); + + // X-frame-options gets in a way when PHP is + // being displayed in an iframe. + delete phpResponse.headers['x-frame-options']; + + console.debug('[ServiceWorker] Response received from the main app', { + phpResponse, + }); + } catch (e) { + console.error(e, { url: url.toString() }); + throw e; + } + + return new Response(phpResponse.body, { + headers: phpResponse.headers, + status: phpResponse.httpStatusCode, + }); +} + +/** + * Sends the message to all the controlled clients + * of this service worker. + * + * This used to be implemented with a BroadcastChannel, but + * it didn't work in Safari. BroadcastChannel breaks iframe + * embedding the playground in Safari. + * + * Weirdly, Safari does not pass any messages from the ServiceWorker + * to Window if the page is rendered inside an iframe. Window to Service + * Worker communication works just fine. + * + * The regular client.postMessage() communication works perfectly, so that's + * what this function uses to broadcast the message. + * + * @param message The message to broadcast. + * @param scope Target worker thread scope. + * @returns The request ID to receive the reply. + */ +export async function broadcastMessageExpectReply(message, scope) { + const requestId = getNextRequestId(); + for (const client of await self.clients.matchAll({ + // Sometimes the client that triggered the current fetch() + // event is considered uncontrolled in Google Chrome. This + // only happens on the first few fetches() after the initial + // registration of the service worker. + includeUncontrolled: true, + })) { + client.postMessage({ + ...message, + /** + * Attach the scope with a URL starting with `/scope:` to this message. + * + * We need this mechanics because this worker broadcasts + * events to all the listeners across all browser tabs. Scopes + * helps WASM workers ignore requests meant for other WASM workers. + */ + scope, + requestId, + }); + } + return requestId; +} + +interface ServiceWorkerConfiguration { + /** + * The version of the service worker – exposed via the /version endpoint. + * + * This is used by the frontend app to know whether it's time to update + * the service worker registration. + */ + version: string | (() => string); + handleRequest?: (event: FetchEvent) => Promise | undefined; +} + +/** + * Guesses whether the given path looks like a PHP file. + * + * @example + * ```js + * seemsLikeAPHPServerPath('/index.php') // true + * seemsLikeAPHPServerPath('/index.php') // true + * seemsLikeAPHPServerPath('/index.php/foo/bar') // true + * seemsLikeAPHPServerPath('/index.html') // false + * seemsLikeAPHPServerPath('/index.html/foo/bar') // false + * seemsLikeAPHPServerPath('/') // true + * ``` + * + * @param path The path to check. + * @returns Whether the path seems like a PHP server path. + */ +export function seemsLikeAPHPServerPath(path: string): boolean { + return seemsLikeAPHPFile(path) || seemsLikeADirectoryRoot(path); +} + +function seemsLikeAPHPFile(path) { + return path.endsWith('.php') || path.includes('.php/'); +} + +function seemsLikeADirectoryRoot(path) { + const lastSegment = path.split('/').pop(); + return !lastSegment.includes('.'); +} + +async function rewritePost(request) { + const contentType = request.headers.get('content-type'); + if (request.method !== 'POST') { + return { + contentType, + body: undefined, + files: undefined, + }; + } + + // If the request contains multipart form data, rewrite it + // to a regular form data and handle files separately. + const isMultipart = contentType + .toLowerCase() + .startsWith('multipart/form-data'); + if (isMultipart) { + try { + const formData = await request.clone().formData(); + const post = {}; + const files = {}; + + for (const key of formData.keys()) { + const value = formData.get(key); + if (value instanceof File) { + files[key] = value; + } else { + post[key] = value; + } + } + + return { + contentType: 'application/x-www-form-urlencoded', + body: new URLSearchParams(post).toString(), + files, + }; + } catch (e) {} + } + + // Otherwise, grab body as literal text + return { + contentType, + body: await request.clone().text(), + files: {}, + }; +} + +/** + * Copy a request with custom overrides. + * + * This function is only needed because Request properties + * are read-only. The only way to change e.g. a URL is to + * create an entirely new request: + * + * https://developer.mozilla.org/en-US/docs/Web/API/Request + * + * @param request + * @param overrides + * @returns The new request. + */ +export async function cloneRequest( + request: Request, + overrides: Record +): Promise { + const body = + ['GET', 'HEAD'].includes(request.method) || 'body' in overrides + ? undefined + : await request.blob(); + return new Request(overrides.url || request.url, { + body, + method: request.method, + headers: request.headers, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + mode: request.mode === 'navigate' ? 'same-origin' : request.mode, + credentials: request.credentials, + cache: request.cache, + redirect: request.redirect, + integrity: request.integrity, + ...overrides, + }); +} diff --git a/packages/php-wasm/src/web/service-worker/worker-library.ts b/packages/php-wasm/src/web/service-worker/worker-library.ts deleted file mode 100644 index c8bd0e8f..00000000 --- a/packages/php-wasm/src/web/service-worker/worker-library.ts +++ /dev/null @@ -1,350 +0,0 @@ -/// -/// -/// - -declare const self: ServiceWorkerGlobalScope; - -import { awaitReply, getNextRequestId } from '../messaging'; -import { - getURLScope, - isURLScoped, - removeURLScope, - setURLScope, -} from '../scope'; -import { getPathQueryFragment } from '../../php-library/urls'; - -/** - * Run this function in the service worker to install the required event - * handlers. - * - * @param config - */ -export function initializeServiceWorker(config: ServiceWorkerConfiguration) { - const { version, handleRequest = defaultRequestHandler } = config; - /** - * Enable the client app to force-update the service worker - * registration. - */ - self.addEventListener('message', (event) => { - if (!event.data) { - return; - } - - if (event.data === 'skip-waiting') { - self.skipWaiting(); - } - }); - - /** - * Ensure the client gets claimed by this service worker right after the registration. - * - * Only requests from the "controlled" pages are resolved via the fetch listener below. - * However, simply registering the worker is not enough to make it the "controller" of - * the current page. The user still has to reload the page. If they don't an iframe - * pointing to /index.php will show a 404 message instead of a homepage. - * - * This activation handles saves the user reloading the page after the initial confusion. - * It immediately makes this worker the controller of any client that registers it. - */ - self.addEventListener('activate', (event) => { - // eslint-disable-next-line no-undef - event.waitUntil(self.clients.claim()); - }); - - /** - * The main method. It captures the requests and loop them back to the - * Worker Thread using the Loopback request - */ - self.addEventListener('fetch', (event) => { - const url = new URL(event.request.url); - - // Provide a custom JSON response in the special /version endpoint - // so the frontend app can know whether it's time to update the - // service worker registration. - if (url.pathname === '/version') { - event.preventDefault(); - const currentVersion = - typeof version === 'function' ? version() : version; - event.respondWith( - new Response(JSON.stringify({ version: currentVersion }), { - headers: { - 'Content-Type': 'application/json', - }, - status: 200, - }) - ); - return; - } - - // Don't handle requests to the service worker script itself. - if (url.pathname.startsWith(self.location.pathname)) { - return; - } - - // Only handle requests from scoped sites. - // So – bale out if the request URL is not scoped and the - // referrer URL is not scoped. - if (!isURLScoped(url)) { - let referrerUrl; - try { - referrerUrl = new URL(event.request.referrer); - } catch (e) { - return; - } - if (!isURLScoped(referrerUrl)) { - // Let the browser handle uncoped requests as is. - return; - } - } - - console.debug( - `[ServiceWorker] Serving request: ${getPathQueryFragment( - removeURLScope(url) - )}` - ); - const responsePromise = handleRequest(event); - if (responsePromise) { - event.respondWith(responsePromise); - } - }); -} - -async function defaultRequestHandler(event) { - event.preventDefault(); - const url = new URL(event.request.url); - const unscopedUrl = removeURLScope(url); - if (!seemsLikeAPHPServerPath(unscopedUrl.pathname)) { - return fetch( - await cloneRequest(event.request, { - url, - }) - ); - } - return PHPRequest(event); -} - -export async function PHPRequest(event) { - let url = new URL(event.request.url); - - if (!isURLScoped(url)) { - try { - const referrerUrl = new URL(event.request.referrer); - url = setURLScope(url, getURLScope(referrerUrl)!); - } catch (e) {} - } - - const { body, files, contentType } = await rewritePost(event.request); - const requestHeaders = {}; - for (const pair of (event.request.headers as any).entries()) { - requestHeaders[pair[0]] = pair[1]; - } - - let phpResponse; - try { - const message = { - type: 'HTTPRequest', - request: { - body, - files, - absoluteUrl: url.toString(), - method: event.request.method, - headers: { - ...requestHeaders, - Host: url.host, - 'Content-type': contentType, - }, - }, - }; - console.debug( - '[ServiceWorker] Forwarding a request to the Worker Thread', - { message } - ); - const requestId = await broadcastMessageExpectReply( - message, - getURLScope(url) - ); - phpResponse = await awaitReply(self, requestId); - - // X-frame-options gets in a way when PHP is - // being displayed in an iframe. - delete phpResponse.headers['x-frame-options']; - - console.debug('[ServiceWorker] Response received from the main app', { - phpResponse, - }); - } catch (e) { - console.error(e, { url: url.toString() }); - throw e; - } - - return new Response(phpResponse.body, { - headers: phpResponse.headers, - status: phpResponse.httpStatusCode, - }); -} - -/** - * Sends the message to all the controlled clients - * of this service worker. - * - * This used to be implemented with a BroadcastChannel, but - * it didn't work in Safari. BroadcastChannel breaks iframe - * embedding the playground in Safari. - * - * Weirdly, Safari does not pass any messages from the ServiceWorker - * to Window if the page is rendered inside an iframe. Window to Service - * Worker communication works just fine. - * - * The regular client.postMessage() communication works perfectly, so that's - * what this function uses to broadcast the message. - * - * @param message The message to broadcast. - * @param scope Target worker thread scope. - * @returns The request ID to receive the reply. - */ -export async function broadcastMessageExpectReply(message, scope) { - const requestId = getNextRequestId(); - for (const client of await self.clients.matchAll({ - // Sometimes the client that triggered the current fetch() - // event is considered uncontrolled in Google Chrome. This - // only happens on the first few fetches() after the initial - // registration of the service worker. - includeUncontrolled: true, - })) { - client.postMessage({ - ...message, - /** - * Attach the scope with a URL starting with `/scope:` to this message. - * - * We need this mechanics because this worker broadcasts - * events to all the listeners across all browser tabs. Scopes - * helps WASM workers ignore requests meant for other WASM workers. - */ - scope, - requestId, - }); - } - return requestId; -} - -interface ServiceWorkerConfiguration { - /** - * The version of the service worker – exposed via the /version endpoint. - * - * This is used by the frontend app to know whether it's time to update - * the service worker registration. - */ - version: string | (() => string); - handleRequest?: (event: FetchEvent) => Promise | undefined; -} - -/** - * Guesses whether the given path looks like a PHP file. - * - * @example - * ```js - * seemsLikeAPHPServerPath('/index.php') // true - * seemsLikeAPHPServerPath('/index.php') // true - * seemsLikeAPHPServerPath('/index.php/foo/bar') // true - * seemsLikeAPHPServerPath('/index.html') // false - * seemsLikeAPHPServerPath('/index.html/foo/bar') // false - * seemsLikeAPHPServerPath('/') // true - * ``` - * - * @param path The path to check. - * @returns Whether the path seems like a PHP server path. - */ -export function seemsLikeAPHPServerPath(path: string): boolean { - return seemsLikeAPHPFile(path) || seemsLikeADirectoryRoot(path); -} - -function seemsLikeAPHPFile(path) { - return path.endsWith('.php') || path.includes('.php/'); -} - -function seemsLikeADirectoryRoot(path) { - const lastSegment = path.split('/').pop(); - return !lastSegment.includes('.'); -} - -async function rewritePost(request) { - const contentType = request.headers.get('content-type'); - if (request.method !== 'POST') { - return { - contentType, - body: undefined, - files: undefined, - }; - } - - // If the request contains multipart form data, rewrite it - // to a regular form data and handle files separately. - const isMultipart = contentType - .toLowerCase() - .startsWith('multipart/form-data'); - if (isMultipart) { - try { - const formData = await request.clone().formData(); - const post = {}; - const files = {}; - - for (const key of formData.keys()) { - const value = formData.get(key); - if (value instanceof File) { - files[key] = value; - } else { - post[key] = value; - } - } - - return { - contentType: 'application/x-www-form-urlencoded', - body: new URLSearchParams(post).toString(), - files, - }; - } catch (e) {} - } - - // Otherwise, grab body as literal text - return { - contentType, - body: await request.clone().text(), - files: {}, - }; -} - -/** - * Copy a request with custom overrides. - * - * This function is only needed because Request properties - * are read-only. The only way to change e.g. a URL is to - * create an entirely new request: - * - * https://developer.mozilla.org/en-US/docs/Web/API/Request - * - * @param request - * @param overrides - * @returns The new request. - */ -export async function cloneRequest( - request: Request, - overrides: Record -): Promise { - const body = - ['GET', 'HEAD'].includes(request.method) || 'body' in overrides - ? undefined - : await request.blob(); - return new Request(overrides.url || request.url, { - body, - method: request.method, - headers: request.headers, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - mode: request.mode === 'navigate' ? 'same-origin' : request.mode, - credentials: request.credentials, - cache: request.cache, - redirect: request.redirect, - integrity: request.integrity, - ...overrides, - }); -} diff --git a/packages/php-wasm/src/web/spawn-worker-thread.ts b/packages/php-wasm/src/web/spawn-worker-thread.ts new file mode 100644 index 00000000..7bbe29c3 --- /dev/null +++ b/packages/php-wasm/src/web/spawn-worker-thread.ts @@ -0,0 +1,54 @@ +export const recommendedWorkerBackend = (function () { + // Firefox doesn't support module workers with dynamic imports, + // let's fall back to iframe workers. + // See https://github.com/mdn/content/issues/24402 + const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; + if (isFirefox) { + return 'iframe'; + } else { + return 'webworker'; + } +})(); + +/** + * Spawns a new Worker Thread. + * + * @param workerUrl The absolute URL of the worker script. + * @param workerBackend The Worker Thread backend to use. Either 'webworker' or 'iframe'. + * @param config + * @returns The spawned Worker Thread. + */ +export function spawnPHPWorkerThread( + workerUrl: string, + workerBackend: 'webworker' | 'iframe' = 'webworker', + startupOptions: Record = {} +) { + workerUrl = addQueryParams(workerUrl, startupOptions); + + if (workerBackend === 'webworker') { + return new Worker(workerUrl, { type: 'module' }); + } else if (workerBackend === 'iframe') { + return createIframe(workerUrl).contentWindow!; + } else { + throw new Error(`Unknown backendName: ${workerBackend}`); + } +} + +function addQueryParams(url, searchParams: Record) { + if (!Object.entries(searchParams).length) { + return url; + } + const urlWithOptions = new URL(url); + for (const [key, value] of Object.entries(searchParams)) { + urlWithOptions.searchParams.set(key, value); + } + return urlWithOptions.toString(); +} + +function createIframe( workerDocumentURL: string ) { + const iframe = document.createElement('iframe'); + iframe.src = workerDocumentURL; + iframe.style.display = 'none'; + document.body.appendChild(iframe); + return iframe; +} diff --git a/packages/php-wasm/src/web/worker-thread/index.ts b/packages/php-wasm/src/web/worker-thread/index.ts deleted file mode 100644 index 0b334905..00000000 --- a/packages/php-wasm/src/web/worker-thread/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./worker-library"; \ No newline at end of file diff --git a/packages/php-wasm/src/web/worker-thread/window-library.ts b/packages/php-wasm/src/web/worker-thread/window-library.ts deleted file mode 100644 index d77de369..00000000 --- a/packages/php-wasm/src/web/worker-thread/window-library.ts +++ /dev/null @@ -1,290 +0,0 @@ -import type { PHPOutput, PHPServerRequest, PHPResponse } from '../../php-library/index'; -import { - postMessageExpectReply, - awaitReply, - MessageResponse, - responseTo, -} from '../messaging'; -import { removeURLScope } from '../scope'; -import { getPathQueryFragment } from '../../php-library/urls'; -import type { DownloadProgressEvent } from '../emscripten-download-monitor'; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const noop = () => null; - -interface WorkerThreadConfig { - /** - * A function to call when a download progress event is received from the worker - */ - onDownloadProgress?: (event: DownloadProgressEvent) => void; - - /** - * A record of options to pass to the worker thread. - */ - options?: Record; -} - -/** - * Spawns a new Worker Thread. - * - * @param backendName The Worker Thread backend to use. Either 'webworker' or 'iframe'. - * @param workerScriptUrl The absolute URL of the worker script. - * @param config - * @returns The spawned Worker Thread. - */ -export async function spawnPHPWorkerThread( - backendName: 'webworker' | 'iframe', - workerScriptUrl: string, - config: WorkerThreadConfig -): Promise { - const { onDownloadProgress = noop } = config; - let messageChannel: WorkerThreadMessageTarget; - - // Pass worker options via the query string - if (config.options) { - const urlWithOptions = new URL(workerScriptUrl); - for (const [key, value] of Object.entries(config.options)) { - urlWithOptions.searchParams.set(key, value); - } - workerScriptUrl = urlWithOptions.toString(); - } - - if (backendName === 'webworker') { - messageChannel = spawnWebWorker(workerScriptUrl, { type: 'module' }); - } else if (backendName === 'iframe') { - messageChannel = spawnIframeWorker(workerScriptUrl); - } else { - throw new Error(`Unknown backendName: ${backendName}`); - } - - messageChannel.setMessageListener((e) => { - if (e.data.type === 'download_progress') { - onDownloadProgress(e.data); - } - }); - - // Keep asking if the worker is alive until we get a response - while (true) { - try { - await messageChannel.sendMessage({ type: 'isAlive' }, 50); - break; - } catch (e) { - // Ignore timeouts - } - await sleep(50); - } - - // Proxy the service worker messages to the worker thread: - const scope = await messageChannel.sendMessage({ type: 'getScope' }, 50); - navigator.serviceWorker.addEventListener( - 'message', - async function onMessage(event) { - console.debug('Message from ServiceWorker', event); - /** - * Ignore events meant for other PHP instances to - * avoid handling the same event twice. - * - * This is important because the service worker posts the - * same message to all application instances across all browser tabs. - */ - if (scope && event.data.scope !== scope) { - return; - } - - const result = await messageChannel.sendMessage(event.data); - // The service worker expects a response when it includes a `requestId` in the message: - if (event.data.requestId) { - event.source!.postMessage( - responseTo(event.data.requestId, result) - ); - } - } - ); - - const absoluteUrl = await messageChannel.sendMessage({ - type: 'getAbsoluteUrl', - }); - - return new SpawnedWorkerThread(messageChannel, absoluteUrl); -} - -export class SpawnedWorkerThread { - messageChannel; - serverUrl; - - constructor(messageChannel, serverUrl) { - this.messageChannel = messageChannel; - this.serverUrl = serverUrl; - } - - /** - * Converts a path to an absolute URL based at the PHPServer - * root. - * - * @param path The server path to convert to an absolute URL. - * @returns The absolute URL. - */ - pathToInternalUrl(path: string): string { - return `${this.serverUrl}${path}`; - } - - /** - * Converts an absolute URL based at the PHPServer to a relative path - * without the server pathname and scope. - * - * @param internalUrl An absolute URL based at the PHPServer root. - * @returns The relative path. - */ - internalUrlToPath(internalUrl: string): string { - return getPathQueryFragment(removeURLScope(new URL(internalUrl))); - } - - /** - * @param code - * @see {PHP.run} - */ - async run(code: string): Promise { - return await this.#rpc('run', { code }); - } - - /** - * @param request - * @see {PHP.request} - */ - async HTTPRequest( - request: PHPServerRequest - ): Promise { - const response = (await this.#rpc('HTTPRequest', { - request, - })) as PHPResponse; - return { - ...response, - get text() { - return new TextDecoder().decode(response.body); - }, - }; - } - - /** - * @param path - * @see {PHP.readFile} - */ - async readFile(path: string): Promise { - return await this.#rpc('readFile', { path }); - } - - /** - * @param path - * @see {PHP.readFile} - */ - async readFileAsBuffer(path: string): Promise { - return await this.#rpc('readFileAsBuffer', { path }); - } - - /** - * @param path - * @param contents - * @see {PHP.writeFile} - */ - async writeFile(path: string, contents: string): Promise { - return await this.#rpc('writeFile', { path, contents }); - } - - /** - * @param path - * @see {PHP.unlink} - */ - async unlink(path: string): Promise { - return await this.#rpc('unlink', { path }); - } - - /** - * @param path - * @see {PHP.mkdirTree} - */ - async mkdirTree(path: string): Promise { - return await this.#rpc('mkdirTree', { path }); - } - - /** - * @param path - * @see {PHP.listFiles} - */ - async listFiles(path: string): Promise { - return await this.#rpc('listFiles', { path }); - } - - /** - * @param path - * @see {PHP.isDir} - */ - async isDir(path: string): Promise { - return await this.#rpc('isDir', { path }); - } - - /** - * @param path - * @see {PHP.fileExists} - */ - async fileExists(path: string): Promise { - return await this.#rpc('fileExists', { path }); - } - - async #rpc(type: string, args?: Record): Promise { - return await this.messageChannel.sendMessage({ - ...args, - type, - }); - } -} - -interface WorkerThreadMessageTarget { - sendMessage(message: any, timeout?: number): Promise>; - setMessageListener(listener: (message: any) => void): void; -} - -function spawnWebWorker(workerURL: string, options: WorkerOptions = {}): WorkerThreadMessageTarget { - console.log("Spawning Web Worker", workerURL); - const worker = new Worker(workerURL, options); - return { - async sendMessage(message: any, timeout: number) { - const requestId = postMessageExpectReply(worker, message); - const response = await awaitReply(worker, requestId, timeout); - return response; - }, - setMessageListener(listener) { - worker.onmessage = listener; - }, - }; -} - -function spawnIframeWorker( - workerDocumentURL: string -): WorkerThreadMessageTarget { - const iframe = document.createElement('iframe'); - iframe.src = workerDocumentURL; - iframe.style.display = 'none'; - document.body.appendChild(iframe); - return { - async sendMessage(message, timeout) { - const requestId = postMessageExpectReply( - iframe.contentWindow!, - message, - '*' - ); - const response = await awaitReply(window, requestId, timeout); - return response; - }, - setMessageListener(listener) { - window.addEventListener( - 'message', - (e) => { - if (e.source === iframe.contentWindow) { - listener(e); - } - }, - false - ); - }, - }; -} diff --git a/packages/php-wasm/src/web/worker-thread/worker-library.ts b/packages/php-wasm/src/web/worker-thread/worker-library.ts deleted file mode 100644 index a2039422..00000000 --- a/packages/php-wasm/src/web/worker-thread/worker-library.ts +++ /dev/null @@ -1,267 +0,0 @@ -/// -/// -/// - -declare const self: WorkerGlobalScope; -declare const window: any; // For the web backend -/* eslint-disable no-inner-declarations */ - -import { startPHP, PHPBrowser, PHPServer } from '../../php-library/index'; -import type { PHP, JavascriptRuntime } from '../../php-library/index'; -import { responseTo } from '../messaging'; -import EmscriptenDownloadMonitor from '../emscripten-download-monitor'; -import type { DownloadProgressEvent } from '../emscripten-download-monitor'; -import { getURLScope } from '../scope'; -export * from '../scope'; - -/** - * Call this in a worker thread script to set the stage for - * offloading the PHP processing. This function: - * - * * Initializes the PHP runtime - * * Starts PHPServer and PHPBrowser - * * Lets the main app know when its ready - * * Listens for messages from the main app - * * Runs the requested operations (like `run_php`) - * * Replies to the main app with the results using the [request/reply protocol](#request-reply-protocol) - * - * Remember: The worker thread code must live in a separate JavaScript file. - * - * A minimal worker thread script looks like this: - * - * ```js - * import { initializeWorkerThread } from 'php-wasm-browser'; - * initializeWorkerThread(); - * ``` - * - * You can customize the PHP loading flow via the first argument: - * - * ```js - * import { initializeWorkerThread, loadPHPWithProgress } from 'php-wasm-browser'; - * initializeWorkerThread( bootBrowser ); - * - * async function bootBrowser({ absoluteUrl }) { - * const [phpLoaderModule, myDependencyLoaderModule] = await Promise.all([ - * import(`/php.js`), - * import(`/wp.js`) - * ]); - * - * const php = await loadPHPWithProgress(phpLoaderModule, [myDependencyLoaderModule]); - * - * const server = new PHPServer(php, { - * documentRoot: '/www', - * absoluteUrl: absoluteUrl - * }); - * - * return new PHPBrowser(server); - * } - * ``` - * - * @param config The worker thread configuration. - * @return The backend object to communicate with the parent thread. - */ -export async function initializeWorkerThread( - config: WorkerThreadConfiguration -): Promise { - const phpBrowser = config.phpBrowser || (await defaultBootBrowser()); - const middleware = config.middleware || ((message, next) => next(message)); - - const absoluteUrl = phpBrowser.server.absoluteUrl; - const scope = getURLScope(new URL(absoluteUrl)); - - // Handle postMessage communication from the main thread - currentBackend.setMessageListener(async (event) => { - const result = await middleware(event.data, doHandleMessage); - - // When `requestId` is present, the other thread expects a response: - if (event.data.requestId) { - const response = responseTo(event.data.requestId, result); - currentBackend.postMessageToParent(response); - } - }); - - async function doHandleMessage(message) { - console.debug( - `[Worker Thread] "${message.type}" message received from a service worker` - ); - - if (message.type === 'isAlive') { - return true; - } else if (message.type === 'getAbsoluteUrl') { - return phpBrowser.server.absoluteUrl; - } else if (message.type === 'getScope') { - return scope; - } else if (message.type === 'readFile') { - return phpBrowser.server.php.readFileAsText(message.path); - } else if (message.type === 'readFileAsBuffer') { - return phpBrowser.server.php.readFileAsBuffer(message.path); - } else if (message.type === 'listFiles') { - return phpBrowser.server.php.listFiles(message.path); - } else if (message.type === 'unlink') { - return phpBrowser.server.php.unlink(message.path); - } else if (message.type === 'isDir') { - return phpBrowser.server.php.isDir(message.path); - } else if (message.type === 'mkdirTree') { - return phpBrowser.server.php.mkdirTree(message.path); - } else if (message.type === 'writeFile') { - return await phpBrowser.server.php.writeFile( - message.path, - message.contents - ); - } else if (message.type === 'fileExists') { - return await phpBrowser.server.php.fileExists(message.path); - } else if (message.type === 'run') { - return phpBrowser.server.php.run(message.code); - } else if (message.type === 'HTTPRequest') { - return await phpBrowser.request(message.request); - } - throw new Error( - `[Worker Thread] Received unexpected message: "${message.type}"` - ); - } - - return currentBackend; -} - -interface WorkerThreadConfiguration { - /** - * The PHP browser instance to use. - */ - phpBrowser?: PHPBrowser; - /** - * Middleware to run before handing a message. - */ - middleware?: (message, next) => Promise; -} - -async function defaultBootBrowser({ absoluteUrl = location.origin } = {}) { - return new PHPBrowser( - new PHPServer(await startPHP('/php.js', currentBackend.jsEnv), { - absoluteUrl, - documentRoot: '/www', - }) - ); -} - -interface WorkerThreadBackend { - jsEnv: JavascriptRuntime; - setMessageListener(handler: any); - postMessageToParent(message: any); - getOptions: () => Record; -} - -const webBackend: WorkerThreadBackend = { - jsEnv: 'WEB' as JavascriptRuntime, // Matches the Env argument in php.js - setMessageListener(handler) { - window.addEventListener( - 'message', - (event) => - handler(event, (response) => - event.source!.postMessage(response, '*' as any) - ), - false - ); - }, - postMessageToParent(message) { - window.parent.postMessage(message, '*'); - }, - getOptions() { - return searchParamsToObject(new URL(window.location).searchParams); - }, -}; - -const webWorkerBackend: WorkerThreadBackend = { - jsEnv: 'WORKER' as JavascriptRuntime, // Matches the Env argument in php.js - setMessageListener(handler) { - onmessage = (event) => { - handler(event, postMessage); - }; - }, - postMessageToParent(message) { - postMessage(message); - }, - getOptions() { - return searchParamsToObject(new URL(self.location.href).searchParams); - }, -}; - -function searchParamsToObject(params: URLSearchParams) { - const result: Record = {}; - params.forEach((value, key) => { - result[key] = value; - }); - return result; -} - -/** - * @returns - */ -export const currentBackend: WorkerThreadBackend = (function () { - /* eslint-disable no-undef */ - if (typeof window !== 'undefined') { - return webBackend; - } else if ( - typeof WorkerGlobalScope !== 'undefined' && - self instanceof WorkerGlobalScope - ) { - return webWorkerBackend; - } - throw new Error(`Unsupported environment`); - - /* eslint-enable no-undef */ -})(); - -/** - * Call this in a Worker Thread to start load the PHP runtime - * and post the progress to the main thread. - * - * @see startPHP - * @param phpLoaderModule The ESM-wrapped Emscripten module. Consult the Dockerfile for the build process. - * @param dataDependenciesModules A list of the ESM-wrapped Emscripten data dependency modules. - * @param phpModuleArgs The Emscripten module arguments, see https://emscripten.org/docs/api_reference/module.html#affecting-execution. - * @returns PHP instance. - */ -export async function loadPHPWithProgress( - phpLoaderModule: any, - dataDependenciesModules: any[] = [], - phpModuleArgs: any = {} -): Promise { - const modules = [phpLoaderModule, ...dataDependenciesModules]; - const assetsSizes = modules.reduce((acc, module) => { - if (module.dependenciesTotalSize > 0) { - const filename = new URL( - module.dependencyFilename, - 'http://example.com' - ).pathname - .split('/') - .pop()!; - acc[filename] = Math.max( - filename in acc ? acc[filename] : 0, - module.dependenciesTotalSize - ); - } - return acc; - }, {} as Record); - const downloadMonitor = new EmscriptenDownloadMonitor(assetsSizes); - (downloadMonitor as any).addEventListener( - 'progress', - (e: CustomEvent) => - currentBackend.postMessageToParent({ - type: 'download_progress', - ...e.detail, - }) - ); - - return await startPHP( - phpLoaderModule, - currentBackend.jsEnv, - { - // Emscripten sometimes prepends a '/' to the path, which - // breaks vite dev mode. - locateFile: path => path, - ...phpModuleArgs, - ...downloadMonitor.phpArgs, - }, - dataDependenciesModules - ); -} diff --git a/packages/php-wasm/tsconfig.build.json b/packages/php-wasm/tsconfig.build.json new file mode 100644 index 00000000..c17ece33 --- /dev/null +++ b/packages/php-wasm/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.build.json", + + "compilerOptions": { + "outDir": "./build" + }, + + "exclude": [ + "src/php-build", + "src/__tests__/*.ts" + ], + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/packages/php-wasm/tsconfig.json b/packages/php-wasm/tsconfig.json index 5626d3df..1864d58c 100644 --- a/packages/php-wasm/tsconfig.json +++ b/packages/php-wasm/tsconfig.json @@ -1,19 +1,3 @@ { - "extends": "./tsconfig.base.json", - "compilerOptions": { - "lib": [ "dom", "ESNext" ], - "declarationDir": "build/web/types", - "outDir": "build/web/types" - }, - "include": [ "src/**/*" ], - "exclude": [ - "src/browser-library/service-worker/worker-library.ts", - "src/browser-library/worker-thread/worker-library.ts", - "src/**/*.node.js", - "src/**/*.node.ts", - "src/php-compilation/**/*", - "src/__tests__/*.ts", - "build/**/*", - "build-*/**/*", - ] + "extends": "../../tsconfig.json" } diff --git a/packages/php-wasm/tsconfig.node.json b/packages/php-wasm/tsconfig.node.json deleted file mode 100644 index 3187490a..00000000 --- a/packages/php-wasm/tsconfig.node.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "declarationDir": "build/node/types", - "outDir": "build/node/types", - }, - "include": [ "src/php-library/*", "src/php/*.node.js" ] -} diff --git a/packages/php-wasm/tsconfig.worker.json b/packages/php-wasm/tsconfig.worker.json deleted file mode 100644 index 85e7f2a4..00000000 --- a/packages/php-wasm/tsconfig.worker.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "lib": ["WebWorker"], - "declarationDir": "build/web/types", - "outDir": "build/web/types" - }, - "include": [ - "src/urls.ts", - - "src/php-library/index.ts", - "src/php-library/php.ts", - "src/php-library/utils.ts", - "src/php-library/php-server.ts", - "src/php-library/php-browser.ts", - - "src/webbrowser-toolkit/service-worker/worker-library.ts", - "src/webbrowser-toolkit/worker-thread/worker-library.ts", - "src/webbrowser-toolkit/emscripten-download-monitor.ts", - "src/webbrowser-toolkit/messaging.ts", - "src/webbrowser-toolkit/scope.ts", - "src/webbrowser-toolkit/utils.ts" - ] -} diff --git a/packages/playground-client/package.json b/packages/playground-client/package.json new file mode 100644 index 00000000..ba7458b3 --- /dev/null +++ b/packages/playground-client/package.json @@ -0,0 +1,54 @@ +{ + "name": "@wordpress/playground-client", + "version": "0.1.4", + "description": "WordPress Playground client.", + "main": "build/index.js", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/wordpress-playground" + }, + "homepage": "https://developer.wordpress.org/playground", + "files": [ + "build" + ], + "type": "module", + "types": "build", + "scripts": { + "dev": "rollup -c rollup.config.mjs -w", + "build": "rollup -c rollup.config.mjs", + "clean": "rm -rf build/*", + "test": "jest" + }, + "author": "The WordPress contributors", + "contributors": [ + { + "name": "Adam Zielinski", + "email": "adam@adamziel.com", + "url": "https://github.com/adamziel" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@rollup/plugin-alias": "^4.0.3", + "comlink": "^4.4.1", + "rollup-plugin-ignore-import": "^1.3.2" + }, + "devDependencies": { + "@rollup/plugin-typescript": "^11.0.0", + "@rollup/plugin-url": "^8.0.1", + "@wordpress/php-wasm": "0.0.1", + "@wordpress/playground": "^0.1.4", + "dts-bundle-generator": "^7.2.0", + "esbuild": "^0.17.11", + "esbuild-plugin-clean": "^1.0.1", + "esbuild-plugin-copy": "^2.0.2", + "eslint": "^8.35.0", + "glob": "^9.2.1", + "npm-run-all": "^4.1.5", + "rollup": "^3.19.1", + "rollup-plugin-dts": "^5.2.0", + "rollup-plugin-ts": "^3.2.0", + "typescript": "beta", + "vite": "^4.1.4" + } +} diff --git a/packages/playground-client/rollup.config.mjs b/packages/playground-client/rollup.config.mjs new file mode 100644 index 00000000..82d527c8 --- /dev/null +++ b/packages/playground-client/rollup.config.mjs @@ -0,0 +1,74 @@ +// rollup.config.js +import { globSync } from 'glob'; +import fs from 'fs'; +import typescript from '@rollup/plugin-typescript'; +import ts from 'rollup-plugin-ts'; +import alias from '@rollup/plugin-alias'; +import url from '@rollup/plugin-url'; +import copy from 'rollup-plugin-copy'; +import dts from 'rollup-plugin-dts'; + +const path = (filename) => new URL(filename, import.meta.url).pathname; +export default [ + { + input: 'src/index.ts', + output: { + dir: 'build/', + format: 'esm', + }, + plugins: [ + typescript({ + tsconfig: './tsconfig.build.json', + emitDeclarationOnly: true, + paths: { + '@wordpress/php-wasm': [ + '../php-wasm/build/web/index.js', + ], + } + }), + url({ + include: ['**/*.wasm'], + }), + copy({ + targets: [ + { + src: '../php-wasm/build/web/index.d.ts', + dest: 'build/dts', + rename: 'php-wasm.d.ts', + }, + { + src: '../playground/build/index.d.ts', + dest: 'build/dts', + rename: 'playground.d.ts', + }, + ], + }), + // { + // name: 'file-content-replace', + // buildEnd() { + // const declarations = fs + // .readFileSync(path`./build/dts/index.d.ts`) + // .toString() + // .replace('@wordpress/php-wasm', './php-wasm.d.ts') + // .replace('@wordpress/playground', './playground.d.ts'); + // fs.writeFileSync(path`./build/dts/index.d.ts`, declarations); + // }, + // }, + ], + }, + // { + // input: 'build/dts/index.d.ts', + // output: [{ file: 'build/index.d.ts', format: 'es' }], + // plugins: [ + // alias({ + // entries: [ + // { + // find: '@php-wasm/php-wasm', + // replacement: './build/dts/php-wasm.d.ts', + // }, + // ], + // }), + // ts(), + // ], + // }, +]; diff --git a/packages/playground-client/src/index.ts b/packages/playground-client/src/index.ts new file mode 100644 index 00000000..c63a5576 --- /dev/null +++ b/packages/playground-client/src/index.ts @@ -0,0 +1,14 @@ +import { consumeAPI } from '@wordpress/php-wasm'; +import type { PHPResponse } from '@wordpress/php-wasm'; +// import type { PlaygroundAPI } from '@wordpress/playground/boot-playground'; + + +/** + * Connects to a remote Playground instance and returns its API. + * + * @param remoteWindow Window where WordPress Playground is loaded + * @returns Playground API object + */ +export function connect(remoteWindow: Window): PHPResponse { + return consumeAPI(remoteWindow); +} diff --git a/packages/playground-client/tsconfig.build.json b/packages/playground-client/tsconfig.build.json new file mode 100644 index 00000000..c684991f --- /dev/null +++ b/packages/playground-client/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.build.json", + + "compilerOptions": { + "emitDeclarationOnly": true, + "outDir": "./build", + "declarationDir": "./build" + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/packages/playground-client/tsconfig.json b/packages/playground-client/tsconfig.json new file mode 100644 index 00000000..79e007b7 --- /dev/null +++ b/packages/playground-client/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} \ No newline at end of file diff --git a/packages/wordpress-playground/package.json b/packages/wordpress-playground/package.json index 2439fcc7..1a259efc 100644 --- a/packages/wordpress-playground/package.json +++ b/packages/wordpress-playground/package.json @@ -30,17 +30,25 @@ "clean": "rm -rf build/*", "test": "jest" }, + "resolutions": { + "react": "18.2.0", + "react-dom": "18.2.0" + }, "author": "The WordPress contributors", "license": "Apache-2.0", "dependencies": { "@wordpress/php-wasm": "0.0.1", "@wordpress/plugin-ide": "0.1.4", + "classnames": "^2.3.2", + "comlink": "^4.4.1", "file-saver": "^2.0.5", "glob": "^9.2.1", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-modal": "^3.16.1", "rollup-plugin-ignore-import": "^1.3.2", "rollup-pluginutils": "^2.8.2", + "typescript-plugin-css-modules": "^4.2.3", "vite-plugin-external": "^1.2.8", "vite-plugin-filter-replace": "^0.1.10" }, diff --git a/packages/wordpress-playground/src/__tests__/migration-tests.ts b/packages/wordpress-playground/src/__tests__/migration-tests.ts index ac1c7ddb..ff67925d 100644 --- a/packages/wordpress-playground/src/__tests__/migration-tests.ts +++ b/packages/wordpress-playground/src/__tests__/migration-tests.ts @@ -1,4 +1,4 @@ -import { startPHP, getPHPLoaderModule } from '@wordpress/php-wasm/node'; +import { loadPHPRuntime, getPHPLoaderModule } from '@wordpress/php-wasm/node'; import { existsSync, rmdirSync, readFileSync } from 'fs'; const { TextDecoder } = require('util'); @@ -87,7 +87,7 @@ describe('generateZipFile()', () => { let php; beforeEach(async () => { - php = await startPHP(phpLoaderModule, 'NODE'); + php = await loadPHPRuntime(await phpLoaderModule); if (existsSync(testDirPath)) { rmdirSync(testDirPath, { recursive: true }); } @@ -176,7 +176,7 @@ describe('readFileFromZipArchive()', () => { let php; beforeEach(async () => { - php = await startPHP(phpLoaderModule, 'NODE'); + php = await loadPHPRuntime(await phpLoaderModule); if (existsSync(testDirPath)) { rmdirSync(testDirPath, { recursive: true }); } @@ -265,7 +265,7 @@ describe('importZipFile()', () => { let php; beforeAll(async () => { - php = await startPHP(phpLoaderModule, 'NODE'); + php = await loadPHPRuntime(await phpLoaderModule); if (existsSync(testDirPath)) { rmdirSync(testDirPath, { recursive: true }); } diff --git a/packages/wordpress-playground/src/app.tsx b/packages/wordpress-playground/src/app.tsx deleted file mode 100644 index e89c850f..00000000 --- a/packages/wordpress-playground/src/app.tsx +++ /dev/null @@ -1,566 +0,0 @@ -import { saveAs } from 'file-saver'; -import { bootWordPress } from './boot'; -import { login, installPlugin, installTheme } from './wp-macros'; -import { cloneResponseMonitorProgress, responseTo } from '@wordpress/php-wasm'; -import { ProgressObserver, ProgressType } from './progress-observer'; -import { PromiseQueue } from './promise-queue'; -import { DOCROOT } from './config'; -// @ts-ignore -import migration from './migration.php?raw'; - -const query = new URL(document.location.href).searchParams as any; - -const wpFrame = document.querySelector('#wp') as HTMLIFrameElement; -const addressBar = document.querySelector('#url-bar')! as HTMLInputElement; - -let wpVersion; -let phpVersion; - -// Migration Logic -const importWindow = document.querySelector('#import-window') as HTMLElement; -const overlay = document.querySelector('#overlay') as HTMLElement; -const exportButton = document.querySelector( - '#export-playground--btn' -) as HTMLButtonElement; -const importOpenModalButton = document.querySelector( - '#import-open-modal--btn' -) as HTMLButtonElement; -const importPlaygroundForm = document.querySelector( - '#import-playground-form' -) as HTMLFormElement; -const importSelectFile = document.querySelector( - '#import-select-file' -) as HTMLInputElement; -const importSelectFileText = document.querySelector( - '#import-select-file--text' -) as HTMLElement; -const importSelectFileButton = document.querySelector( - '#import-select-file--btn' -) as HTMLButtonElement; -const importSubmitButton = document.querySelector( - '#import-submit--btn' -) as HTMLButtonElement; -const importCloseModalButton = document.querySelector( - '#import-close-modal--btn' -) as HTMLButtonElement; - -const databaseExportName = 'databaseExport.xml'; -const databaseExportPath = '/' + databaseExportName; - -let workerThread; -let isBooted = false; - -async function main() { - const preinstallPlugins = query.getAll('plugin').map(toZipName); - // Don't preinstall the default theme - const queryTheme = - query.get('theme') === 'twentytwentythree' ? null : query.get('theme'); - const preinstallTheme = toZipName(queryTheme); - - wpVersion = query.get('wp') ? query.get('wp') : '6.1'; - phpVersion = query.get('php') ? query.get('php') : '8.0'; - - const installPluginProgress = Math.min(preinstallPlugins.length * 15, 45); - const installThemeProgress = preinstallTheme ? 20 : 0; - const bootProgress = 100 - installPluginProgress - installThemeProgress; - - const progress = setupProgressBar(); - workerThread = await bootWordPress({ - onWasmDownloadProgress: progress.partialObserver( - bootProgress, - 'Preparing WordPress...' - ), - phpVersion, - dataModule: wpVersion, - }); - const appMode = query.get('mode') === 'seamless' ? 'seamless' : 'browser'; - if (appMode === 'browser') { - setupAddressBar(workerThread); - } - - if ( - !query.get('disableImportExport') || - query.get('login') || - preinstallPlugins.length || - query.get('theme') - ) { - await login(workerThread, 'admin', 'password'); - } - - if (preinstallTheme) { - // Download the theme file - const response = cloneResponseMonitorProgress( - await fetch('/plugin-proxy?theme=' + preinstallTheme), - progress.partialObserver( - installThemeProgress - 10, - `Installing ${zipNameToHumanName(preinstallTheme)} theme...` - ) - ); - progress.slowlyIncrementBy(10); - - if (response.status === 200) { - const themeFile = new File( - [await response.blob()], - preinstallTheme - ); - - try { - await installTheme(workerThread, themeFile); - } catch (error) { - console.error( - `Proceeding without the ${preinstallTheme} theme. Could not install it in wp-admin. ` + - `The original error was: ${error}` - ); - console.error(error); - } - } else { - console.error( - `Proceeding without the ${preinstallTheme} theme. Could not download the zip bundle from https://downloads.wordpress.org/themes/${preinstallTheme} – ` + - `Is the file name correct?` - ); - } - } - - if (preinstallPlugins.length) { - const downloads = new PromiseQueue(); - const installations = new PromiseQueue(); - - const progressBudgetPerPlugin = - installPluginProgress / preinstallPlugins.length; - - /** - * Install multiple plugins to minimize the processing time. - * - * The downloads are done one after another to get installable - * zip files as soon as possible. Each completed download triggers - * plugin installation without waiting for the next download to - * complete. - */ - await new Promise((finish) => { - for (const preinstallPlugin of preinstallPlugins) { - downloads.enqueue(async () => { - const response = cloneResponseMonitorProgress( - await fetch('/plugin-proxy?plugin=' + preinstallPlugin), - progress.partialObserver( - progressBudgetPerPlugin * 0.66, - `Installing ${zipNameToHumanName( - preinstallPlugin - )} plugin...` - ) - ); - if (response.status !== 200) { - console.error( - `Proceeding without the ${preinstallPlugin} plugin. Could not download the zip bundle from https://downloads.wordpress.org/plugin/${preinstallPlugin} – ` + - `Is the file name correct?` - ); - return null; - } - return new File([await response.blob()], preinstallPlugin); - }); - } - downloads.addEventListener('resolved', (e: any) => { - installations.enqueue(async () => { - if (!e.detail) { - return; - } - progress.slowlyIncrementBy(progressBudgetPerPlugin * 0.33); - try { - await installPlugin(workerThread, e.detail as File); - } catch (error) { - console.error( - `Proceeding without the ${e.detail.name} plugin. Could not install it in wp-admin. ` + - `The original error was: ${error}` - ); - console.error(error); - } - }); - }); - installations.addEventListener('empty', () => { - if (installations.resolved === preinstallPlugins.length) { - finish(null); - } - }); - }); - } - - if (query.get('rpc')) { - console.log('Registering an RPC handler'); - async function handleMessage(data) { - if (data.type === 'rpc') { - return await workerThread[data.method](...data.args); - } else if (data.type === 'go_to') { - wpFrame.src = workerThread.pathToInternalUrl(data.path); - } else if (data.type === 'is_alive') { - return true; - } else if (data.type === 'is_booted') { - return isBooted; - } - } - window.addEventListener('message', async (event) => { - const result = await handleMessage(event.data); - - // When `requestId` is present, the other thread expects a response: - if (event.data.requestId) { - const response = responseTo(event.data.requestId, result); - window.parent.postMessage(response, '*'); - } - }); - - // Notify the parent window about any URL changes in the - // WordPress iframe - wpFrame.addEventListener('load', (e: any) => { - window.parent.postMessage( - { - type: 'new_path', - path: workerThread.internalUrlToPath( - e.currentTarget!.contentWindow.location.href - ), - }, - '*' - ); - }); - } - - if (query.has('ide')) { - let doneFirstBoot = false; - const { WordPressPluginIDE, createBlockPluginFixture } = await import( - '@wordpress/plugin-ide' - ); - const { default: React } = await import('react'); - const { - default: { render }, - } = await import('react-dom'); - render( - { - if (doneFirstBoot) { - (wpFrame.contentWindow as any).eval(bundleContents); - } else { - doneFirstBoot = true; - wpFrame.src = workerThread.pathToInternalUrl( - query.get('url') || '/' - ); - } - }} - />, - document.getElementById('test-snippets')! - ); - } else { - wpFrame.src = workerThread.pathToInternalUrl(query.get('url') || '/'); - } - isBooted = true; -} - -function toZipName(rawInput) { - if (!rawInput) { - return rawInput; - } - if (rawInput.endsWith('.zip')) { - return rawInput; - } - return rawInput + '.latest-stable.zip'; -} - -function setupAddressBar(wasmWorker) { - // Manage the address bar - wpFrame.addEventListener('load', (e: any) => { - addressBar.value = wasmWorker.internalUrlToPath( - e.currentTarget!.contentWindow.location.href - ); - }); - - document.querySelector('#url-bar-form')!.addEventListener('submit', (e) => { - e.preventDefault(); - let requestedPath = addressBar.value; - // Ensure a trailing slash when requesting directory paths - const isDirectory = !requestedPath.split('/').pop()!.includes('.'); - if (isDirectory && !requestedPath.endsWith('/')) { - requestedPath += '/'; - } - wpFrame.src = wasmWorker.pathToInternalUrl(requestedPath); - ( - document.querySelector('#url-bar-form input[type="text"]')! as any - ).blur(); - }); -} - -async function generateZip() { - const databaseExportResponse = await workerThread.HTTPRequest({ - absoluteUrl: workerThread.pathToInternalUrl( - '/wp-admin/export.php?download=true&&content=all' - ), - method: 'GET', - }); - const databaseExportContent = new TextDecoder().decode( - databaseExportResponse.body - ); - await workerThread.writeFile(databaseExportPath, databaseExportContent); - const exportName = `wordpress-playground--wp${wpVersion}--php${phpVersion}.zip`; - const exportPath = `/${exportName}`; - const exportWriteRequest = await workerThread.run({ - code: - migration + - ` generateZipFile('${exportPath}', '${databaseExportPath}', '${DOCROOT}');`, - }); - if (exportWriteRequest.exitCode !== 0) { - throw exportWriteRequest.errors; - } - - const fileBuffer = await workerThread.readFileAsBuffer(exportName); - const file = new File([fileBuffer], exportName); - saveAs(file); -} - -async function importFile() { - if ( - // eslint-disable-next-line no-alert - !confirm( - 'Are you sure you want to import this file? Previous data will be lost.' - ) - ) { - return false; - } - - // Write uploaded file to filesystem for processing with PHP - const userUploadedFileInput = importSelectFile as HTMLInputElement; - const userUploadedFile = userUploadedFileInput.files - ? userUploadedFileInput.files[0] - : null; - if (!userUploadedFile) return; - - const fileArrayBuffer = await userUploadedFile.arrayBuffer(); - const fileContent = new Uint8Array(fileArrayBuffer); - const importPath = '/import.zip'; - - await workerThread.writeFile(importPath, fileContent); - - // Import the database - const databaseFromZipFileReadRequest = await workerThread.run({ - code: - migration + - ` readFileFromZipArchive('${importPath}', '${databaseExportPath}');`, - }); - if (databaseFromZipFileReadRequest.exitCode !== 0) { - throw databaseFromZipFileReadRequest.errors; - } - - const databaseFromZipFileContent = new TextDecoder().decode( - databaseFromZipFileReadRequest.body - ); - - const databaseFile = new File( - [databaseFromZipFileContent], - databaseExportName - ); - - const importerPageOneResponse = await workerThread.HTTPRequest({ - absoluteUrl: workerThread.pathToInternalUrl( - '/wp-admin/admin.php?import=wordpress' - ), - method: 'GET', - }); - - const importerPageOneContent = new DOMParser().parseFromString( - importerPageOneResponse.text, - 'text/html' - ); - - const firstUrlAction = importerPageOneContent - .getElementById('import-upload-form') - ?.getAttribute('action'); - - const stepOneResponse = await workerThread.HTTPRequest({ - absoluteUrl: workerThread.pathToInternalUrl( - `/wp-admin/${firstUrlAction}` - ), - method: 'POST', - files: { import: databaseFile }, - }); - - const importerPageTwoContent = new DOMParser().parseFromString( - stepOneResponse.text, - 'text/html' - ); - - const importerPageTwoForm = importerPageTwoContent.querySelector( - '#wpbody-content form' - ); - const secondUrlAction = importerPageTwoForm?.getAttribute('action'); - - const nonce = ( - importerPageTwoForm?.querySelector( - "input[name='_wpnonce']" - ) as HTMLInputElement - ).value; - - const referrer = ( - importerPageTwoForm?.querySelector( - "input[name='_wp_http_referer']" - ) as HTMLInputElement - ).value; - - const importId = ( - importerPageTwoForm?.querySelector( - "input[name='import_id']" - ) as HTMLInputElement - ).value; - - await workerThread.HTTPRequest({ - absoluteUrl: secondUrlAction, - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - _wpnonce: nonce, - _wp_http_referer: referrer, - import_id: importId, - }).toString(), - }); - - // Import the file system - const importFileSystemRequest = await workerThread.run({ - code: migration + ` importZipFile('${importPath}');`, - }); - if (importFileSystemRequest.exitCode !== 0) { - throw importFileSystemRequest.errors; - } - - return true; -} - -if ( - importWindow && - overlay && - exportButton && - importOpenModalButton && - importPlaygroundForm && - importSelectFileButton && - importSelectFileText && - importSelectFile && - importSubmitButton && - importCloseModalButton -) { - const resetImportWindow = () => { - overlay.style.display = 'none'; - importWindow.style.display = 'none'; - importPlaygroundForm.reset(); - importSelectFileText.innerHTML = 'No file selected'; - importSubmitButton.disabled = true; - }; - - exportButton.addEventListener('click', generateZip); - - importOpenModalButton.addEventListener('click', () => { - importWindow.style.display = 'block'; - overlay.style.display = 'block'; - importCloseModalButton.focus(); - }); - - importSelectFile.addEventListener('change', (e) => { - if (importSelectFile.files === null) return; - importSubmitButton.disabled = false; - importSelectFileText.innerHTML = importSelectFile.files[0].name; - }); - - importSelectFileButton.addEventListener('click', (e) => { - e.preventDefault(); - importPlaygroundForm.reset(); - importSelectFile.click(); - }); - - importSubmitButton.addEventListener('click', async (e) => { - e.preventDefault(); - let uploadAttempt; - try { - uploadAttempt = await importFile(); - } catch (error) { - console.error(error); - importSelectFileText.innerHTML = - 'Unable to import file.
Is it a valid WordPress Playground export?
'; - } - - if (uploadAttempt) { - // eslint-disable-next-line no-alert - alert( - 'File imported! This Playground instance has been updated. Refreshing now.' - ); - resetImportWindow(); - wpFrame.src = workerThread.pathToInternalUrl(addressBar.value); - addressBar.focus(); - } - }); - - importCloseModalButton.addEventListener('click', (e) => { - e.preventDefault(); - resetImportWindow(); - importOpenModalButton.focus(); - }); - - overlay.addEventListener('click', (e) => { - e.preventDefault(); - resetImportWindow(); - }); -} else { - console.error('Migration user interface elements not found.'); -} - -function setupProgressBar() { - // Hide the progress bar when the page is first loaded. - const HideProgressBar = () => { - document - .querySelector('body.is-loading')! - .classList.remove('is-loading'); - wpFrame.removeEventListener('load', HideProgressBar); - }; - wpFrame.addEventListener('load', HideProgressBar); - - const progress = new ProgressObserver( - (progressPercentage, mode, caption) => { - const infiniteWrapper = document.querySelector( - '.progress-bar-wrapper.mode-infinite' - ); - if (infiniteWrapper) { - infiniteWrapper.classList.remove('mode-infinite'); - infiniteWrapper.classList.add('mode-finite'); - } - if (caption && caption.length) { - const captionElement = document.querySelector( - '.progress-bar-overlay-caption' - ) as HTMLElement; - - if (captionElement) { - captionElement.innerText = caption; - } - } - - const progressBarEl = document.querySelector( - '.progress-bar.is-finite' - ) as any; - if (mode === ProgressType.SLOWLY_INCREMENT) { - progressBarEl.classList.add('slowly-incrementing'); - } else { - progressBarEl.classList.remove('slowly-incrementing'); - } - progressBarEl.style.width = `${progressPercentage}%`; - } - ); - - return progress; -} - -function zipNameToHumanName(zipName) { - const mixedCaseName = zipName.split('.').shift()!.replace('-', ' '); - return ( - mixedCaseName.charAt(0).toUpperCase() + - mixedCaseName.slice(1).toLowerCase() - ); -} - -main(); diff --git a/packages/wordpress-playground/src/boot-playground.tsx b/packages/wordpress-playground/src/boot-playground.tsx new file mode 100644 index 00000000..b9a3a96e --- /dev/null +++ b/packages/wordpress-playground/src/boot-playground.tsx @@ -0,0 +1,122 @@ +import { + exposeAPI, + registerServiceWorker, + spawnPHPWorkerThread, + consumeAPI, +} from '@wordpress/php-wasm'; + +import type { InternalWorkerAPI } from './worker-thread'; + +const origin = new URL('/', import.meta.url).origin; + +// @ts-ignore +import moduleWorkerUrl from './worker-thread.ts?worker&url'; +// @ts-ignore +import iframeHtmlUrl from '@wordpress/php-wasm/web/iframe-worker.html?url'; + +import { recommendedWorkerBackend } from '@wordpress/php-wasm'; + +export const workerBackend = recommendedWorkerBackend; +export const workerUrl: string = (function () { + switch (workerBackend) { + case 'webworker': + return new URL(moduleWorkerUrl, origin)+''; + case 'iframe': { + const wasmWorkerUrl = new URL(iframeHtmlUrl, origin); + wasmWorkerUrl.searchParams.set('scriptUrl', moduleWorkerUrl); + return wasmWorkerUrl+''; + } + default: + throw new Error(`Unknown backend: ${workerBackend}`); + } +})(); + +// @ts-ignore +import serviceWorkerPath from './service-worker.ts?worker&url'; +export const serviceWorkerUrl = new URL(serviceWorkerPath, origin); + +assertNotInfiniteLoadingLoop(); + +const query = new URL(document.location.href).searchParams as any; +const wpVersion = query.get('wp') ? query.get('wp') : '6.1'; +const phpVersion = query.get('php') ? query.get('php') : '8.0'; +const internalApi = consumeAPI( + spawnPHPWorkerThread(workerUrl, workerBackend, { + // Vite doesn't deal well with the dot in the parameters name, + // passed to the worker via a query string, so we replace + // it with an underscore + wpVersion: wpVersion.replace('.', '_'), + phpVersion: phpVersion.replace('.', '_'), + }) +); + +const wpFrame = document.querySelector('#wp') as HTMLIFrameElement; + +// If onDownloadProgress is not explicitly re-exposed here, +// Comlink will throw an error and claim the callback +// cannot be cloned. Adding a transfer handler for functions +// doesn't help: +// https://github.com/GoogleChromeLabs/comlink/issues/426#issuecomment-578401454 +// @TODO: Handle the callback conversion automatically and don't explicitly re-expose +// the onDownloadProgress method +const [setAPIReady, playground] = exposeAPI( + { + async onDownloadProgress(fn) { + return internalApi.onDownloadProgress(fn) + }, + async onNavigation(fn) { + // Manage the address bar + wpFrame.addEventListener('load', async (e: any) => { + const path = await playground.internalUrlToPath( + e.currentTarget!.contentWindow.location.href + ); + fn(path); + }); + }, + async goTo(requestedPath: string) { + wpFrame.src = await playground.pathToInternalUrl(requestedPath); + }, + async getCurrentURL() { + return await playground.internalUrlToPath(wpFrame.src); + }, + async setIframeSandboxFlags(flags: string[]) { + wpFrame.setAttribute("sandbox", flags.join(" ")); + } + }, + internalApi +); + +await internalApi.isReady(); +await registerServiceWorker( + internalApi, + await internalApi.scope, + serviceWorkerUrl + '', + // @TODO: source the hash of the service worker file in here + serviceWorkerUrl.pathname +); +wpFrame.src = await playground.pathToInternalUrl('/'); + +setAPIReady(); + +export type PlaygroundAPI = typeof playground; + +/** + * When the service worker fails for any reason, the page displayed inside + * the iframe won't be a WordPress instance we expect from the service worker. + * Instead, it will be the original page trying to load the service worker. This + * causes an infinite loop with a loader inside a loader inside a loader. + */ +function assertNotInfiniteLoadingLoop() { + let isBrowserInABrowser = false; + try { + isBrowserInABrowser = + window.parent !== window && + (window as any).parent.IS_WASM_WORDPRESS; + } catch (e) {} + if (isBrowserInABrowser) { + throw new Error( + 'The service worker did not load correctly. This is a bug, please report it on https://github.com/WordPress/wordpress-playground/issues' + ); + } + (window as any).IS_WASM_WORDPRESS = true; +} diff --git a/packages/wordpress-playground/src/boot.ts b/packages/wordpress-playground/src/boot.ts deleted file mode 100644 index 4bd2fb74..00000000 --- a/packages/wordpress-playground/src/boot.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - registerServiceWorker, - spawnPHPWorkerThread, - SpawnedWorkerThread, - DownloadProgressCallback, -} from '@wordpress/php-wasm'; - -const origin = new URL('/', import.meta.url).origin; -// @ts-ignore -import serviceWorkerPath from './service-worker.ts?worker&url'; -const serviceWorkerUrl = new URL(serviceWorkerPath, origin) - -// @ts-ignore -import moduleWorkerUrl from './worker-thread.ts?worker&url'; -// @ts-ignore -import iframeHtmlUrl from '@wordpress/php-wasm/web/iframe-worker.html?url'; - -export async function bootWordPress( - config: BootConfiguration -): Promise { - const { onWasmDownloadProgress } = config; - assertNotInfiniteLoadingLoop(); - - // Firefox doesn't support module workers with dynamic imports, - // let's fall back to iframe workers. - // See https://github.com/mdn/content/issues/24402 - const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; - let wasmWorkerBackend; - let wasmWorkerUrl; - if (isFirefox) { - wasmWorkerBackend = 'iframe'; - wasmWorkerUrl = new URL(iframeHtmlUrl, origin) - wasmWorkerUrl.searchParams.set('scriptUrl', moduleWorkerUrl); - } else { - wasmWorkerBackend = 'webworker'; - wasmWorkerUrl = new URL(moduleWorkerUrl, origin); - } - - const workerThread = await spawnPHPWorkerThread( - wasmWorkerBackend, - wasmWorkerUrl, - { - onDownloadProgress: onWasmDownloadProgress, - options: { - // Vite doesn't deal well with the dot in the parameters name, - // passed to the worker via a query string, so we replace - // it with an underscore - dataModule: (config.dataModule || '').replace('.', '_'), - phpVersion: (config.phpVersion || '').replace('.', '_'), - }, - } - ); - - await registerServiceWorker( - serviceWorkerUrl + '', - // Use the service worker path as the version – it will always - // contain the latest hash of the service worker script. - serviceWorkerUrl.pathname - ); - return workerThread; -} - -export interface BootConfiguration { - onWasmDownloadProgress: DownloadProgressCallback; - phpVersion?: string; - dataModule?: string; -} - -/** - * When the service worker fails for any reason, the page displayed inside - * the iframe won't be a WordPress instance we expect from the service worker. - * Instead, it will be the original page trying to load the service worker. This - * causes an infinite loop with a loader inside a loader inside a loader. - */ -function assertNotInfiniteLoadingLoop() { - let isBrowserInABrowser = false; - try { - isBrowserInABrowser = - window.parent !== window && - (window as any).parent.IS_WASM_WORDPRESS; - } catch (e) {} - if (isBrowserInABrowser) { - throw new Error( - 'The service worker did not load correctly. This is a bug, please report it on https://github.com/WordPress/wordpress-playground/issues' - ); - } - (window as any).IS_WASM_WORDPRESS = true; -} diff --git a/packages/wordpress-playground/src/client.ts b/packages/wordpress-playground/src/client.ts deleted file mode 100644 index b3ae905d..00000000 --- a/packages/wordpress-playground/src/client.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { SpawnedWorkerThread, postMessageExpectReply, awaitReply } from '@wordpress/php-wasm'; - -export default class PlaygroundClient extends SpawnedWorkerThread { - - constructor(iframe: HTMLIFrameElement) { - super({ - async sendMessage(message, timeout) { - const requestId = postMessageExpectReply( - iframe.contentWindow!, - message, - '*' - ); - const response = await awaitReply(window, requestId, timeout); - return response; - }, - setMessageListener(listener) { - window.addEventListener( - 'message', - (e) => { - if (e.source === iframe.contentWindow) { - listener(e); - } - }, - false - ); - }, - }); - } - -} diff --git a/packages/wordpress-playground/src/components/address-bar/index.tsx b/packages/wordpress-playground/src/components/address-bar/index.tsx new file mode 100644 index 00000000..5396337d --- /dev/null +++ b/packages/wordpress-playground/src/components/address-bar/index.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import css from './style.module.css'; + +interface AddressBarProps { + url?: string; + onUpdate?: (url: string) => void; +} +export default function AddressBar({ url, onUpdate }: AddressBarProps) { + const input = React.useRef(null); + const [value, setValue] = React.useState(url || ''); + const [isFocused, setIsFocused] = React.useState(false); + + React.useEffect(() => { + if (!isFocused && url) { + setValue(url); + } + }, [isFocused, url]); + + const handleSubmit = useCallback(function (e) { + e.preventDefault(); + let requestedPath = input.current!.value; + // Ensure a trailing slash when requesting directory paths + const isDirectory = !requestedPath.split('/').pop()!.includes('.'); + if (isDirectory && !requestedPath.endsWith('/')) { + requestedPath += '/'; + } + onUpdate?.(requestedPath); + input.current!.blur(); + }, [onUpdate]); + + return ( +
+
+ setValue(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + name="url" + type="text" + autoComplete="off" + /> +
+ +
+ ); +} diff --git a/packages/wordpress-playground/src/components/address-bar/style.module.css b/packages/wordpress-playground/src/components/address-bar/style.module.css new file mode 100644 index 00000000..e7799f59 --- /dev/null +++ b/packages/wordpress-playground/src/components/address-bar/style.module.css @@ -0,0 +1,38 @@ +.form { + position: relative; + display: flex; + transition: opacity 0.5s ease; +} + +.input-container { + display: flex; + width: 100%; +} + +.submit { + position: absolute; + width: 1px; + height: 1px; + left: -100000px; + top: -100000px; +} + +.input { + flex-grow: 1; + padding: 5px 10px; + font-family: 'San Francisco', Helvetica, Arial, sans-serif; + font-size: 16px; + font-weight: 50; + height: 26px; + border: 0; + background: #40464d; + border-radius: 8px; + color: #a5afbc; + transition: color 0.5s ease; +} + +.input:focus, +.input:hover { + color: #fff; +} + diff --git a/packages/wordpress-playground/src/components/browser-chrome/index.tsx b/packages/wordpress-playground/src/components/browser-chrome/index.tsx new file mode 100644 index 00000000..ad7ded9b --- /dev/null +++ b/packages/wordpress-playground/src/components/browser-chrome/index.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import css from './style.module.css'; +import AddressBar from '../address-bar'; +import classNames from 'classnames'; + +interface BrowserChromeProps { + children?: React.ReactNode; + toolbarButtons?: React.ReactElement[]; + url?: string; + showAddressBar?: boolean; + onUrlChange?: (url: string) => void; +} + +export default function BrowserChrome({ + children, + url, + onUrlChange, + showAddressBar = true, + toolbarButtons, +}: BrowserChromeProps) { + const addressBarClass = classNames(css.addressBarSlot, { + [css.isHidden]: !showAddressBar, + }); + return ( +
+
+
+ + +
+ +
+ +
+ {toolbarButtons?.map( + (button: React.ReactElement, idx) => + React.cloneElement(button, { + key: button.key || idx, + }) + )} +
+
+
{children}
+
+ This is a cool fun experimental WordPress running in your + browser :) All your changes are private and gone after a + page refresh. +
+
+
+ ); +} + +function WindowControls() { + return ( +
+
+
+
+
+ ); +} diff --git a/packages/wordpress-playground/src/components/browser-chrome/style.module.css b/packages/wordpress-playground/src/components/browser-chrome/style.module.css new file mode 100644 index 00000000..02c074a3 --- /dev/null +++ b/packages/wordpress-playground/src/components/browser-chrome/style.module.css @@ -0,0 +1,137 @@ +/* Full screen mode */ +body.browser-mode { + background-image: url(data:image/jpg;base64,/9j/4AAQSkZJRgABAQABLAEsAAD/4QCMRXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAAEsAAAAAQAAASwAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAGmgAwAEAAAAAQAAAEAAAAAA/+EKYWh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8APD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOmJhNTU4YWFiLTcyYTMtNDdkYy04OTVmLWU0YTI5OTU2YWQzZSIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJFRjk2NzgzNDlGN0JFQ0RBMEFGM0I5QzJCNkI2RjYzNyIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpiYTU1OGFhYi03MmEzLTQ3ZGMtODk1Zi1lNGEyOTk1NmFkM2UiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjAtMDMtMzBUMTE6Mzg6MzktMDQ6MDAiLz4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8P3hwYWNrZXQgZW5kPSJ3Ij8+AP/AABEIAEAAaQMBIgACEQEDEQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2wBDAAEBAQEBAQIBAQIDAgICAwMDAwMDAwQDAwMDAwQFBAQEBAQEBQUFBQUFBQUGBgYGBgYHBwcHBwgICAgICAgICAj/2wBDAQEBAQICAgQCAgQJBgUGCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQn/3QAEAAf/2gAMAwEAAhEDEQA/AP30m1xQOtc5e66pyM15Fc+LgoPzVzF54uXJy9fpGSYe6RrxXVtc9UvdbAyd1c5LrQY43V5Rd+KQ2drdaxv+El+bG6v03LcI2kfy1xfj0pM9yj1cHBzUx1NX6mvFYPEO7jdW3b6yHGC1ex9WPzSWKuekteg8iqxuRnJrlYtRBA5q2tzuNb0qRxVKt2dEsytUwG8cVhxzZrXtpMiu2NMzUitNF7VRktwRXQvFkZ9aqvCCOaUo9DWMNTkLqEVj/Zq665h4PrWN5Z9K52VOn5H/0PsvU/GYjz89cLdePE3Nl/1r5h8R/EpI84kx+NeO3fxWjExHmfrX7Zw3hOZI8njao4Jn3a/jhTxvz+NQxeMg7Z318O2/xMSc4EmfxretfHan+P8AWv1rB4BRgfyPxHWlUrNH29Z+MFZwN3613mmeIPOAO6vhbRvGPmzD585r33wx4gEoUk1csMfJ1fd0Z9WafqJkA5rsLV965FeIaJqRk24Neu6PPvUZ71SoWOJyudNExDYNb9m+eBWOsYxkVqWSktxVezRvTR0P3lqJl61biQslRSR46VhOJ3wiYVymRmsny1roLkfKc1k7FrllDU6JpdT/0fzI8cfF4W2/dLjGe9fLerfHyCG/MZnxk/3q+afjH8R57dJSkpB571+f+oeO9XvNTa4aU4zxz2r+m+CcpcqfNI8vxAhz+5T3Z+6/hP4wx3xVxN+te56T8QRNtO/9a/C/4b/E2/tHRJJCR35/+vX3J4K+If2pULP1x3r9MeFcUfy1meDcajjUVmfqb4X8ZCSZcvX1d4N8TB1Qhq/KvwZ4pLSKQ/XFfZvgDxFIxT5uOKKeGufI5lQ0uj9JfC2sCQLzzX0H4fvgyqSa+JvBWt71Q5r6b8NatlVCmqqYXQ+cUrM+i7SUSKBXSWMWcV53ol0ZAK9X0uMFB615tWFj0qEbmmi7YsVWmrSdSq1mz5wa4pLU74wMS7PBFYny1qXj4BrA+0L6/wCfyrP2YSZ//9L+Nz4ueI2uZ3jDZya+bDJlya63xdq73965znniuF8zuP5//Wr+18pwqoUlBHzmY4j29eUzv/DWrG2uACa+xPh34kAZAWr4EhuGRg69RXsngfxc1tMqO3TFfSUppqzPzPjDIJVI+2pbo/X/AMAaurlDu9K+4vh/qq/uznNfkr8L/G6TbFL88d6/QX4d+JQwQ7vSrhCzsfjGMouSaP028E6sMJg+lfVXhPUg4XB9K/PzwDrLylFTnpX2n4GaSRUZqurFJHydSg3KyPsPwzcb9vpXuWkzAqDXz14WBCLmvbtLl+QAV87inG9j1MJQfY72Qq0dY10ducVKs7BazrqbI5rzzuaOd1CTANcv5jf5/wD1Vq6rccE5rkftlUodjjqNXP/Z); + backdrop-filter: blur(7px); + background-size: cover; + background-attachment: fixed; +} + +.experimental-notice { + display: flex; + padding: 10px 22px; + background: #fff7cc; + font-family: 'Andale mono', Helvetica, Monospace, Arial; + font-style: normal; + font-weight: 100; + font-size: 12px; + line-height: 18px; + align-items: center; + color: #1e1e1e; +} + +body.is-embedded .fake-window-wrapper { + padding: 15px; +} + +.wrapper { + padding: 55px 60px; + height: 100%; +} + +.window { + display: flex; + flex-direction: column; + margin: 0 auto; + max-width: 1200px; + height: 100%; + border-radius: 6px; + overflow: hidden; + + animation: pulse 6s ease-in infinite; +} + +@keyframes pulse { + 0% { + box-shadow: 0 4px 44px rgba(13, 32, 117, 0.5), + 0 0 0 0 rgba(13, 32, 117, 0.5); + } + 50% { + box-shadow: 0 4px 44px rgba(13, 32, 117, 0.5), + 0 0 88px 5px rgba(13, 32, 117, 0.5); + } + 100% { + box-shadow: 0 4px 44px rgba(13, 32, 117, 0.5), + 0 0 0 0 rgba(13, 32, 117, 0.5); + } +} + +.content { + display: flex; + flex-grow: 1; + background: #ffffff; +} + +.toolbar { + position: relative; + display: flex; + flex-grow: 0; + background: #1e2327; + width: 100%; + margin: 0 auto; + padding: 0 22px; + height: 50px; + flex-direction: row; + align-items: center; +} + +.toolbar-buttons { + position: absolute; + right: 10px; +} + +.address-bar-slot { + margin-right: auto; + margin-left: auto; + min-width: 200px; + width: 60%; + opacity: 1; + transition: opacity ease-in 0.25s; +} + +.address-bar-slot.is-hidden { + opacity: 0; + pointer-events: none; +} + +/* .fake-window #wp { + position: relative; + flex-grow: 1; + border: 0; + margin: 0; + padding: 0; + z-index: 6; +} */ + +.window-controls { + position: absolute; + width: 80px; + display: flex; +} + +.window-control { + display: flex; + width: 10px; + height: 10px; + background: #f9f9f9; + border-radius: 50%; + margin: 0 12px 0 0; +} + +.window-control.is-neutral { + background: #a5afbc; +} + +.window-control.is-red { + background: #ff6057; + border: 1px solid #e14640; +} + +.window-control.is-amber { + background: #ffbd2e; + border: 1px solid #dfa123; +} + +.window-control.is-green { + background: #27c93f; + border: 1px solid #1dad2b; +} diff --git a/packages/wordpress-playground/src/components/export-button/index.tsx b/packages/wordpress-playground/src/components/export-button/index.tsx new file mode 100644 index 00000000..9c170d1a --- /dev/null +++ b/packages/wordpress-playground/src/components/export-button/index.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import css from './style.module.css' +import { PlaygroundAPI } from '../../boot-playground'; +import { exportFile } from '../../features/import-export'; + +interface ExportButtonProps { + playground?: PlaygroundAPI +} + +export default function ExportButton({ playground }: ExportButtonProps) { + return ( + + ); +} diff --git a/packages/wordpress-playground/src/components/export-button/style.module.css b/packages/wordpress-playground/src/components/export-button/style.module.css new file mode 100644 index 00000000..d8655180 --- /dev/null +++ b/packages/wordpress-playground/src/components/export-button/style.module.css @@ -0,0 +1,10 @@ +.btn { + cursor: pointer; + background: none; + border: none; +} + +.btn:hover, +.btn:active { + opacity: 0.8; +} diff --git a/packages/wordpress-playground/src/components/import-button/index.tsx b/packages/wordpress-playground/src/components/import-button/index.tsx new file mode 100644 index 00000000..7773197b --- /dev/null +++ b/packages/wordpress-playground/src/components/import-button/index.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import Modal from 'react-modal'; +import css from './style.module.css'; +import { PlaygroundAPI } from '../../boot-playground'; +import ImportForm from '../import-form'; + +interface ExportButtonProps { + playground?: PlaygroundAPI; +} +Modal.setAppElement('#root'); +export default function ImportButton({ playground }: ExportButtonProps) { + const [isOpen, setOpen] = useState(false); + const openModal = () => setOpen(true); + const closeModal = () => setOpen(false); + function handleImported() { + // eslint-disable-next-line no-alert + alert( + 'File imported! This Playground instance has been updated. Refreshing now.' + ); + closeModal(); + playground?.goTo('/'); + } + return ( + <> + + + + + + + ); +} diff --git a/packages/wordpress-playground/src/components/import-button/style.module.css b/packages/wordpress-playground/src/components/import-button/style.module.css new file mode 100644 index 00000000..d8655180 --- /dev/null +++ b/packages/wordpress-playground/src/components/import-button/style.module.css @@ -0,0 +1,10 @@ +.btn { + cursor: pointer; + background: none; + border: none; +} + +.btn:hover, +.btn:active { + opacity: 0.8; +} diff --git a/packages/wordpress-playground/src/components/import-form/index.tsx b/packages/wordpress-playground/src/components/import-form/index.tsx new file mode 100644 index 00000000..cbd4c795 --- /dev/null +++ b/packages/wordpress-playground/src/components/import-form/index.tsx @@ -0,0 +1,141 @@ +import React, { useRef } from 'react'; +import css from './style.module.css'; +import { PlaygroundAPI } from 'src/boot-playground'; +import { importFile } from '../../features/import-export'; + +interface ImportFormProps { + playground: PlaygroundAPI; + onImported: () => void; + onClose: () => void; +} + +export default function ImportForm({ + playground, + onImported, + onClose, +}: ImportFormProps) { + const form = useRef(); + const fileInputRef = useRef(); + const [file, setFile] = React.useState(null); + const [error, setError] = React.useState(''); + function handleSelectFile(e) { + setFile(e.target.files[0]); + } + function handleImportSelectFileClick(e) { + e.preventDefault(); + form.current?.reset(); + fileInputRef.current?.click(); + } + + async function handleSubmit(e) { + e.preventDefault(); + if (!file) { + return; + } + + try { + await importFile(playground, file); + } catch (error) { + setError( + 'Unable to import file. Is it a valid WordPress Playground export?' + ); + return; + } + + onImported(); + } + + return ( +
+
+ +

Import Playground

+

+ You may import a previously exported WordPress + Playground instance here. +

+

+ Known Limitations +
+

    +
  • + Styling changes may take up to one minute to + update. +
  • +
    +
  • + Migrating between different WordPress versions + is not supported. +
  • +
    +
  • + Media files, options/users, and plugin state + will not be included. +
  • +
+

+
+ + + +
+
+
+ ); +} diff --git a/packages/wordpress-playground/src/components/import-form/style.module.css b/packages/wordpress-playground/src/components/import-form/style.module.css new file mode 100644 index 00000000..8e17e312 --- /dev/null +++ b/packages/wordpress-playground/src/components/import-form/style.module.css @@ -0,0 +1,71 @@ +.screenreader-text { + display: none; +} + +.btn { + cursor: pointer; + background: none; + border: none; +} + +.btn:hover, +.btn:active { + opacity: 0.8; +} + +.btn-close { + position: absolute; + top: 5px; + right: 5px; +} + +.modal-inner { + position: relative; + padding: 20px; +} + +.modal-text, +.modal-text-list { + text-align: left; +} + +.modal-text-list { + padding-left: 15px; +} + +.inputs-container { + display: flex; + flex-direction: column; + margin-bottom: 20px; +} + +.inputs-container { + display: flex; + flex-direction: column; + margin-bottom: 20px; +} + +.inputs-container .btn { + background: #1e2327; + color: #fff; + height: 40px; + border-radius: 6px; + margin: 5px 0; +} + +.inputs-container .btn[disabled] { + opacity: 0.5; +} + +.file-input-text { + margin-top: 5px; + margin-bottom: 15px; +} + +.file-input-label .btn { + width: 100%; +} + +.error { + color: red; +} \ No newline at end of file diff --git a/packages/wordpress-playground/src/components/playground-viewport/index.tsx b/packages/wordpress-playground/src/components/playground-viewport/index.tsx new file mode 100644 index 00000000..943376ef --- /dev/null +++ b/packages/wordpress-playground/src/components/playground-viewport/index.tsx @@ -0,0 +1,92 @@ +import React, { ReactElement, Ref, useMemo } from 'react'; +import type { + ProgressObserver, + ProgressObserverEvent, +} from '@wordpress/php-wasm'; + +import css from './style.module.css'; +import BrowserChrome from '../browser-chrome'; +import ProgressBar from '../progress-bar'; +import { usePlayground, useProgressObserver } from '../../hooks'; +import type { PlaygroundAPI } from '../../boot-playground'; + +interface PlaygroundViewportProps { + isSeamless?: boolean; + setupPlayground: ( + playground: PlaygroundAPI, + observer: ProgressObserver + ) => Promise; + toolbarButtons?: React.ReactElement[]; +} + +export default function PlaygroundViewport({ + isSeamless, + setupPlayground, + toolbarButtons, +}: PlaygroundViewportProps) { + const { observer, progress } = useProgressObserver(); + const { playground, url, iframeRef } = usePlayground(async function (api) { + await setupPlayground(api, observer); + }); + + if (isSeamless) { + return ( + + ); + } + + const updatedToolbarButtons = useMemo(() => { + if (!playground || !toolbarButtons?.length) { + return; + } + return toolbarButtons.map((button, index) => + React.cloneElement(button as React.ReactElement, { + key: index, + playground, + }) + ) as ReactElement[]; + }, [playground, toolbarButtons]); + + return ( + playground?.goTo(url)} + > + + + ); +} + +interface LoadedViewportProps { + iframeRef: Ref; + loadingProgress: ProgressObserverEvent; + ready: boolean; +} + +const LoadedViewport = function LoadedViewportComponent({ + iframeRef, + loadingProgress, + ready, +}: LoadedViewportProps) { + return ( +
+ + +
+ ); +}; diff --git a/packages/wordpress-playground/src/components/playground-viewport/style.module.css b/packages/wordpress-playground/src/components/playground-viewport/style.module.css new file mode 100644 index 00000000..6d7f355a --- /dev/null +++ b/packages/wordpress-playground/src/components/playground-viewport/style.module.css @@ -0,0 +1,8 @@ +.full-size { + position: relative; + width: 100%; + height: 100%; + border: 0; + margin: 0; + padding: 0; +} \ No newline at end of file diff --git a/packages/wordpress-playground/src/components/plugin-ide/setup.ts b/packages/wordpress-playground/src/components/plugin-ide/setup.ts new file mode 100644 index 00000000..8ecaf004 --- /dev/null +++ b/packages/wordpress-playground/src/components/plugin-ide/setup.ts @@ -0,0 +1,31 @@ +// if (query.has('ide')) { +// let doneFirstBoot = false; +// const { WordPressPluginIDE, createBlockPluginFixture } = await import( +// '@wordpress/plugin-ide' +// ); +// const { default: React } = await import('react'); +// const { +// default: { render }, +// } = await import('react-dom'); +// render( +// { +// if (doneFirstBoot) { +// (wpFrame.contentWindow as any).eval(bundleContents); +// } else { +// doneFirstBoot = true; +// await playground.goTo(query.get('url') || '/'); +// } +// }} +// />, +// document.getElementById('test-snippets')! +// ); +// } else { +// await playground.goTo(query.get('url') || '/'); +// } diff --git a/packages/wordpress-playground/src/components/progress-bar/index.tsx b/packages/wordpress-playground/src/components/progress-bar/index.tsx new file mode 100644 index 00000000..67bc047a --- /dev/null +++ b/packages/wordpress-playground/src/components/progress-bar/index.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames'; +import React from 'react'; +import css from './style.module.css' +import type { ProgressMode } from '@wordpress/php-wasm'; + +interface ProgressBarProps { + caption: string; + percentFull: number; + mode: ProgressMode; + isIndefinite?: boolean; + visible?: boolean; +} + +const ProgressBar: React.FC = ({ + caption, + percentFull, + mode, + isIndefinite, + visible, +}: ProgressBarProps) => { + const classes = classNames([css.overlay], { + [css.isHidden]: !visible, + }); + return ( +
+

{caption}

+ {isIndefinite ? ( + + ) : ( + + )} +
+ ); +}; + +const Progress = ({ mode, percentFull }) => { + const classes = classNames([css.progressBar, css.isDefinite], { + [css.slowlyIncrementing]: mode === 'slowly-increment', + }); + return ( +
+
+
+ ); +}; + +const ProgressIndefinite = () => { + return ( +
+
+
+ ); +}; + +export default ProgressBar; diff --git a/packages/wordpress-playground/src/components/progress-bar/style.module.css b/packages/wordpress-playground/src/components/progress-bar/style.module.css new file mode 100644 index 00000000..bd9e7263 --- /dev/null +++ b/packages/wordpress-playground/src/components/progress-bar/style.module.css @@ -0,0 +1,89 @@ +.overlay { + position: absolute; + top: 0; + left: 0; + background: #FFF; + z-index: 5; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + opacity: 1; + transition: opacity ease-in 0.25s; +} + +.overlay.is-hidden { + opacity: 0; + pointer-events: none; +} + +.wrapper { + position: relative; + width: 512px; + max-width: 60vw; + height: 4px; + margin: 4px auto; + border-radius: 10px; + background: #e0e0e0; +} + +.wrapper-definite .progress-bar.is-indefinite, +.wrapper-indefinite .progress-bar.is-definite { + opacity: 0; +} + +.wrapper-indefinite .progress-bar.is-indefinite, +.wrapper-definite .progress-bar.is-definite { + opacity: 1; +} + +.progress-bar { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 100%; + width: 0; + background: #3858e9; + border-radius: 2px; + transition: opacity linear 0.25s, width ease-in 0.5s; +} + +.progress-bar.slowly-incrementing { + transition: opacity linear 0.25s, width ease-in 4s; +} + +.progress-bar.is-indefinite { + animation: indefinite-loading 2s linear infinite; +} + +@keyframes indefinite-loading { + 0% { + left: 0%; + right: 100%; + width: 0%; + } + 10% { + left: 0%; + right: 75%; + width: 25%; + } + 90% { + right: 0%; + left: 75%; + width: 25%; + } + 100% { + left: 100%; + right: 0%; + width: 0%; + } +} + +.caption { + font-weight: 400; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 1.1rem; +} diff --git a/packages/wordpress-playground/src/examples.html b/packages/wordpress-playground/src/examples.html new file mode 100644 index 00000000..45d63e1e --- /dev/null +++ b/packages/wordpress-playground/src/examples.html @@ -0,0 +1,40 @@ + + + + WordPress Playground + + + + +
+ + + \ No newline at end of file diff --git a/packages/wordpress-playground/src/examples.tsx b/packages/wordpress-playground/src/examples.tsx new file mode 100644 index 00000000..927f8a9d --- /dev/null +++ b/packages/wordpress-playground/src/examples.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import { ProgressObserver } from '@wordpress/php-wasm'; + +import { toZipName } from './features/common'; +import { login } from './features/login'; +import { installThemeFromDirectory } from './features/install-theme-from-directory'; +import { installPluginsFromDirectory } from './features/install-plugins-from-directory'; +import { PlaygroundAPI } from './boot-playground'; +import PlaygroundViewport from './components/playground-viewport'; +import ExportButton from './components/export-button'; +import ImportButton from './components/import-button'; + +const query = new URL(document.location.href).searchParams; +const isSeamless = (query.get('mode') || 'browser') === 'seamless'; + +const root = createRoot(document.getElementById('root')!); +root.render( + , + + ]} + /> +); + +export async function setupPlayground( + playground: PlaygroundAPI, + progressObserver?: ProgressObserver +) { + const config = { + initialUrl: query.get('url'), + login: query.has('login'), + pluginEditor: query.has('ide'), + plugins: query.getAll('plugin').map(toZipName), + theme: toZipName(query.get('theme')), + importExport: !query.has('disableImportExport'), + }; + + const progressBudgets = { + login: 0, + plugins: Math.min(config.plugins.length * 15, 45), + theme: config.theme ? 20 : 0, + }; + + const totalFeaturesProgressBudgets = sumValues(progressBudgets); + const bootProgressBudget = 100 - totalFeaturesProgressBudgets; + + if (progressObserver) { + await playground.onDownloadProgress( + progressObserver.partialObserver( + bootProgressBudget, + 'Preparing WordPress...' + ) + ); + } + + await playground.isReady(); + + const needsLogin = + config.login || + config.importExport || + config.plugins?.length || + config.theme; + + if (needsLogin) { + await login(playground, 'admin', 'password'); + } + + if (config.theme) { + await installThemeFromDirectory( + playground, + config.theme, + progressBudgets.theme, + progressObserver + ); + } + + if (config.plugins?.length) { + await installPluginsFromDirectory( + playground, + config.plugins, + progressBudgets.plugins, + progressObserver + ); + } +} + +function sumValues(obj: Record) { + return Object.values(obj).reduce((a, b) => a + b, 0); +} diff --git a/packages/wordpress-playground/src/features/common.ts b/packages/wordpress-playground/src/features/common.ts new file mode 100644 index 00000000..3e1033f4 --- /dev/null +++ b/packages/wordpress-playground/src/features/common.ts @@ -0,0 +1,30 @@ +import { PHPResponse } from "@wordpress/php-wasm"; + +export function toZipName(rawInput) { + if (!rawInput) { + return rawInput; + } + if (rawInput.endsWith('.zip')) { + return rawInput; + } + return rawInput + '.latest-stable.zip'; +} + +export function zipNameToHumanName(zipName) { + const mixedCaseName = zipName.split('.').shift()!.replace('-', ' '); + return ( + mixedCaseName.charAt(0).toUpperCase() + + mixedCaseName.slice(1).toLowerCase() + ); +} + +export function asDOM(response: PHPResponse) { + return new DOMParser().parseFromString( + asText(response), + 'text/html' + )!; +} + +export function asText(response: PHPResponse) { + return new TextDecoder().decode(response.body); +} diff --git a/packages/wordpress-playground/src/features/import-export.ts b/packages/wordpress-playground/src/features/import-export.ts new file mode 100644 index 00000000..704a43e5 --- /dev/null +++ b/packages/wordpress-playground/src/features/import-export.ts @@ -0,0 +1,140 @@ +import { saveAs } from 'file-saver'; + +import { DOCROOT } from '../config'; +import type { PlaygroundAPI } from '../boot-playground'; + +// @ts-ignore +import migration from './migration.php?raw'; + +const databaseExportName = 'databaseExport.xml'; +const databaseExportPath = '/' + databaseExportName; + +export async function exportFile(playground: PlaygroundAPI) { + const databaseExportResponse = await playground.request({ + relativeUrl: '/wp-admin/export.php?download=true&&content=all' + }); + const databaseExportContent = new TextDecoder().decode( + databaseExportResponse.body + ); + await playground.writeFile(databaseExportPath, databaseExportContent); + const wpVersion = await playground.wordPressVersion; + const phpVersion = await playground.phpVersion; + const exportName = `wordpress-playground--wp${wpVersion}--php${phpVersion}.zip`; + const exportPath = `/${exportName}`; + const exportWriteRequest = await playground.run({ + code: + migration + + ` generateZipFile('${exportPath}', '${databaseExportPath}', '${DOCROOT}');`, + }); + if (exportWriteRequest.exitCode !== 0) { + throw exportWriteRequest.errors; + } + + const fileBuffer = await playground.readFileAsBuffer(exportName); + const file = new File([fileBuffer], exportName); + saveAs(file); +} + +export async function importFile(playground: PlaygroundAPI, file: File) { + if ( + // eslint-disable-next-line no-alert + !confirm( + 'Are you sure you want to import this file? Previous data will be lost.' + ) + ) { + return false; + } + + // Write uploaded file to filesystem for processing with PHP + const fileArrayBuffer = await file.arrayBuffer(); + const fileContent = new Uint8Array(fileArrayBuffer); + const importPath = '/import.zip'; + + await playground.writeFile(importPath, fileContent); + + // Import the database + const databaseFromZipFileReadRequest = await playground.run({ + code: + migration + + ` readFileFromZipArchive('${importPath}', '${databaseExportPath}');`, + }); + if (databaseFromZipFileReadRequest.exitCode !== 0) { + throw databaseFromZipFileReadRequest.errors; + } + + const databaseFromZipFileContent = new TextDecoder().decode( + databaseFromZipFileReadRequest.body + ); + + const databaseFile = new File( + [databaseFromZipFileContent], + databaseExportName + ); + + const importerPageOneResponse = await playground.request({ + relativeUrl: '/wp-admin/admin.php?import=wordpress', + }); + + const importerPageOneContent = new DOMParser().parseFromString( + new TextDecoder().decode(importerPageOneResponse.body), + 'text/html' + ); + + const firstUrlAction = importerPageOneContent + .getElementById('import-upload-form') + ?.getAttribute('action'); + + const stepOneResponse = await playground.request({ + relativeUrl: `/wp-admin/${firstUrlAction}`, + method: 'POST', + files: { import: databaseFile }, + }); + + const importerPageTwoContent = new DOMParser().parseFromString( + new TextDecoder().decode(stepOneResponse.body), + 'text/html' + ); + + const importerPageTwoForm = importerPageTwoContent.querySelector( + '#wpbody-content form' + ); + const secondUrlAction = importerPageTwoForm?.getAttribute('action') as string; + + const nonce = ( + importerPageTwoForm?.querySelector( + "input[name='_wpnonce']" + ) as HTMLInputElement + ).value; + + const referrer = ( + importerPageTwoForm?.querySelector( + "input[name='_wp_http_referer']" + ) as HTMLInputElement + ).value; + + const importId = ( + importerPageTwoForm?.querySelector( + "input[name='import_id']" + ) as HTMLInputElement + ).value; + + await playground.request({ + relativeUrl: secondUrlAction, + method: 'POST', + formData: { + _wpnonce: nonce, + _wp_http_referer: referrer, + import_id: importId, + } + }); + + // Import the file system + const importFileSystemRequest = await playground.run({ + code: migration + ` importZipFile('${importPath}');`, + }); + if (importFileSystemRequest.exitCode !== 0) { + throw importFileSystemRequest.errors; + } + + return true; +} diff --git a/packages/wordpress-playground/src/features/install-plugin.ts b/packages/wordpress-playground/src/features/install-plugin.ts new file mode 100644 index 00000000..35dbf179 --- /dev/null +++ b/packages/wordpress-playground/src/features/install-plugin.ts @@ -0,0 +1,101 @@ +import type { PlaygroundAPI } from "../boot-playground"; +import { asDOM } from "./common"; + +export async function installPlugin( + playground: PlaygroundAPI, + pluginZipFile: File, + options: any = {} +) { + const activate = 'activate' in options ? options.activate : true; + + // Upload it to WordPress + const pluginForm = await playground.request({ + relativeUrl: '/wp-admin/plugin-install.php?tab=upload', + }); + const pluginFormPage = asDOM(pluginForm); + const pluginFormData = new FormData( + pluginFormPage.querySelector('.wp-upload-form')! as HTMLFormElement + ) as any; + const { pluginzip, ...postData } = Object.fromEntries( + pluginFormData.entries() + ); + + const pluginInstalledResponse = await playground.request({ + relativeUrl: '/wp-admin/update.php?action=upload-plugin', + method: 'POST', + formData: postData, + files: { pluginzip: pluginZipFile }, + }); + + // Activate if needed + if (activate) { + const pluginInstalledPage = asDOM(pluginInstalledResponse); + const activateButtonHref = pluginInstalledPage + .querySelector('#wpbody-content .button.button-primary')! + .attributes.getNamedItem('href')!.value; + const activatePluginUrl = new URL( + activateButtonHref, + await playground.pathToInternalUrl('/wp-admin/') + ).toString(); + await playground.request({ + absoluteUrl: activatePluginUrl, + }); + } + + /** + * Pair the site editor's nested iframe to the Service Worker. + * + * Without the patch below, the site editor initiates network requests that + * aren't routed through the service worker. That's a known browser issue: + * + * * https://bugs.chromium.org/p/chromium/issues/detail?id=880768 + * * https://bugzilla.mozilla.org/show_bug.cgi?id=1293277 + * * https://github.com/w3c/ServiceWorker/issues/765 + * + * The problem with iframes using srcDoc and src="about:blank" as they + * fail to inherit the root site's service worker. + * + * Gutenberg loads the site editor using + + + diff --git a/packages/wordpress-playground/src/worker-utils.ts b/packages/wordpress-playground/src/is-uploaded-file-path.ts similarity index 100% rename from packages/wordpress-playground/src/worker-utils.ts rename to packages/wordpress-playground/src/is-uploaded-file-path.ts diff --git a/packages/wordpress-playground/src/playground.html b/packages/wordpress-playground/src/playground.html deleted file mode 100644 index bfc16097..00000000 --- a/packages/wordpress-playground/src/playground.html +++ /dev/null @@ -1,146 +0,0 @@ - - - - WordPress Playground - - - - -
- -
-
- - - - - - - - diff --git a/packages/wordpress-playground/src/promise-queue.ts b/packages/wordpress-playground/src/promise-queue.ts deleted file mode 100644 index 4a5a9acc..00000000 --- a/packages/wordpress-playground/src/promise-queue.ts +++ /dev/null @@ -1,37 +0,0 @@ -export class PromiseQueue extends EventTarget { - #queue: Array<() => Promise> = []; - #running = false; - #_resolved = 0; - - get resolved() { - return this.#_resolved; - } - - async enqueue(fn: () => Promise) { - this.#queue.push(fn); - this.#run(); - } - - async #run() { - if (this.#running) { - return; - } - try { - this.#running = true; - while (this.#queue.length) { - const next = this.#queue.shift(); - if (!next) { - break; - } - const result = await next(); - ++this.#_resolved; - this.dispatchEvent( - new CustomEvent('resolved', { detail: result }) - ); - } - } finally { - this.#running = false; - this.dispatchEvent(new CustomEvent('empty')); - } - } -} diff --git a/packages/wordpress-playground/src/service-worker.ts b/packages/wordpress-playground/src/service-worker.ts index 57a1410c..4454cf5f 100644 --- a/packages/wordpress-playground/src/service-worker.ts +++ b/packages/wordpress-playground/src/service-worker.ts @@ -1,17 +1,19 @@ -declare const self: any; +/// + +declare const self: ServiceWorkerGlobalScope; import { awaitReply, getURLScope, removeURLScope } from '@wordpress/php-wasm'; import { + convertFetchEventToPHPRequest, initializeServiceWorker, seemsLikeAPHPServerPath, - PHPRequest, cloneRequest, broadcastMessageExpectReply, } from '@wordpress/php-wasm/web/service-worker'; -import { isUploadedFilePath } from './worker-utils'; +import { isUploadedFilePath } from './is-uploaded-file-path'; -if (!self.document) { - // Workaround: vide translates import.meta.url +if (!(self as any).document) { + // Workaround: vite translates import.meta.url // to document.currentScript which fails inside of // a service worker because document is undefined // @ts-ignore @@ -24,7 +26,7 @@ initializeServiceWorker({ // Always use a random version in development to avoid caching issues. // In production, use the service worker path as the version – it will always // contain the latest hash of the service worker script. - version: import.meta.env.DEV ? (() => Math.random()) : new URL(self.location).pathname, + version: import.meta.env.DEV ? (() => Math.random()+'') : self.location.pathname, handleRequest(event) { const fullUrl = new URL(event.request.url); let scope = getURLScope(fullUrl); @@ -49,7 +51,7 @@ initializeServiceWorker({ `/wp-content/themes/${defaultTheme}` ) ) { - return await PHPRequest(event); + return await convertFetchEventToPHPRequest(event); } const request = await rewriteRequest( event.request, @@ -90,7 +92,7 @@ async function getScopedWpDetails(scope: string): Promise { if (!scopeToWpModule[scope]) { const requestId = await broadcastMessageExpectReply( { - type: 'getWordPressModuleDetails', + method: 'getWordPressModuleDetails', }, scope ); diff --git a/packages/wordpress-playground/src/wordpress.html b/packages/wordpress-playground/src/wordpress.html deleted file mode 100644 index 7353e23a..00000000 --- a/packages/wordpress-playground/src/wordpress.html +++ /dev/null @@ -1,550 +0,0 @@ - - - - WordPress Playground - - - - - -
- -
-
- - - - - - - - diff --git a/packages/wordpress-playground/src/worker-thread.ts b/packages/wordpress-playground/src/worker-thread.ts index 576e92d1..1c288f89 100644 --- a/packages/wordpress-playground/src/worker-thread.ts +++ b/packages/wordpress-playground/src/worker-thread.ts @@ -1,176 +1,102 @@ -import type { PHP } from '@wordpress/php-wasm'; -import { PHPServer, PHPBrowser, getPHPLoaderModule } from '@wordpress/php-wasm'; +/// +/// + +declare const self: Window | WorkerGlobalScope; +declare const window: Window | undefined; + import { - initializeWorkerThread, - loadPHPWithProgress, - currentBackend, + loadPHPRuntime, + PHP, + PHPServer, + PHPBrowser, + PHPPublicAPI, setURLScope, -} from '@wordpress/php-wasm/web/worker-thread'; + exposeAPI, + getPHPLoaderModule, + parseWorkerStartupOptions, + EmscriptenDownloadMonitor, +} from '@wordpress/php-wasm'; import { DOCROOT, wordPressSiteUrl } from './config'; -import { isUploadedFilePath } from './worker-utils'; -import { getWordPressModule } from './wp-modules-urls'; +import { isUploadedFilePath } from './is-uploaded-file-path'; +import patchWordPress from './wp-patch'; + +const php = new PHP(); const scope = Math.random().toFixed(16); const scopedSiteUrl = setURLScope(wordPressSiteUrl, scope).toString(); +const server = new PHPServer(php, { + documentRoot: DOCROOT, + absoluteUrl: scopedSiteUrl, + isStaticFilePath: isUploadedFilePath, +}); -startWordPress().then(({ browser, wpLoaderModule, staticAssetsDirectory }) => - initializeWorkerThread({ - phpBrowser: browser, - middleware: (message, next) => { - if (message.type === 'getWordPressModuleDetails') { - return { - staticAssetsDirectory, - defaultTheme: wpLoaderModule.defaultThemeName, - }; - } - return next(message); - }, - }) -); - -async function startWordPress() { - // Expect underscore, not a dot. Vite doesn't deal well with the dot in the - // parameters names passed to the worker via a query string. - const requestedWPVersion = (currentBackend.getOptions().dataModule || '6_1').replace('_','.'); - const requestedPHPVersion = (currentBackend.getOptions().phpVersion || '8_0').replace('_','.'); +const browser = new PHPBrowser(server); +const monitor = new EmscriptenDownloadMonitor(); - const [phpLoaderModule, wpLoaderModule] = await Promise.all([ - /** - * Vite is extremely stubborn and refuses to load the PHP loader modules - * when the import path is static. It fails with this error: - * - * [vite:worker] Invalid value "iife" for option "output.format" - UMD and IIFE output formats are not supported for code-splitting builds. - * - * It only works with a dynamic import, but then Vite complains that it - * can't find the module. So we have to use @vite-ignore to suppress the - * error. - */ - getPHPLoaderModule(requestedPHPVersion), - getWordPressModule(requestedWPVersion), - ]); +class InternalWorkerAPIClass extends PHPPublicAPI { + scope: string; + wordPressVersion: string; + phpVersion: string; - const php = await loadPHPWithProgress(phpLoaderModule, [wpLoaderModule]); - - new WordPressPatcher(php).patch(); - php.writeFile('/wordpress/phpinfo.php', ' - `${contents} define('WP_HOME', '${JSON.stringify(DOCROOT)}');` - ); +const [setApiReady, publicApi] = exposeAPI( + new InternalWorkerAPIClass(browser, monitor, scope, wpVersion, phpVersion) +); - // Force the site URL to be $scopedSiteUrl: - // Interestingly, it doesn't work when put in a mu-plugin. - this.#patchFile( - `${DOCROOT}/wp-includes/plugin.php`, - (contents) => - contents + - ` - function _wasm_wp_force_site_url() { - return ${JSON.stringify(scopedSiteUrl)}; - } - add_filter( "option_home", '_wasm_wp_force_site_url', 10000 ); - add_filter( "option_siteurl", '_wasm_wp_force_site_url', 10000 ); - ` - ); - } - #disableSiteHealth() { - this.#patchFile( - `${DOCROOT}/wp-includes/default-filters.php`, - (contents) => - contents.replace( - /add_filter[^;]+wp_maybe_grant_site_health_caps[^;]+;/i, - '' - ) - ); - } - #disableWpNewBlogNotification() { - this.#patchFile( - `${DOCROOT}/wp-config.php`, - // The original version of this function crashes WASM WordPress, let's define an empty one instead. - (contents) => - `${contents} function wp_new_blog_notification(...$args){} ` - ); - } - #replaceRequestsTransports() { - this.#patchFile( - `${DOCROOT}/wp-config.php`, - (contents) => `${contents} define('USE_FETCH_FOR_REQUESTS', false);` - ); +export type InternalWorkerAPI = typeof publicApi; - // Force the fsockopen and cUrl transports to report they don't work: - const transports = [ - `${DOCROOT}/wp-includes/Requests/Transport/fsockopen.php`, - `${DOCROOT}/wp-includes/Requests/Transport/cURL.php`, - ]; - for (const transport of transports) { - // One of the transports might not exist in the latest WordPress version. - if (!this.#php.fileExists(transport)) continue; - this.#patchFile(transport, (contents) => - contents.replace( - 'public static function test', - 'public static function test( $capabilities = array() ) { return false; } public static function test2' - ) - ); - } +// Load PHP and WordPress modules: - // Add fetch and dummy transports for HTTP requests - this.#php.mkdirTree(`${DOCROOT}/wp-content/mu-plugins/includes`); - this.#php.writeFile( - `${DOCROOT}/wp-content/mu-plugins/includes/requests_transport_fetch.php`, - transportFetch - ); - this.#php.writeFile( - `${DOCROOT}/wp-content/mu-plugins/includes/requests_transport_dummy.php`, - transportDummy - ); - this.#php.writeFile( - `${DOCROOT}/wp-content/mu-plugins/add_requests_transport.php`, - addRequests - ); +const [phpLoaderModule, wpLoaderModule] = await Promise.all([ + getPHPLoaderModule(phpVersion), + getWordPressModule(wpVersion), +]); +monitor.setModules([phpLoaderModule, wpLoaderModule]); +php.initializeRuntime( + await loadPHPRuntime(phpLoaderModule, monitor.getEmscriptenArgs(), [ + wpLoaderModule, + ]) +); +patchWordPress(php, scopedSiteUrl); - // Various tweaks - this.#php.writeFile( - `${DOCROOT}/wp-content/mu-plugins/1-show-admin-credentials-on-wp-login.php`, - showAdminCredentialsOnWpLogin - ); - } - #patchFile(path, callback) { - this.#php.writeFile(path, callback(this.#php.readFileAsText(path))); +setApiReady(); + +export function getWordPressModule(version) { + switch (version) { + case '5.9': + return import('./wordpress/wp-5.9.js'); + case '6.0': + return import('./wordpress/wp-6.0.js'); + case '6.1': + return import('./wordpress/wp-6.1.js'); + case 'nightly': + return import('./wordpress/wp-nightly.js'); } + throw new Error(`Unsupported WordPress module: ${version}`); } diff --git a/packages/wordpress-playground/src/wp-client.ts b/packages/wordpress-playground/src/wp-client.ts new file mode 100644 index 00000000..badff069 --- /dev/null +++ b/packages/wordpress-playground/src/wp-client.ts @@ -0,0 +1,10 @@ +import type { PlaygroundAPI } from './boot-playground'; +import { consumeAPI } from '@wordpress/php-wasm' + +export async function connectToPlayground(iframe: HTMLIFrameElement, url: string) { + iframe.src = url; + await new Promise((resolve) => { + iframe.addEventListener('load', resolve, false); + }); + return consumeAPI(iframe.contentWindow!) as PlaygroundAPI; +} diff --git a/packages/wordpress-playground/src/wp-macros.ts b/packages/wordpress-playground/src/wp-macros.ts deleted file mode 100644 index 5c1b93af..00000000 --- a/packages/wordpress-playground/src/wp-macros.ts +++ /dev/null @@ -1,212 +0,0 @@ -import type { SpawnedWorkerThread } from '@wordpress/php-wasm'; - -export async function login( - workerThread: SpawnedWorkerThread, - user = 'admin', - password = 'password' -) { - await workerThread.HTTPRequest({ - absoluteUrl: workerThread.pathToInternalUrl('/wp-login.php'), - }); - - await workerThread.HTTPRequest({ - absoluteUrl: workerThread.pathToInternalUrl('/wp-login.php'), - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - log: user, - pwd: password, - rememberme: 'forever', - }).toString(), - }); -} - -export async function installPlugin( - workerThread: SpawnedWorkerThread, - pluginZipFile: File, - options: any = {} -) { - const activate = 'activate' in options ? options.activate : true; - - // Upload it to WordPress - const pluginForm = await workerThread.HTTPRequest({ - absoluteUrl: workerThread.pathToInternalUrl( - '/wp-admin/plugin-install.php?tab=upload' - ), - }); - const pluginFormPage = new DOMParser().parseFromString( - pluginForm.text, - 'text/html' - ); - const pluginFormData = new FormData( - pluginFormPage.querySelector('.wp-upload-form')! as HTMLFormElement - ) as any; - const { pluginzip, ...postData } = Object.fromEntries( - pluginFormData.entries() - ); - - const pluginInstalledResponse = await workerThread.HTTPRequest({ - absoluteUrl: workerThread.pathToInternalUrl( - '/wp-admin/update.php?action=upload-plugin' - ), - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams(postData).toString(), - files: { pluginzip: pluginZipFile }, - }); - - // Activate if needed - if (activate) { - const pluginInstalledPage = new DOMParser().parseFromString( - pluginInstalledResponse.text, - 'text/html' - )!; - const activateButtonHref = pluginInstalledPage - .querySelector('#wpbody-content .button.button-primary')! - .attributes.getNamedItem('href')!.value; - const activatePluginUrl = new URL( - activateButtonHref, - workerThread.pathToInternalUrl('/wp-admin/') - ).toString(); - await workerThread.HTTPRequest({ - absoluteUrl: activatePluginUrl, - }); - } - - /** - * Pair the site editor's nested iframe to the Service Worker. - * - * Without the patch below, the site editor initiates network requests that - * aren't routed through the service worker. That's a known browser issue: - * - * * https://bugs.chromium.org/p/chromium/issues/detail?id=880768 - * * https://bugzilla.mozilla.org/show_bug.cgi?id=1293277 - * * https://github.com/w3c/ServiceWorker/issues/765 - * - * The problem with iframes using srcDoc and src="about:blank" as they - * fail to inherit the root site's service worker. - * - * Gutenberg loads the site editor using