Skip to content

Commit c2e504e

Browse files
committed
Add CLI support for custom PHP extensions
1 parent 9e29f99 commit c2e504e

6 files changed

Lines changed: 257 additions & 14 deletions

File tree

packages/playground/cli/README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,50 @@ The `server` command supports the following optional arguments:
9494
- `--wordpress-install-mode <mode>`: Control how Playground prepares WordPress before booting. Defaults to `download-and-install`. Other options: `install-from-existing-files` (install using files you've mounted), `install-from-existing-files-if-needed` (same, but skip setup when an existing site is detected), and `do-not-attempt-installing` (never download or install WordPress).
9595
- `--skip-sqlite-setup`: Do not set up the SQLite database integration.
9696
- `--verbosity`: Output logs and progress messages (choices: "quiet", "normal", "debug"). Defaults to "normal".
97-
9897
- `--debug`: Print the PHP error log if an error occurs during boot.
9998
- `--follow-symlinks`: Allow Playground to follow symlinks by automatically mounting symlinked directories and files encountered in mounted directories. ⚠️ Warning: Following symlinks will expose files outside mounted directories to Playground and could be a security risk.
10099
- `--workers=<n|auto>`: Number of request-handling worker threads. Pass a positive integer, or `auto` to use one worker per CPU core (minus one). Defaults to `min(6, cpus-1)`. Useful for multi-client workloads (e.g. parallel e2e suites) that need more than 6 in-flight requests.
101100
- `--experimental-multi-worker`: Deprecated. Use `--workers=<n|auto>` instead. The value of this flag is ignored.
102101
- `--phpmyadmin[=<path>]`: Install phpMyAdmin for database management. The phpMyAdmin URL will be printed after boot. Optionally specify a custom URL path (default: `/phpmyadmin`).
103102
- `--internal-cookie-store`: Enables Playground's internal cookie handling. When active, Playground uses an HttpCookieStore to manage and persist cookies across requests. If disabled, cookies are handled externally, like by a browser in Node.js.
103+
- `--php-extension=<manifest>`: Load a custom PHP.wasm extension manifest before PHP starts. Accepts local paths, `file:` URLs, and `http(s):` URLs. Can be used multiple times.
104+
- `--php-extension-config=<path>`: Load a JSON extension config before PHP starts. Use this for direct `.so` URLs or extension-specific `iniEntries` and `env` settings. Can be used multiple times.
105+
106+
### Loading Custom PHP.wasm Extensions
107+
108+
Custom extensions built with `@php-wasm/compile-extension` can be loaded with
109+
`--php-extension`:
110+
111+
```bash
112+
npx @wp-playground/cli@latest server \
113+
--php=8.4 \
114+
--php-extension=./dist/wp_mysql_parser/manifest.json
115+
```
116+
117+
The manifest selects the `.so` artifact matching the active PHP version and can
118+
stage sidecar files before PHP starts. External extensions are JSPI-only, so use
119+
Node.js 23 or newer.
120+
121+
Use `--php-extension-config` when the extension needs more runtime settings:
122+
123+
```json
124+
{
125+
"source": {
126+
"format": "manifest",
127+
"manifestUrl": "./dist/spx/manifest.json"
128+
},
129+
"iniEntries": {
130+
"spx.http_enabled": "1"
131+
},
132+
"env": {
133+
"SPX_DATA_DIR": "/internal/shared/spx/data"
134+
}
135+
}
136+
```
137+
138+
```bash
139+
npx @wp-playground/cli@latest server --php-extension-config=./spx.json
140+
```
104141

105142
## Need some help with the CLI?
106143

packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
mergeDefinedConstants,
3333
} from '../run-cli';
3434
import type { CLIOutput } from '../cli-output';
35-
import { legacyPHPExtensionsObjectToExtensionsArray } from '../php-extensions';
35+
import { cliExtensionArgsToExtensionsArray } from '../php-extensions';
3636

