Skip to content

Commit

Permalink
PHP: Replace Worker Threads with Web Workers
Browse files Browse the repository at this point in the history
Iframe worker threads were introduced as a workaround for limitations in
web browsers. Namely:

* Chrome crashed when using WASM in web workers
* Firefox didn't support ESM workers at all

Both problems are now solved:

* #1
* mdn/content#26774

There are no more reasons to keep maintaining the iframe worker thread
backend. Let's remove it and lean fully on web workers.
  • Loading branch information
adamziel committed Jun 19, 2023
1 parent d71b88a commit c5c47c9
Show file tree
Hide file tree
Showing 10 changed files with 16 additions and 132 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ The [`@php-wasm/web`](https://github.com/WordPress/wordpress-playground/blob/tru

- [**Browser tab orchestrates everything**](./09-browser-tab-orchestrates-execution.md) – The browser tab is the main program. Closing or reloading it means destroying the entire execution environment.
- [**Iframe-based rendering**](./10-browser-iframe-rendering.md) – Every response produced by the PHP server must be rendered in an iframe to avoid reloading the browser tab when the user clicks on a link.
- [**PHP Worker Thread**](./11-browser-php-worker-threads.md) – The PHP server is slow and must run in a worker thread, otherwise handling requests freezes the website UI.
- [**PHP Worker Thread**](./11-browser-php-worker-threads.md) – The PHP server is slow and must run in a web worker, otherwise handling requests freezes the website UI.
- [**Service Worker routing**](./12-browser-service-workers.md) – All HTTP requests originating in that iframe must be intercepted by a Service worker and passed on to the PHP worker thread for rendering.
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,14 @@ Here's what that boot sequence looks like in code:
**/app.ts**:

```ts
import { consumeAPI, PHPClient, recommendedWorkerBackend, registerServiceWorker, spawnPHPWorkerThread } from '@php-wasm/web';
import { consumeAPI, PHPClient, registerServiceWorker, spawnPHPWorkerThread } from '@php-wasm/web';

const workerUrl = '/worker-thread.js';

export async function startApp() {
const phpClient = consumeAPI<PlaygroundWorkerEndpoint>(
await spawnPHPWorkerThread(
workerUrl, // Valid Worker script URL
recommendedWorkerBackend, // "webworker" or "iframe", see the docstring
{
wpVersion: 'latest',
phpVersion: '7.4', // Startup options
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PHP Worker Threads

PHP is always ran in a separate thread we'll call a "Worker Thread." This happens to ensure the PHP runtime doesn't slow down the website.
PHP is always ran in a [web worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) to ensure the PHP runtime doesn't slow down the user interface of the main website.

Imagine the following code:

Expand All @@ -11,81 +11,22 @@ Imagine the following code:

As soon as you click that button the browser will freeze and you won't be able to type in the input. That's just how browsers work. Whether it's a for loop or a PHP server, running intensive tasks slows down the user interface.

### Initiating the worker thread
### Initiating web workers

Worker threads are separate programs that can process heavy tasks outside of the main application. They must be initiated by the main JavaScript program living in the browser tab. Here's how:
Web workers are separate programs that can process heavy tasks outside of the main application. They must be initiated by the main JavaScript program living in the browser tab. Here's how:

```ts
const phpClient = consumeAPI<PHPClient>(
spawnPHPWorkerThread(
'/worker-thread.js', // Valid Worker script URL
recommendedWorkerBackend // "webworker" or "iframe", see the docstring
'/worker-thread.js' // Valid Worker script URL
)
);
await phpClient.isReady();
await phpClient.run({ code: `<?php echo "Hello from the thread!";` });
```

Worker threads can use any multiprocessing technique like an iframe, WebWorker, or a SharedWorker (not implemented). See the next sections to learn more about the supported backends.
### Controlling web workers

### Controlling the worker thread
Exchanging messages is the only way to control web workers. The main application has no access to functions or variables inside of a web workeer. It can only send and receive messages using `worker.postMessage` and `worker.onmessage = function(msg) { }`.

The main application controls the worker thread by sending and receiving messages. This is implemented via a backend-specific flavor of `postMessage` and `addEventListener('message', fn)`.

Exchanging messages is the only way to control the worker threads. Remember – it is separate programs. The main app cannot access any functions or variables defined inside of the worker thread.

Conveniently, [consumeAPI](/api/web/function/consumeAPI) returns an easy-to-use API object that exposes specific worker thread features and handles the message exchange internally.

### Worker thread backends

Worker threads can use any multiprocessing technique like an iframe, WebWorker, or a SharedWorker. This package provides two backends out of the box:

#### `webworker`

Spins a new `Worker` instance with the given Worker Thread script. This is the classic solution for multiprocessing in the browser and it almost became the only, non-configurable backend. The `iframe` backend is handy to work around webworkers limitations in the browsers. For example, [Firefox does not support module workers](https://github.com/mdn/content/issues/24402) and [WASM used to crash webworkers in Chrome](https://github.com/WordPress/wordpress-playground/issues/1).

Example usage:

```ts
const phpClient = consumeAPI<PHPClient>(spawnPHPWorkerThread('/worker-thread.js', 'webworker'));
```

#### `iframe`

Loads the PHPRequestHandler in a new iframe to avoid crashes in browsers based on Google Chrome.

The browser will **typically** run an iframe in a separate thread in one of the two cases:

1. The `iframe-worker.html` is served with the `Origin-Agent-Cluster: ?1` header. If you're running the Apache webserver, this package ships a `.htaccess` that will add the header for you.
2. The `iframe-worker.html` is served from a different origin. For convenience, you could point a second domain name to the same server and directory and use it just for the `iframe-worker.html`.

Pick your favorite option and make sure to use it for serving the `iframe-worker.html`.

Example usage:

**/app.js**:

```ts
const phpClient = consumeAPI<PHPClient>(spawnPHPWorkerThread('/iframe-worker.html?script=/worker-thread.js', 'iframe'));
```

**/iframe-worker.html** (Also provided in `@php-wasm/web` package):

```js
<!DOCTYPE html>
<html>
<head></head>
<body style="padding: 0; margin: 0">
<script>
const script = document.createElement('script');
script.type = 'module';
script.src = getEscapeScriptName();
document.body.appendChild(script);

function getEscapeScriptName() {
// Grab ?script= query parameter and securely escape it
}
</script>
</body>
</html>
```
This can be tedious, which is why Playground provides a convenient [consumeAPI](/api/web/function/consumeAPI) function that abstracts the message exchange and exposes specific functions from the web worker. This is why we can call `phpClient.run` in the example above.
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export async function convertFetchEventToPHPRequest(event: FetchEvent) {
* what this function uses to broadcast the message.
*
* @param message The message to broadcast.
* @param scope Target worker thread scope.
* @param scope Target web worker scope.
* @returns The request ID to receive the reply.
*/
export async function broadcastMessageExpectReply(message: any, scope: string) {
Expand Down
5 changes: 1 addition & 4 deletions packages/php-wasm/web/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,4 @@ export { getPHPLoaderModule } from './get-php-loader-module';
export { registerServiceWorker } from './register-service-worker';

export { parseWorkerStartupOptions } from './worker-thread/parse-startup-options';
export {
spawnPHPWorkerThread,
recommendedWorkerBackend,
} from './worker-thread/spawn-php-worker-thread';
export { spawnPHPWorkerThread } from './worker-thread/spawn-php-worker-thread';
2 changes: 1 addition & 1 deletion packages/php-wasm/web/src/lib/register-service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export async function registerServiceWorker<
// the update:
await registration.update();

// Proxy the service worker messages to the worker thread:
// Proxy the service worker messages to the web worker:
navigator.serviceWorker.addEventListener(
'message',
async function onMessage(event) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,16 @@
/**
* Recommended Worker Thread backend.
* It's typically "webworker", but in Firefox it's "iframe"
* because Firefox doesn't support module workers with dynamic imports.
* See https://github.com/mdn/content/issues/24402
*/
export const recommendedWorkerBackend = (function () {
const isFirefox =
typeof navigator !== 'undefined' &&
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 async function spawnPHPWorkerThread(
workerUrl: string,
workerBackend: 'webworker' | 'iframe' = 'webworker',
startupOptions: Record<string, string> = {}
) {
workerUrl = addQueryParams(workerUrl, startupOptions);

if (workerBackend === 'webworker') {
return new Worker(workerUrl, { type: 'module' });
} else if (workerBackend === 'iframe') {
return (await createIframe(workerUrl)).contentWindow!;
} else {
throw new Error(`Unknown backendName: ${workerBackend}`);
}
return new Worker(workerUrl, { type: 'module' });
}

function addQueryParams(
Expand Down
9 changes: 0 additions & 9 deletions packages/playground/remote/iframe-worker.html

This file was deleted.

23 changes: 3 additions & 20 deletions packages/playground/remote/src/lib/boot-playground-remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
spawnPHPWorkerThread,
exposeAPI,
consumeAPI,
recommendedWorkerBackend,
} from '@php-wasm/web';

import type { PlaygroundWorkerEndpoint } from './worker-thread';
Expand All @@ -21,24 +20,8 @@ const origin = new URL('/', (import.meta || {}).url).origin;

// @ts-ignore
import moduleWorkerUrl from './worker-thread?worker&url';
// Hardcoded for now, this file lives in the /public folder
// @ts-ignore
const iframeHtmlUrl = '/iframe-worker.html';

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}`);
}
})();

export const workerUrl: string = new URL(moduleWorkerUrl, origin) + '';

// @ts-ignore
import serviceWorkerPath from '../../service-worker.ts?worker&url';
Expand Down Expand Up @@ -77,7 +60,7 @@ export async function bootPlaygroundRemote() {
LatestSupportedPHPVersion
);
const workerApi = consumeAPI<PlaygroundWorkerEndpoint>(
await spawnPHPWorkerThread(workerUrl, workerBackend, {
await spawnPHPWorkerThread(workerUrl, {
wpVersion,
phpVersion,
persistent: query.has('persistent') ? 'true' : 'false',
Expand Down
1 change: 0 additions & 1 deletion packages/playground/remote/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ export default defineConfig({
rollupOptions: {
input: {
wordpress: path('/remote.html'),
'iframe-worker': path('/iframe-worker.html'),
},
},
},
Expand Down

0 comments on commit c5c47c9

Please sign in to comment.