Wasm

Pharにしたアプリケーションは、ブラウザの中でも動きます。service workerがWebAssemblyにコンパイルされたPHP(php-cgi-wasm)を起動し、ブラウザのリクエストをすべてapp.pharに渡します。サーバーにPHPランタイムもアプリケーションサーバーも要りません。静的ファイルを置ける場所さえあれば動きます。

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

リソースはHTMLを返し、_links<a><form>になります。状態はSQLite(pdo_sqlite)に書かれ、ブラウザのIndexedDBが保持します。

動くデモはkoriym/wasm-todo公開ページ)です。

BEAR.Package 1.24以降が必要です。ProdModuleに書くReadOnlyAppModulePharと同じです。

ビルド

アーカイブはビルドマシンで作ります。Pharと同じです。

composer compile          # bin/compile.php が prod-html-app をコンパイルして app.phar にパック

HTMLで答えるために、HtmlModuleQiqModuleをインストールします。テンプレートはvar/qiq/templateに置きます。ProdModuleに入れるQiqProdModuleはコンパイルステップを登録するので、テンプレートはビルドでvar/build/{context}/qiqにコンパイルされ、アーカイブがそれを運びます。起動してからテンプレートをコンパイルすることはありません。

wasmの中でパックはできません。Compiler::phar()phar.readonly=0の子プロセスを使い、wasmにプロセスはないからです。wasmがするのは、出来上がったアーカイブの起動だけです。

動かす

index.htmlがservice workerを登録します。workerはapp.pharをwasmの仮想ファイルシステムに置き、すべてのリクエストをPHPに渡します。

ブラウザはservice workerを使い捨てます。killされて再起動したときのファイルシステムは空です。ライブラリのfiles:プリロードを使い、起動のたびに/index.phpに入れ直します。

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));

JavaScriptはこれだけです。

pharsqliteの拡張はphp-cgi-wasmの既定ビルドに入っていません。php-wasm-pharphp-wasm-sqlitesharedLibsで渡します。

バンドルはNode.jsで行い、出来たものを静的サーバーに置きます。

cd wasm
npm install
npm run build             # sw.js をバンドルし、dist/ にアセットを集める
npx serve dist            # 任意の静的サーバー

サブパス

GitHub Pagesはhttps://{user}.github.io/{repo}/のようにサブパスで配信します。workerは自身のURLからbasePathを導出し、エントリポイントがBASE_PATHを剥がしてからルーティングします。リソースが返すリンクは相対なので、どのサブパスでも正しいままです。303のLocationも相対で、要求URLを基準に解決されます。だからルートはすべて同じ深さに置きます。静的ホストにはアプリのルートに対応するファイルがないので、404.htmlが同じブートストラップを返し、ディープリンクの初回訪問もworkerに引き継がれます。

永続化

wasmの仮想ファイルシステムのうち、/persistはIndexedDBに同期されるマウントです。ここに置いたSQLiteのデータベースはリロードしても残ります。デモではTodoRepository/persist/todo.dbに書きます。

wasmの制限

MySQLは動きません。ブラウザのwasmにはraw TCPソケットがなく、mysqlipdo_mysqlも接続できません。ストレージはブラウザの中で動くSQLiteかPGlite、あるいはリモートのCloudflare D1です。制限はTCPであって、HTTPではありません。

デモ

koriym/wasm-todoは、リソース4つ(Todos/Todoと、遷移のtodo-toggle/todo-delete)、Qiqのテンプレート、TodoRepository、service worker、ビルドスクリプトの構成です。ページリソースが実装するのはonGetonPostだけで、状態を変える遷移はそれぞれリソースになっています。だからフォームはPOSTだけで足ります。GitHub Actionsがpharをビルドし、esbuildでバンドルし、GitHub Pagesが配信します。