npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/phasm

Included 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 intact

Nothing 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 processphp &, (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 survive

The 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/emscripten

Live 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.

License

ISC