Skip to content

Commit dea8e0b

Browse files
authored
[Playground CLI] Separate Blueprints v1 and Blueprints v2 code paths (#2396)
## Motivation for the change, related issues Reorganizes Playground CLI code: * `src/blueprints-v1` has all the code specific to Blueprints v1 * `src/blueprints-v2` has all the code specific to Blueprints v2 * `tests` has all the unit tests No other changes are made ## Testing Instructions (or ideally a Blueprint) Confirm the CI checks pass.
1 parent f693bac commit dea8e0b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+528
-1032
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ jobs:
293293
with:
294294
submodules: true
295295
- uses: ./.github/actions/prepare-playground
296-
- run: packages/playground/cli/src/test/test-running-unbuilt-cli.sh
296+
- run: packages/playground/cli/tests/test-running-unbuilt-cli.sh
297297

298298
build:
299299
runs-on: ubuntu-latest
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import { logger } from '@php-wasm/logger';
2+
import { EmscriptenDownloadMonitor, ProgressTracker } from '@php-wasm/progress';
3+
import type { SupportedPHPVersion } from '@php-wasm/universal';
4+
import { consumeAPI } from '@php-wasm/universal';
5+
import type {
6+
BlueprintBundle,
7+
BlueprintDeclaration,
8+
} from '@wp-playground/blueprints';
9+
import { compileBlueprint, isBlueprintBundle } from '@wp-playground/blueprints';
10+
import { RecommendedPHPVersion, zipDirectory } from '@wp-playground/common';
11+
import fs from 'fs';
12+
import path from 'path';
13+
import { resolveWordPressRelease } from '@wp-playground/wordpress';
14+
import {
15+
CACHE_FOLDER,
16+
cachedDownload,
17+
fetchSqliteIntegration,
18+
readAsFile,
19+
} from './download';
20+
import type { PlaygroundCliBlueprintV1Worker } from './worker-thread-v1';
21+
// @ts-ignore
22+
import importedWorkerV1UrlString from './worker-thread-v1?worker&url';
23+
import type { MessagePort as NodeMessagePort } from 'worker_threads';
24+
import type { RunCLIArgs, SpawnedWorker } from '../run-cli';
25+
26+
/**
27+
* Boots Playground CLI workers using Blueprint version 1.
28+
*
29+
* Progress tracking, downloads, steps, and all other features are
30+
* implemented in TypeScript and orchestrated by this class.
31+
*/
32+
export class BlueprintsV1Handler {
33+
private phpVersion: SupportedPHPVersion | undefined;
34+
private lastProgressMessage = '';
35+
36+
private siteUrl: string;
37+
private processIdSpaceLength: number;
38+
private args: RunCLIArgs;
39+
40+
constructor(
41+
args: RunCLIArgs,
42+
options: {
43+
siteUrl: string;
44+
processIdSpaceLength: number;
45+
}
46+
) {
47+
this.args = args;
48+
this.siteUrl = options.siteUrl;
49+
this.processIdSpaceLength = options.processIdSpaceLength;
50+
}
51+
52+
getWorkerUrl() {
53+
return importedWorkerV1UrlString;
54+
}
55+
56+
async bootPrimaryWorker(
57+
phpPort: NodeMessagePort,
58+
fileLockManagerPort: NodeMessagePort
59+
) {
60+
const compiledBlueprint = await this.compileInputBlueprint(
61+
this.args['additional-blueprint-steps'] || []
62+
);
63+
this.phpVersion = compiledBlueprint.versions.php;
64+
65+
let wpDetails: any = undefined;
66+
// @TODO: Rename to FetchProgressMonitor. There's nothing Emscripten
67+
// about that class anymore.
68+
const monitor = new EmscriptenDownloadMonitor();
69+
if (!this.args.skipWordPressSetup) {
70+
let progressReached100 = false;
71+
monitor.addEventListener('progress', ((
72+
e: CustomEvent<ProgressEvent & { finished: boolean }>
73+
) => {
74+
if (progressReached100) {
75+
return;
76+
}
77+
78+
// @TODO Every progress bar will want percentages. The
79+
// download monitor should just provide that.
80+
const { loaded, total } = e.detail;
81+
// Use floor() so we don't report 100% until truly there.
82+
const percentProgress = Math.floor(
83+
Math.min(100, (100 * loaded) / total)
84+
);
85+
progressReached100 = percentProgress === 100;
86+
87+
if (!this.args.quiet) {
88+
this.writeProgressUpdate(
89+
process.stdout,
90+
`Downloading WordPress ${percentProgress}%...`,
91+
progressReached100
92+
);
93+
}
94+
}) as any);
95+
96+
wpDetails = await resolveWordPressRelease(this.args.wp);
97+
logger.log(
98+
`Resolved WordPress release URL: ${wpDetails?.releaseUrl}`
99+
);
100+
}
101+
102+
const preinstalledWpContentPath =
103+
wpDetails &&
104+
path.join(
105+
CACHE_FOLDER,
106+
`prebuilt-wp-content-for-wp-${wpDetails.version}.zip`
107+
);
108+
const wordPressZip = !wpDetails
109+
? undefined
110+
: fs.existsSync(preinstalledWpContentPath)
111+
? readAsFile(preinstalledWpContentPath)
112+
: await cachedDownload(
113+
wpDetails.releaseUrl,
114+
`${wpDetails.version}.zip`,
115+
monitor
116+
);
117+
118+
logger.log(`Fetching SQLite integration plugin...`);
119+
const sqliteIntegrationPluginZip = this.args.skipSqliteSetup
120+
? undefined
121+
: await fetchSqliteIntegration(monitor);
122+
123+
const followSymlinks = this.args.followSymlinks === true;
124+
const trace = this.args.experimentalTrace === true;
125+
126+
const mountsBeforeWpInstall = this.args['mount-before-install'] || [];
127+
const mountsAfterWpInstall = this.args.mount || [];
128+
129+
const playground = consumeAPI<PlaygroundCliBlueprintV1Worker>(phpPort);
130+
131+
// Comlink communication proxy
132+
await playground.isConnected();
133+
134+
logger.log(`Booting WordPress...`);
135+
136+
await playground.useFileLockManager(fileLockManagerPort);
137+
await playground.bootAsPrimaryWorker({
138+
phpVersion: this.phpVersion,
139+
wpVersion: compiledBlueprint.versions.wp,
140+
absoluteUrl: this.siteUrl,
141+
mountsBeforeWpInstall,
142+
mountsAfterWpInstall,
143+
wordPressZip: wordPressZip && (await wordPressZip!.arrayBuffer()),
144+
sqliteIntegrationPluginZip:
145+
await sqliteIntegrationPluginZip!.arrayBuffer(),
146+
firstProcessId: 0,
147+
processIdSpaceLength: this.processIdSpaceLength,
148+
followSymlinks,
149+
trace,
150+
internalCookieStore: this.args.internalCookieStore,
151+
withXdebug: this.args.xdebug,
152+
});
153+
154+
if (
155+
wpDetails &&
156+
!this.args['mount-before-install'] &&
157+
!fs.existsSync(preinstalledWpContentPath)
158+
) {
159+
logger.log(`Caching preinstalled WordPress for the next boot...`);
160+
fs.writeFileSync(
161+
preinstalledWpContentPath,
162+
(await zipDirectory(playground, '/wordpress'))!
163+
);
164+
logger.log(`Cached!`);
165+
}
166+
167+
return playground;
168+
}
169+
170+
async bootSecondaryWorker({
171+
worker,
172+
fileLockManagerPort,
173+
firstProcessId,
174+
}: {
175+
worker: SpawnedWorker;
176+
fileLockManagerPort: NodeMessagePort;
177+
firstProcessId: number;
178+
}) {
179+
const additionalPlayground = consumeAPI<PlaygroundCliBlueprintV1Worker>(
180+
worker.phpPort
181+
);
182+
183+
await additionalPlayground.isConnected();
184+
await additionalPlayground.useFileLockManager(fileLockManagerPort);
185+
await additionalPlayground.bootAsSecondaryWorker({
186+
phpVersion: this.phpVersion,
187+
absoluteUrl: this.siteUrl,
188+
mountsBeforeWpInstall: this.args['mount-before-install'] || [],
189+
mountsAfterWpInstall: this.args['mount'] || [],
190+
// Skip WordPress zip because we share the /wordpress directory
191+
// populated by the initial worker.
192+
wordPressZip: undefined,
193+
// Skip SQLite integration plugin for now because we
194+
// will copy it from primary's `/internal` directory.
195+
sqliteIntegrationPluginZip: undefined,
196+
dataSqlPath: '/wordpress/wp-content/database/.ht.sqlite',
197+
firstProcessId,
198+
processIdSpaceLength: this.processIdSpaceLength,
199+
followSymlinks: this.args.followSymlinks === true,
200+
trace: this.args.experimentalTrace === true,
201+
// @TODO: Move this to the request handler or else every worker
202+
// will have a separate cookie store.
203+
internalCookieStore: this.args.internalCookieStore,
204+
withXdebug: this.args.xdebug,
205+
});
206+
await additionalPlayground.isReady();
207+
return additionalPlayground;
208+
}
209+
210+
async compileInputBlueprint(additionalBlueprintSteps: any[]) {
211+
const args = this.args;
212+
const resolvedBlueprint = args.blueprint as BlueprintDeclaration;
213+
/**
214+
* @TODO This looks similar to the resolveBlueprint() call in the website package:
215+
* https://github.com/WordPress/wordpress-playground/blob/ce586059e5885d185376184fdd2f52335cca32b0/packages/playground/website/src/main.tsx#L41
216+
*
217+
* Also the Blueprint Builder tool does something similar.
218+
* Perhaps all these cases could be handled by the same function?
219+
*/
220+
const blueprint: BlueprintDeclaration | BlueprintBundle =
221+
isBlueprintBundle(resolvedBlueprint)
222+
? resolvedBlueprint
223+
: {
224+
login: args.login,
225+
...(resolvedBlueprint || {}),
226+
preferredVersions: {
227+
php:
228+
args.php ??
229+
resolvedBlueprint?.preferredVersions?.php ??
230+
RecommendedPHPVersion,
231+
wp:
232+
args.wp ??
233+
resolvedBlueprint?.preferredVersions?.wp ??
234+
'latest',
235+
...(resolvedBlueprint?.preferredVersions || {}),
236+
},
237+
};
238+
239+
const tracker = new ProgressTracker();
240+
let lastCaption = '';
241+
let progressReached100 = false;
242+
tracker.addEventListener('progress', (e: any) => {
243+
if (progressReached100) {
244+
return;
245+
}
246+
progressReached100 = e.detail.progress === 100;
247+
248+
// Use floor() so we don't report 100% until truly there.
249+
const progressInteger = Math.floor(e.detail.progress);
250+
lastCaption =
251+
e.detail.caption || lastCaption || 'Running the Blueprint';
252+
const message = `${lastCaption.trim()}${progressInteger}%`;
253+
if (!args.quiet) {
254+
this.writeProgressUpdate(
255+
process.stdout,
256+
message,
257+
progressReached100
258+
);
259+
}
260+
});
261+
return await compileBlueprint(blueprint as BlueprintDeclaration, {
262+
progress: tracker,
263+
additionalSteps: additionalBlueprintSteps,
264+
});
265+
}
266+
267+
writeProgressUpdate(
268+
writeStream: NodeJS.WriteStream,
269+
message: string,
270+
finalUpdate: boolean
271+
) {
272+
if (message === this.lastProgressMessage) {
273+
// Avoid repeating the same message
274+
return;
275+
}
276+
this.lastProgressMessage = message;
277+
278+
if (writeStream.isTTY) {
279+
// Overwrite previous progress updates in-place for a quieter UX.
280+
writeStream.cursorTo(0);
281+
writeStream.write(message);
282+
writeStream.clearLine(1);
283+
284+
if (finalUpdate) {
285+
writeStream.write('\n');
286+
}
287+
} else {
288+
// Fall back to writing one line per progress update
289+
writeStream.write(`${message}\n`);
290+
}
291+
}
292+
}

packages/playground/cli/src/worker-thread.ts renamed to packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { bootWordPress } from '@wp-playground/wordpress';
1515
import { rootCertificates } from 'tls';
1616
import { jspi } from 'wasm-feature-detect';
1717
import { MessageChannel, type MessagePort, parentPort } from 'worker_threads';
18-
import { mountResources } from './mounts';
18+
import { mountResources } from '../mounts';
1919

2020
export interface Mount {
2121
hostPath: string;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { BlueprintDeclaration } from '@wp-playground/blueprints';
2+
3+
export type BlueprintV2Declaration = string | BlueprintDeclaration | undefined;
4+
export type ParsedBlueprintV2Declaration =
5+
| { type: 'inline-file'; contents: string }
6+
| { type: 'file-reference'; reference: string };
7+
8+
export function parseBlueprintDeclaration(
9+
source: BlueprintV2Declaration | ParsedBlueprintV2Declaration
10+
): ParsedBlueprintV2Declaration {
11+
if (
12+
typeof source === 'object' &&
13+
'type' in source &&
14+
['inline-file', 'file-reference'].includes(source.type)
15+
) {
16+
return source;
17+
}
18+
if (!source) {
19+
return {
20+
type: 'inline-file',
21+
contents: '{}',
22+
};
23+
}
24+
if (typeof source !== 'string') {
25+
// If source is an object, assume it's a Blueprint declaration object and
26+
// convert it to a JSON string.
27+
return {
28+
type: 'inline-file',
29+
contents: JSON.stringify(source),
30+
};
31+
}
32+
try {
33+
// If source is valid JSON, return it as is.
34+
JSON.parse(source);
35+
return {
36+
type: 'inline-file',
37+
contents: source,
38+
};
39+
} catch {
40+
return {
41+
type: 'file-reference',
42+
reference: source,
43+
};
44+
}
45+
}

0 commit comments

Comments
 (0)