diff --git a/packages/php-wasm/universal/src/lib/php-request-handler.ts b/packages/php-wasm/universal/src/lib/php-request-handler.ts index 83e88c82cd7..5d2e7d42852 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -753,7 +753,8 @@ export class PHPRequestHandler implements AsyncDisposable { $_SERVER: this.prepare_$_SERVER_superglobal( originalRequestUrl, rewrittenRequestUrl, - scriptPath + scriptPath, + request.protocolVersion ), body, scriptPath, @@ -863,12 +864,14 @@ export class PHPRequestHandler implements AsyncDisposable { private prepare_$_SERVER_superglobal( originalRequestUrl: URL, rewrittenRequestUrl: URL, - resolvedScriptPath: string + resolvedScriptPath: string, + protocolVersion?: string ): Record { const $_SERVER: Record = { REMOTE_ADDR: '127.0.0.1', DOCUMENT_ROOT: this.#DOCROOT, HTTPS: this.#ABSOLUTE_URL.startsWith('https://') ? 'on' : '', + SERVER_PROTOCOL: protocolVersion || 'HTTP/1.1', }; /** diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index 7e50542bf9e..957f4e0cd9c 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -115,6 +115,13 @@ export interface PHPRequest { * and sent with a `multipart/form-data` header. */ body?: string | Uint8Array | Record; + + /** + * HTTP protocol version string for $_SERVER['SERVER_PROTOCOL']. + * Examples: 'HTTP/1.0', 'HTTP/1.1', 'HTTP/2.0'. + * Default: 'HTTP/1.1'. + */ + protocolVersion?: string; } export interface PHPRunOptions { diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 21c08621c13..f373d5fd237 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -41,6 +41,7 @@ import { parseDefineNumberArguments, } from './defines'; import { isPortInUse, startServer } from './start-server'; +import { resolveTlsCertificate } from './tls'; import type { PlaygroundCliBlueprintV1Worker } from './blueprints-v1/worker-thread-v1'; import type { PlaygroundCliBlueprintV2Worker } from './blueprints-v2/worker-thread-v2'; import type { XdebugOptions } from '@php-wasm/node'; @@ -340,6 +341,29 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) { 'Port to listen on when serving. Defaults to 9400 when available.', type: 'number', }, + http2: { + describe: + 'Enable HTTP/2 with TLS. Requires HTTPS certificates ' + + '(auto-provisioned via mkcert or self-signed if not supplied).', + type: 'boolean', + default: false, + }, + 'ssl-cert': { + describe: + 'Path to a TLS certificate file (PEM format). Used with --http2.', + type: 'string', + }, + 'ssl-key': { + describe: + 'Path to a TLS private key file (PEM format). Used with --http2.', + type: 'string', + }, + workers: { + describe: + 'Number of PHP worker threads to spawn. Defaults to 6.', + type: 'number', + default: 6, + }, 'experimental-multi-worker': { deprecated: 'This option is not needed. Multiple workers are always used.', @@ -436,6 +460,12 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) { type: 'boolean', default: false, }, + // HTTP/2 and TLS + http2: serverOnlyOptions['http2'], + 'ssl-cert': serverOnlyOptions['ssl-cert'], + 'ssl-key': serverOnlyOptions['ssl-key'], + // Worker pool + workers: serverOnlyOptions['workers'], // Define constants define: sharedOptions['define'], 'define-bool': sharedOptions['define-bool'], @@ -813,6 +843,12 @@ export interface RunCLIArgs { 'truncate-new-site-directory'?: boolean; allow?: string; + // --------- Server command args ----------- + http2?: boolean; + 'ssl-cert'?: string; + 'ssl-key'?: string; + workers?: number; + // --------- Start command args ----------- path?: string; skipBrowser?: boolean; @@ -987,33 +1023,49 @@ export async function runCLI(args: RunCLIArgs): Promise { let wordPressReady = false; let isFirstRequest = true; + const useHttp2 = !!args.http2; + const tlsCertificate = useHttp2 + ? resolveTlsCertificate({ + sslCert: args['ssl-cert'] as string | undefined, + sslKey: args['ssl-key'] as string | undefined, + debug: args.verbosity === 'debug', + }) + : undefined; + const server = await startServer({ port: args.port ? args.port : !(await isPortInUse(selectedPort)) ? selectedPort : 0, + http2: useHttp2, + tlsCertificate, onBind: async (server: Server, port: number) => { const host = '127.0.0.1'; - const serverUrl = `http://${host}:${port}`; + const scheme = useHttp2 ? 'https' : 'http'; + const serverUrl = `${scheme}://${host}:${port}`; const siteUrl = args['site-url'] || serverUrl; /** - * With HTTP 1.1, browsers typically support 6 parallel connections per domain. + * With HTTP/1.1, browsers typically support 6 parallel + * connections per domain. * > browsers open several connections to each domain, - * > sending parallel requests. Default was once 2 to 3 connections, - * > but this has now increased to a more common use of 6 parallel connections. + * > sending parallel requests. Default was once 2 to 3 + * > connections, but this has now increased to a more + * > common use of 6 parallel connections. * https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Connection_management_in_HTTP_1.x#domain_sharding * - * While our HTTP server only supports HTTP 1.1 and while we are trying to limit the - * memory requirements of multiple workers, let's hard-code the number of request-handling - * workers to 6. + * Going higher than browsers' max concurrent requests + * seems pointless, and going lower may increase the + * likelihood of deadlock due to workers blocking and + * waiting for file locks. * - * Going higher than browsers' max concurrent requests seems pointless, - * and going lower may increase the likelihood of deadlock due to workers - * blocking and waiting for file locks. + * With HTTP/2, multiplexing removes the per-domain + * connection limit, so we size the pool based on + * available system memory instead. */ - const targetWorkerCount = 6; + const targetWorkerCount = (args.workers as number) ?? 6; + warnIfInsufficientMemoryForWorkers(targetWorkerCount); /* * Use a real temp dir as a target for the following Playground paths @@ -1870,6 +1922,34 @@ function openInBrowser(url: string): void { }); } +export const ESTIMATED_WORKER_MEMORY_BYTES = 100 * 1024 * 1024; // ~100MB per worker + +/** + * Logs a warning when selected worker count appears too high for + * currently free system memory. + * + * Uses 50% of free memory as the recommended worker budget so the system + * has headroom for the OS, browser, and other processes. + */ +export function warnIfInsufficientMemoryForWorkers(workerCount: number): void { + const freeMemory = os.freemem(); + const recommendedByMemory = Math.floor( + (freeMemory * 0.5) / ESTIMATED_WORKER_MEMORY_BYTES + ); + const freeMemoryMb = Math.round(freeMemory / 1024 / 1024); + if (workerCount > recommendedByMemory) { + logger.warn( + `Selected workers (${workerCount}) may exceed available free memory budget ` + + `(free memory: ${freeMemoryMb}MB, recommended max: ${recommendedByMemory}). ` + + 'Continuing with requested worker count.' + ); + } else { + logger.log( + `Worker count: ${workerCount} (free memory: ${freeMemoryMb}MB)` + ); + } +} + async function zipSite( playground: Pooled, outfile: string diff --git a/packages/playground/cli/src/start-server.ts b/packages/playground/cli/src/start-server.ts index 6e627f0ff3c..9091a8864a3 100644 --- a/packages/playground/cli/src/start-server.ts +++ b/packages/playground/cli/src/start-server.ts @@ -1,7 +1,6 @@ import { exec as execCb } from 'child_process'; import { promisify } from 'util'; import type { PHPRequest, StreamedPHPResponse } from '@php-wasm/universal'; -import type { Request, Response } from 'express'; import express from 'express'; import type { IncomingMessage, Server, ServerResponse } from 'http'; import type { AddressInfo } from 'net'; @@ -9,6 +8,8 @@ import { Readable } from 'stream'; import { pipeline } from 'stream/promises'; import type { RunCLIServer } from './run-cli'; import { logger } from '@php-wasm/logger'; +import http2 from 'http2'; +import type { TlsCertificate } from './tls'; const exec = promisify(execCb); @@ -19,6 +20,8 @@ export interface ServerOptions { * Handler for requests. Always returns StreamedPHPResponse. */ handleRequest: (request: PHPRequest) => Promise; + http2?: boolean; + tlsCertificate?: TlsCertificate; } export function isPortInUse(port: number): Promise { @@ -37,6 +40,69 @@ export function isPortInUse(port: number): Promise { export async function startServer( options: ServerOptions ): Promise { + const requestListener = createRequestListener(options.handleRequest); + + // TODO: Remove Express path when HTTP/2 becomes the default + const server = options.http2 + ? await startHttp2Server(options, requestListener) + : await startExpressServer(options, requestListener); + + const address = server.address(); + const port = (address! as AddressInfo).port; + + // Codespaces ports default to private, breaking CORS. + // Publish once the tunnel is ready. + const codespaceName = process.env['CODESPACE_NAME']; + if (codespaceName) { + setCodespacesPortPublic(port, codespaceName); + } + + return await options.onBind(server, port); +} + +type RequestListener = ( + req: { url?: string; method?: string; headers: Record } & { + on: (event: string, cb: (...args: any[]) => void) => void; + }, + res: { + statusCode: number; + headersSent: boolean; + setHeader: (name: string, value: string | string[]) => void; + } & NodeJS.WritableStream +) => Promise; + +function createRequestListener( + handleRequest: ServerOptions['handleRequest'] +): RequestListener { + return async (req, res) => { + try { + const httpVersion = (req as any).httpVersion; + const phpRequest: PHPRequest = { + url: req.url ?? '/', + headers: parseHeaders(req.headers), + method: (req.method ?? 'GET') as any, + body: await bufferRequestBody(req), + protocolVersion: httpVersion + ? `HTTP/${httpVersion}` + : undefined, + }; + + const response = await handleRequest(phpRequest); + await handleStreamedResponse(response, res as any); + } catch (error) { + logger.error(error); + if (!res.headersSent) { + res.statusCode = 500; + res.end('Internal Server Error'); + } + } + }; +} + +async function startExpressServer( + options: ServerOptions, + requestListener: RequestListener +): Promise> { const app = express(); const server = await new Promise< @@ -54,37 +120,65 @@ export async function startServer( .once('error', reject); }); - app.use('/', async (req, res) => { - try { - const phpRequest: PHPRequest = { - url: req.url, - headers: parseHeaders(req), - method: req.method as any, - body: await bufferRequestBody(req), - }; - - const response = await options.handleRequest(phpRequest); - await handleStreamedResponse(response, res); - } catch (error) { - logger.error(error); - if (!res.headersSent) { - res.statusCode = 500; - res.end('Internal Server Error'); - } - } + app.use('/', (req, res) => { + requestListener(req, res); }); - const address = server.address(); - const port = (address! as AddressInfo).port; + return server; +} - // Codespaces ports default to private, breaking CORS. - // Publish once the tunnel is ready. - const codespaceName = process.env['CODESPACE_NAME']; - if (codespaceName) { - setCodespacesPortPublic(port, codespaceName); +async function startHttp2Server( + options: ServerOptions, + requestListener: RequestListener +): Promise { + if (!options.tlsCertificate) { + throw new Error('TLS certificate is required for HTTP/2.'); } - return await options.onBind(server, port); + const activeSessions = new Set(); + + const server = await new Promise( + (resolve, reject) => { + const h2Server = http2.createSecureServer( + { + key: options.tlsCertificate!.key, + cert: options.tlsCertificate!.cert, + allowHTTP1: true, + }, + (req, res) => { + requestListener(req, res); + } + ); + + h2Server.on('session', (session) => { + activeSessions.add(session); + session.on('close', () => activeSessions.delete(session)); + }); + + h2Server + .listen(options.port, () => { + const address = h2Server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('Server address is not available')); + } else { + resolve(h2Server); + } + }) + .once('error', reject); + } + ); + + // Http2SecureServer doesn't have closeAllConnections() (only + // http.Server does). Provide one so the shutdown code in run-cli.ts + // can force-close long-lived HTTP/2 sessions the same way it does + // for HTTP/1.1 connections. + (server as any).closeAllConnections = () => { + for (const session of activeSessions) { + session.destroy(); + } + }; + + return server as unknown as Server; } /** @@ -93,7 +187,10 @@ export async function startServer( */ async function handleStreamedResponse( streamedResponse: StreamedPHPResponse, - res: Response + res: { + statusCode: number; + setHeader(name: string, value: string | string[]): void; + } & NodeJS.WritableStream ): Promise { // Wait for headers to be available const [headers, httpStatusCode] = await Promise.all([ @@ -128,10 +225,12 @@ async function handleStreamedResponse( } } -const bufferRequestBody = async (req: Request): Promise => +const bufferRequestBody = async (req: { + on(event: string, cb: (...args: any[]) => void): void; +}): Promise => await new Promise((resolve) => { const body: Uint8Array[] = []; - req.on('data', (chunk) => { + req.on('data', (chunk: Buffer) => { body.push(chunk); }); req.on('end', () => { @@ -152,12 +251,19 @@ async function setCodespacesPortPublic(port: number, codespaceName: string) { } } -const parseHeaders = (req: Request): Record => { +/** + * Parses headers from an HTTP request object into a flat record. + * Filters out HTTP/2 pseudo-headers (keys starting with `:`) since + * PHP doesn't understand them. + */ +const parseHeaders = (headers: Record): Record => { const requestHeaders: Record = {}; - if (req.rawHeaders && req.rawHeaders.length) { - for (let i = 0; i < req.rawHeaders.length; i += 2) { - requestHeaders[req.rawHeaders[i].toLowerCase()] = - req.rawHeaders[i + 1]; + for (const [key, value] of Object.entries(headers)) { + if (key.startsWith(':')) { + continue; + } + if (value !== undefined) { + requestHeaders[key.toLowerCase()] = String(value); } } return requestHeaders; diff --git a/packages/playground/cli/src/tls.ts b/packages/playground/cli/src/tls.ts new file mode 100644 index 00000000000..35d578c099d --- /dev/null +++ b/packages/playground/cli/src/tls.ts @@ -0,0 +1,182 @@ +import { execSync, execFileSync } from 'child_process'; +import { readFileSync, writeFileSync, unlinkSync, mkdtempSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { logger } from '@php-wasm/logger'; + +function certStdio(debug: boolean): ('pipe' | 'inherit')[] { + return debug ? ['inherit', 'inherit', 'inherit'] : ['pipe', 'pipe', 'pipe']; +} + +export interface TlsCertificate { + key: string; + cert: string; +} + +/** + * Generates an ephemeral self-signed TLS certificate and private key + * using the system's OpenSSL (or LibreSSL) installation. + * + * The certificate is valid for localhost and 127.0.0.1, expires in + * 30 days, and is intended for local development only. + */ +export function generateSelfSignedCert(debug = false): TlsCertificate { + const tempDir = mkdtempSync(join(tmpdir(), 'playground-tls-')); + const keyPath = join(tempDir, 'key.pem'); + const certPath = join(tempDir, 'cert.pem'); + const confPath = join(tempDir, 'openssl.cnf'); + + const opensslConf = `[req] +distinguished_name = req_dn +x509_extensions = v3_req +prompt = no + +[req_dn] +CN = localhost + +[v3_req] +subjectAltName = DNS:localhost,IP:127.0.0.1 +`; + + try { + writeFileSync(confPath, opensslConf); + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + keyPath, + '-out', + certPath, + '-days', + '30', + '-config', + confPath, + ], + { stdio: certStdio(debug) } + ); + return { + key: readFileSync(keyPath, 'utf8'), + cert: readFileSync(certPath, 'utf8'), + }; + } finally { + try { + unlinkSync(keyPath); + } catch { + // ignore + } + try { + unlinkSync(certPath); + } catch { + // ignore + } + try { + unlinkSync(confPath); + } catch { + // ignore + } + } +} + +/** + * Checks whether mkcert is installed and has a trusted CA root. + * Returns the CA root path if available, or null if mkcert is not + * usable. + */ +export function getMkcertCaRoot(): string | null { + try { + const caRoot = execSync('mkcert -CAROOT', { + stdio: ['pipe', 'pipe', 'pipe'], + }) + .toString() + .trim(); + if (!caRoot) { + return null; + } + try { + readFileSync(join(caRoot, 'rootCA.pem')); + return caRoot; + } catch { + return null; + } + } catch { + return null; + } +} + +/** + * Generates a locally-trusted TLS certificate using mkcert. + * Requires mkcert to be installed with its CA root set up. + */ +export function generateMkcertCert(debug = false): TlsCertificate { + const tempDir = mkdtempSync(join(tmpdir(), 'playground-tls-')); + const keyPath = join(tempDir, 'key.pem'); + const certPath = join(tempDir, 'cert.pem'); + + try { + execFileSync( + 'mkcert', + [ + '-key-file', + keyPath, + '-cert-file', + certPath, + 'localhost', + '127.0.0.1', + ], + { stdio: certStdio(debug) } + ); + return { + key: readFileSync(keyPath, 'utf8'), + cert: readFileSync(certPath, 'utf8'), + }; + } finally { + try { + unlinkSync(keyPath); + } catch { + // ignore + } + try { + unlinkSync(certPath); + } catch { + // ignore + } + } +} + +/** + * Resolves TLS certificates using a priority chain: + * 1. User-supplied certs (--ssl-cert / --ssl-key) + * 2. mkcert (if installed with trusted CA) + * 3. Self-signed fallback + */ +export function resolveTlsCertificate(options: { + sslCert?: string; + sslKey?: string; + debug?: boolean; +}): TlsCertificate { + if (options.sslCert && options.sslKey) { + logger.log('TLS: using provided certificates'); + return { + key: readFileSync(options.sslKey, 'utf8'), + cert: readFileSync(options.sslCert, 'utf8'), + }; + } + + const debug = !!options.debug; + const caRoot = getMkcertCaRoot(); + if (caRoot) { + logger.log('TLS: using mkcert (locally-trusted)'); + return generateMkcertCert(debug); + } + + logger.log( + 'TLS: using self-signed certificate' + + ' (install mkcert for warning-free HTTPS)' + ); + return generateSelfSignedCert(debug); +} diff --git a/packages/playground/cli/tests/compute-worker-count.spec.ts b/packages/playground/cli/tests/compute-worker-count.spec.ts new file mode 100644 index 00000000000..245862e0598 --- /dev/null +++ b/packages/playground/cli/tests/compute-worker-count.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type * as PhpWasmLoggerModule from '@php-wasm/logger'; +import { logger } from '@php-wasm/logger'; +import os from 'os'; +import { + warnIfInsufficientMemoryForWorkers, + ESTIMATED_WORKER_MEMORY_BYTES, +} from '../src/run-cli'; + +vi.mock('@php-wasm/logger', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }; +}); + +describe('warnIfInsufficientMemoryForWorkers', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('logs a warning when selected workers exceed memory-based recommendation', () => { + vi.spyOn(os, 'freemem').mockReturnValue(50 * 1024 * 1024); // 50MB + const warnSpy = vi.spyOn(logger, 'warn'); + warnIfInsufficientMemoryForWorkers(2); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('logs an info message when selected workers fit memory budget', () => { + // 800MB free -> 400MB budget -> 4 workers recommended + vi.spyOn(os, 'freemem').mockReturnValue(800 * 1024 * 1024); + const logSpy = vi.spyOn(logger, 'log'); + warnIfInsufficientMemoryForWorkers(4); + expect(logSpy).toHaveBeenCalledTimes(1); + }); + + it('uses 50% of free memory divided by estimated worker size', () => { + const freeMemory = 1200 * 1024 * 1024; // 1200MB + vi.spyOn(os, 'freemem').mockReturnValue(freeMemory); + const expectedRecommendation = Math.floor( + (freeMemory * 0.5) / ESTIMATED_WORKER_MEMORY_BYTES + ); + const warnSpy = vi.spyOn(logger, 'warn'); + warnIfInsufficientMemoryForWorkers(expectedRecommendation + 1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `recommended max: ${expectedRecommendation}` + ) + ); + expect(expectedRecommendation).toBe(6); + }); +}); diff --git a/packages/playground/cli/tests/run-cli.spec.ts b/packages/playground/cli/tests/run-cli.spec.ts index e0f65ce81a2..8739bb9d75e 100644 --- a/packages/playground/cli/tests/run-cli.spec.ts +++ b/packages/playground/cli/tests/run-cli.spec.ts @@ -1,6 +1,8 @@ import path from 'node:path'; import os from 'node:os'; import http from 'node:http'; +import http2 from 'node:http2'; +import https from 'node:https'; import { runCLI, parseOptionsAndRunCLI, @@ -1723,3 +1725,105 @@ describe('other run-cli behaviors', () => { }); }); }); + +describe( + 'HTTP/2 integration', + () => { + function h2Get(url: string): Promise<{ status: number; body: string }> { + const parsed = new URL(url); + return new Promise((resolve, reject) => { + const client = http2.connect(parsed.origin, { + rejectUnauthorized: false, + }); + client.on('error', reject); + const req = client.request({ ':path': parsed.pathname }); + let body = ''; + let status = 0; + req.on('response', (headers) => { + status = headers[':status'] as number; + }); + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on('end', () => { + client.close(); + resolve({ status, body }); + }); + req.on('error', reject); + req.end(); + }); + } + + test('serves over HTTPS, reports correct SERVER_PROTOCOL for h2 and h1, and filters pseudo-headers', async () => { + await using cliServer = await runCLI({ + command: 'server', + http2: true, + workers: 4, + wordpressInstallMode: 'do-not-attempt-installing', + skipSqliteSetup: true, + blueprint: undefined, + }); + + // serverUrl uses https:// scheme with --http2 + expect(cliServer.serverUrl).toMatch(/^https:\/\//); + + // Consume the auto-login 302 redirect on the first request + await h2Get(new URL('/', cliServer.serverUrl).href); + + // Write test PHP files + await cliServer.playground.writeFile( + '/wordpress/protocol.php', + '' + ); + await cliServer.playground.writeFile( + '/wordpress/headers.php', + ` str_starts_with($k, 'HTTP_:'), + ARRAY_FILTER_USE_KEY + ); + echo json_encode($pseudo); + ?>` + ); + + // $_SERVER['SERVER_PROTOCOL'] reports HTTP/2.0 for h2 requests + const h2Result = await h2Get( + new URL('/protocol.php', cliServer.serverUrl).href + ); + expect(h2Result.status).toBe(200); + expect(h2Result.body).toBe('HTTP/2.0'); + + // $_SERVER['SERVER_PROTOCOL'] reports HTTP/1.1 for h1 fallback + const h1Body = await new Promise((resolve, reject) => { + const req = https.get( + new URL('/protocol.php', cliServer.serverUrl).href, + { rejectUnauthorized: false }, + (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + res.on('end', () => resolve(data)); + } + ); + req.on('error', reject); + }); + expect(h1Body).toBe('HTTP/1.1'); + + // No HTTP/2 pseudo-headers leak into $_SERVER + const headersResult = await h2Get( + new URL('/headers.php', cliServer.serverUrl).href + ); + expect(headersResult.status).toBe(200); + const pseudoHeaders = JSON.parse(headersResult.body); + expect( + Array.isArray(pseudoHeaders) + ? pseudoHeaders.length + : Object.keys(pseudoHeaders).length + ).toBe(0); + }); + }, + 60_000 * 5 +); diff --git a/packages/playground/cli/tests/start-server.spec.ts b/packages/playground/cli/tests/start-server.spec.ts index 2fffe96dfdf..e9cfefd1ccb 100644 --- a/packages/playground/cli/tests/start-server.spec.ts +++ b/packages/playground/cli/tests/start-server.spec.ts @@ -1,13 +1,17 @@ -import { describe, it, expect, vi, type Mock } from 'vitest'; +import { describe, it, expect, vi, beforeAll, type Mock } from 'vitest'; import http from 'http'; +import https from 'https'; +import http2 from 'http2'; import { pipeline } from 'stream/promises'; +import type { PHPRequest } from '@php-wasm/universal'; import { StreamedPHPResponse } from '@php-wasm/universal'; import { startServer } from '../src/start-server'; +import { generateSelfSignedCert, type TlsCertificate } from '../src/tls'; import { logger } from '@php-wasm/logger'; import type StreamPromisesModule from 'stream/promises'; vi.mock('@php-wasm/logger', () => ({ - logger: { error: vi.fn() }, + logger: { log: vi.fn(), error: vi.fn() }, })); vi.mock('stream/promises', async (importOriginal) => { @@ -15,6 +19,29 @@ vi.mock('stream/promises', async (importOriginal) => { return { ...actual, pipeline: vi.fn(actual.pipeline) }; }); +function makeOkResponse(body: string): StreamedPHPResponse { + return new StreamedPHPResponse( + new ReadableStream({ + start(controller) { + const json = JSON.stringify({ + status: 200, + headers: ['content-type: text/plain'], + }); + controller.enqueue(new TextEncoder().encode(json)); + controller.close(); + }, + }), + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)); + controller.close(); + }, + }), + new ReadableStream({ start: (c) => c.close() }), + Promise.resolve(0) + ); +} + describe('startServer', () => { it('does not log an error when piping to a destroyed stream (ERR_STREAM_UNABLE_TO_PIPE)', async () => { const pipelineMock = vi.mocked(pipeline); @@ -233,3 +260,198 @@ describe('startServer', () => { } }); }); + +describe('startServer with HTTP/2', () => { + let tlsCert: TlsCertificate; + + beforeAll(() => { + tlsCert = generateSelfSignedCert(); + }); + + function h2Request( + port: number, + path: string + ): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const client = http2.connect(`https://127.0.0.1:${port}`, { + rejectUnauthorized: false, + }); + client.on('error', reject); + const req = client.request({ ':path': path }); + let body = ''; + let status = 0; + req.on('response', (headers) => { + status = headers[':status'] as number; + }); + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on('end', () => { + client.close(); + resolve({ status, body }); + }); + req.on('error', reject); + req.end(); + }); + } + + function startH2Server( + handleRequest: (request: PHPRequest) => Promise + ) { + return startServer({ + port: 0, + http2: true, + tlsCertificate: tlsCert, + handleRequest, + async onBind(server, port) { + return { server, port } as any; + }, + }); + } + + it('starts and responds to HTTP/2 requests', async () => { + const cliServer = await startH2Server(async () => + makeOkResponse('h2 works') + ); + const { server, port } = cliServer as any; + + try { + const { status, body } = await h2Request(port, '/'); + expect(status).toBe(200); + expect(body).toBe('h2 works'); + } finally { + server.close(); + (server as any).closeAllConnections(); + } + }); + + it('passes protocolVersion HTTP/2.0 to handleRequest', async () => { + let capturedRequest: PHPRequest | undefined; + const cliServer = await startH2Server(async (req) => { + capturedRequest = req; + return makeOkResponse('ok'); + }); + const { server, port } = cliServer as any; + + try { + await h2Request(port, '/test'); + expect(capturedRequest).toBeDefined(); + expect(capturedRequest!.protocolVersion).toBe('HTTP/2.0'); + } finally { + server.close(); + (server as any).closeAllConnections(); + } + }); + + it('filters HTTP/2 pseudo-headers from request', async () => { + let capturedRequest: PHPRequest | undefined; + const cliServer = await startH2Server(async (req) => { + capturedRequest = req; + return makeOkResponse('ok'); + }); + const { server, port } = cliServer as any; + + try { + await h2Request(port, '/test'); + expect(capturedRequest).toBeDefined(); + const pseudoHeaders = Object.keys( + capturedRequest!.headers ?? {} + ).filter((k) => k.startsWith(':')); + expect(pseudoHeaders).toHaveLength(0); + } finally { + server.close(); + (server as any).closeAllConnections(); + } + }); + + it('includes host header with no pseudo-headers leaking', async () => { + let capturedRequest: PHPRequest | undefined; + const cliServer = await startServer({ + port: 0, + http2: true, + tlsCertificate: tlsCert, + handleRequest: async (req) => { + capturedRequest = req; + return makeOkResponse('ok'); + }, + async onBind(server, port) { + return { server, port } as any; + }, + }); + const { server, port } = cliServer as any; + + try { + // Send a request with an explicit host-like header + await new Promise((resolve, reject) => { + const client = http2.connect(`https://127.0.0.1:${port}`, { + rejectUnauthorized: false, + }); + client.on('error', reject); + const req = client.request({ + ':path': '/', + ':method': 'GET', + }); + req.on('response', () => {}); + req.on('data', () => {}); + req.on('end', () => { + client.close(); + resolve(); + }); + req.on('error', reject); + req.end(); + }); + expect(capturedRequest).toBeDefined(); + // Pseudo-headers must not leak through to PHP + const headerKeys = Object.keys(capturedRequest!.headers ?? {}); + expect(headerKeys.filter((k) => k.startsWith(':'))).toHaveLength(0); + } finally { + server.close(); + (server as any).closeAllConnections(); + } + }); + + it('supports HTTP/1.1 fallback via allowHTTP1', async () => { + let capturedRequest: PHPRequest | undefined; + const cliServer = await startH2Server(async (req) => { + capturedRequest = req; + return makeOkResponse('h1 fallback'); + }); + const { server, port } = cliServer as any; + + try { + const body = await new Promise((resolve, reject) => { + const req = https.get( + `https://127.0.0.1:${port}/`, + { rejectUnauthorized: false }, + (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + res.on('end', () => resolve(data)); + } + ); + req.on('error', reject); + }); + expect(body).toBe('h1 fallback'); + expect(capturedRequest).toBeDefined(); + expect(capturedRequest!.protocolVersion).toBe('HTTP/1.1'); + } finally { + server.close(); + (server as any).closeAllConnections(); + } + }); + + it('throws when HTTP/2 is requested without TLS certificate', async () => { + await expect( + startServer({ + port: 0, + http2: true, + handleRequest: async () => makeOkResponse('nope'), + async onBind(server, port) { + return { server, port } as any; + }, + }) + ).rejects.toThrow('TLS certificate is required for HTTP/2.'); + }); +}); diff --git a/packages/playground/cli/tests/tls.spec.ts b/packages/playground/cli/tests/tls.spec.ts new file mode 100644 index 00000000000..36779ffcb83 --- /dev/null +++ b/packages/playground/cli/tests/tls.spec.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import type * as ChildProcessModule from 'child_process'; +import { X509Certificate } from 'crypto'; +import { writeFileSync, mkdtempSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { + generateSelfSignedCert, + resolveTlsCertificate, + type TlsCertificate, +} from '../src/tls'; + +vi.mock('@php-wasm/logger', () => ({ + logger: { log: vi.fn(), error: vi.fn() }, +})); + +describe('generateSelfSignedCert', () => { + let cert: TlsCertificate; + + beforeAll(() => { + cert = generateSelfSignedCert(); + }); + + it('returns valid PEM key and cert', () => { + expect(cert.key).toContain('-----BEGIN PRIVATE KEY-----'); + expect(cert.cert).toContain('-----BEGIN CERTIFICATE-----'); + }); + + it('cert covers localhost and 127.0.0.1', () => { + const x509 = new X509Certificate(cert.cert); + const san = x509.subjectAltName ?? ''; + expect(san).toContain('DNS:localhost'); + expect(san).toContain('IP Address:127.0.0.1'); + }); + + it('cert has CN=localhost', () => { + const x509 = new X509Certificate(cert.cert); + expect(x509.subject).toContain('CN=localhost'); + }); +}); + +describe('getMkcertCaRoot', () => { + it('returns null when mkcert is not installed', async () => { + vi.resetModules(); + vi.doMock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: (cmd: string, ...args: any[]) => { + if (typeof cmd === 'string' && cmd.includes('mkcert')) { + throw new Error('command not found: mkcert'); + } + return actual.execSync(cmd, ...args); + }, + }; + }); + + const { getMkcertCaRoot } = await import('../src/tls'); + const result = getMkcertCaRoot(); + expect(result).toBeNull(); + vi.doUnmock('child_process'); + }); +}); + +describe('resolveTlsCertificate', () => { + it('reads user-supplied cert and key files', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'tls-test-')); + const cert = generateSelfSignedCert(); + const certPath = join(tempDir, 'cert.pem'); + const keyPath = join(tempDir, 'key.pem'); + writeFileSync(certPath, cert.cert); + writeFileSync(keyPath, cert.key); + + const result = resolveTlsCertificate({ + sslCert: certPath, + sslKey: keyPath, + }); + expect(result.cert).toBe(cert.cert); + expect(result.key).toBe(cert.key); + }); + + it('falls back to self-signed cert when no user certs and no mkcert', async () => { + vi.resetModules(); + vi.doMock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: (cmd: string, ...args: any[]) => { + if (typeof cmd === 'string' && cmd.includes('mkcert')) { + throw new Error('command not found: mkcert'); + } + return actual.execSync(cmd, ...args); + }, + }; + }); + + const { resolveTlsCertificate: freshResolve } = + await import('../src/tls'); + const result = freshResolve({}); + expect(result.key).toContain('-----BEGIN PRIVATE KEY-----'); + expect(result.cert).toContain('-----BEGIN CERTIFICATE-----'); + vi.doUnmock('child_process'); + }); +});