On Workers
pqdoctor-web is pqdoctor as a one-box web page on Cloudflare Workers. Type a hostname. The Worker opens raw TCP sockets (cloudflare:sockets connect() with secureTransport: "off"), sends pqdoctor’s seven ClientHellos one at a time, reads only the server’s first flight, and shows the verdict, the cause and the fix.
Not a port
Section titled “Not a port”The diagnosis is the same Rust engine as the CLI and Flarebox, compiled to WebAssembly (about 200 KB, a plain C ABI with JSON in and out, no wasm-bindgen). TypeScript only does the I/O. Its probe loop mirrors the Rust one: 5 s connect and read timeouts, 120 ms between probes, one retry for a silent probe, and stop if nothing is listening.
The ML-DSA check runs there too. The TLS 1.3 handshake is a state machine in the wasm crate: the Worker gets a ClientHello for a client key made from crypto.getRandomValues, writes it to the socket, and feeds back every byte it reads until the engine decides. The X25519 exchange and key schedule run in WebAssembly, and Finished is never sent.
Socket limits
Section titled “Socket limits”Checked against Cloudflare’s TCP sockets and limits docs on 2026-10-13:
- No sockets to Cloudflare IPs, localhost or private networks, and none on port 25. So the Worker resolves the name first, over DNS over HTTPS. A name on Cloudflare’s published ranges gets “served by Cloudflare — check the origin behind it” without a probe. Private or reserved addresses are refused.
- At most 6 connections waiting for a response at once per invocation. Extra ones are queued, not rejected. A check uses one socket at a time.
- Subrequests: 50 per invocation on Free, 10,000 on Paid. A check needs at most about 19: up to 16 sockets (7 probes, up to 7 retries, 2 certificate probes), 2 DNS lookups and 1
HEAD.
On top of those: port 443 only, hostnames only (no IP literals, single-label or reserved names), 10 checks per minute per client IP (the Workers Rate Limiting binding), and results cached for 10 minutes per host. Failures aren’t cached.
Proven locally
Section titled “Proven locally”In wrangler dev (wrangler 4.145 / workerd) and Node:
- The WebAssembly engine gives the same report as the CLI: verdict, cause, headline, fixes and every probe summary, on post-quantum, classical and PQ-codepoint-resetting test servers.
- The TypeScript probe loop over real sockets agrees with the CLI on 6 of 6 real hosts: github.com, www.northeastern.edu, nginx.org, www.debian.org, cloudflare.com and www.mit.edu.
- The ML-DSA check gives the CLI’s authentication section exactly, on real OpenSSL 3.5 servers with an ECDSA-only certificate and with ECDSA + ML-DSA-44. For github.com in
wrangler dev, it matched byte for byte (2026-10-14). - github.com (not PQ,
no-mlkem) and www.northeastern.edu (PQ) work end to end. cloudflare.com takes the “on Cloudflare” path, IPs and localhost are refused, and the rate limiter returns 429 after 10 checks a minute.
Not proven until deployed
Section titled “Not proven until deployed”wrangler dev sockets leave from the local machine, not Cloudflare’s network. So these are open:
- How production egress treats the split-hello probes. The Worker writes the two halves 40 ms apart, but whether they leave as separate TCP segments isn’t observable locally, so the
pq-split-droppedwarning may not reproduce. - Whether some origins answer Cloudflare’s egress differently (rate limits, geo or bot rules) from a residential IP.
- That the Cloudflare-IP block behaves as documented. The Worker never tries, but a name could move onto Cloudflare between the DNS answer and the connect.