diff --git a/packages/php-wasm/universal/src/lib/load-php-runtime.ts b/packages/php-wasm/universal/src/lib/load-php-runtime.ts index f8d6d3c5e0..64192c59cf 100644 --- a/packages/php-wasm/universal/src/lib/load-php-runtime.ts +++ b/packages/php-wasm/universal/src/lib/load-php-runtime.ts @@ -121,17 +121,15 @@ export async function loadPHPRuntime( phpModuleArgs: EmscriptenOptions = {}, dataDependenciesModules: DataModule[] = [] ): Promise { - let resolvePhpReady: any, resolveDepsReady: any; - const depsReady = new Promise((resolve) => { - resolveDepsReady = resolve; - }); - const phpReady = new Promise((resolve) => { - resolvePhpReady = resolve; - }); + const [phpReady, resolvePHP, rejectPHP] = makePromise(); + const [depsReady, resolveDeps] = makePromise(); const PHPRuntime = phpLoaderModule.init(currentJsRuntime, { onAbort(reason) { - console.error('WASM aborted: '); + rejectPHP(reason); + resolveDeps(); + // This can happen after PHP has been initialized so + // let's just log it. console.error(reason); }, ENV: {}, @@ -145,20 +143,24 @@ export async function loadPHPRuntime( if (phpModuleArgs.onRuntimeInitialized) { phpModuleArgs.onRuntimeInitialized(); } - resolvePhpReady(); + resolvePHP(); }, monitorRunDependencies(nbLeft) { if (nbLeft === 0) { delete PHPRuntime.monitorRunDependencies; - resolveDepsReady(); + resolveDeps(); } }, }); - for (const { default: loadDataModule } of dataDependenciesModules) { - loadDataModule(PHPRuntime); - } + + await Promise.all( + dataDependenciesModules.map(({ default: dataModule }) => + dataModule(PHPRuntime) + ) + ); + if (!dataDependenciesModules.length) { - resolveDepsReady(); + resolveDeps(); } await depsReady; @@ -195,6 +197,20 @@ export const currentJsRuntime = (function () { } })(); +/** + * Creates and exposes Promise resolve/reject methods for later use. + */ +const makePromise = () => { + const methods: any = []; + + const promise = new Promise((resolve, reject) => { + methods.push(resolve, reject); + }); + methods.unshift(promise); + + return methods as [Promise, (v?: any) => void, (e?: any) => void]; +}; + export type PHPRuntime = any; export type PHPLoaderModule = { diff --git a/packages/php-wasm/web/src/lib/api.ts b/packages/php-wasm/web/src/lib/api.ts index fc9071d249..7875db0ec5 100644 --- a/packages/php-wasm/web/src/lib/api.ts +++ b/packages/php-wasm/web/src/lib/api.ts @@ -74,14 +74,16 @@ export type PublicAPI = RemoteAPI< export function exposeAPI( apiMethods?: Methods, pipedApi?: PipedAPI -): [() => void, PublicAPI] { +): [() => void, (e: Error) => void, PublicAPI] { setupTransferHandlers(); const connected = Promise.resolve(); let setReady: any; - const ready = new Promise((resolve) => { + let setFailed: any; + const ready = new Promise((resolve, reject) => { setReady = resolve; + setFailed = reject; }); const methods = proxyClone(apiMethods); @@ -104,7 +106,7 @@ export function exposeAPI( ? Comlink.windowEndpoint(self.parent) : undefined ); - return [setReady, exposedApi]; + return [setReady, setFailed, exposedApi]; } let isTransferHandlersSetup = false; diff --git a/packages/php-wasm/web/src/lib/worker-thread/spawn-php-worker-thread.ts b/packages/php-wasm/web/src/lib/worker-thread/spawn-php-worker-thread.ts index d27b166a7e..5169f51d40 100644 --- a/packages/php-wasm/web/src/lib/worker-thread/spawn-php-worker-thread.ts +++ b/packages/php-wasm/web/src/lib/worker-thread/spawn-php-worker-thread.ts @@ -10,7 +10,27 @@ export async function spawnPHPWorkerThread( startupOptions: Record = {} ) { workerUrl = addQueryParams(workerUrl, startupOptions); - return new Worker(workerUrl, { type: 'module' }); + const worker = new Worker(workerUrl, { type: 'module' }); + return new Promise((resolve, reject) => { + worker.onerror = (e) => { + const error = new Error( + `WebWorker failed to load at ${workerUrl}. ${ + e.message ? `Original error: ${e.message}` : '' + }` + ); + (error as any).filename = e.filename; + reject(error); + }; + // There is no way to know when the worker script has started + // executing, so we use a message to signal that. + function onStartup(event: { data: string }) { + if (event.data === 'worker-script-started') { + resolve(worker); + worker.removeEventListener('message', onStartup); + } + } + worker.addEventListener('message', onStartup); + }); } function addQueryParams( diff --git a/packages/playground/compile-wordpress/Dockerfile b/packages/playground/compile-wordpress/Dockerfile index 2d1b8867c2..897f7f5e4d 100644 --- a/packages/playground/compile-wordpress/Dockerfile +++ b/packages/playground/compile-wordpress/Dockerfile @@ -195,6 +195,10 @@ COPY ./build-assets/esm-prefix.js ./build-assets/esm-suffix.js /root/ # This tells web browsers it's a new file and they should reload it. RUN cat /root/output/wp.js \ | sed -E "s#'[^']*wp\.data'#dependencyFilename#g" \ + | sed -E 's#xhr.onerror = #xhr.onerror = onLoadingFailed; const z = #g' \ + | sed -E 's#throw new Error\(xhr.+$#onLoadingFailed(event);#g' \ + | sed -E 's#runWithFS#runWithFSThenResolve#g' \ + | sed -E 's#function runWithFSThenResolve#function runWithFSThenResolve() { runWithFS(); resolve(); }; function runWithFS#g' \ > /tmp/wp.js && \ mv /tmp/wp.js /root/output/wp.js; diff --git a/packages/playground/compile-wordpress/build-assets/esm-prefix.js b/packages/playground/compile-wordpress/build-assets/esm-prefix.js index 8074850880..e2a950a3e8 100644 --- a/packages/playground/compile-wordpress/build-assets/esm-prefix.js +++ b/packages/playground/compile-wordpress/build-assets/esm-prefix.js @@ -13,4 +13,10 @@ export const defaultThemeName = WP_THEME_NAME; // into an ESM module. // This replaces the Emscripten's MODULARIZE=1 which pollutes the // global namespace and does not play well with import() mechanics. -export default function(PHPModule) { \ No newline at end of file +export default function (PHPModule) { + return new Promise(function(resolve, reject) { + function onLoadingFailed(error) { + const wrappingError = new Error(`Failed to load data dependency module "${dependencyFilename}"${typeof error === 'string' ? ` (${error})` : ''}`); + wrappingError.cause = error instanceof Error ? error : null; + reject(wrappingError); + }; \ No newline at end of file diff --git a/packages/playground/compile-wordpress/build-assets/esm-suffix.js b/packages/playground/compile-wordpress/build-assets/esm-suffix.js index e72d7156ca..927c61af17 100644 --- a/packages/playground/compile-wordpress/build-assets/esm-suffix.js +++ b/packages/playground/compile-wordpress/build-assets/esm-suffix.js @@ -1,2 +1,3 @@ // See esm-prefix.js + }); } \ No newline at end of file diff --git a/packages/playground/remote/remote.html b/packages/playground/remote/remote.html index cd120152bd..dab269ad75 100644 --- a/packages/playground/remote/remote.html +++ b/packages/playground/remote/remote.html @@ -21,6 +21,27 @@ body { overflow: hidden; } + + body.has-error { + background: #f1f1f1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 20px; + font-family: Arial, Helvetica, sans-serif; + line-height: 1.4; + } + body.has-error .error-message { + padding: 20px; + max-width: 800px; + } + body.has-error button { + margin-top: 15px; + font-size: 20px; + padding: 5px 10px; + cursor: pointer; + } @@ -33,7 +54,35 @@ document.body.classList.add('is-embedded'); } import { bootPlaygroundRemote } from './src/index'; - bootPlaygroundRemote(); + try { + await bootPlaygroundRemote(); + } catch (e) { + document.body.className = 'has-error'; + document.body.innerHTML = ''; + + const div = document.createElement('div'); + div.className = 'error-message'; + div.innerText = 'Ooops! WordPress Playground had a hiccup!'; + if ( + e?.message?.includes( + 'The user denied permission to use Service Worker' + ) + ) { + div.innerText = + 'It looks like you have disabled third-party cookies in your browser. This tends to ' + + 'also disable the ServiceWorker API used by WordPress Playground. Please re-enable ' + + 'third-party cookies and try again.'; + } + + document.body.append(div); + + const button = document.createElement('button'); + button.innerText = 'Try again'; + button.onclick = () => { + window.location.reload(); + }; + document.body.append(button); + } diff --git a/packages/playground/remote/src/lib/boot-playground-remote.ts b/packages/playground/remote/src/lib/boot-playground-remote.ts index 301acbc96b..3728100683 100644 --- a/packages/playground/remote/src/lib/boot-playground-remote.ts +++ b/packages/playground/remote/src/lib/boot-playground-remote.ts @@ -145,18 +145,23 @@ export async function bootPlaygroundRemote() { // 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(webApi, workerApi); + const [setAPIReady, setAPIError, playground] = exposeAPI(webApi, workerApi); - await workerApi.isReady(); - await registerServiceWorker( - workerApi, - await workerApi.scope, - serviceWorkerUrl + '' - ); - wpFrame.src = await playground.pathToInternalUrl('/'); - setupPostMessageRelay(wpFrame, getOrigin(await playground.absoluteUrl)); + try { + await workerApi.isReady(); + await registerServiceWorker( + workerApi, + await workerApi.scope, + serviceWorkerUrl + '' + ); + wpFrame.src = await playground.pathToInternalUrl('/'); + setupPostMessageRelay(wpFrame, getOrigin(await playground.absoluteUrl)); - setAPIReady(); + setAPIReady(); + } catch (e) { + setAPIError(e as Error); + throw e; + } /* * An asssertion to make sure Playground Client is compatible diff --git a/packages/playground/remote/src/lib/worker-thread.ts b/packages/playground/remote/src/lib/worker-thread.ts index 80539060c1..1e1b1132f7 100644 --- a/packages/playground/remote/src/lib/worker-thread.ts +++ b/packages/playground/remote/src/lib/worker-thread.ts @@ -27,6 +27,9 @@ import { import { applyWordPressPatches } from '@wp-playground/blueprints'; import { journalMemfsToOpfs } from './opfs/journal-memfs-to-opfs'; +// post message to parent +self.postMessage('worker-script-started'); + const startupOptions = parseWorkerStartupOptions<{ wpVersion?: string; phpVersion?: string; @@ -127,42 +130,46 @@ export class PlaygroundWorkerEndpoint extends WebPHPEndpoint { } } -const [setApiReady] = exposeAPI( +const [setApiReady, setAPIError] = exposeAPI( new PlaygroundWorkerEndpoint(php, monitor, scope, wpVersion, phpVersion) ); +try { + await phpReady; + + if (!useOpfs || !wordPressAvailableInOPFS) { + /** + * When WordPress is restored from OPFS, these patches are already applied. + * Thus, let's not apply them again. + */ + await wordPressModule; + applyWebWordPressPatches(php); + await applyWordPressPatches(php, { + wordpressPath: DOCROOT, + patchSecrets: true, + disableWpNewBlogNotification: true, + addPhpInfo: true, + disableSiteHealth: true, + }); + } -await phpReady; + if (useOpfs) { + if (wordPressAvailableInOPFS) { + await copyOpfsToMemfs(php, opfsDir!, DOCROOT); + } else { + await copyMemfsToOpfs(php, opfsDir!, DOCROOT); + } -if (!useOpfs || !wordPressAvailableInOPFS) { - /** - * When WordPress is restored from OPFS, these patches are already applied. - * Thus, let's not apply them again. - */ - await wordPressModule; - applyWebWordPressPatches(php); + journalMemfsToOpfs(php, opfsDir!, DOCROOT); + } + + // Always setup the current site URL. await applyWordPressPatches(php, { wordpressPath: DOCROOT, - patchSecrets: true, - disableWpNewBlogNotification: true, - addPhpInfo: true, - disableSiteHealth: true, + siteUrl: scopedSiteUrl, }); -} - -if (useOpfs) { - if (wordPressAvailableInOPFS) { - await copyOpfsToMemfs(php, opfsDir!, DOCROOT); - } else { - await copyMemfsToOpfs(php, opfsDir!, DOCROOT); - } - journalMemfsToOpfs(php, opfsDir!, DOCROOT); + setApiReady(); +} catch (e) { + setAPIError(e as Error); + throw e; } - -// Always setup the current site URL. -await applyWordPressPatches(php, { - wordpressPath: DOCROOT, - siteUrl: scopedSiteUrl, -}); - -setApiReady();