@alganet/phasm
v0.6.0
Published
PHP 8.5 compiled to WebAssembly
Readme
phasm
PHP 8.5 compiled to WebAssembly. Run PHP in the browser or in Node.js.
npm install @alganet/phasmIncluded Extensions
calendar, ctype, dom, fileinfo, filter, iconv, libxml, mbstring, opcache, openssl, pcntl, pdo, pdo_sqlite, phar, session, simplexml, sqlite3, tokenizer, xml, xmlwriter, zip, zlib.
Plus PHP's always-on core: Core, date, hash, json, lexbor, pcre, random, Reflection, SPL, standard, uri.
get_loaded_extensions() is the authority; npm test asserts this list matches
what the binary actually links, so it cannot drift again.
openssl is the crypto, not the network. openssl_encrypt, the digest and
HMAC surface, RSA and EC keys, signing, X.509, PKCS#7 and PKCS#12 all work, and
so does openssl_random_pseudo_bytes — seeded from /dev/urandom, which is
crypto.getRandomValues() in a browser and randomBytes() under Node. The
ssl:// and tls:// transports register and their stream contexts parse, but
this target has no sockets for them to sit on, so nothing dials out. Use fetch
from the host for that.
Trimmed to what a browser build can reach: no legacy provider (so no RC4), no engines or loadable modules, no QUIC, no kernel TLS, no compression, no argon2.
Node.js
import Phasm from "@alganet/phasm";
const php = await Phasm();
const { stdout, stderr, exitCode } = php.run({
script: `<?php echo "Hello from PHP!\\n";`,
});Browser
<script src="https://unpkg.com/@alganet/phasm/dist/php.js"></script>
<pre id="output"></pre>
<script>
Phasm().then((php) => {
const { stdout } = php.run({ code: 'echo "Hello from PHP!";' });
document.getElementById("output").textContent = stdout;
});
</script>TypeScript
Types ship with the package — Phasm, PhasmOptions, PhasmModule, PhasmFS,
PhasmRunOptions, PhasmRunResult, PhasmRequest and PhasmResponse are all
declared, no @types package needed. The subpaths carry their own: Store and
MountOptions on @alganet/phasm/mount.
import Phasm, { type PhasmModule, type PhasmRunResult } from "@alganet/phasm";
const php: PhasmModule = await Phasm();
const result: PhasmRunResult = php.run({ code: "echo 1;" });Running PHP
run() takes one call's worth of everything and gives back what it produced:
const { stdout, stderr, exitCode } = php.run({
args: ["app.php", "--verbose"],
files: { "/project/app.php": '<?php echo getenv("APP_ENV"), $argv[1];' },
stdin: "piped in",
cwd: "/project",
env: { APP_ENV: "dev" },
});| Option | Description |
|--------------|----------------------------------------------------------------------|
| args | argv after argv[0]. Wins over code and script. |
| code | A snippet, as -r — no <?php tag. |
| script | PHP source, mounted at /main.php and run. |
| files | Written first; missing directories are created. |
| stdin | Text, bytes, or a function pulled from as PHP reads. |
| cwd, env | This call only. |
| onOutput | (bytes, channel) as output happens, for streaming. |
| collect | false to skip buffering when onOutput already has the bytes. |
onOutput gets each chunk exactly once, and throwing from it is how a sink
refuses: the write fails for PHP, which normally ends the call, and the error
comes back out of run() rather than as a bare non-zero status. That is the
path a shell builtin is on — writing to a device that refuses it — so the
refusal reaches the script instead of looking like output nobody read.
Everything there is per call: cwd and env are gone by the next one, output
belongs to this call alone, stdin is refilled. The filesystem, the instance and
its ini survive. The options and the {stdout, stderr, exitCode} result are
wasi-sh's, so a shell run and a PHP run
compose without an adapter between them.
One instance, many runs. phasm builds its own SAPI (sapi/phasm) rather
than the stock CLI, so each call is a full request without ever exiting the
process: the exit status is per call, errors go to stderr, and a fatal error or
exit() leaves the module usable. Booting PHP costs ~70 ms and a warm call
~1 ms, so reuse the instance.
Runaway recursion is a PHP error, not a crash. Recursion inside the engine
and its extensions — json_encode(), serialize(), var_dump(), the compiler
— is C recursion, and deep enough it reaches a limit belonging to the JS engine
rather than to PHP. phasm builds PHP with its own zend.max_allowed_stack_size
guard, 512K by default, so those stop with the error each of them already
raises for the case — json_encode() returns false with JSON_ERROR_DEPTH,
serialize() throws — instead of the call ending from outside PHP. That budget
is a few hundred levels of nesting, far past what real data carries. Raise it,
or pass -1 to switch the guard off, through
phasmStartup("zend.max_allowed_stack_size=…").
Ordinary PHP recursion is not affected: a function calling itself runs in the VM, not on the C stack, and goes hundreds of thousands deep.
The guard covers what PHP itself guards, which is not everything — unserialize()
with its max_depth disabled still gets there, and so does recursion that runs
the module out of memory. Exhausting the stack is the one failure that is not an
exit status: there is no PHP error left to raise, so the call throws rather
than returning. Catch it if the call site cares — the instance itself survives,
because the abandoned request is finished before the error reaches you.
A script that will not stop can be stopped from outside. Pass an
interrupted callback and PHP polls it while the script runs; answering true
ends the call with a fatal and an exit status of 130, the status a shell
reports for a ^C:
const started = Date.now();
const r = php.run({
code: "while (true) {}",
interrupted: () => Date.now() - started > 5000,
});
r.exitCode; // 130 — and the instance, its filesystem and its opcache are intactNothing else can be running to interrupt a script — one wasm guest owns its
thread for as long as it runs — so PHP asks for itself, at the safe points its
VM already checks, about one opcode in a few hundred. Keep the callback cheap.
What it does not reach is a loop with no PHP opcode in it: catastrophic
backtracking inside preg_match, a huge str_repeat, usleep(). Those stop
when the function returns. Passing nothing installs no polling at all, and a
call with no interrupted cannot be stopped — the only honest default for work
that never opted in. phasmHandleRequest() takes the same option, and a
request stopped that way still answers, with a 500.
Underneath, phasmRun(args, opts) returns the status and leaves output wherever
the module's stdio points — reach for it when you are routing stdio yourself,
and for anything else phasmCapture(fn) collects around a call run() does not
cover. callMain() is still there and still one-shot: it re-enters the CLI's
main(), which ends in exit(). Pick one entry point per module; they cannot
be mixed, and each refuses the call rather than leaving you with a dead
instance.
Configuration
Phasm() is a factory function that accepts standard Emscripten Module
options and returns a promise that resolves to the initialized module:
| Option | Description |
|------------------|---------------------------------------------------------|
| arguments | Default CLI arguments for a bare callMain(). |
| print(text) | Callback for stdout produced outside run(). |
| printErr(text) | Callback for stderr produced outside run(). |
| stdin() | Callback to provide stdin input. Return null for EOF. |
The module owns its standard streams, which is what lets run() return one
call's output rather than a global stream: print, printErr and stdin are
consulted whenever no run() is in flight, so they keep behaving as they always
did. Installing your own FS.init() sinks still works and still wins — run()
then says it cannot capture, rather than reporting that PHP printed nothing.
Caching compiled scripts
opcache is built in and does nothing until you name a directory for it to cache into. Two settings, both required, and the directory has to exist before PHP starts:
const php = await Phasm();
php.FS.mkdir("/cache");
php.phasmStartup("opcache.file_cache=/cache\nopcache.file_cache_only=1");file_cache_only is not optional here. opcache normally caches into shared
memory and treats the file cache as a second tier; wasm has no shared memory to
offer it, so without this the accelerator finds no backend and switches itself
off. A file cache is the only mode this build has.
What it buys, over 80 files of ~500 KiB: first run 31 ms → 10 ms, and every run after ~13 ms → ~5 ms. Compiled scripts are ordinary files, so a cache directory in a shared or persistent store survives the instance that filled it — that is what makes the first number a cold page load rather than a warm one. Budget for the size: the cache runs several times larger than the source it was compiled from (3.3 MiB for that 510 KiB).
Entries are keyed by a build id, so a cache is only ever read back by the exact
php.wasm that wrote it; a cache built against an older release is ignored, not
mis-read. Sources are validated by timestamp, so the store has to keep real
mtimes — against one that reports 0 for everything, nothing is ever cached
and nothing says so. A file is cacheable from the second after it was written,
which is what keeps an edit from being shadowed by the entry compiled from the
version before it; set opcache.validate_timestamps=0 if a project is genuinely
read-only and you want that check gone.
As a shell command
run() is what a shell needs, and it is deliberately not PHP's own vocabulary —
its options and its result are wasi-sh's,
which is why the two compose with almost nothing between them:
const { stdout, stderr, exitCode } = php.run({
args: ["-r", 'echo getenv("HOME");'],
cwd: "/site",
env: { HOME: "/site" },
collect: false,
onOutput: (bytes, channel) => channel === "stdout" ? out(bytes) : err(bytes),
});A host builtin over that is about thirty lines, and it comes with pipes,
redirects, $(…) and $? already working, because by dispatch time the shell
has installed the redirections. What cannot work is anything needing a
process — php &, (php x), exec php, find -exec php — because a builtin
is not one. Both guests must share one filesystem: paths are passed through as
typed and nothing is copied. The shared filesystem
is one more call.
^C works, and this is the option that makes it work. interrupted is a
closure the embedder passes; PHP samples it at the VM safe points it already
runs, so a runaway php -r 'while (true);' comes back in milliseconds with
130 in $? and the instance, its filesystem and its warm opcache intact.
Without it the only way out of that command is terminate(), which takes all
three. test/interrupt.test.mjs proves it over real shared memory, which is the
only place it can be proved.
Serving HTTP
phasmHandleRequest() runs a real PHP request rather than a command, so
header(), status codes and the superglobals work as they do under any other
web SAPI — because PHP's own request machinery produces them, rather than them
being filled in afterwards:
const res = php.phasmHandleRequest({
method: "POST",
url: "/blog/?page=2",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new TextEncoder().encode("title=hello"),
docroot: "/site",
});
// { status, headers, body } — body is bytes, so images surviveThe path resolves under docroot, a directory resolves to its index.php, and
a missing one is a 404. A status of 0 means the path is not a PHP script:
that is a decline rather than an error, and the caller should serve the file
itself — PHP has no business deciding that a .css file is text/css.
A path that continues past a script is split the way CGI splits it, so
/index.php/users/1 runs index.php with PATH_INFO=/users/1 — the
no-rewrite front controller, which is the one routing shape a project can rely
on with nothing configured anywhere. SCRIPT_NAME is the script, PHP_SELF
carries the path info too, and PATH_TRANSLATED is the path info resolved
against the docroot. The deepest script that exists wins, and a prefix that is
a file but not a script is a 404 rather than a decline.
There is still no front-controller rewrite: /users/1, with no script in it
anywhere, is a 404 and not /index.php. That is the caller's decision to make,
the same way declining a .css file is — a framework that wants every request
to reach one script asks for it directly:
let res = php.phasmHandleRequest({ url, docroot: "/site" });
if (res.status === 404) {
res = php.phasmHandleRequest({ url: `/index.php?${query}`, docroot: "/site" });
}The shape is the web platform's on purpose, so a service worker can pass a
Request almost straight in and build a Response almost straight out —
headers comes back as [name, value] pairs, which new Headers() accepts and
which keeps repeated Set-Cookie headers intact. Requests and phasmRun()
commands share one instance and one filesystem.
One inherited default worth knowing: phasm takes the CLI's output_buffering=0,
so the headers are committed as soon as a script echoes anything, and a
register_shutdown_function() that calls header() after that is too late.
Pass phasmStartup("output_buffering=4096") before the first call for the
behaviour php-fpm gives you.
Virtual Filesystem
Phasm uses Emscripten's virtual filesystem. run({ files }) writes into it, and
FS is there for everything else — it outlives the call, so a script can leave
something behind for the next one:
php.run({ files: { "/data.json": JSON.stringify({ key: "value" }) } });
php.run({ code: 'file_put_contents("/out.txt", file_get_contents("/data.json"));' });
php.FS.readFile("/out.txt", { encoding: "utf8" });See the Emscripten File System API for the full reference.
Sharing a filesystem
That filesystem is phasm's own, which stops being enough the moment something
else — a shell, an editor, a service worker — has to see the same project.
mountStore() hands ownership over: the store stays outside and JS-owned, and
PHP reads and writes through it.
import Phasm from "@alganet/phasm";
import { mountStore } from "@alganet/phasm/mount";
import { memoryFs } from "wasi-sh/fs";
const store = memoryFs({ "/app/index.php": '<?php echo "hi";' });
const php = await Phasm();
await mountStore(php, store, { path: "/app" });
php.run({ args: ["/app/index.php"] }).stdout; // 'hi'A store is any object carrying the twelve synchronous, path-addressed methods of
ZenFS's FileSystem — which is also
wasi-sh's fs contract, so a shell's
store, a @zenfs/core filesystem and a persistent OPFS-backed one are all the
same argument here. Nothing is copied in at mount time or flushed out at the
end: a file the shell just wrote is the file PHP opens, an edit made outside is
the code the next request runs, and a database PHP writes is a file the page
still has after a reload.
path is where the store appears in PHP's filesystem; root is the directory
of the store that lands there, and it defaults to path. That default is the
one to keep — it makes the mount an identity mapping, so /app in the shell is
/app in PHP, which is what a shell typing php app/index.php depends on.
Mounting at / is not possible: Emscripten's root is already in memory and
/dev, /tmp and /proc have to stay there. Mount the directories the project
lives in, one call each.
@zenfs/core and @zenfs/emscripten are optional peer dependencies — the
Emscripten-side translation is theirs, and embedding PHP in a page without
mounting anything installs neither:
npm install @zenfs/core @zenfs/emscriptenLive Demo
alganet.github.io/phasm — one file, one Run
button. The smallest thing that shows PHP running in a page: no build step in
sight, php.wasm fetched and run() called.
Contributing
See CONTRIBUTING.md for build instructions and development guidelines.
Acknowledgements
mountStore() is built on ZenFS — @zenfs/core
and @zenfs/emscripten, LGPL-3.0-or-later
with a web-application exception, used unmodified.
The binary statically links OpenSSL
(Apache-2.0, unmodified, pinned to a version and a SHA-256 in
scripts/env.sh) and carries upstream's own apps/openssl.cnf
verbatim at /usr/local/ssl/openssl.cnf inside the module's filesystem —
ext/openssl cannot generate a key without one.
