Skip to content

HTTP/2 support in Playground CLI#3419

Draft
ashfame wants to merge 14 commits intotrunkfrom
cli_http2
Draft

HTTP/2 support in Playground CLI#3419
ashfame wants to merge 14 commits intotrunkfrom
cli_http2

Conversation

@ashfame
Copy link
Copy Markdown
Member

@ashfame ashfame commented Mar 20, 2026

Motivation for the change, related issues

The Playground CLI currently serves WordPress over HTTP/1.1, which limits browser connections to 6 parallel TCP connections per origin. HTTP/2 multiplexes all requests over a single TLS connection, eliminating head-of-line blocking and improving page load performance — especially for asset-heavy WordPress pages.

Implementation details

TLS certificate provisioning (tls.ts)

  • Smart fallback chain: user-supplied certs (--ssl-cert / --ssl-key) > mkcert (if installed with trusted CA) > self-signed fallback with tip message
  • mkcert detection via mkcert -CAROOT and CA root validation
  • Self-signed certs generated via OpenSSL, valid for localhost/127.0.0.1, 30-day expiry
  • All temp files cleaned up after cert generation

HTTP/2 server (start-server.ts)

  • Opt-in via --http2 flag; default HTTP/1.1 Express path is unchanged
  • Uses Node.js native http2.createSecureServer() with allowHTTP1: true for backward compatibility
  • Shared RequestListener abstraction works with both Express and HTTP/2 request/response types
  • HTTP/2 pseudo-headers (:method, :path, :scheme, :authority) filtered out in parseHeaders() so they don't leak into PHP's $_SERVER

SERVER_PROTOCOL plumbing

  • req.httpVersion (set by Node.js for both HTTP/1.1 and HTTP/2) is read and passed through as protocolVersion on PHPRequest
  • PHPRequestHandler forwards it as $_SERVER['SERVER_PROTOCOL'] to PHP
  • PHP correctly reports HTTP/2.0 for h2 requests and HTTP/1.1 for h1 requests — no C SAPI changes needed

Worker pool sizing (run-cli.ts)

  • Worker count is explicit: --workers (default 6). The CLI spawns exactly that many PHP worker threads; it does not auto-scale based on memory.
  • Free memory is checked at startup using the heuristic ~100MB per worker and 50% of os.freemem() as a budget. If the chosen worker count looks high relative to free RAM, the CLI logs a warning and continues with the requested count.

New CLI flags

  • --http2 — enable HTTP/2 with TLS
  • --ssl-cert / --ssl-key — supply custom TLS certificates
  • --workers — number of PHP worker threads (default 6)

Testing Instructions (or ideally a Blueprint)

Start the CLI with HTTP/2 enabled:

npx nx dev playground-cli server --http2

1. Protocol negotiation (Chrome DevTools)

Open the Network tab, enable the "Protocol" column. Load https://localhost:9400/. Requests should show h2.

2. Multiplexing

In the Network tab Waterfall, requests should fire concurrently over a single connection — no 6-connection staircase pattern.

3. HTTP/1.1 fallback

curl -k --http1.1 https://localhost:9400/

Should return a valid response (confirms allowHTTP1: true works).

4. HTTP/2 via curl

curl -k --http2 -v https://localhost:9400/

Look for ALPN: server accepted h2 and < HTTP/2 200.

5. $_SERVER['SERVER_PROTOCOL'] correctness

Create a blueprint (test-bp.json):

{
  "steps": [
    {
      "step": "writeFile",
      "path": "/wordpress/server-info.php",
      "data": "<?php header('Content-Type: application/json'); echo json_encode(['SERVER_PROTOCOL' => $_SERVER['SERVER_PROTOCOL'], 'HTTP_headers' => array_filter($_SERVER, fn($k) => str_starts_with($k, 'HTTP_'), ARRAY_FILTER_USE_KEY)], JSON_PRETTY_PRINT);"
    }
  ]
}
npx nx dev playground-cli server --http2 --blueprint=test-bp.json

curl -ks --http2 https://localhost:9400/server-info.php | jq .SERVER_PROTOCOL
# → "HTTP/2.0"

