Skip to content

[CLI] Optimization: Serve static files without occupying PHP workers#3437

Draft
brandonpayton wants to merge 3 commits intotrunkfrom
streaming-only
Draft

[CLI] Optimization: Serve static files without occupying PHP workers#3437
brandonpayton wants to merge 3 commits intotrunkfrom
streaming-only

Conversation

@brandonpayton
Copy link
Copy Markdown
Member

@brandonpayton brandonpayton commented Mar 26, 2026

Summary

  • Extracts routing logic from PHPRequestHandler into a standalone RequestRouter class that resolves request URLs to routing decisions without any PHP dependency
  • The CLI creates its own RequestRouter on the main thread backed by the host filesystem, serving static files directly from disk without a worker round-trip

Note: @fredrikekelund proposed this earlier, but we did not pursue it because the perf tests didn't seem to show clear gains and because there were concerns about skipping php-wasm request routing. This PR attempts to address the request routing concerns and seems to reliably lead to a 10-20% improvement in site editor loading.

How it works

RequestRouter is a pure routing engine that takes a RouterFilesystem abstraction (isFile/isDir) and resolves requests through rewrite rules, path aliases, directory handling, and file-not-found fallbacks — the same logic previously buried inside PHPRequestHandler.requestStreamed().

In the CLI, the router is wired to the host filesystem via the mount table. When a request resolves to a static file that exists on disk, it's served directly from the main thread using Node.js streams. PHP requests and files not found on the host (e.g., runtime-generated files) fall through to the worker pool as before.

PHPRequestHandler now uses RequestRouter internally — this is a refactor of existing logic with no behavior change for non-CLI consumers.

Performance

10 rounds, sequential, medians. Two runs for consistency:

Metric trunk streaming-only Run 1 Δ Run 2 Δ
siteEditorLoad 1.98s / 2.07s 1.62s / 1.83s -18.2% -11.6%
templatesViewLoad 123ms / 126ms 125ms / 122ms +1.6% -3.2%
templateOpen 1.97s / 2.00s 1.94s / 1.94s -1.5% -3.0%
blockAdd 1.39s / 1.56s 1.58s / 1.57s +13.7% +0.6%
templateSave 857ms / 860ms 842ms / 843ms -1.8% -2.0%
serverStartup 5.49s / 6.18s 5.87s / 6.18s +6.9% 0.0%

The siteEditorLoad improvement (12-18%) is the main signal — static files served from the main thread free up PHP workers for the heavy initial PHP requests. Other metrics are within noise.

Key files

File Change
packages/php-wasm/universal/src/lib/request-router.ts NewRequestRouter, RouterFilesystem, ResolvedRoute
packages/php-wasm/universal/src/lib/request-router.spec.ts New — 16 tests covering all routing scenarios
packages/php-wasm/universal/src/lib/php-request-handler.ts Refactored to use RequestRouter internally
packages/playground/cli/src/run-cli.ts Main-thread router + host FS serving

Test plan

  • npx nx test php-wasm-universal — 174 tests pass (including 16 new RequestRouter tests)
  • npx nx perf playground-cli -- --rounds=3 — no regression
  • Manual: npx nx dev playground-cli server — WordPress loads, admin works, static assets served, pretty permalinks work

🤖 Generated with Claude Code

Extract routing logic from PHPRequestHandler into a standalone
RequestRouter class. The CLI creates its own router on the main thread
backed by the host filesystem, serving static files directly from disk
without a worker round-trip. PHP requests fall through to workers.

Also adds FSHelpers.streamFile() for chunked file reading from both
MEMFS and NODEFS-backed filesystems.
The CLI serves static files directly from the host filesystem via
Node.js createReadStream, so streamFile() in FSHelpers/PHP is unused
by this PR. Revert PHPRequestHandler to trunk's readFileAsBuffer
approach for static files.
@brandonpayton
Copy link
Copy Markdown
Member Author

There are some legitimate test failures here, particularly around PHPRequestHandler, but they should be straightforward to fix.

The Playground CLI test failure seems to be due to a single test setup that is broken by the change.

Comment on lines +48 to +50
* A pure routing engine that resolves a request URL to a routing
* decision. Has no PHP dependency — only needs a filesystem
* abstraction for isFile/isDir checks.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

Comment on lines +1480 to +1483
// Create main-thread router backed by the
// host filesystem so static files can be served
// without a worker round-trip.
const routerResult = createMainThreadRouter(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great naming and documentation, I like how explicit the "main thread" part of it is since it's an important part of the design.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was actually generated by Claude based on my prompts. I like the naming here too.

const route = mainThreadRouter.resolve(request);
switch (route.type) {
case 'static-file': {
const hostPath = mapVfsToHost(route.fsPath);
Copy link
Copy Markdown
Collaborator

@adamziel adamziel Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this done to go straight to fs instead of going through emscripten layers of MEMFS, NODEFS, ..., filesystem? That's sensible. Let's just be extra careful about sandbox escapes here – if something is not accessible via VFS, we should not expose it here. Some test coverage would be useful to test for various escape scenarios involving symlinks, ../ traversals, / absolute paths etc.

Copy link
Copy Markdown
Member Author

@brandonpayton brandonpayton Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adamziel that is an excellent point. I really don't like working outside of the VFS, but it seems like a good direction in general. It's similar to how nginx serves static files directly instead of relaying such requests to php-fpm.

I agree that we need thorough, paranoid tests here.


/**
* Serves a static file from the host filesystem as a
* StreamedPHPResponse, using Node.js streams to avoid
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, great docs highlighting the streaming aspect of it. These are so important for when someone modifies this code and, without context, may end up buffering things.

@fredrikekelund
Copy link
Copy Markdown
Contributor

Thanks for exploring this further, @brandonpayton 🙏 I'm glad we managed to squeeze out some additional performance with this approach.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants