Wasm

A Phar application also runs in the browser. A service worker boots PHP compiled to WebAssembly (php-cgi-wasm) and hands every request to app.phar. No PHP runtime, no application server — static file hosting is all it takes.

browser ──fetch──> service worker ──> php-cgi-wasm ──> app.phar (BEAR.Sunday)
                    (wasm/sw.js)       └─ pdo_sqlite

Resources return HTML, and _links become <a> and <form>. State goes to SQLite (pdo_sqlite), held by the browser’s IndexedDB.

A working demo is koriym/wasm-todo (live page).

Requires BEAR.Package 1.24+. The ReadOnlyAppModule in ProdModule is the one Phar shows.

Build

The archive is built on the build machine, as with Phar.

composer compile          # bin/compile.php compiles prod-html-app and packs app.phar

To answer in HTML, HtmlModule installs QiqModule over var/qiq/template. QiqProdModule in ProdModule registers a compile step, so the build compiles the templates into var/build/{context}/qiq and the archive carries them. No template is compiled after boot.

Packing cannot happen inside wasm: Compiler::phar() uses a subprocess with phar.readonly=0, and wasm has no processes. What wasm does is boot the finished archive.

Run

index.html registers the service worker. The worker places app.phar on the wasm virtual filesystem and routes every request through PHP.

Browsers discard service workers. A restarted worker has an empty filesystem. Use the runtime’s files: preload to place /index.php again on every start.

import { PhpCgiWebBase } from 'php-cgi-wasm/PhpCgiWebBase.mjs';
import Php85CgiWorker from 'php-cgi-wasm/php8.5-cgi-worker.mjs';
import phar from 'php-wasm-phar';
import sqlite from 'php-wasm-sqlite';

const php = new PhpCgiWebBase(
    Promise.resolve({ default: Php85CgiWorker }),
    {
        version: '8.5',
        sharedLibs: [phar, sqlite],
        files: [{ parent: '/', name: 'index.php', url: 'app.phar' }],
        env: { CONTEXT: 'prod-html-app' },
    }
);

self.addEventListener('fetch', event => php.handleFetchEvent(event));

This is all the JavaScript there is.

The phar and sqlite extensions are not in php-cgi-wasm’s default build; php-wasm-phar and php-wasm-sqlite hand them to sharedLibs.

Node.js does the bundling, and the result goes on any static server.

cd wasm
npm install
npm run build             # bundles sw.js and collects assets into dist/
npx serve dist            # any static server

Subpath

GitHub Pages serves under a subpath such as https://{user}.github.io/{repo}/. The worker derives its basePath from its own URL, and the entry point strips BASE_PATH before routing. Resources return relative links, so links stay correct under any subpath. A 303 Location is relative too and resolves against the request URL, so every route sits at the same depth. A static host has no file for an app route, so 404.html answers with the same bootstrap and the first visit to a deep link is handed to the worker.

Persistence

On the wasm virtual filesystem, /persist is the mount synced to IndexedDB: a SQLite database placed there survives reloads. In the demo, TodoRepository writes /persist/todo.db.

Wasm limits

MySQL does not run. Browser wasm has no raw TCP sockets, so neither mysqli nor pdo_mysql can connect. Storage is SQLite or PGlite, both running in the browser, or a remote Cloudflare D1. The limit is TCP, not HTTP.

Demo

koriym/wasm-todo is four resources (Todos/Todo, plus todo-toggle/todo-delete for the transitions), Qiq templates, a TodoRepository, a service worker and a build script. The page resources implement onGet and onPost only, and a transition that changes state is a resource of its own, so a form never needs more than POST. GitHub Actions builds the phar, esbuild bundles it, and GitHub Pages serves it.