Skip to content

Commit 38515ca

Browse files
committed
Add Blueprint PHP extension loading step
1 parent 0a01f5d commit 38515ca

8 files changed

Lines changed: 3103 additions & 1603 deletions

File tree

packages/playground/blueprints/public/blueprint-schema-validator.js

Lines changed: 2785 additions & 1601 deletions
Large diffs are not rendered by default.

packages/playground/blueprints/public/blueprint-schema.json

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,87 @@
878878
},
879879
"required": ["step", "themeData"]
880880
},
881+
{
882+
"type": "object",
883+
"additionalProperties": false,
884+
"properties": {
885+
"progress": {
886+
"type": "object",
887+
"properties": {
888+
"weight": {
889+
"type": "number"
890+
},
891+
"caption": {
892+
"type": "string"
893+
}
894+
},
895+
"additionalProperties": false
896+
},
897+
"step": {
898+
"type": "string",
899+
"const": "loadPHPExtension"
900+
},
901+
"source": {
902+
"$ref": "#/definitions/FileReference",
903+
"description": "The extension `.so` file or a compile-extension `manifest.json`."
904+
},
905+
"sourceFormat": {
906+
"$ref": "#/definitions/PHPExtensionSourceFormat",
907+
"description": "Defaults to \"manifest\" for `.json` files and \"so\" otherwise."
908+
},
909+
"name": {
910+
"type": "string",
911+
"description": "Required when loading a `.so` file whose filename is not named after the extension."
912+
},
913+
"loadTiming": {
914+
"$ref": "#/definitions/PHPExtensionLoadTiming",
915+
"description": "Defaults to \"auto\"."
916+
},
917+
"loadWithIniDirective": {
918+
"$ref": "#/definitions/PHPExtensionIniDirective",
919+
"description": "Defaults to \"extension\". Use \"zend_extension\" for extensions like Xdebug."
920+
},
921+
"iniEntries": {
922+
"type": "object",
923+
"additionalProperties": {
924+
"type": "string"
925+
},
926+
"description": "Extra `php.ini` entries to write next to the extension."
927+
},
928+
"extraFiles": {
929+
"$ref": "#/definitions/DirectoryReference",
930+
"description": "Extra files required by the extension, such as ICU data or shared libraries."
931+
},
932+
"extraFilesPath": {
933+
"type": "string",
934+
"description": "Where to write `extraFiles`. Defaults to an extension-specific assets directory."
935+
},
936+
"env": {
937+
"type": "object",
938+
"additionalProperties": {
939+
"type": "string"
940+
},
941+
"description": "Runtime environment variables needed by the extension."
942+
},
943+
"phpVersion": {
944+
"type": "string",
945+
"description": "Overrides manifest artifact selection. Defaults to the running PHP version."
946+
},
947+
"asyncMode": {
948+
"$ref": "#/definitions/PHPWasmAsyncMode",
949+
"description": "Overrides manifest artifact selection. Defaults to the running async mode."
950+
},
951+
"manifestBaseUrl": {
952+
"type": "string",
953+
"description": "Base URL for relative artifact paths in an inline or bundled manifest."
954+
},
955+
"extensionDir": {
956+
"type": "string",
957+
"description": "Where to install the extension `.so` and `.ini` files."
958+
}
959+
},
960+
"required": ["source", "step"]
961+
},
881962
{
882963
"type": "object",
883964
"additionalProperties": false,
@@ -1504,6 +1585,22 @@
15041585
},
15051586
"additionalProperties": false
15061587
},
1588+
"PHPExtensionSourceFormat": {
1589+
"type": "string",
1590+
"enum": ["so", "manifest"]
1591+
},
1592+
"PHPExtensionLoadTiming": {
1593+
"type": "string",
1594+
"enum": ["before-php-startup", "after-php-startup", "auto"]
1595+
},
1596+
"PHPExtensionIniDirective": {
1597+
"type": "string",
1598+
"enum": ["extension", "zend_extension"]
1599+
},
1600+
"PHPWasmAsyncMode": {
1601+
"type": "string",
1602+
"enum": ["jspi", "asyncify"]
1603+
},
15071604
"PHPRequest": {
15081605
"type": "object",
15091606
"properties": {

packages/playground/blueprints/src/lib/steps/handlers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export { exportWXR } from './export-wxr';
2020
export { unzip } from './unzip';
2121
export { installPlugin } from './install-plugin';
2222
export { installTheme } from './install-theme';
23+
export { loadPHPExtension } from './load-php-extension';
2324
export { login } from './login';
2425
export { resetData } from './reset-data';
2526
export { runWpInstallationWizard } from './run-wp-installation-wizard';

packages/playground/blueprints/src/lib/steps/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import type { EnableMultisiteStep } from './enable-multisite';
3636
import type { WPCLIStep } from './wp-cli';
3737
import type { ResetDataStep } from './reset-data';
3838
import type { SetSiteLanguageStep } from './set-site-language';
39+
import type { LoadPHPExtensionStep } from './load-php-extension';
3940

4041
export type Step = GenericStep<FileReference, DirectoryReference>;
4142
export type StepDefinition = Step & {
@@ -63,6 +64,7 @@ export type GenericStep<FileResource, DirectoryResource> =
6364
| ImportWordPressFilesStep<FileResource>
6465
| InstallPluginStep<FileResource, DirectoryResource>
6566
| InstallThemeStep<FileResource, DirectoryResource>
67+
| LoadPHPExtensionStep<FileResource, DirectoryResource>
6668
| LoginStep
6769
| MkdirStep
6870
| MvStep
@@ -96,6 +98,7 @@ export type {
9698
InstallPluginOptions,
9799
InstallThemeStep,
98100
InstallThemeOptions,
101+
LoadPHPExtensionStep,
99102
LoginStep,
100103
MkdirStep,
101104
MvStep,
@@ -127,7 +130,7 @@ export type StepProgress = {
127130

128131
export type StepHandler<
129132
S extends GenericStep<File, Directory>,
130-
Return = any
133+
Return = any,
131134
> = (
132135
/**
133136
* A PHP instance or Playground client.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
PHP_EXTENSION_PRELOAD_DIR,
5+
PHP_EXTENSIONS_DIR,
6+
} from '@php-wasm/universal';
7+
import { loadPHPExtension } from './load-php-extension';
8+
9+
const PHP_INI_PATH = '/internal/shared/php.ini';
10+
11+
describe('loadPHPExtension Blueprint step', () => {
12+
it('loads a direct .so resource and a directory resource for extra files', async () => {
13+
const php = createFakePHP();
14+
const soBytes = new Uint8Array([1, 2, 3]);
15+
16+
await loadPHPExtension(php as any, {
17+
source: new File([soBytes], 'example.so'),
18+
extraFiles: {
19+
name: 'example-assets',
20+
files: {
21+
'data.txt': 'sidecar',
22+
},
23+
},
24+
extraFilesPath: '/internal/shared/example-assets',
25+
});
26+
27+
expect(php.files.get(`${PHP_EXTENSIONS_DIR}/example.so`)).toEqual(
28+
soBytes
29+
);
30+
expect(php.files.get(`${PHP_EXTENSIONS_DIR}/example.ini`)).toBe(
31+
`extension=${PHP_EXTENSIONS_DIR}/example.so`
32+
);
33+
expect(
34+
String(php.files.get(`${PHP_EXTENSION_PRELOAD_DIR}/example.php`))
35+
).toContain("dl('example.so')");
36+
expect(php.files.get('/internal/shared/example-assets/data.txt')).toBe(
37+
'sidecar'
38+
);
39+
});
40+
});
41+
42+
function createFakePHP() {
43+
const files = new Map<string, string | Uint8Array>([
44+
[PHP_INI_PATH, 'memory_limit=256M\n'],
45+
]);
46+
const directories = new Set<string>(['/internal', '/internal/shared']);
47+
48+
return {
49+
files,
50+
directories,
51+
async fileExists(path: string) {
52+
return files.has(path) || directories.has(path);
53+
},
54+
async mkdir(path: string) {
55+
directories.add(path);
56+
},
57+
async mkdirTree(path: string) {
58+
directories.add(path);
59+
},
60+
async writeFile(path: string, data: string | Uint8Array) {
61+
files.set(path, data);
62+
},
63+
async readFileAsText(path: string) {
64+
const value = files.get(path);
65+
if (value instanceof Uint8Array) {
66+
return new TextDecoder().decode(value);
67+
}
68+
return value ?? '';
69+
},
70+
};
71+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import {
2+
loadPHPExtension as loadPHPExtensionFromSource,
3+
type LoadedPHPExtension,
4+
type PHPExtensionIniDirective,
5+
type PHPExtensionLoadTiming,
6+
type PHPExtensionSourceFormat,
7+
type PHPWasmAsyncMode,
8+
} from '@php-wasm/universal';
9+
import type { StepHandler } from '.';
10+
import type { Directory } from '../v1/resources';
11+
12+
type SourceFile = File & { sourceUrl?: string };
13+
14+
/**
15+
* @inheritDoc loadPHPExtension
16+
* @example
17+
*
18+
* <code>
19+
* {
20+
* "step": "loadPHPExtension",
21+
* "source": {
22+
* "resource": "url",
23+
* "url": "https://example.com/extensions/example/manifest.json"
24+
* },
25+
* "sourceFormat": "manifest"
26+
* }
27+
* </code>
28+
*/
29+
export interface LoadPHPExtensionStep<FileResource, DirectoryResource> {
30+
step: 'loadPHPExtension';
31+
/** The extension `.so` file or a compile-extension `manifest.json`. */
32+
source: FileResource;
33+
/** Defaults to "manifest" for `.json` files and "so" otherwise. */
34+
sourceFormat?: PHPExtensionSourceFormat;
35+
/** Required when loading a `.so` file whose filename is not named after the extension. */
36+
name?: string;
37+
/** Defaults to "auto". */
38+
loadTiming?: PHPExtensionLoadTiming;
39+
/** Defaults to "extension". Use "zend_extension" for extensions like Xdebug. */
40+
loadWithIniDirective?: PHPExtensionIniDirective;
41+
/** Extra `php.ini` entries to write next to the extension. */
42+
iniEntries?: Record<string, string>;
43+
/** Extra files required by the extension, such as ICU data or shared libraries. */
44+
extraFiles?: DirectoryResource;
45+
/** Where to write `extraFiles`. Defaults to an extension-specific assets directory. */
46+
extraFilesPath?: string;
47+
/** Runtime environment variables needed by the extension. */
48+
env?: Record<string, string>;
49+
/** Overrides manifest artifact selection. Defaults to the running PHP version. */
50+
phpVersion?: string;
51+
/** Overrides manifest artifact selection. Defaults to the running async mode. */
52+
asyncMode?: PHPWasmAsyncMode;
53+
/** Base URL for relative artifact paths in an inline or bundled manifest. */
54+
manifestBaseUrl?: string;
55+
/** Where to install the extension `.so` and `.ini` files. */
56+
extensionDir?: string;
57+
}
58+
59+
export const loadPHPExtension: StepHandler<
60+
LoadPHPExtensionStep<File, Directory>,
61+
Promise<LoadedPHPExtension>
62+
> = async (
63+
playground,
64+
{
65+
source,
66+
sourceFormat,
67+
name,
68+
loadTiming,
69+
loadWithIniDirective,
70+
iniEntries,
71+
extraFiles,
72+
extraFilesPath,
73+
env,
74+
phpVersion,
75+
asyncMode,
76+
manifestBaseUrl,
77+
extensionDir,
78+
}
79+
) => {
80+
const format = sourceFormat ?? inferSourceFormat(source);
81+
const bytes = new Uint8Array(await source.arrayBuffer());
82+
83+
return await loadPHPExtensionFromSource(playground, {
84+
source:
85+
format === 'manifest'
86+
? {
87+
format,
88+
manifest: JSON.parse(new TextDecoder().decode(bytes)),
89+
baseUrl: manifestBaseUrl ?? getSourceUrl(source),
90+
}
91+
: {
92+
format,
93+
name: name ?? inferExtensionName(source),
94+
bytes,
95+
},
96+
name,
97+
loadTiming,
98+
loadWithIniDirective,
99+
iniEntries,
100+
extraFiles: extraFiles
101+
? {
102+
targetPath: extraFilesPath,
103+
files: extraFiles.files,
104+
}
105+
: undefined,
106+
env,
107+
phpVersion,
108+
asyncMode,
109+
extensionDir,
110+
});
111+
};
112+
113+
function inferSourceFormat(source: File): PHPExtensionSourceFormat {
114+
return source.name.endsWith('.json') ? 'manifest' : 'so';
115+
}
116+
117+
function inferExtensionName(source: File): string | undefined {
118+
return source.name.endsWith('.so') ? source.name.slice(0, -3) : undefined;
119+
}
120+
121+
function getSourceUrl(source: SourceFile): string | undefined {
122+
return source.sourceUrl;
123+
}

packages/playground/blueprints/src/lib/v1/resources.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
rewriteGithubProxyUrl,
77
ZipResource,
88
Resource,
9+
isResourceReference,
910
} from './resources';
1011
import { expect, describe, it, vi, beforeEach, afterEach } from 'vitest';
1112
import { StreamedFile } from '@php-wasm/stream-compression';
@@ -47,6 +48,20 @@ describe('UrlResource', () => {
4748
});
4849
});
4950

51+
describe('isResourceReference', () => {
52+
it('recognizes literal directory resources', () => {
53+
expect(
54+
isResourceReference({
55+
resource: 'literal:directory',
56+
name: 'assets',
57+
files: {
58+
'data.txt': 'sidecar',
59+
},
60+
})
61+
).toBe(true);
62+
});
63+
});
64+
5065
describe('GitDirectoryResource', () => {
5166
describe('resolve', () => {
5267
it.each([

packages/playground/blueprints/src/lib/v1/resources.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export type { FileTree };
5959
export const ResourceTypes = [
6060
'vfs',
6161
'literal',
62+
'literal:directory',
6263
'wordpress.org/themes',
6364
'wordpress.org/plugins',
6465
'url',
@@ -562,7 +563,12 @@ export abstract class FetchResource extends Resource<File> {
562563
response.headers.get('content-disposition') || ''
563564
) ||
564565
encodeURIComponent(url);
565-
return new File([await response.arrayBuffer()], filename);
566+
const file = new File([await response.arrayBuffer()], filename);
567+
Object.defineProperty(file, 'sourceUrl', {
568+
value: url,
569+
enumerable: false,
570+
});
571+
return file;
566572
} catch (e) {
567573
throw new ResourceDownloadError(
568574
`Could not download "${url}".\n\n` +

0 commit comments

Comments
 (0)