Development¶
This page covers the architecture, the build pipeline, and how to add a Nextcloud version. The authoritative reference for why Nextcloud runs at all under php-wasm is feasibility-spike.md — read it first.
Architecture¶
The playground is a static site with five layers and no backend. PHP runs in
the browser via @php-wasm.
index.html
-> src/shell/main.js (toolbar, iframe host, blueprint import/export, status)
-> remote.html
-> src/remote/main.js (registers the service worker, hosts the scoped iframe)
-> sw.js (intercepts same-origin requests, routes to the runtime)
-> php-worker.js (owns the @php-wasm PHP instance, crash recovery)
-> src/runtime/bootstrap.js (mount core, install, config, blueprint, autologin)
-> src/runtime/vfs.js (mount the readonly core bundle into MEMFS)
-> @php-wasm/web (via php-loader.js + php-compat.js)
Responsibilities:
- Shell UI (
index.html,src/shell/main.js,src/styles/app.css) — address bar, side panel, blueprint editor, runtime/PHP-version settings. - Runtime host (
remote.html,src/remote/main.js) — registers the SW and hosts the scoped Nextcloud iframe with a boot progress overlay. - Routing (
sw.js,php-worker.js) — the service worker classifies requests (static asset / scoped runtime / other) and forwards scoped requests to the PHP worker; the worker runs them through@php-wasmwith crash recovery. - Runtime boot (
src/runtime/*) — see the boot flow below. - Local dev server (
scripts/dev-server.mjs).
Boot flow (src/runtime/bootstrap.js)¶
- Resolve config + blueprint. Merge
playground.config.jsonwith the active blueprint into the effective config. - Load the manifest and compare to persisted state (
manifestVersion). If the persisted DB matches the current bundle and we're not doing a clean boot, skip the install and just re-apply lightweight steps. - Mount the readonly core — extract the trimmed Nextcloud ZIP bundle into
MEMFS under the Nextcloud root (
src/runtime/vfs.js). The Phase-0 spike instead mounts the source over NODEFS (no copy) in Node; the browser must extract into MEMFS, which is the bundle-size constraint. - Create the mutable layout — the Nextcloud data/config directories plus persisted session state.
- Write config and the PHP prepend.
auto_prepend_fileinstalls the posix polyfill and per-request server setup (src/runtime/php-prepend.js, written to/internal/shared/auto_prepend_file.php— the only path@php-wasmreads). Apply php.ini viasetPhpIniEntries(). - Install Nextcloud — run
occ maintenance:install --database sqlite ...through the shebang-freeconsole.phpwrapper (see below), then set the WASM-safe config flags (filelocking.enabled => false,enabledPreviewProviders => [],check_data_directory_permissions => false, nomemcache.local, plustrusted_domainsandoverwrite.cli.url). - Apply the blueprint — users, groups, apps, config, files, shares (each via
occor a web request). See blueprint-json.md. - Autologin the admin user (when enabled) so the iframe lands on the dashboard with a valid session cookie.
Why the patches exist¶
Under @php-wasm, php_sapi_name() returns wasm and the build is
--disable-posix. Nextcloud is made to run with:
- a posix polyfill (
auto_prepend_file, fakewww-datauid 33), and - a five-patch source set gated on
PHP_SAPI === 'wasm'(no-ops on a real server):base.phpCLI detection, twoConfig.phpflockguards, theconsole.phpowner check, and theAvatar.phpletter-avatar bbox fallback.
The exact lines and rationale are in feasibility-spike.md
and the nextcloud-internals agent skill.
The occ wrapper¶
occ cannot be required directly — its #!/usr/bin/env php shebang is emitted
as text and breaks declare(strict_types=1). Run a shebang-free wrapper as the
main script that sets $_SERVER['argv']/argc, unset($_SERVER['REQUEST_URI'])
(so patched base.php treats it as CLI), chdir()s into the Nextcloud root, and
requires console.php. The reference implementation is the occ() helper in
spike/run-spike.mjs.
Storage model¶
- Readonly core — mounted into MEMFS under the Nextcloud root (never copied into persistent storage wholesale).
- Mutable data dir + SQLite — under the Nextcloud runtime tree, journalled to browser persistence alongside the mutable config and session state.
- Sessions — file-based under
/persist/....
Build pipeline¶
make deps # npm install
make prepare # build:version + sync-browser-deps + build-worker + prepare-runtime
make bundle # fetch a Nextcloud release, trim it, emit bundle + manifest
make serve # dev server (PORT, default 8085)
make up # bundle + serve
The Build ID¶
Every build is stamped with a Build ID that names one deployed artifact:
20260816T065012Z-9e39f37d
└──── UTC build time ───┘ └ commit ┘
It is deliberately not a semantic version — the playground ships a rolling release. Because the timestamp is the build time (not the commit time), rebuilding an unchanged commit still produces a new ID, so two deployments of the same source are distinguishable:
20260816T065012Z-9e39f37d # rebuild
20260823T060003Z-9e39f37d # same source, new artifact
A local build appends -dirty when the working tree has uncommitted changes. CI
builds are never dirty.
Generate it locally — make prepare and make test already do this for you:
npm run build:version # write the metadata files
node scripts/write-build-version.mjs --print-version # print the ID only
BUILD_VERSION=20260816T065012Z-9e39f37d npm run build:version # pin an exact ID
Where to find it:
| Where | What you get |
|---|---|
| Info panel → Runtime → Playground build | The running build, click to copy. |
| Runtime log | One Playground build … line at startup. |
assets/build-version.json |
buildVersion, generatedAt, gitSha, dirty. |
src/generated/build-version.js |
BUILD_VERSION for app code. |
| Sentry | The issue's release. |
Both generated files are git-ignored: nothing hand-maintains an identifier. This
replaced an earlier scheme where scripts/esbuild.worker.mjs wrote a content hash of
the worker bundle into a committed src/generated/build-version.js — that value
could not distinguish two builds of the same source, and it churned in git.
The Deploy Pages workflow computes the ID once and exports it through
$GITHUB_ENV, so every later step (including the second make prepare that
make bundle-all triggers) reuses it, and the GitHub Pages artifact and the
Cloudflare Pages deploy of the same _site report the same build.
The Build ID is also the cache version: it keys the Service Worker's fs-dist-…
cache (activation drops older generations), the sw.js?v=… registration and the
versioned worker URL. It does not key persistent user data — deploying
invalidates code caches without wiping a visitor's site.
The Build ID identifies the Playground itself, never the Nextcloud version running inside it — those are shown separately.
Key scripts (scripts/):
fetch-nextcloud-release.sh— download a Nextcloud release tarball.build-nextcloud-bundle.sh— trim and package the readonly core bundle.generate-manifest.mjs— emit the bundle manifest the runtime reads.prepare-runtime.mjs,sync-browser-deps.mjs— runtime/browser asset prep.esbuild.worker.mjs— bundle the service worker to a root-level classic IIFE (Firefox does not support ES-module service workers).
The runtime readers that must stay in sync with the bundle layout:
src/runtime/manifest.js and src/runtime/vfs.js. If you change the bundle
structure, update the manifest generator and these readers together.
Bundle trimming (the size problem)¶
Nextcloud 31 extracts to 807 MB / 26,865 files — too heavy for a browser
tab. The build trims aggressively: strip **/tests/, **/*.map, **/cypress/,
**/screenshots/, **/l10n/*.po; drop heavy optional apps and updater/; keep
dist/ (compiled UI) and 3rdparty/; keep the default-enabled core app set. If
a trimmed MEMFS bundle is still too large, the fallback is an OPFS-backed mount
for the readonly core. Details in feasibility-spike.md
and the wasm-browser-runtime skill.
How to add a Nextcloud version¶
- Add an entry to
src/shared/nextcloud-versions.js:
js
{ major: "33", release: "latest-33", php: "8.3", default: false }
major— used for the manifest file name and bundle directory.release— the release id passed toscripts/fetch-nextcloud-release.sh.php— the php-wasm runtime version it is built/tested against.-
default— exactly one entry should betrue. -
Build the bundle for that release (
make bundle, with the release override as needed) and confirm the manifest is generated. -
Verify the WASM patch set still applies cleanly against the new source (the patched line numbers in
lib/base.php,lib/private/Config.php,console.php,lib/private/Avatar/Avatar.phpcan shift between majors). -
Smoke-test:
occ maintenance:installexits 0,occ status/status.phpreports installed with the rightversionstring, and/index.php/loginrenders. The Phase-0 spike (spike/run-spike.mjs) is the template.
Testing¶
make test # node --test tests/*.test.mjs (pure helpers, blueprint, prepend)
make test-e2e # Playwright browser suite (shell, runtime, blueprint)
make lint # Biome — must pass clean
See the unit-testing and e2e-playwright agent skills for conventions.
OPcache (?opcache=0)¶
OPcache's file cache never starts under php-wasm's wasm SAPI on PHP 8.3, so
by default ("opcache": true in playground.config.json) the runtime reports
the uwsgi SAPI name and the cache runs. Append ?opcache=0 to the shell URL
to keep the wasm SAPI and turn the cache off; a URL value always overrides
the config. The parameter travels
shell → remote.html → PHP worker; isOpcacheEnabled() in
src/shared/config.js is the single decision point. It requires a core bundle
built by the current scripts/build-nextcloud-bundle.sh (its wasm patches
also accept uwsgi). Behaviour, memory cost and the persistence caveat are in
known issues; tests/e2e/opcache-canary.spec.mjs
asserts that a rewritten PHP file recompiles with and without the cache. The
"OPcache file cache" checkbox in the Info panel writes the same parameter into
the shell URL (history.replaceState), asks the worker to flush the
persistence journal (flush-persistence → persistence-flushed; the debounced
flush could otherwise leave the install marker behind and the next boot would
reinstall) and reloads only the runtime iframe with the same scope: a new
worker starts with the new SAPI name and /persist is kept ("Apply & Reload");
combined with a version change the destructive "Apply & Reset" flow still wins. Use scripts/perf-compare.mjs with and
without the parameter before changing the default.
Performance comparisons¶
When working on boot time, bundle size, persistence, service worker caching, or other performance-sensitive changes, use the comparison script:
node scripts/perf-compare.mjs \
--base=https://nextcloud-playground.pages.dev/ \
--candidate=https://<your-branch-or-pr>.nextcloud-playground.pages.dev/ \
--iterations=5
Key flags:
--base/--candidate(also accept--prod/--preview,--before/--after)--iterations=N(default 3; boots are expensive)--no-clean— measure warm boots (caches, SW, IndexedDB journals)--headed— visible browser for debugging--json— machine-readable output for archiving results--label-base=... --label-candidate=...— nicer names in the report
The script forces clean boots by default (unique ?blueprint-data= on every
run). This exercises the full expensive path: streaming the core bundle,
extracting into MEMFS, running occ maintenance:install, etc. This is usually
the path you care about for performance PRs.
Make target (basic usage):
BASE=https://nextcloud-playground.pages.dev/ \
CANDIDATE=https://my-pr-slug.nextcloud-playground.pages.dev/ \
ITERATIONS=5 make perf-compare
See the top of scripts/perf-compare.mjs for more examples and caveats
(network variance, Cloudflare POPs, etc.).
Typical workflow for a perf PR:
- Open a PR → Cloudflare Pages posts a preview URL in the comments.
- Run the comparison locally against production (or against
main). - Paste the summary (or the JSON) into the PR description or a comment.
- Optionally re-run with
--no-cleanto show warm-boot improvements.
When to update docs¶
Update docs in the same change if you touch: playground.config.json, the
default blueprint, the boot flow in src/runtime/bootstrap.js, the storage /
manifest model, the bundle build, the WASM patch set, or the SW routing.
Exclusive scope ownership¶
Reset can clear the stored blueprint before the next remote host starts. The remote host treats that missing blueprint as the default configuration, including the optional sandboxed-iframe compatibility setting.
The Reset regression waits for a new remote-host document with clean=1, then
checks the current Nextcloud Files DOM and a single POST response. Detachment of
an app frame alone is not a Reset boundary: the bootstrap/autologin navigation
can also replace that frame. Resolve the live nested documents after navigation
rather than retaining an old app-frame handle.
Each scope has one active remote host, enforced by a native Web Lock before any
worker starts or persisted state is cleared. A duplicated tab or explicit
?scope= opened twice reports an ownership conflict; close the original owner
and explicitly reload the other tab, or use a fresh scope. Different scopes
remain independent. Web Locks are required; a missing or denied API fails closed.
pagehide terminates PHP before releasing ownership; BFCache restores reload the
remote host. Shell notifications are isolated by iframe generation, and HTTP
listening starts only after worker configuration. The Service Worker, response
UUIDs, persistence namespaces and safe crash-replay rules are unchanged. No POST
is queued or replayed on handover. Deployments must close old tabs and refresh
stale Service Worker caches because older code does not acquire the lock.
Concurrent app-frame routing: separate follow-up¶
A local Chromium check against both unchanged main and the ownership fix found that, with two distinct scopes open, a POST from the second Nextcloud app iframe can reach the static server and return HTTP 405 instead of entering the PHP channel. Reload of that tab can also fail to commit. This is not corrected by the ownership gate. The independent-scope regression verifies native lock ownership and the controlled remote-host HTTP bridge; ordinary reload and Reset are tested separately. Do not interpret those checks as validation of every concurrent Nextcloud app-frame navigation. Investigate routing separately before claiming full multi-instance browser support.
One cause is fixed (issue #140). The shell and remote host used to register the
Service Worker with scope and runtime in its script URL, which sw.js never
read. The script URL is the registration's identity, so opening a second scope
installed a new worker that took control of every open tab, including one with
a request in flight. The URL now carries only v=<build>; sw.js takes the
scope and runtime from the /playground/<scope>/<runtime>/ path. The
independent-scope e2e asserts that no new worker is installed or waiting. A
later local check sent POSTs from both app iframes and all reached the PHP
channel through the Service Worker, but the static-server 405 was never
reproduced before the fix either, so treat it as unconfirmed rather than
resolved.