Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,8 @@ kimaki tunnel -- sh -c 'npm run wp-codebox -- run \

When a caller exposes the local Playground through a tunnel or proxy, pass `--preview-public-url <url>` to report that public URL in `artifacts.preview.url`, `metadata.json`, and `files/review.json`. WP Codebox also passes the same URL to Playground as `site-url` and defines `WP_HOME` / `WP_SITEURL` in the sandbox config, so WordPress-generated links and canonical redirects align with the public preview URL. The local proxy URL remains recorded as `preview.localUrl`. If the fixed port is already occupied, WP Codebox fails clearly with `EADDRINUSE` and the requested `--preview-port` value.

Playground's content-addressed `custom-*.zip` WordPress archives are retained separately from stable release archives and cleaned after runtime use. The custom cache defaults to a seven-day age limit, 1 GiB, and 20 archives. Configure those bounds with `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_AGE_MS`, `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_BYTES`, and `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_COUNT`; configure crashed legacy lock recovery with `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_STALE_LOCK_MS`. All maintenance uses `WP_CODEBOX_PLAYGROUND_WORDPRESS_CACHE_DIR` when set. The Playground runtime package exports `maintainPlaygroundCustomArchiveCache()` for deterministic `diagnose`, `dry-run`, and `apply` evidence.

Remote-host previews can opt into `--preview-bind <host>` with `--preview-port`. The flag changes the WP Codebox preview proxy bind address only; the upstream runtime server remains loopback-bound until backend bind-host control is available. The default stays `127.0.0.1`. Use `--preview-bind 0.0.0.0` only behind trusted firewall, tunnel, or reverse-proxy controls because the sandbox preview is reachable for the hold duration.

## Runtime Episodes
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"test:playground-readonly-mounts-integration": "tsx tests/playground-readonly-mounts-integration.test.ts",
"test:playground-phpunit-readonly-cache-integration": "tsx tests/playground-phpunit-readonly-cache.integration.test.ts",
"test:playground-phpunit-bootstrap-failure-integration": "tsx tests/playground-phpunit-bootstrap-failure.integration.test.ts",
"test:playground-custom-archive-cache": "tsx tests/playground-custom-archive-cache.test.ts",
"test:phpunit-runtime-failure-diagnostics": "tsx tests/phpunit-runtime-failure-diagnostics.test.ts",
"test:php-wasm-extension-manifests": "tsx tests/php-wasm-extension-manifests.test.ts",
"test:browser-task-builder": "tsx tests/browser-task-builder.test.ts",
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-playground/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export { runBrowserMultiActorScenarioCommand } from "./browser-multi-actor-scena
export { browserStorageStateCookieHostSummary, browserStorageStateFromWordPressAuthCookies, normalizeBrowserStorageStatePayload, wordpressFixtureUserStorageStatePhpCode, type BrowserAuthStorageState, type BrowserStorageStateCookie, type BrowserStorageStateImportResult, type BrowserStorageStateImportSummary, type WordPressFixtureUserSpec, type WordPressFixtureUserStorageStateEnvelope } from "./browser-auth-storage-state.js"
export { createHostCommandTool, type HostCommandToolConfig } from "./host-command-tool.js"
export { PlaygroundRuntimeBackend, createPlaygroundRuntimeBackend, playgroundRuntimeBackendProvider } from "./playground-runtime.js"
export { maintainPlaygroundCustomArchiveCache, playgroundWordPressArchiveCacheDirectory, type PlaygroundCustomArchiveCacheMaintenance, type PlaygroundCustomArchiveCacheMaintenanceOptions, type PlaygroundCustomArchiveCachePolicy } from "./playground-wordpress-archive-cache.js"
export { collectBrowserArtifactMetrics, collectWordPressEpisodeArtifacts, collectWordPressRuntimeArtifacts, createWordPressEpisode, createWordPressRuntime, runWordPressEpisodeActions, type WordPressEpisodeSpec, type WordPressRuntimeActionHooks, type WordPressRuntimeSpec } from "./public.js"
export { preflightPhpWasmRuntimeAssets, PhpWasmRuntimeAssetIntegrityError, type PhpWasmRuntimeAssetPreflight, type PhpWasmRuntimeAssetPreflightOptions } from "./php-wasm-preflight.js"
export { browserPreviewAuthCookieUrls, browserPreviewNetworkPolicySummary, browserPreviewReadinessError, browserPreviewRouting, browserPreviewSecureContextError, browserPreviewTopology, browserPreviewOrigins, resolveBrowserPreviewUrl, type BrowserPreviewNetworkPolicy, type BrowserPreviewTopology } from "./browser-preview-routing.js"
Expand Down
83 changes: 35 additions & 48 deletions packages/runtime-playground/src/playground-cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import { normalizeLiveProgressEvent, previewLease, type BrowserStartupProgressEv
import { randomInt } from "node:crypto"
import { existsSync } from "node:fs"
import { createServer as createHttpServer, type Server as HttpServer } from "node:http"
import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises"
import { homedir } from "node:os"
import { mkdir, readFile, rename, rm, stat, unlink, utimes, writeFile } from "node:fs/promises"
import { basename, dirname, join } from "node:path"
import { createServer as createNetServer } from "node:net"
import * as PlaygroundStorage from "@wp-playground/storage"
import { resolveWordPressRelease } from "@wp-playground/wordpress"
import { phpEnvAssignments, phpWpConfigDefineAssignments } from "./php-snippets.js"
import { stageReadonlyPlaygroundMounts, type ReadonlyMountStaging } from "./mount-materialization.js"
import { acquirePlaygroundArchiveReference, isCustomPlaygroundWordPressArchive, maintainPlaygroundCustomArchiveCache, playgroundWordPressArchiveCacheDirectory, withPlaygroundArchiveCacheLock, type PlaygroundArchiveReference, type PlaygroundCustomArchiveCacheMaintenance } from "./playground-wordpress-archive-cache.js"

const DEFAULT_RUNTIME_PHP_INI_ENTRIES = { memory_limit: "512M" }

Expand All @@ -38,8 +38,6 @@ export interface PlaygroundCliModule {
}): Promise<PlaygroundCliServer>
}

