Skip to content

Commit 3d49790

Browse files
committed
use cli verbosity flag for cert debug + add tests
1 parent ba0ccbf commit 3d49790

File tree

6 files changed

+538
-36
lines changed

6 files changed

+538
-36
lines changed

packages/playground/cli/src/run-cli.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,7 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer | void> {
10361036
? resolveTlsCertificate({
10371037
sslCert: args['ssl-cert'] as string | undefined,
10381038
sslKey: args['ssl-key'] as string | undefined,
1039+
debug: args.verbosity === 'debug',
10391040
})
10401041
: undefined;
10411042

@@ -1933,7 +1934,7 @@ function openInBrowser(url: string): void {
19331934
});
19341935
}
19351936

1936-
const ESTIMATED_WORKER_MEMORY_BYTES = 100 * 1024 * 1024; // ~100MB per worker
1937+
export const ESTIMATED_WORKER_MEMORY_BYTES = 100 * 1024 * 1024; // ~100MB per worker
19371938

19381939
/**
19391940
* Determines the number of PHP worker threads to spawn based on
@@ -1942,7 +1943,10 @@ const ESTIMATED_WORKER_MEMORY_BYTES = 100 * 1024 * 1024; // ~100MB per worker
19421943
* Uses 50% of free memory as the budget for workers so the system
19431944
* has headroom for the OS, browser, and other processes.
19441945
*/
1945-
function computeWorkerCount(minWorkers: number, maxWorkers: number): number {
1946+
export function computeWorkerCount(
1947+
minWorkers: number,
1948+
maxWorkers: number
1949+
): number {
19461950
const freeMemory = os.freemem();
19471951
const memoryBased = Math.floor(
19481952
(freeMemory * 0.5) / ESTIMATED_WORKER_MEMORY_BYTES

packages/playground/cli/src/tls.ts

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@ import { join } from 'path';
44
import { tmpdir } from 'os';
55
import { logger } from '@php-wasm/logger';
66

7-
// Flip to true to surface openssl/mkcert output for debugging
8-
const DEBUG_CERT_GENERATION = false;
9-
const CERT_STDIO: ('pipe' | 'inherit')[] = DEBUG_CERT_GENERATION
10-
? ['inherit', 'inherit', 'inherit']
11-
: ['pipe', 'pipe', 'pipe'];
7+
function certStdio(debug: boolean): ('pipe' | 'inherit')[] {
8+
return debug ? ['inherit', 'inherit', 'inherit'] : ['pipe', 'pipe', 'pipe'];
9+
}
1210

1311
export interface TlsCertificate {
1412
key: string;
@@ -22,7 +20,7 @@ export interface TlsCertificate {
2220
* The certificate is valid for localhost and 127.0.0.1, expires in
2321
* 30 days, and is intended for local development only.
2422
*/
25-
export function generateSelfSignedCert(): TlsCertificate {
23+
export function generateSelfSignedCert(debug = false): TlsCertificate {
2624
const tempDir = mkdtempSync(join(tmpdir(), 'playground-tls-'));
2725
const keyPath = join(tempDir, 'key.pem');
2826
const certPath = join(tempDir, 'cert.pem');
@@ -42,21 +40,25 @@ subjectAltName = DNS:localhost,IP:127.0.0.1
4240

4341
try {
4442
writeFileSync(confPath, opensslConf);
45-
execFileSync('openssl', [
46-
'req',
47-
'-x509',
48-
'-newkey',
49-
'rsa:2048',
50-
'-nodes',
51-
'-keyout',
52-
keyPath,
53-
'-out',
54-
certPath,
55-
'-days',
56-
'30',
57-
'-config',
58-
confPath,
59-
], { stdio: CERT_STDIO });
43+
execFileSync(
44+
'openssl',
45+
[
46+
'req',
47+
'-x509',
48+
'-newkey',
49+
'rsa:2048',
50+
'-nodes',
51+
'-keyout',
52+
keyPath,
53+
'-out',
54+
certPath,
55+
'-days',
56+
'30',
57+
'-config',
58+
confPath,
59+
],
60+
{ stdio: certStdio(debug) }
61+
);
6062
return {
6163
key: readFileSync(keyPath, 'utf8'),
6264
cert: readFileSync(certPath, 'utf8'),
@@ -110,20 +112,24 @@ export function getMkcertCaRoot(): string | null {
110112
* Generates a locally-trusted TLS certificate using mkcert.
111113
* Requires mkcert to be installed with its CA root set up.
112114
*/
113-
export function generateMkcertCert(): TlsCertificate {
115+
export function generateMkcertCert(debug = false): TlsCertificate {
114116
const tempDir = mkdtempSync(join(tmpdir(), 'playground-tls-'));
115117
const keyPath = join(tempDir, 'key.pem');
116118
const certPath = join(tempDir, 'cert.pem');
117119

118120
try {
119-
execFileSync('mkcert', [
120-
'-key-file',
121-
keyPath,
122-
'-cert-file',
123-
certPath,
124-
'localhost',
125-
'127.0.0.1',
126-
], { stdio: CERT_STDIO });
121+
execFileSync(
122+
'mkcert',
123+
[
124+
'-key-file',
125+
keyPath,
126+
'-cert-file',
127+
certPath,
128+
'localhost',
129+
'127.0.0.1',
130+
],
131+
{ stdio: certStdio(debug) }
132+
);
127133
return {
128134
key: readFileSync(keyPath, 'utf8'),
129135
cert: readFileSync(certPath, 'utf8'),
@@ -151,6 +157,7 @@ export function generateMkcertCert(): TlsCertificate {
151157
export function resolveTlsCertificate(options: {
152158
sslCert?: string;
153159
sslKey?: string;
160+
debug?: boolean;
154161
}): TlsCertificate {
155162
if (options.sslCert && options.sslKey) {
156163
logger.log('TLS: using provided certificates');
@@ -160,15 +167,16 @@ export function resolveTlsCertificate(options: {
160167
};
161168
}
162169

170+
const debug = !!options.debug;
163171
const caRoot = getMkcertCaRoot();
164172
if (caRoot) {
165173
logger.log('TLS: using mkcert (locally-trusted)');
166-
return generateMkcertCert();
174+
return generateMkcertCert(debug);
167175
}
168176

169177
logger.log(
170178
'TLS: using self-signed certificate' +
171179
' (install mkcert for warning-free HTTPS)'
172180
);
173-
return generateSelfSignedCert();
181+
return generateSelfSignedCert(debug);
174182
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import os from 'os';
3+
import {
4+
computeWorkerCount,
5+
ESTIMATED_WORKER_MEMORY_BYTES,
6+
} from '../src/run-cli';
7+
8+
vi.mock('@php-wasm/logger', async (importOriginal) => {
9+
const actual = await importOriginal<typeof import('@php-wasm/logger')>();
10+
return {
11+
...actual,
12+
logger: { log: vi.fn(), error: vi.fn(), debug: vi.fn() },
13+
};
14+
});
15+
16+
describe('computeWorkerCount', () => {
17+
afterEach(() => {
18+
vi.restoreAllMocks();
19+
});
20+
21+
it('returns minWorkers when free memory is very low', () => {
22+
vi.spyOn(os, 'freemem').mockReturnValue(50 * 1024 * 1024); // 50MB
23+
expect(computeWorkerCount(2, 12)).toBe(2);
24+
});
25+
26+
it('returns maxWorkers when free memory is very high', () => {
27+
vi.spyOn(os, 'freemem').mockReturnValue(100 * 1024 * 1024 * 1024); // 100GB
28+
expect(computeWorkerCount(2, 12)).toBe(12);
29+
});
30+
31+
it('returns memory-based count when between min and max', () => {
32+
// 800MB free -> 400MB budget -> 4 workers
33+
vi.spyOn(os, 'freemem').mockReturnValue(800 * 1024 * 1024);
34+
expect(computeWorkerCount(2, 12)).toBe(4);
35+
});
36+
37+
it('clamps to minWorkers even when memory suggests fewer', () => {
38+
// 300MB free -> 150MB budget -> 1 worker, but min is 4
39+
vi.spyOn(os, 'freemem').mockReturnValue(300 * 1024 * 1024);
40+
expect(computeWorkerCount(4, 8)).toBe(4);
41+
});
42+
43+
it('clamps to maxWorkers even when memory suggests more', () => {
44+
// 4GB free -> 2GB budget -> 20 workers, but max is 8
45+
vi.spyOn(os, 'freemem').mockReturnValue(4 * 1024 * 1024 * 1024);
46+
expect(computeWorkerCount(2, 8)).toBe(8);
47+
});
48+
49+
it('uses 50% of free memory divided by estimated worker size', () => {
50+
const freeMem = 1200 * 1024 * 1024; // 1200MB
51+
vi.spyOn(os, 'freemem').mockReturnValue(freeMem);
52+
const expected = Math.floor(
53+
(freeMem * 0.5) / ESTIMATED_WORKER_MEMORY_BYTES
54+
);
55+
expect(computeWorkerCount(1, 100)).toBe(expected);
56+
expect(expected).toBe(6);
57+
});
58+
});

packages/playground/cli/tests/run-cli.spec.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import path from 'node:path';
22
import os from 'node:os';
33
import http from 'node:http';
4+
import http2 from 'node:http2';
5+
import https from 'node:https';
46
import {
57
runCLI,
68
parseOptionsAndRunCLI,
@@ -1723,3 +1725,106 @@ describe('other run-cli behaviors', () => {
17231725
});
17241726
});
17251727
});
1728+
1729+
describe(
1730+
'HTTP/2 integration',
1731+
() => {
1732+
function h2Get(url: string): Promise<{ status: number; body: string }> {
1733+
const parsed = new URL(url);
1734+
return new Promise((resolve, reject) => {
1735+
const client = http2.connect(parsed.origin, {
1736+
rejectUnauthorized: false,
1737+
});
1738+
client.on('error', reject);
1739+
const req = client.request({ ':path': parsed.pathname });
1740+
let body = '';
1741+
let status = 0;
1742+
req.on('response', (headers) => {
1743+
status = headers[':status'] as number;
1744+
});
1745+
req.on('data', (chunk: Buffer) => {
1746+
body += chunk.toString();
1747+
});
1748+
req.on('end', () => {
1749+
client.close();
1750+
resolve({ status, body });
1751+
});
1752+
req.on('error', reject);
1753+
req.end();
1754+
});
1755+
}
1756+
1757+
test('serves over HTTPS, reports correct SERVER_PROTOCOL for h2 and h1, and filters pseudo-headers', async () => {
1758+
await using cliServer = await runCLI({
1759+
command: 'server',
1760+
http2: true,
1761+
'min-workers': 2,
1762+
'max-workers': 4,
1763+
wordpressInstallMode: 'do-not-attempt-installing',
1764+
skipSqliteSetup: true,
1765+
blueprint: undefined,
1766+
});
1767+
1768+
// serverUrl uses https:// scheme with --http2
1769+
expect(cliServer.serverUrl).toMatch(/^https:\/\//);
1770+
1771+
// Consume the auto-login 302 redirect on the first request
1772+
await h2Get(new URL('/', cliServer.serverUrl).href);
1773+
1774+
// Write test PHP files
1775+
await cliServer.playground.writeFile(
1776+
'/wordpress/protocol.php',
1777+
'<?php echo $_SERVER["SERVER_PROTOCOL"]; ?>'
1778+
);
1779+
await cliServer.playground.writeFile(
1780+
'/wordpress/headers.php',
1781+
`<?php
1782+
header('Content-Type: application/json');
1783+
$pseudo = array_filter(
1784+
$_SERVER,
1785+
fn($k) => str_starts_with($k, 'HTTP_:'),
1786+
ARRAY_FILTER_USE_KEY
1787+
);
1788+
echo json_encode($pseudo);
1789+
?>`
1790+
);
1791+
1792+
// $_SERVER['SERVER_PROTOCOL'] reports HTTP/2.0 for h2 requests
1793+
const h2Result = await h2Get(
1794+
new URL('/protocol.php', cliServer.serverUrl).href
1795+
);
1796+
expect(h2Result.status).toBe(200);
1797+
expect(h2Result.body).toBe('HTTP/2.0');
1798+
1799+
// $_SERVER['SERVER_PROTOCOL'] reports HTTP/1.1 for h1 fallback
1800+
const h1Body = await new Promise<string>((resolve, reject) => {
1801+
const req = https.get(
1802+
new URL('/protocol.php', cliServer.serverUrl).href,
1803+
{ rejectUnauthorized: false },
1804+
(res) => {
1805+
let data = '';
1806+
res.on('data', (chunk: Buffer) => {
1807+
data += chunk.toString();
1808+
});
1809+
res.on('end', () => resolve(data));
1810+
}
1811+
);
1812+
req.on('error', reject);
1813+
});
1814+
expect(h1Body).toBe('HTTP/1.1');
1815+
1816+
// No HTTP/2 pseudo-headers leak into $_SERVER
1817+
const headersResult = await h2Get(
1818+
new URL('/headers.php', cliServer.serverUrl).href
1819+
);
1820+
expect(headersResult.status).toBe(200);
1821+
const pseudoHeaders = JSON.parse(headersResult.body);
1822+
expect(
1823+
Array.isArray(pseudoHeaders)
1824+
? pseudoHeaders.length
1825+
: Object.keys(pseudoHeaders).length
1826+
).toBe(0);
1827+
});
1828+
},
1829+
60_000 * 5
1830+
);

0 commit comments

Comments
 (0)