From 2a4c676c1b19e8989bd5ed8710b9cc53f4dca22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 2 Nov 2025 00:12:44 +0100 Subject: [PATCH 1/6] [TCP Proxy] Produce the correct request path in parseHttpRequest() Solves a TCP<->fetch() issue where query strings were being double-encoded and duplicated. When PHP tried to request `/core/version-check/1.7/?channel=beta` via file_get_contents(), parseHttpRequest() would source an invalid URL for fetch(): `/core/version-check/1.7/%3Fchannel=beta?channel=beta` This is caused by a redundant and incorrect assignment `url.pathname = parsedHeaders.path;` that this PR removes. ## Testing instructions CI --- .../src/lib/tcp-over-fetch-websocket.spec.ts | 193 ++++++++++++++++++ .../web/src/lib/tcp-over-fetch-websocket.ts | 1 - 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts b/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts index 58704489d1..78cb3d8967 100644 --- a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts +++ b/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts @@ -368,6 +368,199 @@ describe('RawBytesFetch', () => { ); expect(decodedRequestBody).toEqual(encodedBodyBytes); }); + + it('parseHttpRequest should handle a path and query string', async () => { + const requestBytes = `GET /core/version-check/1.7/?channel=beta HTTP/1.1\r\nHost: playground.internal\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'playground.internal', + 'http' + ); + expect(request.url).toEqual( + 'http://playground.internal/core/version-check/1.7/?channel=beta' + ); + }); + + it('parseHttpRequest should handle a simple path without query string', async () => { + const requestBytes = `GET /api/users HTTP/1.1\r\nHost: example.com\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'example.com', + 'http' + ); + expect(request.url).toEqual('http://example.com/api/users'); + }); + + it('parseHttpRequest should handle root path', async () => { + const requestBytes = `GET / HTTP/1.1\r\nHost: example.com\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'example.com', + 'https' + ); + expect(request.url).toEqual('https://example.com/'); + }); + + it('parseHttpRequest should handle multiple query parameters', async () => { + const requestBytes = `GET /search?q=test&page=2&sort=asc HTTP/1.1\r\nHost: api.example.com\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'api.example.com', + 'https' + ); + expect(request.url).toEqual( + 'https://api.example.com/search?q=test&page=2&sort=asc' + ); + }); + + it('parseHttpRequest should handle path with trailing slash', async () => { + const requestBytes = `GET /api/users/ HTTP/1.1\r\nHost: example.com\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'example.com', + 'http' + ); + expect(request.url).toEqual('http://example.com/api/users/'); + }); + + it('parseHttpRequest should handle nested paths', async () => { + const requestBytes = `GET /api/v1/users/123/posts/456 HTTP/1.1\r\nHost: example.com\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'example.com', + 'https' + ); + expect(request.url).toEqual( + 'https://example.com/api/v1/users/123/posts/456' + ); + }); + + it('parseHttpRequest should handle URL-encoded characters in path', async () => { + const requestBytes = `GET /search/hello%20world HTTP/1.1\r\nHost: example.com\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'example.com', + 'http' + ); + expect(request.url).toEqual('http://example.com/search/hello%20world'); + }); + + it('parseHttpRequest should handle special characters in query string', async () => { + const requestBytes = `GET /search?q=hello+world&filter=a%26b HTTP/1.1\r\nHost: example.com\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'example.com', + 'http' + ); + expect(request.url).toEqual( + 'http://example.com/search?q=hello+world&filter=a%26b' + ); + }); + + it('parseHttpRequest should handle empty query parameter values', async () => { + const requestBytes = `GET /api?key1=&key2=value2 HTTP/1.1\r\nHost: example.com\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'example.com', + 'http' + ); + expect(request.url).toEqual('http://example.com/api?key1=&key2=value2'); + }); + + it('parseHttpRequest should handle path with hash fragment', async () => { + // Note: Hash fragments are typically not sent in HTTP requests, + // but if they are, the URL constructor should handle them + const requestBytes = `GET /page#section HTTP/1.1\r\nHost: example.com\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'example.com', + 'http' + ); + expect(request.url).toEqual('http://example.com/page#section'); + }); + + it('parseHttpRequest should handle path with query and hash', async () => { + const requestBytes = `GET /page?param=value#section HTTP/1.1\r\nHost: example.com\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'example.com', + 'http' + ); + expect(request.url).toEqual( + 'http://example.com/page?param=value#section' + ); + }); + + it('parseHttpRequest should preserve Host header over default host', async () => { + const requestBytes = `GET /api HTTP/1.1\r\nHost: custom.host.com\r\n\r\n`; + const request = await RawBytesFetch.parseHttpRequest( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(requestBytes)); + controller.close(); + }, + }), + 'default.host.com', // Different from Host header + 'https' + ); + // Should use the Host header, not the default host parameter + expect(request.url).toEqual('https://custom.host.com/api'); + }); }); type MakeRequestOptions = { diff --git a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts b/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts index 97f53bf124..918edf65cd 100644 --- a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts +++ b/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts @@ -686,7 +686,6 @@ export class RawBytesFetch { */ const hostname = parsedHeaders.headers.get('Host') ?? host; const url = new URL(parsedHeaders.path, protocol + '://' + hostname); - url.pathname = parsedHeaders.path; return new Request(url.toString(), { method: parsedHeaders.method, From afc4ad536a5cfc9533fb77cd14e7b9bd49194b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 2 Nov 2025 00:17:29 +0100 Subject: [PATCH 2/6] Brush up tests --- .../src/lib/tcp-over-fetch-websocket.spec.ts | 51 +------------------ 1 file changed, 1 insertion(+), 50 deletions(-) diff --git a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts b/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts index 78cb3d8967..6b43c1b02c 100644 --- a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts +++ b/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts @@ -416,55 +416,6 @@ describe('RawBytesFetch', () => { expect(request.url).toEqual('https://example.com/'); }); - it('parseHttpRequest should handle multiple query parameters', async () => { - const requestBytes = `GET /search?q=test&page=2&sort=asc HTTP/1.1\r\nHost: api.example.com\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'api.example.com', - 'https' - ); - expect(request.url).toEqual( - 'https://api.example.com/search?q=test&page=2&sort=asc' - ); - }); - - it('parseHttpRequest should handle path with trailing slash', async () => { - const requestBytes = `GET /api/users/ HTTP/1.1\r\nHost: example.com\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'example.com', - 'http' - ); - expect(request.url).toEqual('http://example.com/api/users/'); - }); - - it('parseHttpRequest should handle nested paths', async () => { - const requestBytes = `GET /api/v1/users/123/posts/456 HTTP/1.1\r\nHost: example.com\r\n\r\n`; - const request = await RawBytesFetch.parseHttpRequest( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(requestBytes)); - controller.close(); - }, - }), - 'example.com', - 'https' - ); - expect(request.url).toEqual( - 'https://example.com/api/v1/users/123/posts/456' - ); - }); - it('parseHttpRequest should handle URL-encoded characters in path', async () => { const requestBytes = `GET /search/hello%20world HTTP/1.1\r\nHost: example.com\r\n\r\n`; const request = await RawBytesFetch.parseHttpRequest( @@ -480,7 +431,7 @@ describe('RawBytesFetch', () => { expect(request.url).toEqual('http://example.com/search/hello%20world'); }); - it('parseHttpRequest should handle special characters in query string', async () => { + it('parseHttpRequest should handle URL-encoded characters in query string', async () => { const requestBytes = `GET /search?q=hello+world&filter=a%26b HTTP/1.1\r\nHost: example.com\r\n\r\n`; const request = await RawBytesFetch.parseHttpRequest( new ReadableStream({ From aa024f4286b2735434af3d7a4334ad7b3d6c38ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 2 Nov 2025 00:12:13 +0100 Subject: [PATCH 3/6] Explore Blueprint v2 overrides --- .../src/lib/resolve-runtime-configuration.ts | 59 ++++- .../blueprints/src/lib/v2/run-blueprint-v2.ts | 13 +- .../client/src/apply-blueprint-overrides.ts | 120 +++++++++ .../client/src/blueprints-v1-handler.ts | 12 +- .../client/src/blueprints-v2-handler.ts | 47 +++- packages/playground/client/src/index.ts | 25 ++ ...layground-worker-endpoint-blueprints-v2.ts | 12 +- .../src/lib/playground-worker-endpoint.ts | 6 + .../src/lib/state/redux/boot-site-client.ts | 1 + .../src/lib/state/redux/slice-sites.ts | 31 +-- .../state/url/resolve-blueprint-from-url.ts | 227 ++++++++++++------ 11 files changed, 452 insertions(+), 101 deletions(-) create mode 100644 packages/playground/client/src/apply-blueprint-overrides.ts diff --git a/packages/playground/blueprints/src/lib/resolve-runtime-configuration.ts b/packages/playground/blueprints/src/lib/resolve-runtime-configuration.ts index 2252b4b789..67cd0721ed 100644 --- a/packages/playground/blueprints/src/lib/resolve-runtime-configuration.ts +++ b/packages/playground/blueprints/src/lib/resolve-runtime-configuration.ts @@ -1,11 +1,30 @@ import { RecommendedPHPVersion } from '@wp-playground/common'; +import type { SupportedPHPVersion } from '@php-wasm/universal'; import { BlueprintReflection } from './reflection'; import type { Blueprint, RuntimeConfiguration } from './types'; import { compileBlueprintV1 } from './v1/compile'; import type { BlueprintV1 } from './v1/types'; +/** + * BlueprintOverrides type - matches the type from @wp-playground/client + * but defined here to avoid circular dependencies. + */ +export interface BlueprintOverrides { + blueprintOverrides?: { + wordpressVersion?: string; + phpVersion?: string; + additionalSteps?: any[]; + }; + applicationOptions?: { + landingPage?: string; + login?: boolean; + networkAccess?: boolean; + }; +} + export async function resolveRuntimeConfiguration( - blueprint: Blueprint + blueprint: Blueprint, + overrides?: BlueprintOverrides ): Promise { const reflection = await BlueprintReflection.create(blueprint); if (reflection.getVersion() === 1) { @@ -30,12 +49,42 @@ export async function resolveRuntimeConfiguration( constants: {}, }; } else { - // @TODO: actually compute the runtime configuration based on the resolved Blueprint v2 + // For Blueprint v2, compute runtime configuration from the blueprint and overrides + const declaration = reflection.getDeclaration() as any; + + // Determine WordPress version (priority: override > blueprint > default) + const wpVersion = + overrides?.blueprintOverrides?.wordpressVersion || + declaration.wordpressVersion || + 'latest'; + + // Determine PHP version (priority: override > blueprint > default) + let phpVersion: SupportedPHPVersion = RecommendedPHPVersion; + if (overrides?.blueprintOverrides?.phpVersion) { + phpVersion = overrides.blueprintOverrides + .phpVersion as SupportedPHPVersion; + } else if (declaration.phpVersion) { + // Handle both string and object forms of phpVersion + if (typeof declaration.phpVersion === 'string') { + phpVersion = declaration.phpVersion as SupportedPHPVersion; + } else if (declaration.phpVersion.recommended) { + phpVersion = declaration.phpVersion + .recommended as SupportedPHPVersion; + } + } + + // Determine networking (priority: override > blueprint > default) + const networking = + overrides?.applicationOptions?.networkAccess ?? + declaration.applicationOptions?.['wordpress-playground'] + ?.networkAccess ?? + true; + return { - phpVersion: RecommendedPHPVersion, - wpVersion: 'latest', + phpVersion, + wpVersion, intl: false, - networking: true, + networking, constants: {}, extraLibraries: [], }; diff --git a/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts b/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts index 8899efdd2b..b17de864d7 100644 --- a/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts +++ b/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts @@ -124,6 +124,7 @@ export async function runBlueprintV2( /** * Prepare hooks, filters, and run the Blueprint: */ + console.log('options.blueprintOverrides', options.blueprintOverrides); await php?.writeFile( '/tmp/run-blueprints.php', ` 'blueprint.target_resolved', ])); } -playground_add_filter('blueprint.target_resolved', 'playground_on_blueprint_target_resolved'); +//playground_add_filter('blueprint.target_resolved', 'playground_on_blueprint_target_resolved'); -playground_add_filter('blueprint.resolved', 'playground_on_blueprint_resolved'); +//playground_add_filter('blueprint.resolved', 'playground_on_blueprint_resolved'); function playground_on_blueprint_resolved($blueprint) { $additional_blueprint_steps = json_decode(${phpVar( JSON.stringify(options.blueprintOverrides?.additionalSteps || []) @@ -154,7 +155,6 @@ function playground_on_blueprint_resolved($blueprint) { $additional_blueprint_steps ); } - $wp_version_override = json_decode(${phpVar( JSON.stringify(options.blueprintOverrides?.wordpressVersion || null) )}, true); @@ -222,6 +222,13 @@ require( "/tmp/blueprints.phar" ); ])) as StreamedPHPResponse; streamedResponse.finished.finally(unbindMessageListener); + streamedResponse.stdout.pipeTo( + new WritableStream({ + write(chunk) { + console.log('stdout', new TextDecoder().decode(chunk)); + }, + }) + ); return streamedResponse; } diff --git a/packages/playground/client/src/apply-blueprint-overrides.ts b/packages/playground/client/src/apply-blueprint-overrides.ts new file mode 100644 index 0000000000..c1ac41d775 --- /dev/null +++ b/packages/playground/client/src/apply-blueprint-overrides.ts @@ -0,0 +1,120 @@ +import type { + BlueprintV1Declaration, + BlueprintV1, + BlueprintBundle, +} from '@wp-playground/blueprints'; +import { isBlueprintBundle } from '@wp-playground/blueprints'; +import { RecommendedPHPVersion } from '@wp-playground/common'; +import type { BlueprintOverrides } from './index'; + +/** + * Apply BlueprintOverrides to a Blueprint v1. + * This is used by the v1 handler to reconcile URL parameter overrides + * with the blueprint definition. + * + * Note: For bundle blueprints, this only works on the in-memory representation. + * The bundle itself is not modified. + */ +export function applyBlueprintOverrides( + blueprint: BlueprintV1, + overrides: BlueprintOverrides +): BlueprintV1 { + // If it's a bundle, we can't modify it - return as is + // The overrides will be applied during compilation + if (isBlueprintBundle(blueprint)) { + return blueprint; + } + + return applyOverridesToDeclaration(blueprint, overrides); +} + +function applyOverridesToDeclaration( + blueprint: BlueprintV1Declaration, + overrides: BlueprintOverrides +): BlueprintV1Declaration { + // Create a mutable copy of the blueprint to avoid mutating the original + // (which may be frozen/sealed from Redux store) + const mutableBlueprint: BlueprintV1Declaration = { + ...blueprint, + preferredVersions: blueprint.preferredVersions + ? { ...blueprint.preferredVersions } + : ({} as any), + features: blueprint.features ? { ...blueprint.features } : {}, + steps: blueprint.steps ? [...blueprint.steps] : [], + }; + + // Apply PHP version override + if (overrides.blueprintOverrides?.phpVersion) { + mutableBlueprint.preferredVersions!.php = overrides.blueprintOverrides + .phpVersion as any; + } else if (!mutableBlueprint.preferredVersions!.php) { + mutableBlueprint.preferredVersions!.php = RecommendedPHPVersion; + } + + // Apply WordPress version override + if (overrides.blueprintOverrides?.wordpressVersion) { + mutableBlueprint.preferredVersions!.wp = + overrides.blueprintOverrides.wordpressVersion; + } else if (!mutableBlueprint.preferredVersions!.wp) { + mutableBlueprint.preferredVersions!.wp = 'latest'; + } + + // Apply network access override + if (overrides.applicationOptions?.networkAccess !== undefined) { + mutableBlueprint.features!['networking'] = + overrides.applicationOptions.networkAccess; + } + + // Apply login override + if (overrides.applicationOptions?.login !== undefined) { + mutableBlueprint.login = overrides.applicationOptions.login; + } + + // Apply landing page override + if (overrides.applicationOptions?.landingPage) { + mutableBlueprint.landingPage = overrides.applicationOptions.landingPage; + } + + // Apply additional steps (language, multisite, Gutenberg PR, etc.) + if (overrides.blueprintOverrides?.additionalSteps) { + for (const step of overrides.blueprintOverrides.additionalSteps) { + // Check if this step type already exists to avoid duplicates + const stepType = (step as any).step; + const existingStep = mutableBlueprint.steps!.find( + (s) => s && (s as any).step === stepType + ); + + // For some steps like setSiteLanguage, we want to avoid duplicates + // For others like mkdir/writeFile/unzip/installPlugin, we want to add them + if (!existingStep || stepType !== 'setSiteLanguage') { + if (stepType === 'mkdir' || stepType === 'writeFile') { + // Add these at the beginning for PR installations + mutableBlueprint.steps!.unshift(step); + } else { + mutableBlueprint.steps!.push(step); + } + } + } + } + + /* + * The 6.3 release includes a caching bug where + * registered styles aren't enqueued when they + * should be. This isn't present in all environments + * but it does here in the Playground. For now, + * the fix is to define `WP_DEVELOPMENT_MODE = all` + * to bypass the style cache. + * + * @see https://core.trac.wordpress.org/ticket/59056 + */ + if (mutableBlueprint.preferredVersions?.wp === '6.3') { + mutableBlueprint.steps!.unshift({ + step: 'defineWpConfigConsts', + consts: { + WP_DEVELOPMENT_MODE: 'all', + }, + }); + } + + return mutableBlueprint; +} diff --git a/packages/playground/client/src/blueprints-v1-handler.ts b/packages/playground/client/src/blueprints-v1-handler.ts index d989091cd1..210b5cf565 100644 --- a/packages/playground/client/src/blueprints-v1-handler.ts +++ b/packages/playground/client/src/blueprints-v1-handler.ts @@ -6,6 +6,7 @@ import { runBlueprintV1Steps, resolveRuntimeConfiguration, BlueprintReflection, + applyBlueprintOverrides, } from '.'; import { collectPhpLogs, logger } from '@php-wasm/logger'; import { consumeAPI } from '@php-wasm/universal'; @@ -27,12 +28,18 @@ export class BlueprintsV1Handler { shouldInstallWordPress, sqliteDriverVersion, onClientConnected, + blueprintOverrides, } = this.options; const executionProgress = progressTracker!.stage(0.5); const downloadProgress = progressTracker!.stage(); // Set a default blueprint if none is provided. - const blueprint = this.options.blueprint || {}; + let blueprint = this.options.blueprint || {}; + + // Apply overrides if provided (reconcile URL parameters with blueprint) + if (blueprintOverrides) { + blueprint = applyBlueprintOverrides(blueprint, blueprintOverrides); + } // Connect the Comlink API client to the remote worker, // boot the playground, and run the blueprint steps. @@ -44,7 +51,8 @@ export class BlueprintsV1Handler { progressTracker.pipe(playground); const runtimeConfiguration = await resolveRuntimeConfiguration( - blueprint + blueprint, + blueprintOverrides ); await playground.onDownloadProgress(downloadProgress.loadingListener); await playground.boot({ diff --git a/packages/playground/client/src/blueprints-v2-handler.ts b/packages/playground/client/src/blueprints-v2-handler.ts index 9179050625..7008dc687e 100644 --- a/packages/playground/client/src/blueprints-v2-handler.ts +++ b/packages/playground/client/src/blueprints-v2-handler.ts @@ -1,5 +1,6 @@ import type { ProgressTracker } from '@php-wasm/progress'; import type { PlaygroundClient, StartPlaygroundOptions } from '.'; +import { BlueprintReflection } from '@wp-playground/blueprints'; import { collectPhpLogs, logger } from '@php-wasm/logger'; import { consumeAPI } from '@php-wasm/universal'; @@ -11,16 +12,49 @@ export class BlueprintsV2Handler { progressTracker: ProgressTracker ) { const { - blueprint, + blueprint: rawBlueprint, onClientConnected, corsProxy, mounts, sapiName, scope, + blueprintOverrides, } = this.options; const downloadProgress = progressTracker!.stage(0.25); const executionProgress = progressTracker!.stage(0.75); + // Convert v1 blueprint to v2 if needed + let blueprint: any = rawBlueprint; + if (rawBlueprint) { + const reflection = await BlueprintReflection.create(rawBlueprint); + if (reflection.getVersion() === 1) { + // Convert v1 to minimal v2 blueprint + blueprint = { + version: 2, + wordpressVersion: + blueprintOverrides?.blueprintOverrides + ?.wordpressVersion || 'latest', + }; + } + } else { + // Create minimal v2 blueprint if none provided + blueprint = { + version: 2, + wordpressVersion: + blueprintOverrides?.blueprintOverrides?.wordpressVersion || + 'latest', + }; + } + + // Resolve runtime configuration to get PHP/WP versions + const { resolveRuntimeConfiguration } = await import( + '@wp-playground/blueprints' + ); + const runtimeConfiguration = await resolveRuntimeConfiguration( + blueprint, + blueprintOverrides + ); + // Connect the Comlink API client to the remote worker, // boot the playground, and run the blueprint steps. const playground = consumeAPI( @@ -87,10 +121,15 @@ export class BlueprintsV2Handler { mounts, sapiName, scope: scope ?? Math.random().toFixed(16), + phpVersion: runtimeConfiguration.phpVersion, + wpVersion: runtimeConfiguration.wpVersion, + withICU: runtimeConfiguration.intl, + withNetworking: runtimeConfiguration.networking, corsProxyUrl: corsProxy, experimentalBlueprintsV2Runner: true, // Pass the declaration directly – the worker runs the V2 runner. blueprint: blueprint as any, + blueprintOverrides: blueprintOverrides?.blueprintOverrides, } as any); await playground.isReady(); @@ -99,8 +138,10 @@ export class BlueprintsV2Handler { collectPhpLogs(logger, playground); onClientConnected?.(playground); - // @TODO: Get the landing page from the Blueprint. - playground.goTo('/'); + // Navigate to landing page from overrides or default to root + const landingPage = + blueprintOverrides?.applicationOptions?.landingPage || '/'; + playground.goTo(landingPage); /** * Pre-fetch WordPress update checks to speed up the initial wp-admin load. diff --git a/packages/playground/client/src/index.ts b/packages/playground/client/src/index.ts index 4d97aca96a..10905f5c2d 100644 --- a/packages/playground/client/src/index.ts +++ b/packages/playground/client/src/index.ts @@ -1,4 +1,5 @@ export * from '@wp-playground/blueprints'; +export { applyBlueprintOverrides } from './apply-blueprint-overrides'; export type { HTTPMethod, @@ -38,6 +39,23 @@ import { remoteDevServerHost, remoteDevServerPort } from '../../build-config'; import { BlueprintsV1Handler } from './blueprints-v1-handler'; import { BlueprintsV2Handler } from './blueprints-v2-handler'; +/** + * Blueprint overrides extracted from URL parameters or other sources. + * These overrides can modify WordPress/PHP versions and add additional steps. + */ +export interface BlueprintOverrides { + blueprintOverrides?: { + wordpressVersion?: string; + phpVersion?: string; + additionalSteps?: any[]; + }; + applicationOptions?: { + landingPage?: string; + login?: boolean; + networkAccess?: boolean; + }; +} + export interface StartPlaygroundOptions { iframe: HTMLIFrameElement; remoteUrl: string; @@ -48,6 +66,13 @@ export interface StartPlaygroundOptions { * Prefer experimental Blueprints v2 PHP runner instead of TypeScript steps */ experimentalBlueprintsV2Runner?: boolean; + /** + * Blueprint overrides extracted from URL parameters or other sources. + * Applied differently based on blueprint version: + * - v1: Applied in the handler via applyQueryOverrides() + * - v2: Passed to runBlueprintV2() as blueprintOverrides + */ + blueprintOverrides?: BlueprintOverrides; onBlueprintStepCompleted?: OnStepCompleted; onBlueprintValidated?: (blueprint: BlueprintV1Declaration) => void; /** diff --git a/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v2.ts b/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v2.ts index 9ae23f5800..e51f14366c 100644 --- a/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v2.ts +++ b/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v2.ts @@ -4,6 +4,7 @@ import { PlaygroundWorkerEndpoint } from './playground-worker-endpoint'; import type { WorkerBootOptions } from './playground-worker-endpoint'; import { runBlueprintV2 } from '@wp-playground/blueprints'; import type { BlueprintV2Declaration } from '@wp-playground/blueprints'; + /* @ts-ignore */ import { corsProxyUrl as defaultCorsProxyUrl } from 'virtual:cors-proxy-url'; @@ -23,6 +24,7 @@ class PlaygroundWorkerEndpointV2 extends PlaygroundWorkerEndpoint { withNetworking = true, corsProxyUrl, blueprint, + blueprintOverrides, }: WorkerBootOptions) { if (this.booted) { throw new Error('Playground already booted'); @@ -54,10 +56,18 @@ class PlaygroundWorkerEndpointV2 extends PlaygroundWorkerEndpoint { ); } + // Now run the blueprint to apply additional configuration const streamed = await runBlueprintV2({ php: primaryPhp, cliArgs: ['--site-url=' + siteUrl], blueprint: blueprint as BlueprintV2Declaration, + blueprintOverrides: blueprintOverrides + ? { + wordpressVersion: + blueprintOverrides.wordpressVersion, + additionalSteps: blueprintOverrides.additionalSteps, + } + : undefined, onMessage: async (message: any) => { this.dispatchEvent({ type: 'blueprint.message', @@ -82,4 +92,4 @@ class PlaygroundWorkerEndpointV2 extends PlaygroundWorkerEndpoint { const [setApiReady, setAPIError] = exposeAPI( new PlaygroundWorkerEndpointV2(downloadMonitor) -); \ No newline at end of file +); diff --git a/packages/playground/remote/src/lib/playground-worker-endpoint.ts b/packages/playground/remote/src/lib/playground-worker-endpoint.ts index 6a5282662d..7f43e71a05 100644 --- a/packages/playground/remote/src/lib/playground-worker-endpoint.ts +++ b/packages/playground/remote/src/lib/playground-worker-endpoint.ts @@ -73,6 +73,12 @@ export type WorkerBootOptions = { experimentalBlueprintsV2Runner?: boolean; /** Blueprint v2 declaration to run in the worker when experimental mode is on */ blueprint?: BlueprintDeclaration; + /** Blueprint overrides to apply (for both v1 and v2) */ + blueprintOverrides?: { + wordpressVersion?: string; + phpVersion?: string; + additionalSteps?: any[]; + }; }; /** @inheritDoc PHPClient */ diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 7f5e87e613..10828108b8 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -126,6 +126,7 @@ export function bootSiteClient( new URLSearchParams(window.location.search).get( 'experimental-blueprints-v2-runner' ) === 'yes', + blueprintOverrides: site.metadata.blueprintOverrides, // Intercept the Playground client even if the // Blueprint fails. onClientConnected: (playground) => { diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index b776f5d137..d715172857 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -13,11 +13,12 @@ import { type RuntimeConfiguration, resolveRuntimeConfiguration, } from '@wp-playground/blueprints'; +import type { BlueprintOverrides } from '@wp-playground/client'; import { type BlueprintSource, resolveBlueprintFromURL, type ResolvedBlueprint, - applyQueryOverrides, + extractBlueprintOverridesFromURL, } from '../url/resolve-blueprint-from-url'; import { logger } from '@php-wasm/logger'; @@ -189,10 +190,7 @@ export function updateSite({ * @returns */ export function addSite(siteInfo: SiteInfo) { - return async ( - dispatch: PlaygroundDispatch, - getState: () => PlaygroundReduxState - ) => { + return async (dispatch: PlaygroundDispatch) => { if (siteInfo.metadata.storage === 'none') { throw new Error( 'Cannot add a temporary site. Use setTemporarySiteSpec instead.' @@ -297,15 +295,11 @@ export function setTemporarySiteSpec( ); } - const reflection = await BlueprintReflection.create( - resolvedBlueprint.blueprint + // Extract overrides from URL parameters (unified for both v1 and v2) + // Store them without applying - the handlers will reconcile them + const overrides: BlueprintOverrides = extractBlueprintOverridesFromURL( + playgroundUrlWithQueryApiArgs.searchParams ); - if (reflection.getVersion() === 1) { - resolvedBlueprint.blueprint = await applyQueryOverrides( - resolvedBlueprint.blueprint, - playgroundUrlWithQueryApiArgs.searchParams - ); - } // Compute the runtime configuration based on the resolved Blueprint: const newSiteInfo: SiteInfo = { @@ -319,8 +313,11 @@ export function setTemporarySiteSpec( originalBlueprint: resolvedBlueprint.blueprint, originalBlueprintSource: resolvedBlueprint.source!, runtimeConfiguration: await resolveRuntimeConfiguration( - resolvedBlueprint.blueprint + resolvedBlueprint.blueprint, + overrides )!, + // Store overrides for both v1 and v2 - handlers will apply them + blueprintOverrides: overrides, }, }; dispatch(sitesSlice.actions.addSite(newSiteInfo)); @@ -379,6 +376,12 @@ export interface SiteMetadata { runtimeConfiguration: RuntimeConfiguration; originalBlueprint: BlueprintV1; originalBlueprintSource: BlueprintSource; + + /** + * Blueprint overrides extracted from URL parameters. + * Stored for both v1 and v2, then reconciled in the respective handlers. + */ + blueprintOverrides?: BlueprintOverrides; } export const { setOPFSSitesLoadingState } = sitesSlice.actions; diff --git a/packages/playground/website/src/lib/state/url/resolve-blueprint-from-url.ts b/packages/playground/website/src/lib/state/url/resolve-blueprint-from-url.ts index 71101455cd..f5a1b9b898 100644 --- a/packages/playground/website/src/lib/state/url/resolve-blueprint-from-url.ts +++ b/packages/playground/website/src/lib/state/url/resolve-blueprint-from-url.ts @@ -3,6 +3,7 @@ import type { BlueprintBundle, StepDefinition, BlueprintV1, + BlueprintOverrides, } from '@wp-playground/client'; import { getBlueprintDeclaration, @@ -132,19 +133,25 @@ export async function resolveBlueprintFromURL( } } +/** + * Apply Blueprint overrides to a Blueprint v1 declaration or bundle. + * Extracts overrides from URL parameters and applies them to the blueprint. + * + * @param blueprint The Blueprint v1 declaration or bundle to modify + * @param query URL search parameters containing overrides + * @returns Modified blueprint with overrides applied + */ export async function applyQueryOverrides( blueprint: BlueprintV1Declaration | BlueprintBundle, query: URLSearchParams ): Promise { - /** - * Allow overriding PHP and WordPress versions defined in a Blueprint - * via query params. - */ + const overrides = extractBlueprintOverridesFromURL(query); + if (isBlueprintBundle(blueprint)) { let blueprintObject = await getBlueprintDeclaration(blueprint); - blueprintObject = applyQueryOverridesToDeclaration( + blueprintObject = applyOverridesToV1Declaration( blueprintObject, - query + overrides ); return new OverlayFilesystem([ new InMemoryFilesystem({ @@ -153,76 +160,81 @@ export async function applyQueryOverrides( blueprint, ]); } else { - return applyQueryOverridesToDeclaration(blueprint, query); + return applyOverridesToV1Declaration(blueprint, overrides); } } -function applyQueryOverridesToDeclaration( +/** + * Apply overrides to a Blueprint v1 declaration. + * Translates the unified overrides object into v1 Blueprint structure modifications. + */ +function applyOverridesToV1Declaration( blueprint: BlueprintV1Declaration, - query: URLSearchParams + overrides: BlueprintOverrides ): BlueprintV1Declaration { - /** - * Allow overriding PHP and WordPress versions defined in a Blueprint - * via query params. - */ + // Initialize blueprint structures if needed if (!blueprint.preferredVersions) { blueprint.preferredVersions = {} as any; } - blueprint.preferredVersions!.php = - (query.get('php') as any) || - blueprint.preferredVersions!.php || - RecommendedPHPVersion; - blueprint.preferredVersions!.wp = - query.get('wp') || blueprint.preferredVersions!.wp || 'latest'; - - // Features if (!blueprint.features) { blueprint.features = {}; } + if (!blueprint.steps) { + blueprint.steps = []; + } - /** - * Networking is enabled by default, so we only need to disable it - * if the query param is explicitly set to something other than "yes". - */ - if (query.get('networking') && query.get('networking') !== 'yes') { - blueprint.features['networking'] = false; + // Apply PHP version override + if (overrides.blueprintOverrides?.phpVersion) { + blueprint.preferredVersions!.php = overrides.blueprintOverrides + .phpVersion as any; + } else if (!blueprint.preferredVersions!.php) { + blueprint.preferredVersions!.php = RecommendedPHPVersion; } - // Language - if (query.get('language')) { - if ( - !blueprint?.steps?.find( - (step) => step && (step as any).step === 'setSiteLanguage' - ) - ) { - blueprint.steps?.push({ - step: 'setSiteLanguage', - language: query.get('language')!, - }); - } + // Apply WordPress version override + if (overrides.blueprintOverrides?.wordpressVersion) { + blueprint.preferredVersions!.wp = + overrides.blueprintOverrides.wordpressVersion; + } else if (!blueprint.preferredVersions!.wp) { + blueprint.preferredVersions!.wp = 'latest'; } - // Multisite - if (query.get('multisite') === 'yes') { - if ( - !blueprint?.steps?.find( - (step) => step && (step as any).step === 'enableMultisite' - ) - ) { - blueprint.steps?.push({ - step: 'enableMultisite', - }); - } + // Apply network access override + if (overrides.applicationOptions?.networkAccess !== undefined) { + blueprint.features['networking'] = + overrides.applicationOptions.networkAccess; } - // Login - if (query.get('login') !== 'no') { - blueprint.login = true; + // Apply login override + if (overrides.applicationOptions?.login !== undefined) { + blueprint.login = overrides.applicationOptions.login; } - // Landing page - if (query.get('url')) { - blueprint.landingPage = query.get('url')!; + // Apply landing page override + if (overrides.applicationOptions?.landingPage) { + blueprint.landingPage = overrides.applicationOptions.landingPage; + } + + // Apply additional steps (language, multisite, Gutenberg PR, etc.) + if (overrides.blueprintOverrides?.additionalSteps) { + for (const step of overrides.blueprintOverrides.additionalSteps) { + // Check if this step type already exists to avoid duplicates + const stepType = (step as any).step; + const existingStep = blueprint.steps.find( + (s) => s && (s as any).step === stepType + ); + + // For some steps like setSiteLanguage, we want to avoid duplicates + // For others like mkdir/writeFile/unzip/installPlugin, we want to add them + if (!existingStep || stepType !== 'setSiteLanguage') { + if (stepType === 'mkdir' || stepType === 'writeFile') { + // Add these at the beginning for PR installations + blueprint.steps.unshift(step); + } else { + blueprint.steps.push(step); + } + } + } } /* @@ -236,7 +248,7 @@ function applyQueryOverridesToDeclaration( * @see https://core.trac.wordpress.org/ticket/59056 */ if (blueprint.preferredVersions?.wp === '6.3') { - blueprint.steps?.unshift({ + blueprint.steps.unshift({ step: 'defineWpConfigConsts', consts: { WP_DEVELOPMENT_MODE: 'all', @@ -244,15 +256,72 @@ function applyQueryOverridesToDeclaration( }); } + return blueprint; +} + +/** + * Extract Blueprint overrides from URL query parameters. + * This creates a unified overrides object that can be used for both: + * - Blueprint v1: Applied directly to the blueprint via applyQueryOverrides() + * - Blueprint v2: Passed to runBlueprintV2() as blueprintOverrides + * + * Supported URL parameters: + * - ?wp=6.3 - Override WordPress version + * - ?php=8.0 - Override PHP version + * - ?language=es_ES - Set site language + * - ?multisite=yes - Enable multisite + * - ?url=/some-path - Set landing page + * - ?login=yes/no - Control login behavior + * - ?networking=yes/no - Control network access + * - ?core-pr=12345 - Use WordPress core PR build + * - ?gutenberg-pr=67890 - Use Gutenberg PR build + */ +export function extractBlueprintOverridesFromURL( + query: URLSearchParams +): BlueprintOverrides { + const result: BlueprintOverrides = {}; + + // WordPress version override + if (query.get('wp')) { + result.blueprintOverrides = result.blueprintOverrides || {}; + result.blueprintOverrides.wordpressVersion = query.get('wp')!; + } + + // Core PR override if (query.has('core-pr')) { const prNumber = query.get('core-pr'); - blueprint.preferredVersions!.wp = `https://playground.wordpress.net/plugin-proxy.php?org=WordPress&repo=wordpress-develop&workflow=Test%20Build%20Processes&artifact=wordpress-build-${prNumber}&pr=${prNumber}`; + result.blueprintOverrides = result.blueprintOverrides || {}; + result.blueprintOverrides.wordpressVersion = `https://playground.wordpress.net/plugin-proxy.php?org=WordPress&repo=wordpress-develop&workflow=Test%20Build%20Processes&artifact=wordpress-build-${prNumber}&pr=${prNumber}`; + } + + // PHP version override + if (query.get('php')) { + result.blueprintOverrides = result.blueprintOverrides || {}; + result.blueprintOverrides.phpVersion = query.get('php')!; + } + + // Additional steps array for various overrides + const additionalSteps: any[] = []; + + // Language override + if (query.get('language')) { + additionalSteps.push({ + step: 'setSiteLanguage', + language: query.get('language')!, + }); + } + + // Multisite override + if (query.get('multisite') === 'yes') { + additionalSteps.push({ + step: 'enableMultisite', + }); } + // Gutenberg PR override if (query.has('gutenberg-pr')) { const prNumber = query.get('gutenberg-pr'); - blueprint.steps = blueprint.steps || []; - blueprint.steps.unshift( + additionalSteps.push( { step: 'mkdir', path: '/tmp/pr', @@ -266,18 +335,6 @@ function applyQueryOverridesToDeclaration( caption: `Downloading Gutenberg PR ${prNumber}`, }, }, - /** - * GitHub CI artifacts are doubly zipped: - * - * pr.zip - * gutenberg.zip - * gutenberg.php - * ... other files ... - * - * This step extracts the inner zip file so that we get - * access directly to gutenberg.zip and can use it to - * install the plugin. - */ { step: 'unzip', zipPath: '/tmp/pr/pr.zip', @@ -293,5 +350,29 @@ function applyQueryOverridesToDeclaration( ); } - return blueprint; + if (additionalSteps.length > 0) { + result.blueprintOverrides = result.blueprintOverrides || {}; + result.blueprintOverrides.additionalSteps = additionalSteps; + } + + // Application options (Playground-specific) + result.applicationOptions = {}; + + // Landing page + if (query.get('url')) { + result.applicationOptions.landingPage = query.get('url')!; + } + + // Login control + if (query.get('login') !== null) { + result.applicationOptions.login = query.get('login') !== 'no'; + } + + // Network access + if (query.get('networking')) { + result.applicationOptions.networkAccess = + query.get('networking') === 'yes'; + } + + return result; } From ec0e55432225342656a95f0b1d8b1d6ae590561b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 2 Nov 2025 01:10:55 +0100 Subject: [PATCH 4/6] lint --- .../blueprints/src/lib/v2/run-blueprint-v2.ts | 8 -------- .../client/src/apply-blueprint-overrides.ts | 14 ++++++++++---- .../playground/client/src/blueprints-v2-handler.ts | 4 +++- .../website/src/lib/state/redux/slice-sites.ts | 1 - 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts b/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts index b17de864d7..1428855958 100644 --- a/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts +++ b/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts @@ -124,7 +124,6 @@ export async function runBlueprintV2( /** * Prepare hooks, filters, and run the Blueprint: */ - console.log('options.blueprintOverrides', options.blueprintOverrides); await php?.writeFile( '/tmp/run-blueprints.php', ` Date: Mon, 3 Nov 2025 00:35:32 +0100 Subject: [PATCH 5/6] Uncomment overrides application --- packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts b/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts index 1428855958..a92c1900c9 100644 --- a/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts +++ b/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts @@ -141,9 +141,9 @@ function playground_on_blueprint_target_resolved() { 'type' => 'blueprint.target_resolved', ])); } -//playground_add_filter('blueprint.target_resolved', 'playground_on_blueprint_target_resolved'); +playground_add_filter('blueprint.target_resolved', 'playground_on_blueprint_target_resolved'); -//playground_add_filter('blueprint.resolved', 'playground_on_blueprint_resolved'); +playground_add_filter('blueprint.resolved', 'playground_on_blueprint_resolved'); function playground_on_blueprint_resolved($blueprint) { $additional_blueprint_steps = json_decode(${phpVar( JSON.stringify(options.blueprintOverrides?.additionalSteps || []) From 280c04c05cec1b2f74e9945d06c5ff77c06a52b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 3 Nov 2025 01:08:27 +0100 Subject: [PATCH 6/6] Transpile core-pr and other url overrides. Get to a v1->v2 transpilation issue in gutenberg-pr query api param --- .../blueprints/src/lib/v2/run-blueprint-v2.ts | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts b/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts index a92c1900c9..28d18c12f9 100644 --- a/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts +++ b/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts @@ -56,6 +56,7 @@ export async function runBlueprintV2( } } cliArgs.push('--site-path=/wordpress'); + cliArgs.push('--allow=read-local-fs'); /** * Divergence from blueprints.phar – the default database engine is @@ -148,11 +149,28 @@ function playground_on_blueprint_resolved($blueprint) { $additional_blueprint_steps = json_decode(${phpVar( JSON.stringify(options.blueprintOverrides?.additionalSteps || []) )}, true); + + // TODO: detect v1 step format vs v2 stepformat if(count($additional_blueprint_steps) > 0) { - $blueprint['additionalStepsAfterExecution'] = array_merge( - $blueprint['additionalStepsAfterExecution'] ?? [], - $additional_blueprint_steps + // Additional steps from URL overrides are in v1 format + // We need to transpile them to v2 format before merging + $transpiler = new WordPress\\Blueprints\\Versions\\Version1\\V1ToV2Transpiler( + new WordPress\\Blueprints\\Logger\\NoopLogger() ); + $temp_v1_blueprint = [ + 'steps' => $additional_blueprint_steps + ]; + $upgraded_blueprint = $transpiler->upgrade($temp_v1_blueprint); + + // Extract the transpiled steps from the upgraded blueprint + $transpiled_steps = $upgraded_blueprint['additionalStepsAfterExecution'] ?? []; + + if(count($transpiled_steps) > 0) { + $blueprint['additionalStepsAfterExecution'] = array_merge( + $blueprint['additionalStepsAfterExecution'] ?? [], + $transpiled_steps + ); + } } $wp_version_override = json_decode(${phpVar( JSON.stringify(options.blueprintOverrides?.wordpressVersion || null) @@ -222,5 +240,14 @@ require( "/tmp/blueprints.phar" ); streamedResponse.finished.finally(unbindMessageListener); + // TODO: Report these errors in the web implementation + streamedResponse.stderr.pipeTo( + new WritableStream({ + write(chunk) { + console.log('stderr', new TextDecoder().decode(chunk)); + }, + }) + ); + return streamedResponse; }