const PLAYGROUND_WORDPRESS_CACHE_DIRECTORY_ENV = "WP_CODEBOX_PLAYGROUND_WORDPRESS_CACHE_DIR"

export interface PlaygroundCliStartupOptions {
onProgress?: (event: BrowserStartupProgressEvent) => void | Promise<void>
cliModule?: PlaygroundCliModule
Expand All @@ -66,6 +64,9 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
})
emitProgress("preview:loading-client", "running", "Loading preview")
let readonlyMountStaging: ReadonlyMountStaging | undefined
let archiveReference: PlaygroundArchiveReference | undefined
let cacheMaintenance: PlaygroundCustomArchiveCacheMaintenance | undefined
let usesArchiveCache = false
try {
if (spec.preview?.port) {
await assertPreviewPortAvailable(spec.preview.port)
Expand All @@ -78,15 +79,23 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
const wordpressInstallMode = spec.environment.wordpressInstallMode ?? "install-from-existing-files"
const bootstrapIniEntries = runtimeBootstrapPhpIniEntries(spec)
const useProgrammaticRunner = shouldUseProgrammaticPlaygroundRunner(spec, options)
usesArchiveCache = !wordpressDirectory && !spec.environment.assets?.wordpressZip
readonlyMountStaging = await stageReadonlyPlaygroundMounts(mounts)
const stagedMounts = readonlyMountStaging.mounts
const wordpressStartupAsset = wordpressDirectory ? undefined : await resolvePlaygroundWordPressStartupAsset(spec.environment.version, spec.environment.assets?.wordpressZip)
archiveReference = wordpressStartupAsset?.archiveReference
if (usesArchiveCache) {
cacheMaintenance = await maintainPlaygroundCustomArchiveCache().catch(() => undefined)
}
const cacheValidation = wordpressStartupAsset?.cacheValidation ?? {
version: spec.environment.version ?? "mounted-wordpress-source",
sourceUrl: wordpressDirectory ?? "",
source: "pre-resolved" as const,
invalidArchives: [],
}
if (cacheMaintenance) {
cacheValidation.retention = cacheMaintenance
}
const blueprintSummary = summarizeBlueprint(spec.environment.blueprint)
if (blueprintSummary.steps > 0) {
emitProgress("preview:applying-blueprint", "running", "Applying site setup", blueprintSummary)
Expand Down Expand Up @@ -168,6 +177,10 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
try {
await proxiedServer[Symbol.asyncDispose]()
} finally {
await archiveReference?.release().catch(() => undefined)
if (usesArchiveCache) {
await maintainPlaygroundCustomArchiveCache().catch(() => undefined)
}
await readonlyMountStaging?.[Symbol.asyncDispose]()
}
},
Expand All @@ -177,6 +190,10 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
error: errorDetail(error),
})

