From b340e78ffe37f4a9d1ed957c34550152ae99544f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 23 Jul 2024 15:14:13 +0200 Subject: [PATCH 1/4] Refactor OfflineModeCache and await the static assets backfilling if they're in cache --- .../blueprints/public/blueprint-schema.json | 2 +- packages/playground/remote/service-worker.ts | 48 +++++--- .../remote/src/lib/boot-playground-remote.ts | 10 +- .../remote/src/lib/offline-mode-cache.ts | 114 +++++++++--------- .../remote/src/lib/worker-thread.ts | 40 ++++-- 5 files changed, 125 insertions(+), 89 deletions(-) diff --git a/packages/playground/blueprints/public/blueprint-schema.json b/packages/playground/blueprints/public/blueprint-schema.json index 7f4e15a7ed..add0ec9118 100644 --- a/packages/playground/blueprints/public/blueprint-schema.json +++ b/packages/playground/blueprints/public/blueprint-schema.json @@ -569,7 +569,7 @@ "description": "The name of the theme to import content from." } }, - "required": ["step", "themeSlug"] + "required": ["step"] }, { "type": "object", diff --git a/packages/playground/remote/service-worker.ts b/packages/playground/remote/service-worker.ts index c44ee68465..ca8b4afa59 100644 --- a/packages/playground/remote/service-worker.ts +++ b/packages/playground/remote/service-worker.ts @@ -14,7 +14,6 @@ import { import { wordPressRewriteRules } from '@wp-playground/wordpress'; import { reportServiceWorkerMetrics } from '@php-wasm/logger'; -import { buildVersion } from 'virtual:remote-config'; import { OfflineModeCache } from './src/lib/offline-mode-cache'; if (!(self as any).document) { @@ -27,26 +26,33 @@ if (!(self as any).document) { } reportServiceWorkerMetrics(self); -const cache = new OfflineModeCache(buildVersion, self.location.hostname); -/** - * For offline mode to work we need to cache all required assets. - * - * These assets are listed in the `/assets-required-for-offline-mode.json` file - * and contain JavaScript, CSS, and other assets required to load the site without - * making any network requests. - */ -cache.cacheOfflineModeAssets(); +async function initialize() { + const cache = await OfflineModeCache.getInstance(); -/** - * Cleanup old cache. - * - * We cache data based on `buildVersion` which is updated whenever Playground is built. - * So when a new version of Playground is deployed, the service worker will remove the old cache and cache the new assets. - * - * If your build version doesn't change while developing locally check `buildVersionPlugin` for more details on how it's generated. - */ -cache.cleanup(); + /** + * For offline mode to work we need to cache all required assets. + * + * These assets are listed in the `/assets-required-for-offline-mode.json` file + * and contain JavaScript, CSS, and other assets required to load the site without + * making any network requests. + */ + cache.cacheOfflineModeAssets(); + + /** + * Cleanup old cache. + * + * We cache data based on `buildVersion` which is updated whenever Playground is built. + * So when a new version of Playground is deployed, the service worker will remove the old cache and cache the new assets. + * + * If your build version doesn't change while developing locally check `buildVersionPlugin` for more details on how it's generated. + */ + cache.cleanup(); + + return cache; +} + +const cachePromise = initialize(); /** * Handle fetch events and respond with cached assets if available. @@ -83,7 +89,9 @@ self.addEventListener('fetch', (event) => { * Respond with cached assets if available. * If the asset is not cached, fetch it from the network and cache it. */ - event.respondWith(cache.cachedFetch(event.request)); + event.respondWith( + cachePromise.then((cache) => cache.cachedFetch(event.request)) + ); }); initializeServiceWorker({ diff --git a/packages/playground/remote/src/lib/boot-playground-remote.ts b/packages/playground/remote/src/lib/boot-playground-remote.ts index 054bc50266..d5881dab94 100644 --- a/packages/playground/remote/src/lib/boot-playground-remote.ts +++ b/packages/playground/remote/src/lib/boot-playground-remote.ts @@ -254,11 +254,7 @@ export async function bootPlaygroundRemote() { * 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 { + if (await webApi.hasCachedStaticFilesRemovedFromMinifiedBuild()) { // Note the .setProgress() call 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(). @@ -269,6 +265,10 @@ export async function bootPlaygroundRemote() { }); // Backfilling will not overwrite any existing files so it's safe to call here. await webApi.backfillStaticFilesRemovedFromMinifiedBuild(); + } else { + wpFrame.addEventListener('load', () => { + webApi.backfillStaticFilesRemovedFromMinifiedBuild(); + }); } /* diff --git a/packages/playground/remote/src/lib/offline-mode-cache.ts b/packages/playground/remote/src/lib/offline-mode-cache.ts index f58110b088..1ee6f644a0 100644 --- a/packages/playground/remote/src/lib/offline-mode-cache.ts +++ b/packages/playground/remote/src/lib/offline-mode-cache.ts @@ -1,28 +1,69 @@ import { isURLScoped } from '@php-wasm/scopes'; +import { buildVersion } from 'virtual:remote-config'; + +const CACHE_NAME_PREFIX = 'playground-cache'; +const LATEST_CACHE_NAME = `${CACHE_NAME_PREFIX}-${buildVersion}`; export class OfflineModeCache { - readonly cacheNamePrefix = 'playground-cache'; + public cache: Cache; + private hostname = self.location.hostname; + + private static instance?: OfflineModeCache; + + static async getInstance() { + if (!OfflineModeCache.instance) { + const cache = await caches.open(LATEST_CACHE_NAME); + OfflineModeCache.instance = new OfflineModeCache(cache); + } + return OfflineModeCache.instance; + } + + private constructor(cache: Cache) { + this.cache = cache; + } + + async cleanup() { + const keys = await caches.keys(); + const oldKeys = keys.filter( + (key) => + key.startsWith(CACHE_NAME_PREFIX) && key !== LATEST_CACHE_NAME + ); + return Promise.all(oldKeys.map((key) => caches.delete(key))); + } - private cacheName: string; - private hostname: string; + async cachedFetch(request: Request): Promise { + if (!this.shouldCacheUrl(new URL(request.url))) { + return await fetch(request); + } - constructor(cacheVersion: string, hostname: string) { - this.hostname = hostname; - this.cacheName = `${this.cacheNamePrefix}-${cacheVersion}`; + let response = await this.cache.match(request, { ignoreSearch: true }); + if (!response) { + response = await fetch(request); + if (response.ok) { + await this.cache.put(request, response.clone()); + } + } + + return response; } - addCache = async (key: Request, response: Response) => { - const clonedResponse = response.clone(); - const cache = await caches.open(this.cacheName); - await cache.put(key, clonedResponse); - }; + async cacheOfflineModeAssets(): Promise { + if (!this.shouldCacheUrl(new URL(location.href))) { + return; + } - getCache = async (key: Request) => { - const cache = caches.open(this.cacheName); - return await cache.then((c) => c.match(key, { ignoreSearch: true })); - }; + // Get the cache manifest and add all the files to the cache + const manifestResponse = await fetch( + '/assets-required-for-offline-mode.json' + ); + const websiteUrls = await manifestResponse.json(); + await this.cache.addAll([...websiteUrls, ...['/']]); + } - shouldCacheUrl = (url: URL) => { + private shouldCacheUrl(url: URL) { + if (url.href.includes('wordpress-static.zip')) { + return true; + } /** * The development environment uses Vite which doesn't work offline because it dynamically generates assets. * Check the README for offline development instructions. @@ -54,44 +95,5 @@ export class OfflineModeCache { * Allow only requests to the same hostname to be cached. */ return this.hostname === url.hostname; - }; - - cleanup = async () => { - const keys = await caches.keys(); - const oldKeys = keys.filter( - (key) => - key.startsWith(this.cacheNamePrefix) && key !== this.cacheName - ); - return Promise.all(oldKeys.map((key) => caches.delete(key))); - }; - - cachedFetch = async (request: Request): Promise => { - const url = new URL(request.url); - if (!this.shouldCacheUrl(url)) { - return await fetch(request); - } - const cacheKey = request; - const cache = await this.getCache(cacheKey); - if (cache) { - return cache; - } - const response = await fetch(request); - await this.addCache(cacheKey, response); - return response; - }; - - cacheOfflineModeAssets = async (): Promise => { - if (!this.shouldCacheUrl(new URL(location.href))) { - return; - } - - const cache = await caches.open(this.cacheName); - - // Get the cache manifest and add all the files to the cache - const manifestResponse = await fetch( - '/assets-required-for-offline-mode.json' - ); - const websiteUrls = await manifestResponse.json(); - await cache.addAll([...websiteUrls, ...['/']]); - }; + } } diff --git a/packages/playground/remote/src/lib/worker-thread.ts b/packages/playground/remote/src/lib/worker-thread.ts index c890e36c8d..c36a8fc756 100644 --- a/packages/playground/remote/src/lib/worker-thread.ts +++ b/packages/playground/remote/src/lib/worker-thread.ts @@ -46,6 +46,7 @@ import { import { wpVersionToStaticAssetsDirectory } from '@wp-playground/wordpress-builds'; import { logger } from '@php-wasm/logger'; import { unzipFile } from '@wp-playground/common'; +import { OfflineModeCache } from './offline-mode-cache'; /** * Startup options are received from spawnPHPWorkerThread using a message event. @@ -282,29 +283,54 @@ async function backfillStaticFilesRemovedFromMinifiedBuild(php: PHP) { if (!staticAssetsDirectory) { return; } - const response = await fetch( - joinPaths('/', staticAssetsDirectory, 'wordpress-static.zip') + const staticAssetsUrl = joinPaths( + '/', + staticAssetsDirectory, + 'wordpress-static.zip' ); - if (!response.ok) { + // We don't have the WordPress assets cached yet. Let's fetch them and cache them without + // awaiting the response. We're awaiting the backfillStaticFilesRemovedFromMinifiedBuild() + // call in the web app and we don't want to block the initial load on this download. + const response = await fetch(staticAssetsUrl); + + // We have the WordPress assets already cached, let's unzip them and finish. + 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, + 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, ''); + php.writeFile(remoteAssetListPath, ''); } catch (e) { logger.warn('Failed to download WordPress assets', e); } } +async function hasCachedStaticFilesRemovedFromMinifiedBuild(php: PHP) { + const wpVersion = await getLoadedWordPressVersion(php.requestHandler!); + const staticAssetsDirectory = wpVersionToStaticAssetsDirectory(wpVersion); + if (!staticAssetsDirectory) { + return false; + } + const staticAssetsUrl = joinPaths( + '/', + staticAssetsDirectory, + 'wordpress-static.zip' + ); + const cache = await OfflineModeCache.getInstance(); + const response = await cache.cache.match(staticAssetsUrl, { + ignoreSearch: true, + }); + return !!response; +} + const apiEndpoint = new PlaygroundWorkerEndpoint( downloadMonitor, scope, From dcb2f50467974a4f6af48751fa46253b56b6c8c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 24 Jul 2024 00:00:24 +0200 Subject: [PATCH 2/4] Load wordpress-static.zip from cache when available. Ensure the very first request is already cached. --- packages/playground/client/vite.config.ts | 9 ++ packages/playground/remote/service-worker.ts | 83 ++++++++++++------- .../remote/src/lib/boot-playground-remote.ts | 53 ++++++++---- .../remote/src/lib/offline-mode-cache.ts | 1 + .../remote/src/lib/playground-client.ts | 1 + .../remote/src/lib/worker-thread.ts | 6 ++ 6 files changed, 108 insertions(+), 45 deletions(-) diff --git a/packages/playground/client/vite.config.ts b/packages/playground/client/vite.config.ts index 695c014b74..70acd1ecfc 100644 --- a/packages/playground/client/vite.config.ts +++ b/packages/playground/client/vite.config.ts @@ -6,6 +6,8 @@ import { join } from 'path'; import ignoreWasmImports from '../ignore-wasm-imports'; // eslint-disable-next-line @nx/enforce-module-boundaries import { viteTsConfigPaths } from '../../vite-extensions/vite-ts-config-paths'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { buildVersionPlugin } from '../../vite-extensions/vite-build-version'; export default defineConfig({ cacheDir: '../../../node_modules/.vite/playground-client', @@ -18,6 +20,13 @@ export default defineConfig({ tsconfigPath: join(__dirname, 'tsconfig.lib.json'), }), ignoreWasmImports(), + + // @wp-playground/client doesn't actually use the remote-config virtual module, + // @wp-playground/remote package does. + // @wp-playground/client imports a few things from @wp-playground/remote and, + // even though it doesn't involve the remote-config virtual module, the bundler + // still needs to know what to do when it sees `import from "virtual:remote-config"`. + buildVersionPlugin('remote-config'), ], // Configuration for building your library. diff --git a/packages/playground/remote/service-worker.ts b/packages/playground/remote/service-worker.ts index ca8b4afa59..d412ae712c 100644 --- a/packages/playground/remote/service-worker.ts +++ b/packages/playground/remote/service-worker.ts @@ -25,37 +25,35 @@ if (!(self as any).document) { self.document = {}; } -reportServiceWorkerMetrics(self); - -async function initialize() { - const cache = await OfflineModeCache.getInstance(); - - /** - * For offline mode to work we need to cache all required assets. - * - * These assets are listed in the `/assets-required-for-offline-mode.json` file - * and contain JavaScript, CSS, and other assets required to load the site without - * making any network requests. - */ - cache.cacheOfflineModeAssets(); - - /** - * Cleanup old cache. - * - * We cache data based on `buildVersion` which is updated whenever Playground is built. - * So when a new version of Playground is deployed, the service worker will remove the old cache and cache the new assets. - * - * If your build version doesn't change while developing locally check `buildVersionPlugin` for more details on how it's generated. - */ - cache.cleanup(); - - return cache; -} - -const cachePromise = initialize(); +/** + * Ensures the very first Playground load is controlled by this service worker. + * + * This is necessary because service workers don't control any pages loaded + * before they are activated. This includes the page that actually registers + * the service worker. You need to reload it before `navigator.serviceWorker.controller` + * is set and the fetch() requests are intercepted here. + * + * However, the initial Playground load already downloads a few large assets, + * like a 12MB wordpress-static.zip file. We need to cache them these requests. + * Otherwise they'll be fetched again on the next page load. + * + * client.claim() only affects pages loaded before the initial servie worker + * registration. It shouldn't have unwanted side effects in our case. All these + * pages would get controlled eventually anyway. + * + * See: + * * The service worker lifecycle https://web.dev/articles/service-worker-lifecycle + * * Clients.claim() docs https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim + */ +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); /** - * Handle fetch events and respond with cached assets if available. + * Handle fetch() caching: + * + * * Put the initial fetch response in the cache + * * Serve the subsequent requests from the cache */ self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); @@ -75,12 +73,14 @@ self.addEventListener('fetch', (event) => { if (isURLScoped(url)) { return; } + let referrerUrl; try { referrerUrl = new URL(event.request.referrer); } catch (e) { // ignore } + if (referrerUrl && isURLScoped(referrerUrl)) { return; } @@ -94,6 +94,31 @@ self.addEventListener('fetch', (event) => { ); }); +reportServiceWorkerMetrics(self); + +const cachePromise = OfflineModeCache.getInstance().then((cache) => { + /** + * For offline mode to work we need to cache all required assets. + * + * These assets are listed in the `/assets-required-for-offline-mode.json` file + * and contain JavaScript, CSS, and other assets required to load the site without + * making any network requests. + */ + cache.cacheOfflineModeAssets(); + + /** + * Cleanup old cache. + * + * We cache data based on `buildVersion` which is updated whenever Playground is built. + * So when a new version of Playground is deployed, the service worker will remove the old cache and cache the new assets. + * + * If your build version doesn't change while developing locally check `buildVersionPlugin` for more details on how it's generated. + */ + cache.cleanup(); + + return cache; +}); + initializeServiceWorker({ handleRequest(event) { const fullUrl = new URL(event.request.url); diff --git a/packages/playground/remote/src/lib/boot-playground-remote.ts b/packages/playground/remote/src/lib/boot-playground-remote.ts index d5881dab94..287565e203 100644 --- a/packages/playground/remote/src/lib/boot-playground-remote.ts +++ b/packages/playground/remote/src/lib/boot-playground-remote.ts @@ -208,10 +208,19 @@ export async function bootPlaygroundRemote() { /** * Download WordPress assets. + * @see backfillStaticFilesRemovedFromMinifiedBuild in the worker-thread.ts */ async backfillStaticFilesRemovedFromMinifiedBuild() { await phpApi.backfillStaticFilesRemovedFromMinifiedBuild(); }, + + /** + * Checks whether we have the missing WordPress assets readily + * available in the request cache. + */ + async hasCachedStaticFilesRemovedFromMinifiedBuild() { + return await phpApi.hasCachedStaticFilesRemovedFromMinifiedBuild(); + }, }; await phpApi.isConnected(); @@ -244,28 +253,40 @@ export async function bootPlaygroundRemote() { } /** - * 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. + * When we're running WordPress from a minified bundle, we're missing some static assets. + * The section below backfills them if needed. It doesn't do anything if the assets are already + * in place, or when WordPress is loaded from a non-minified bundle. * - * 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. + * Minified bundles are shipped without most static assets to reduce the bundle size and + * the loading time. When WordPress loads for the first time, the browser parses all the + *