curl -ks --http1.1 https://localhost:9400/server-info.php | jq .SERVER_PROTOCOL
# → "HTTP/1.1"

6. No pseudo-header leakage

curl -ks --http2 https://localhost:9400/server-info.php | jq .HTTP_headers

Should only contain normal headers (HTTP_HOST, HTTP_ACCEPT, etc.) — no HTTP_:METHOD, HTTP_:PATH, etc.

7. TLS cert fallback chain

  • With --http2 --ssl-cert=... --ssl-key=... → logs "TLS: using provided certificates"
  • With --http2 and mkcert installed → logs "TLS: using mkcert (locally-trusted)"
  • With --http2 and no mkcert → logs "TLS: using self-signed certificate (install mkcert for warning-free HTTPS)"; browser shows cert warning
# Create a cert (using mkcert here, but OpenSSL would also do)
mkcert -key-file /tmp/test-key.pem -cert-file /tmp/test-cert.pem localhost 127.0.0.1

# Test by supplying certificate
npx nx dev playground-cli server --http2 --ssl-cert=/tmp/test-cert.pem --ssl-key=/tmp/test-key.pem
# Test by relying on mkcert
npx nx dev playground-cli server --http2
# Test auto generation of certificate by moving mkcert out of path temporarily
PATH=$(echo "$PATH" | tr ':' '\n' | grep -v "$(dirname $(which mkcert))" | tr '\n' ':') npx nx dev playground-cli server --http2

Results

All 7 tests verified and passing.

@ashfame ashfame self-assigned this Mar 20, 2026
@adamziel
Copy link
Copy Markdown
Collaborator

adamziel commented Mar 23, 2026

Does this actually speed things up? Do we have any numbers that say 6 workers are slower than the alternative? Plenty of production WordPress sites run on 2 workers or 4 workers.

If we were to go with this, we'd need to:

  • Have CI tests for Windows, Mac, Linux and document everything that can go wrong with cert generation.
  • Scrutinize all the AI-generated stuff. min-workers and max-workers is weird. Does thi conflict with @brandonpayton's multi-worker work? Why not a single number? Also, why a new constant for debugging instead of reusing the existing CLI option? Could we start over with a decisions coming from a person as opposed to reviewing a bunch of design decisions implicitly made by an LLM?

@ashfame
Copy link
Copy Markdown
Member Author

ashfame commented Mar 23, 2026

Hey @adamziel, appreciate the quick feedback. Let me address the concerns.

On whether this actually speeds things up:

That's exactly what we want to find out. @brandonpayton asked me to work on this exploration. This PR isn't a proposal to ship HTTP/2 as is, but rather the infrastructure to run benchmarks and see if there's a measurable difference. The hypothesis is that HTTP/2 multiplexing eliminates the 6-connection-per-domain bottleneck in HTTP/1.1, which should matter for WordPress pages that trigger many sub-requests.

You're right that plenty of production WordPress sites run on 2-4 workers. But as a development tool, our job is to cater to the wild edge cases as much as the median. Some WP installs easily generate lots of parallel asset requests and we need to handle those well too. Running on a beefy machine, the tool should consume as much as resources available at its disposal to deliver results as fast as possible. HTTP/1.1 caps us at 6 concurrent connections per domain, which puts a hard ceiling on how many workers can actually be utilized in parallel. HTTP/2 multiplexing removes that ceiling entirely, opening the door to exploring higher worker counts where the workload demands it.

On the design decisions:

I want to push back a little on the framing that these are "LLM design decisions." I collaborated with AI as a tool, but every design choice was either mine or something I evaluated and approved. It was iterative, not a one-shot generation that I rubber-stamped. And its still just a draft PR :)

On the specific points:

  • min-workers / max-workers: This builds on top of Brandon's multi-worker work, it doesn't introduce a new parallelism model. All I'm doing is controlling the worker count with a loose heuristic based on free memory, and the min/max flags are just bounds for that calculation. This is only here to support benchmarking different worker counts, not necessarily the final UX.

  • DEBUG_CERT_GENERATION constant: Fair point, this is my first time working with the CLI and I didn't realize --verbosity=debug existed. I will change it.

