diff --git a/packages/playground/common/src/index.ts b/packages/playground/common/src/index.ts index 8dcd02bc66..ad95b489e9 100644 --- a/packages/playground/common/src/index.ts +++ b/packages/playground/common/src/index.ts @@ -21,7 +21,8 @@ const tmpPath = '/tmp/file.zip'; export const unzipFile = async ( php: UniversalPHP, zipPath: string | File, - extractToPath: string + extractToPath: string, + overwriteFiles = true ) => { if (zipPath instanceof File) { const zipFile = zipPath; @@ -34,10 +35,11 @@ export const unzipFile = async ( const js = phpVars({ zipPath, extractToPath, + overwriteFiles, }); await php.run({ code: `open($zipPath); if ($res === TRUE) { - $zip->extractTo($extractTo); - $zip->close(); - chmod($extractTo, 0777); + for ($i = 0; $i < $zip->numFiles; $i++) { + $filename = $zip->getNameIndex($i); + $fileinfo = pathinfo($filename); + $extractFilePath = rtrim($extractTo, '/') . '/' . $filename; + // Check if file exists and $overwriteFiles is false + if (!file_exists($extractFilePath) || $overwriteFiles) { + // Extract file + $zip->extractTo($extractTo, $filename); + } + } + $zip->close(); + chmod($extractTo, 0777); } else { throw new Exception("Could not unzip file"); } } - unzip(${js.zipPath}, ${js.extractToPath}); + unzip(${js.zipPath}, ${js.extractToPath}, ${js.overwriteFiles}); `, }); if (await php.fileExists(tmpPath)) { diff --git a/packages/playground/remote/src/lib/boot-playground-remote.ts b/packages/playground/remote/src/lib/boot-playground-remote.ts index 7925957644..db3a82cc52 100644 --- a/packages/playground/remote/src/lib/boot-playground-remote.ts +++ b/packages/playground/remote/src/lib/boot-playground-remote.ts @@ -205,6 +205,13 @@ export async function bootPlaygroundRemote() { ) { return await phpApi.bindOpfs(options, onProgress); }, + + /** + * Download WordPress assets. + */ + async backfillStaticFilesRemovedFromMinifiedBuild() { + await webApi.backfillStaticFilesRemovedFromMinifiedBuild(); + }, }; await phpApi.isConnected(); @@ -236,6 +243,33 @@ export async function bootPlaygroundRemote() { throw e; } + /** + * When WordPress is loaded from a minified bundle, some assets are removed to reduce the bundle size. + * This function backfills the missing assets. If WordPress is loaded from a non-minified bundle, + * we don't need to backfill because the assets are already included. + * + * If the browser is online we download the WordPress assets asynchronously to speed up the boot process. + * Missing assets will be fetched on demand from the Playground server until they are downloaded. + * + * If the browser is offline, we await the backfill or WordPress assets + * from cache to ensure Playground is fully functional before boot finishes. + */ + if (window.navigator.onLine) { + wpFrame.addEventListener('load', () => { + webApi.backfillStaticFilesRemovedFromMinifiedBuild(); + }); + } else { + // Note this will run even if the static files are already in place, e.g. when running + // a non-minified build or an offline site. It doesn't seem like a big problem worth introducing + // a new API method like `webApi.needsBackfillingStaticFilesRemovedFromMinifiedBuild(). + webApi.setProgress({ + caption: 'Downloading WordPress assets', + isIndefinite: false, + visible: true, + }); + await webApi.backfillStaticFilesRemovedFromMinifiedBuild(); + } + /* * An assertion to make sure Playground Client is compatible * with Remote @@ -299,7 +333,8 @@ function assertNotInfiniteLoadingLoop() { } 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' + `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/playground/remote/src/lib/playground-client.ts b/packages/playground/remote/src/lib/playground-client.ts index eeb23f1bf1..dede3f2eca 100644 --- a/packages/playground/remote/src/lib/playground-client.ts +++ b/packages/playground/remote/src/lib/playground-client.ts @@ -52,6 +52,7 @@ export interface WebClientMixin extends ProgressReceiver { replayFSJournal: PlaygroundWorkerEndpoint['replayFSJournal']; addEventListener: PlaygroundWorkerEndpoint['addEventListener']; removeEventListener: PlaygroundWorkerEndpoint['removeEventListener']; + backfillStaticFilesRemovedFromMinifiedBuild: PlaygroundWorkerEndpoint['backfillStaticFilesRemovedFromMinifiedBuild']; /** @inheritDoc @php-wasm/universal!UniversalPHP.onMessage */ onMessage: PlaygroundWorkerEndpoint['onMessage']; diff --git a/packages/playground/remote/src/lib/worker-thread.ts b/packages/playground/remote/src/lib/worker-thread.ts index e8b30d2f15..885b2c229d 100644 --- a/packages/playground/remote/src/lib/worker-thread.ts +++ b/packages/playground/remote/src/lib/worker-thread.ts @@ -37,13 +37,14 @@ import transportFetch from './playground-mu-plugin/playground-includes/wp_http_f import transportDummy from './playground-mu-plugin/playground-includes/wp_http_dummy.php?raw'; /** @ts-ignore */ import playgroundWebMuPlugin from './playground-mu-plugin/0-playground.php?raw'; -import { PHPWorker } from '@php-wasm/universal'; +import { PHP, PHPWorker } from '@php-wasm/universal'; import { bootWordPress, getLoadedWordPressVersion, } from '@wp-playground/wordpress'; import { wpVersionToStaticAssetsDirectory } from '@wp-playground/wordpress-builds'; import { logger } from '@php-wasm/logger'; +import { unzipFile } from '@wp-playground/common'; /** * Startup options are received from spawnPHPWorkerThread using a message event. @@ -194,6 +195,113 @@ export class PlaygroundWorkerEndpoint extends PHPWorker { async replayFSJournal(events: FilesystemOperation[]) { return replayFSJournal(this.__internal_getPHP()!, events); } + + async backfillStaticFilesRemovedFromMinifiedBuild() { + await backfillStaticFilesRemovedFromMinifiedBuild( + this.__internal_getPHP()! + ); + } +} + +/** + * Downloads and unzips a ZIP bundle of all the static assets removed from + * the currently loaded minified WordPress build. Doesn't do anything if the + * assets are already downloaded or if a non-minified WordPress build is loaded. + * + * ## Asset Loading + * + * To load Playground faster, we default to minified WordPress builds shipped + * without most CSS files, JS files, and other static assets. + * + * When Playground requests a static asset that is not in the minified build, the service + * worker consults the list of the assets removed during the minification process. Such + * a list is shipped with every minified build in a file called `wordpress-remote-asset-paths`. + * + * For example, when `/wp-includes/css/dist/block-library/common.min.css` isn't found + * in the Playground filesystem, the service worker looks for it in `/wordpress/wordpress-remote-asset-paths` + * and finds it there. This means it's available on the remote server, so the service + * worker fetches it from an URL like: + * + * https://playground.wordpress.net/wp-6.5/wp-includes/css/dist/block-library/common.min.css + * + * ## Assets backfilling + * + * Running Playground offline isn't possible without shipping all the static assets into the browser. + * Downloading every CSS and JS file one request at a time would be slow to run and tedious to maintain. + * This is where this function comes in! + * + * It downloads a zip archive containing all the static files removed from the currently running + * minified build, and unzips them in the Playground filesystem. Once it finishes, the WordPress + * installation running in the browser is complete and the service worker will no longer have + * to backfill any static assets again. + * + * This process is started after the Playground boots (see `bootPlaygroundRemote`) and the first + * page is rendered. This way we're not delaying the initial Playground paint with a large download. + * + * ## Prevent backfilling if assets are already available + * + * Running this function twice, or running it on a non-minified build will have no effect. + * + * The backfilling only runs when a non-empty `wordpress-remote-asset-paths` file + * exists. When one is missing, we're not running a minified build. When one is empty, + * it means the backfilling process was already done – this function empties the file + * after the backfilling is done. + * + * ### Downloading assets during backfill + * + * Each WordPress release has a corresponding static assets directory on the Playground.WordPress.net server. + * The file is downloaded from the server and unzipped into the WordPress document root. + * + * ### Skipping existing files during unzipping + * + * If any of the files already exist, they are skipped and not overwritten. + * By skipping existing files, we ensure that the backfill process doesn't overwrite any user changes. + */ +async function backfillStaticFilesRemovedFromMinifiedBuild(php: PHP) { + if (!php.requestHandler) { + logger.warn('No PHP request handler available'); + return; + } + + try { + const remoteAssetListPath = joinPaths( + php.requestHandler.documentRoot, + 'wordpress-remote-asset-paths' + ); + + if ( + !php.fileExists(remoteAssetListPath) || + (await php.readFileAsText(remoteAssetListPath)) === '' + ) { + return; + } + const wpVersion = await getLoadedWordPressVersion(php.requestHandler); + const staticAssetsDirectory = + wpVersionToStaticAssetsDirectory(wpVersion); + if (!staticAssetsDirectory) { + return; + } + const response = await fetch( + joinPaths('/', staticAssetsDirectory, 'wordpress-static.zip') + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch WordPress static assets: ${response.status} ${response.statusText}` + ); + } + + await unzipFile( + php, + new File([await response.blob()], 'wordpress-static.zip'), + php.requestHandler.documentRoot, + false + ); + // Clear the remote asset list to indicate that the assets are downloaded. + await php.writeFile(remoteAssetListPath, ''); + } catch (e) { + logger.warn('Failed to download WordPress assets', e); + } } const apiEndpoint = new PlaygroundWorkerEndpoint( diff --git a/packages/playground/wordpress-builds/build/Dockerfile b/packages/playground/wordpress-builds/build/Dockerfile index fb62d4cc26..9a2e3a9717 100644 --- a/packages/playground/wordpress-builds/build/Dockerfile +++ b/packages/playground/wordpress-builds/build/Dockerfile @@ -32,6 +32,7 @@ RUN rm -rf wordpress/wp-content/plugins/akismet RUN cp -r wordpress wordpress-static && \ cd wordpress-static && \ find ./ -name '*.php' -delete && \ + find ./ -name '*.sqlite' -delete && \ # Keep only the static files inside the directories like wp-admin or wp-content: find . -maxdepth 1 -type f -delete && \ # Remove all empty directories @@ -44,11 +45,19 @@ RUN rm -rf wordpress-static/wp-content/mu-plugins # to request a remote asset or delegate the request for a missing file to PHP. RUN find wordpress-static -type f | sed 's#^wordpress-static/##'> wordpress-remote-asset-paths -# Make the remote asset listing available remotely so it can be downloaded +# Make the remote asset listing available remotely so it can be downloaded # directly in cases where an older minified WordPress build without this file # has been saved to browser storage. RUN cp wordpress-remote-asset-paths wordpress-static/ +# ZIP the static files +RUN cd wordpress-static/ && \ + zip -r ../wordpress-static.zip . && \ + cd .. + +# Move ZIP to the public output directory +RUN cp wordpress-static.zip wordpress-static/ + # Move the static files to the final output directory RUN mkdir /root/output/$OUT_FILENAME RUN mv wordpress-static/* /root/output/$OUT_FILENAME/ @@ -135,6 +144,6 @@ RUN cd wordpress && \ # Build the final wp.zip file RUN mv wordpress /wordpress && \ cp wordpress-remote-asset-paths /wordpress/ && \ + cp wordpress-static.zip /wordpress/ && \ cd /wordpress && \ zip /root/output/$OUT_FILENAME.zip -r . - \ No newline at end of file diff --git a/packages/playground/wordpress-builds/public/wp-6.2/wordpress-static.zip b/packages/playground/wordpress-builds/public/wp-6.2/wordpress-static.zip new file mode 100644 index 0000000000..7aef272335 Binary files /dev/null and b/packages/playground/wordpress-builds/public/wp-6.2/wordpress-static.zip differ diff --git a/packages/playground/wordpress-builds/public/wp-6.3/wordpress-static.zip b/packages/playground/wordpress-builds/public/wp-6.3/wordpress-static.zip new file mode 100644 index 0000000000..5e359036b6 Binary files /dev/null and b/packages/playground/wordpress-builds/public/wp-6.3/wordpress-static.zip differ diff --git a/packages/playground/wordpress-builds/public/wp-6.4/wordpress-static.zip b/packages/playground/wordpress-builds/public/wp-6.4/wordpress-static.zip new file mode 100644 index 0000000000..c23c645614 Binary files /dev/null and b/packages/playground/wordpress-builds/public/wp-6.4/wordpress-static.zip differ diff --git a/packages/playground/wordpress-builds/public/wp-6.5/wordpress-static.zip b/packages/playground/wordpress-builds/public/wp-6.5/wordpress-static.zip new file mode 100644 index 0000000000..d45a763f76 Binary files /dev/null and b/packages/playground/wordpress-builds/public/wp-6.5/wordpress-static.zip differ diff --git a/packages/playground/wordpress-builds/public/wp-beta/wordpress-static.zip b/packages/playground/wordpress-builds/public/wp-beta/wordpress-static.zip new file mode 100644 index 0000000000..9effb6f39d Binary files /dev/null and b/packages/playground/wordpress-builds/public/wp-beta/wordpress-static.zip differ diff --git a/packages/playground/wordpress-builds/public/wp-nightly/wordpress-static.zip b/packages/playground/wordpress-builds/public/wp-nightly/wordpress-static.zip new file mode 100644 index 0000000000..01149593c3 Binary files /dev/null and b/packages/playground/wordpress-builds/public/wp-nightly/wordpress-static.zip differ diff --git a/packages/playground/wordpress/src/boot.ts b/packages/playground/wordpress/src/boot.ts index 2299ed431f..089ba32616 100644 --- a/packages/playground/wordpress/src/boot.ts +++ b/packages/playground/wordpress/src/boot.ts @@ -219,7 +219,7 @@ async function isWordPressInstalled(php: PHP) { return ( ( await php.run({ - code: `