await archiveReference?.release().catch(() => undefined)
if (usesArchiveCache) {
await maintainPlaygroundCustomArchiveCache().catch(() => undefined)
}
await readonlyMountStaging?.[Symbol.asyncDispose]()
if (spec.preview?.port && errorHasCode(error, "EADDRINUSE")) {
throw new PlaygroundPreviewPortUnavailableError(spec.preview.port, error)
Expand Down Expand Up @@ -545,13 +562,14 @@ export interface PlaygroundWordPressArchiveCacheValidation {
reason: string
deleted: boolean
}>
retention?: PlaygroundCustomArchiveCacheMaintenance
}

export interface PlaygroundWordPressArchiveCacheValidationOptions {
deleteInvalid?: boolean
}

export async function validatePlaygroundWordPressArchiveCache(versionQuery: string | undefined, cacheDirectory = defaultPlaygroundWordPressArchiveCacheDirectory(), options: PlaygroundWordPressArchiveCacheValidationOptions = { deleteInvalid: true }): Promise<PlaygroundWordPressArchiveCacheValidation> {
export async function validatePlaygroundWordPressArchiveCache(versionQuery: string | undefined, cacheDirectory = playgroundWordPressArchiveCacheDirectory(), options: PlaygroundWordPressArchiveCacheValidationOptions = { deleteInvalid: true }): Promise<PlaygroundWordPressArchiveCacheValidation> {
const release = await resolveWordPressReleaseForStartup(versionQuery)
const version = release.version
const sourceUrl = release.releaseUrl
Expand Down Expand Up @@ -593,6 +611,7 @@ interface PlaygroundWordPressStartupAsset {
wp: string | undefined
localPath?: string
cacheValidation: PlaygroundWordPressArchiveCacheValidation
archiveReference?: PlaygroundArchiveReference
}

interface ResolvedWordPressReleaseForStartup {
Expand All @@ -603,7 +622,7 @@ interface ResolvedWordPressReleaseForStartup {

type WordPressReleaseResolver = typeof resolveWordPressRelease

export async function resolvePlaygroundWordPressStartupAsset(versionQuery: string | undefined, wordpressZip?: string, cacheDirectory = defaultPlaygroundWordPressArchiveCacheDirectory()): Promise<PlaygroundWordPressStartupAsset> {
export async function resolvePlaygroundWordPressStartupAsset(versionQuery: string | undefined, wordpressZip?: string, cacheDirectory = playgroundWordPressArchiveCacheDirectory()): Promise<PlaygroundWordPressStartupAsset> {
if (wordpressZip) {
const version = startupAssetVersion(versionQuery, wordpressZip)
const cacheValidation = await validateWordPressArchivePaths(version, wordpressZip, isHttpUrl(wordpressZip) ? [] : [wordpressZip], { deleteInvalid: false })
Expand All @@ -616,6 +635,11 @@ export async function resolvePlaygroundWordPressStartupAsset(versionQuery: strin
const archivePaths = [cachedArchivePath, join(cacheDirectory, `prebuilt-wp-content-for-wp-${release.version}.zip`)]
const cacheValidation = await validateWordPressArchivePaths(release.version, release.releaseUrl, archivePaths, { deleteInvalid: true })
if (existsSync(cachedArchivePath)) {
if (isCustomPlaygroundWordPressArchive(cachedArchivePath)) {
const accessedAt = new Date()
await utimes(cachedArchivePath, accessedAt, accessedAt)
}
const archiveReference = isCustomPlaygroundWordPressArchive(cachedArchivePath) ? await acquirePlaygroundArchiveReference(cachedArchivePath) : undefined
return {
wp: undefined,
localPath: cachedArchivePath,
Expand All @@ -624,6 +648,7 @@ export async function resolvePlaygroundWordPressStartupAsset(versionQuery: strin
source: "cache",
cache: { status: "hit", archivePath: cachedArchivePath, lockPath: lock.path, waitedMs: lock.waitedMs },
},
archiveReference,
}
}

Expand All @@ -634,6 +659,7 @@ export async function resolvePlaygroundWordPressStartupAsset(versionQuery: strin
throw new PlaygroundStartupAssetError("wordpress-archive-cache", release.releaseUrl, versionQuery ?? "latest", new Error(`Downloaded WordPress archive is invalid: ${invalidDownloadedArchive.reason}`))
}

const archiveReference = isCustomPlaygroundWordPressArchive(cachedArchivePath) ? await acquirePlaygroundArchiveReference(cachedArchivePath) : undefined
return {
wp: undefined,
localPath: cachedArchivePath,
Expand All @@ -643,48 +669,9 @@ export async function resolvePlaygroundWordPressStartupAsset(versionQuery: strin
source: release.source,
cache: { status: "downloaded", archivePath: cachedArchivePath, lockPath: lock.path, waitedMs: lock.waitedMs },
},
archiveReference,
}
})
}

function defaultPlaygroundWordPressArchiveCacheDirectory(): string {
return process.env[PLAYGROUND_WORDPRESS_CACHE_DIRECTORY_ENV] || join(homedir(), ".wordpress-playground")
}

interface PlaygroundArchiveCacheLock {
path: string
waitedMs: number
}

async function withPlaygroundArchiveCacheLock<T>(cacheDirectory: string, version: string, callback: (lock: PlaygroundArchiveCacheLock) => Promise<T>): Promise<T> {
await mkdir(cacheDirectory, { recursive: true })
const lockPath = join(cacheDirectory, `${version}.zip.lock`)
const startedAt = Date.now()

for (;;) {
try {
await mkdir(lockPath)
break
} catch (error) {
if (!errorHasCode(error, "EEXIST")) {
throw error
}
if (Date.now() - startedAt > 120_000) {
throw new PlaygroundStartupAssetError("wordpress-archive-cache-lock", lockPath, version, new Error("Timed out waiting for WordPress archive cache lock"))
}
await delay(100)
}
}

try {
return await callback({ path: lockPath, waitedMs: Date.now() - startedAt })
} finally {
await rm(lockPath, { recursive: true, force: true })
}
}

async function delay(ms: number): Promise<void> {
await new Promise((resolveDelay) => setTimeout(resolveDelay, ms))
}, (lockPath) => new PlaygroundStartupAssetError("wordpress-archive-cache-lock", lockPath, release.version, new Error("Timed out waiting for WordPress archive cache lock")))
}

async function downloadWordPressArchiveToCache(sourceUrl: string, archivePath: string): Promise<void> {
Expand All @@ -707,7 +694,7 @@ async function downloadWordPressArchiveToCache(sourceUrl: string, archivePath: s
}
}

export async function resolveWordPressReleaseForStartup(versionQuery: string | undefined, cacheDirectory = defaultPlaygroundWordPressArchiveCacheDirectory(), resolver: WordPressReleaseResolver = resolveWordPressRelease): Promise<ResolvedWordPressReleaseForStartup> {
export async function resolveWordPressReleaseForStartup(versionQuery: string | undefined, cacheDirectory = playgroundWordPressArchiveCacheDirectory(), resolver: WordPressReleaseResolver = resolveWordPressRelease): Promise<ResolvedWordPressReleaseForStartup> {
const exactVersion = exactWordPressVersion(versionQuery)
if (exactVersion) {
return {
Expand Down
Loading
Loading