3737
/**
3838
* Boots Playground CLI workers using Blueprint version 1.
@@ -198,7 +198,7 @@ export class BlueprintsV1Handler {
198198
processId: worker.processId,
199199
followSymlinks: this.args.followSymlinks === true,
200200
trace: this.args.experimentalTrace === true,
201-
extensions: legacyPHPExtensionsObjectToExtensionsArray(this.args),
201+
extensions: cliExtensionArgsToExtensionsArray(this.args),
202202
nativeInternalDirPath,
203203
pathAliases: this.args.pathAliases,
204204
});

packages/playground/cli/src/blueprints-v2/blueprints-v2-handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
mergeDefinedConstants,
1414
} from '../run-cli';
1515
import type { CLIOutput } from '../cli-output';
16-
import { legacyPHPExtensionsObjectToExtensionsArray } from '../php-extensions';
16+
import { cliExtensionArgsToExtensionsArray } from '../php-extensions';
1717

1818
/**
1919
* Boots Playground CLI workers using Blueprint version 2.
@@ -83,7 +83,7 @@ export class BlueprintsV2Handler {
8383
siteUrl: this.siteUrl,
8484
processId: worker.processId,
8585
trace: this.args.verbosity === 'debug',
86-
extensions: legacyPHPExtensionsObjectToExtensionsArray(this.args),
86+
extensions: cliExtensionArgsToExtensionsArray(this.args),
8787
nativeInternalDirPath,
8888
mountsBeforeWpInstall: this.args['mount-before-install'] || [],
8989
mountsAfterWpInstall: this.args.mount || [],

packages/playground/cli/src/php-extensions.ts

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
1-
import type { PHPExtension, XdebugOptions } from '@php-wasm/node';
1+
import { readFileSync } from 'node:fs';
2+
import type {
3+
PHPExtension,
4+
RuntimePHPExtensionSource,
5+
XdebugOptions,
6+
} from '@php-wasm/node';
27

38
/**
4-
* Converts the legacy Playground CLI extension options object into the runtime
5-
* `extensions` array.
9+
* Converts Playground CLI extension options into the runtime `extensions`
10+
* array.
611
*
7-
* The CLI still receives extensions as individual options: `intl`, `redis`,
8-
* `memcached`, and `xdebug`. The PHP runtime no longer has separate `with*`
9-
* entry points for new callers; it expects one array that can contain built-in
10-
* extension names and, elsewhere, external extension sources. This function is
11-
* the CLI boundary between those two shapes.
12+
* The CLI receives built-in extensions as individual options (`intl`, `redis`,
13+
* `memcached`, and `xdebug`) and external extensions as manifest/config paths.
14+
* The PHP runtime expects one array that can contain built-in names and
15+
* external extension sources side by side.
1216
*
1317
* Xdebug is the only CLI extension here with options. A plain `true` becomes
1418
* the built-in `xdebug` request, while an object preserves the Xdebug settings
1519
* and passes them through to the Node runtime.
1620
*/
17-
export function legacyPHPExtensionsObjectToExtensionsArray(args: {
21+
export function cliExtensionArgsToExtensionsArray(args: {
1822
intl?: boolean;
1923
redis?: boolean;
2024
memcached?: boolean;
2125
xdebug?: boolean | XdebugOptions;
26+
phpExtension?: string[];
27+
'php-extension'?: string[];
28+
phpExtensionConfig?: string[];
29+
'php-extension-config'?: string[];
2230
}): PHPExtension[] {
2331
const extensions: PHPExtension[] = [];
2432
if (args.intl) {
@@ -37,5 +45,89 @@ export function legacyPHPExtensionsObjectToExtensionsArray(args: {
3745
: 'xdebug'
3846
);
3947
}
48+
for (const manifestUrl of getArrayOption(args, 'phpExtension')) {
49+
extensions.push({
50+
source: {
51+
format: 'manifest',
52+
manifestUrl,
53+
},
54+
});
55+
}
56+
for (const configPath of getArrayOption(args, 'phpExtensionConfig')) {
57+
extensions.push(readPHPExtensionConfig(configPath));
58+
}
4059
return extensions;
4160
}
61+
62+
export function readPHPExtensionConfig(
63+
configPath: string
64+
): RuntimePHPExtensionSource {
65+
let config: unknown;
66+
try {
67+
config = JSON.parse(readFileSync(configPath, 'utf8'));
68+
} catch (error) {
69+
throw new Error(`Could not read PHP extension config: ${configPath}`, {
70+
cause: error,
71+
});
72+
}
73+
74+
if (!isRecord(config) || !isRecord(config['source'])) {
75+
throw new Error(
76+
`Invalid PHP extension config: ${configPath}. Expected an object with a source field.`
77+
);
78+
}
79+
80+
const source = config['source'];
81+
if (source['format'] === 'so') {
82+
throw new Error(
83+
`Invalid PHP extension config: ${configPath}. The CLI cannot load direct bytes; use a manifest or URL source.`
84+
);
85+
}
86+
if (source['format'] === 'url') {
87+
if (typeof source['url'] !== 'string') {
88+
throw new Error(
89+
`Invalid PHP extension config: ${configPath}. A URL source requires a string url.`
90+
);
91+
}
92+
return config as RuntimePHPExtensionSource;
93+
}
94+
if (source['format'] === 'manifest') {
95+
if (
96+
typeof source['manifestUrl'] !== 'string' &&
97+
!isRecord(source['manifest'])
98+
) {
99+
throw new Error(
100+
`Invalid PHP extension config: ${configPath}. A manifest source requires manifestUrl or manifest.`
101+
);
102+
}
103+
return config as RuntimePHPExtensionSource;
104+
}
105+
106+
throw new Error(
107+
`Invalid PHP extension config: ${configPath}. Unknown source format.`
108+
);
109+
}
110+
111+
function getArrayOption(
112+
args: {
113+
phpExtension?: string[];
114+
'php-extension'?: string[];
115+
phpExtensionConfig?: string[];
116+
'php-extension-config'?: string[];
117+
},
118+
camelCaseKey: 'phpExtension' | 'phpExtensionConfig'
119+
): string[] {
120+
const dashCaseKey =
121+
camelCaseKey === 'phpExtension'
122+
? 'php-extension'
123+
: 'php-extension-config';
124+
const value = args[camelCaseKey] ?? args[dashCaseKey];
125+
if (value === undefined) {
126+
return [];
127+
}
128+
return Array.isArray(value) ? value : [value];
129+
}
130+
131+
function isRecord(value: unknown): value is Record<string, unknown> {
132+
return typeof value === 'object' && value !== null && !Array.isArray(value);
133+
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,20 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) {
299299
type: 'boolean',
300300
default: false,
301301
},
302+
'php-extension': {
303+
describe:
304+
'Load a custom PHP.wasm extension manifest before PHP starts. Can be a local path, file: URL, or http(s) URL. Can be used multiple times.',
305+
type: 'array',
306+
string: true,
307+
nargs: 1,
308+
},
309+
'php-extension-config': {
310+
describe:
311+
'Load a JSON PHP.wasm extension config before PHP starts. Use this for direct .so URLs or extension-specific ini/env settings. Can be used multiple times.',
312+
type: 'array',
313+
string: true,
314+
nargs: 1,
315+
},
302316
'experimental-unsafe-ide-integration': {
303317
describe:
304318
'Enable experimental IDE development tools. This option edits IDE config files ' +
@@ -422,6 +436,8 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) {
422436
type: 'boolean',
423437
default: false,
424438
},
439+
'php-extension': sharedOptions['php-extension'],
440+
'php-extension-config': sharedOptions['php-extension-config'],
425441
'experimental-unsafe-ide-integration':
426442
sharedOptions['experimental-unsafe-ide-integration'],
427443
'skip-browser': {
@@ -896,6 +912,8 @@ export interface RunCLIArgs {
896912
redis?: boolean;
897913
memcached?: boolean;
898914
xdebug?: boolean | XdebugOptions;
915+
phpExtension?: string[];
916+
phpExtensionConfig?: string[];
899917
experimentalUnsafeIdeIntegration?: string[];
900918
experimentalDevtools?: boolean;
901919
'experimental-blueprints-v2-runner'?: boolean;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import { tmpdir } from 'node:os';
4+
import {
5+
cliExtensionArgsToExtensionsArray,
6+
readPHPExtensionConfig,
7+
} from '../src/php-extensions';
8+
9+
describe('CLI PHP extensions', () => {
10+
test('converts built-in extension flags to runtime extension requests', () => {
11+
expect(
12+
cliExtensionArgsToExtensionsArray({
13+
intl: true,
14+
redis: true,
15+
memcached: true,
16+
xdebug: true,
17+
})
18+
).toEqual(['intl', 'redis', 'memcached', 'xdebug']);
19+
});
20+
21+
test('converts --php-extension values to manifest extension requests', () => {
22+
expect(
23+
cliExtensionArgsToExtensionsArray({
24+
phpExtension: [
25+
'./dist/wp_mysql_parser/manifest.json',
26+
'https://example.com/spx/manifest.json',
27+
],
28+
})
29+
).toEqual([
30+
{
31+
source: {
32+
format: 'manifest',
33+
manifestUrl: './dist/wp_mysql_parser/manifest.json',
34+
},
35+
},
36+
{
37+
source: {
38+
format: 'manifest',
39+
manifestUrl: 'https://example.com/spx/manifest.json',
40+
},
41+
},
42+
]);
43+
});
44+
45+
test('reads --php-extension-config JSON files', async () => {
46+
const tempDir = await mkdtemp(path.join(tmpdir(), 'php-extension-'));
47+
const configPath = path.join(tempDir, 'extension.json');
48+
await writeFile(
49+
configPath,
50+
JSON.stringify({
51+
name: 'wp_mysql_parser',
52+
source: {
53+
format: 'url',
54+
url: './dist/wp_mysql_parser-php8.4-jspi.so',
55+
},
56+
iniEntries: {
57+
'wp_mysql_parser.mode': 'parser',
58+
},
59+
})
60+
);
61+
62+
try {
63+
expect(readPHPExtensionConfig(configPath)).toEqual({
64+
name: 'wp_mysql_parser',
65+
source: {
66+
format: 'url',
67+
url: './dist/wp_mysql_parser-php8.4-jspi.so',
68+
},
69+
iniEntries: {
70+
'wp_mysql_parser.mode': 'parser',
71+
},
72+
});
73+
expect(
74+
cliExtensionArgsToExtensionsArray({
75+
phpExtensionConfig: [configPath],
76+
})
77+
).toEqual([readPHPExtensionConfig(configPath)]);
78+
} finally {
79+
await rm(tempDir, { recursive: true, force: true });
80+
}
81+
});
82+
83+
test('rejects config files without an external extension source', async () => {
84+
const tempDir = await mkdtemp(path.join(tmpdir(), 'php-extension-'));
85+
const configPath = path.join(tempDir, 'extension.json');
86+
await writeFile(configPath, JSON.stringify({ name: 'broken' }));
87+
88+
try {
89+
expect(() => readPHPExtensionConfig(configPath)).toThrow(
90+
'Expected an object with a source field'
91+
);
92+
} finally {
93+
await rm(tempDir, { recursive: true, force: true });
94+
}
95+
});
96+
});

0 commit comments

Comments
 (0)