On CI/cross-platform cert generation:

For TLS, the primary path I want to rely on is mkcert. It's an exceptionally well-supported tool across all major operating systems. The self-signed OpenSSL fallback is just a convenience so --http2 works even without mkcert installed. And all of this sits behind the --http2 flag, none of it touches the default HTTP/1.1 path.

Bottom line: This is meant to answer the question "does HTTP/2 help?" with data. HTTP/2 is functional at this point in this PR. Next steps would be to run benchmarks and then we can discuss the implementation details further.

ashfame added 11 commits March 23, 2026 21:30
Add tls.ts with:
- Self-signed cert generation via OpenSSL (with SAN for localhost/127.0.0.1)
- mkcert detection (checks installed + CA root trusted)
- mkcert cert generation for locally-trusted HTTPS
- Certificate resolver with priority chain: user-supplied > mkcert > self-signed

Made-with: Cursor
- Add --http2, --ssl-cert, --ssl-key CLI flags to both `server` and `start` commands
- Add --min-workers, --max-workers CLI flags (wiring comes in a later commit)
- Branch start-server.ts: --http2 uses http2.createSecureServer(), default stays Express
- Extract shared request handler logic to work with both Express and HTTP/2 types
- Filter HTTP/2 pseudo-headers (keys starting with :) in parseHeaders()
- Use https:// URL scheme when --http2 is active
- Wire TLS certificate resolver into the server startup path

Made-with: Cursor
Replace the hardcoded targetWorkerCount = 6 with a calculation based
on available system memory (50% of os.freemem()), clamped between
--min-workers (default 2) and --max-workers (default 12).

This adapts worker count to the device rather than assuming 6 is
always appropriate.

Made-with: Cursor
The WASM SAPI hardcodes proto_num=1000 (HTTP/1.0) and never sets
$_SERVER['SERVER_PROTOCOL']. Fix this by:

- Adding optional protocolVersion field to PHPRequest
- Populating it from req.httpVersion in the CLI server
- Setting SERVER_PROTOCOL in prepare_$_SERVER_superglobal (defaults
  to HTTP/1.1)

The server_array_entries in the C SAPI are registered last, so
$_SERVER overrides from JS take precedence. No C/WASM changes needed.

Made-with: Cursor
The http2, ssl-cert, ssl-key, min-workers, and max-workers options
were defined in yargs and used at runtime but missing from the
RunCLIArgs TypeScript interface, causing typecheck failures.

Made-with: Cursor
Http2SecureServer doesn't have closeAllConnections() (it extends
tls.Server, not http.Server). Without force-closing the long-lived
HTTP/2 sessions, server.close() hangs forever waiting for them to
end naturally. Polyfill closeAllConnections by tracking active
sessions and destroying them on shutdown.

Made-with: Cursor
Pipe stdio for openssl and mkcert subprocesses so their
banners don't clutter the terminal. Condense log messages
to a single "TLS: ..." line for all three cert resolution
paths. A DEBUG_CERT_GENERATION flag allows surfacing the
subprocess output when needed.

Made-with: Cursor
@adamziel
Copy link
Copy Markdown
Collaborator

adamziel commented Mar 23, 2026

Thank you @ashfame for elaborating! I was a bit worried and I see how I mistook this exploration for a production proposal. I'm glad that you've worked so diligently in here. Lovely work and I'm very curious what we'll find out here.

ashfame added 3 commits March 23, 2026 21:37
Switch worker pool sizing to a single explicit --workers option and keep startup behavior deterministic. Retain free-memory checks as advisory warnings only, so the CLI continues with the user-requested worker count.

Made-with: Cursor
@brandonpayton
Copy link
Copy Markdown
Member

So far, @ashfame and I have not seen reliable performance improvements in this experiment. As part of the exploration, we discussed more efficient ways to serve static files, and I created a draft PR here for that. It seems to show a modest 10-20% performance improvement reliably on my machine.

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.

3 participants