diff --git a/README.md b/README.md index 499f242ee5..749499e68d 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,35 @@ PHP=7.4 npx @php-wasm/cli -v npx @php-wasm/cli phpcbf ``` +### Test offline support + +To test the offline support you need to build the website and run a local server: + +```bash +PLAYGROUND_URL=http://localhost:9999 npx nx run playground-website:build:wasm-wordpress-net +``` + +Then you can run a local server: + +```bash +php -S localhost:9999 -t dist/packages/playground/wasm-wordpress-net +``` + +or using Docker: + +```bash +docker run --rm -p 9999:80 -v $(pwd)/dist/packages/playground/wasm-wordpress-net:/usr/share/nginx/html:ro nginx:alpine +``` + +#### Using the browser to test the offline support + +1. Open the browser and go to `http://localhost:9999`. +2. Open the browser's developer tools. +3. Go to the "Network" tab. +4. Select "Offline" in the throttling dropdown menu. +5. Refresh the page. + + ## How can I contribute? WordPress Playground is an open-source project and welcomes all contributors from code to design, and from documentation to triage. If the feature you need is missing, you are more than welcome to start a discussion, open an issue, and even propose a Pull Request to implement it. diff --git a/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts b/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts index c71ef35ed8..cc47d821bf 100644 --- a/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts +++ b/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts @@ -178,7 +178,6 @@ export async function broadcastMessageExpectReply(message: any, scope: string) { } interface ServiceWorkerConfiguration { - cacheVersion: string; handleRequest?: (event: FetchEvent) => Promise | undefined; } diff --git a/packages/php-wasm/web-service-worker/tsconfig.lib.json b/packages/php-wasm/web-service-worker/tsconfig.lib.json index 829b0bc14c..029f024836 100644 --- a/packages/php-wasm/web-service-worker/tsconfig.lib.json +++ b/packages/php-wasm/web-service-worker/tsconfig.lib.json @@ -5,6 +5,8 @@ "declaration": true, "types": ["node"] }, - "include": ["src/**/*.ts"], + "include": [ + "src/**/*.ts", + ], "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] } diff --git a/packages/php-wasm/web/src/lib/register-service-worker.ts b/packages/php-wasm/web/src/lib/register-service-worker.ts index 55baaebc6c..930bcc1eee 100644 --- a/packages/php-wasm/web/src/lib/register-service-worker.ts +++ b/packages/php-wasm/web/src/lib/register-service-worker.ts @@ -2,6 +2,7 @@ import { PHPWorker } from '@php-wasm/universal'; import { PhpWasmError } from '@php-wasm/util'; import { responseTo } from '@php-wasm/web-service-worker'; import { Remote } from 'comlink'; +import { logger } from '@php-wasm/logger'; export interface Client extends Remote {} @@ -65,7 +66,15 @@ export async function registerServiceWorker(scope: string, scriptUrl: string) { // Check if there's a new service worker available and, if so, enqueue // the update: - await registration.update(); + try { + await registration.update(); + } catch (e) { + // registration.update() throws if it can't reach the server. + // We're swallowing the error to keep the app working in offline mode + // or when playground.wordpress.net is down. We can be sure we have a functional + // service worker at this point because sw.register() succeeded. + logger.error('Failed to update service worker.', e); + } // Proxy the service worker messages to the web worker: navigator.serviceWorker.addEventListener( 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 177722a967..06b3f5d0d7 100644 --- a/packages/playground/remote/service-worker.ts +++ b/packages/playground/remote/service-worker.ts @@ -2,7 +2,7 @@ declare const self: ServiceWorkerGlobalScope; -import { getURLScope, removeURLScope } from '@php-wasm/scopes'; +import { getURLScope, isURLScoped, removeURLScope } from '@php-wasm/scopes'; import { applyRewriteRules } from '@php-wasm/universal'; import { awaitReply, @@ -14,7 +14,7 @@ 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) { // Workaround: vite translates import.meta.url @@ -25,10 +25,101 @@ if (!(self as any).document) { self.document = {}; } +/** + * 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() 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); + + /** + * Don't cache requests to the service worker script itself. + */ + if (url.pathname.startsWith(self.location.pathname)) { + return; + } + + /** + * Don't cache requests to scoped URLs or if the referrer URL is scoped. + * + * These requests are made to the PHP Worker Thread and are not static assets. + */ + if (isURLScoped(url)) { + return; + } + + let referrerUrl; + try { + referrerUrl = new URL(event.request.referrer); + } catch (e) { + // ignore + } + + if (referrerUrl && isURLScoped(referrerUrl)) { + return; + } + + /** + * Respond with cached assets if available. + * If the asset is not cached, fetch it from the network and cache it. + */ + event.respondWith( + cachePromise.then((cache) => cache.cachedFetch(event.request)) + ); +}); + 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(); + + /** + * Remove outdated files from the 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.removeOutdatedFiles(); + + return cache; +}); + initializeServiceWorker({ - cacheVersion: buildVersion, handleRequest(event) { const fullUrl = new URL(event.request.url); let scope = getURLScope(fullUrl); diff --git a/packages/playground/remote/src/lib/boot-playground-remote.ts b/packages/playground/remote/src/lib/boot-playground-remote.ts index c063500344..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,30 +253,43 @@ 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. + * + * 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 + *