[CLI] Optimization: Serve static files without occupying PHP workers#3437
[CLI] Optimization: Serve static files without occupying PHP workers#3437brandonpayton wants to merge 3 commits intotrunkfrom
Conversation
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.
|
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. |
| * 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. |
| // Create main-thread router backed by the | ||
| // host filesystem so static files can be served | ||
| // without a worker round-trip. | ||
| const routerResult = createMainThreadRouter( |
There was a problem hiding this comment.
Great naming and documentation, I like how explicit the "main thread" part of it is since it's an important part of the design.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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 |
There was a problem hiding this comment.
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.
|
Thanks for exploring this further, @brandonpayton 🙏 I'm glad we managed to squeeze out some additional performance with this approach. |
Summary
PHPRequestHandlerinto a standaloneRequestRouterclass that resolves request URLs to routing decisions without any PHP dependencyRequestRouteron the main thread backed by the host filesystem, serving static files directly from disk without a worker round-tripNote: @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
RequestRouteris a pure routing engine that takes aRouterFilesystemabstraction (isFile/isDir) and resolves requests through rewrite rules, path aliases, directory handling, and file-not-found fallbacks — the same logic previously buried insidePHPRequestHandler.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.
PHPRequestHandlernow usesRequestRouterinternally — this is a refactor of existing logic with no behavior change for non-CLI consumers.Performance
10 rounds, sequential, medians. Two runs for consistency:
The
siteEditorLoadimprovement (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
packages/php-wasm/universal/src/lib/request-router.tsRequestRouter,RouterFilesystem,ResolvedRoutepackages/php-wasm/universal/src/lib/request-router.spec.tspackages/php-wasm/universal/src/lib/php-request-handler.tsRequestRouterinternallypackages/playground/cli/src/run-cli.tsTest plan
npx nx test php-wasm-universal— 174 tests pass (including 16 new RequestRouter tests)npx nx perf playground-cli -- --rounds=3— no regressionnpx nx dev playground-cli server— WordPress loads, admin works, static assets served, pretty permalinks work🤖 Generated with Claude Code