diff --git a/docs/browser-contained-site-facade.md b/docs/browser-contained-site-facade.md index 895509c2..fb28383b 100644 --- a/docs/browser-contained-site-facade.md +++ b/docs/browser-contained-site-facade.md @@ -17,7 +17,7 @@ The contained-site facade is the product-facing browser lane for WordPress previ ## Boundary -Consumers open or create the contained site, then call `window.wpCodeboxBrowser.v1.startBrowserPreview(response.preview_boot, { iframe, signal, disposeClient })`. `preview_boot` is the public descriptor and requires one hydratable `blueprint_ref`; inline blueprint data is not a product response contract. A successful result preserves the existing `client` and result envelope and adds async `dispose()`. Disposal is idempotent, resets the supplied iframe, and returns `wp-codebox/browser-preview-dispose-result/v1` cleanup evidence; it never removes the caller-owned iframe. `runtime_release_requested` records that iframe reset, while `runtime_terminated` remains false unless `disposeClient(client)` returns `{ runtime_terminated: true }`. `pending_work_cancellation_requested` records that the lifecycle stopped awaiting work; `pending_work_cancelled` remains false because the browser runtime has no cancellation API for an active import or client start. `stale_result_suppression_enabled` records that late callbacks and late client results cannot regain lifecycle ownership. `disposeClient` may return void, a boolean client-release assertion, or `{ client_released?: boolean, runtime_terminated?: boolean }`; the result exposes structured assertions as `client_release_evidence`. Aborting the signal rejects startup with `browser_preview_aborted` and suppresses later Playground lifecycle callbacks. +Consumers open or create the contained site, then call `window.wpCodeboxBrowser.v1.startBrowserPreview(response.preview_boot, { iframe, signal, disposeClient, startupTimeoutMs })`. `preview_boot` is the public descriptor and requires one hydratable `blueprint_ref`; inline blueprint data is not a product response contract. A successful result preserves the existing `client` and result envelope and adds async `dispose()`. Startup has a 30-second readiness deadline by default; provide a positive `startupTimeoutMs` to override it, or `0`, `null`, or `false` to disable it for a caller-owned longer operation. Deadline expiry rejects with the structured `browser_preview_startup_timeout` runtime error. Its `data` is `wp-codebox/browser-preview-startup-timeout/v1` and includes `phase`, `timeout_ms`, `scope`, `session_id`, and completed cleanup evidence. Disposal is idempotent, resets the supplied iframe, and returns `wp-codebox/browser-preview-dispose-result/v1` cleanup evidence; it never removes the caller-owned iframe. `runtime_release_requested` records that iframe reset, while `runtime_terminated` remains false unless `disposeClient(client)` returns `{ runtime_terminated: true }`. `pending_work_cancellation_requested` records that the lifecycle stopped awaiting work; `pending_work_cancelled` remains false because the browser runtime has no cancellation API for an active import or client start. `stale_result_suppression_enabled` records that late callbacks and late client results cannot regain lifecycle ownership. A same-scope replacement terminalizes the old startup with `browser_preview_replaced`; its cleanup continues independently so an uncooperative `disposeClient` cannot delay the replacement. `disposeClient` may return void, a boolean client-release assertion, or `{ client_released?: boolean, runtime_terminated?: boolean }`; the result exposes structured assertions as `client_release_evidence`. Aborting the signal rejects startup with `browser_preview_aborted` and suppresses later Playground lifecycle callbacks. ## Diagnostics diff --git a/docs/public-api-contract.md b/docs/public-api-contract.md index 4be50473..bff6e014 100644 --- a/docs/public-api-contract.md +++ b/docs/public-api-contract.md @@ -82,12 +82,18 @@ Browser sessions that load the WordPress plugin browser runtime publish `window.wpCodeboxBrowser.v1`, the stable browser SDK for product consumers. The product preview path is `open-or-create-browser-contained-site` followed by -`window.wpCodeboxBrowser.v1.startBrowserPreview(response.preview_boot, { iframe })`. +`window.wpCodeboxBrowser.v1.startBrowserPreview(response.preview_boot, { iframe, startupTimeoutMs })`. `preview_boot` is the canonical `wp-codebox/browser-preview-boot-config/v1` DTO; it contains a required hydratable `blueprint_ref` object. Consumers do not pass -inline blueprints or import the raw browser backend `startPlaygroundWeb`. The -optional `signal` cancels startup and an accepted start result exposes idempotent -async `dispose()` cleanup without changing its `client` field or result envelope. +inline blueprints or import the raw browser backend `startPlaygroundWeb`. Startup +has a 30-second readiness deadline by default; a positive `startupTimeoutMs` +overrides it, while `0`, `null`, and `false` disable it for a caller-owned longer +operation. Expiry rejects with `browser_preview_startup_timeout`, whose data uses +`wp-codebox/browser-preview-startup-timeout/v1` and includes phase, timeout, +scope, session identity, and disposal evidence. The optional `signal` cancels +startup and remains distinguishable as `browser_preview_aborted`; an accepted +start result exposes idempotent async `dispose()` cleanup without changing its +`client` field or result envelope. Consumer-facing WordPress abilities use the `wp-codebox/*` namespace. Public docs and schemas describe the canonical Codebox-owned names that integrations diff --git a/packages/wordpress-plugin/assets/browser-runtime.js b/packages/wordpress-plugin/assets/browser-runtime.js index 34452862..a27cc1aa 100644 --- a/packages/wordpress-plugin/assets/browser-runtime.js +++ b/packages/wordpress-plugin/assets/browser-runtime.js @@ -704,7 +704,32 @@ }; const browserPreviewLifecycles = new Map(); + const defaultBrowserPreviewStartupTimeoutMs = 30000; const browserPreviewAbortError = () => runtimeError( 'browser_preview_start', 'browser_preview_aborted', 'Browser preview start was aborted.' ); + const browserPreviewReplacedError = () => runtimeError( 'browser_preview_start', 'browser_preview_replaced', 'Browser preview start was replaced by a newer preview for the same scope.' ); + const browserPreviewStartupTimeoutMs = ( options = {} ) => { + const value = options.startupTimeoutMs; + if ( value === false || value === null || value === 0 ) { + return null; + } + + return typeof value === 'number' && Number.isFinite( value ) && value > 0 + ? Math.floor( value ) + : defaultBrowserPreviewStartupTimeoutMs; + }; + const browserPreviewStartupTimeoutError = ( boot, scope, timeoutMs ) => runtimeError( + 'browser_preview_start', + 'browser_preview_startup_timeout', + `Browser preview startup did not become ready within ${ timeoutMs }ms.`, + { + schema: 'wp-codebox/browser-preview-startup-timeout/v1', + phase: 'startup', + timeout_ms: timeoutMs, + scope: scope || null, + session_id: boot.session_id || null, + cleanup: null, + } + ); const resetBrowserPreviewIframe = ( iframe ) => { if ( iframe && typeof iframe === 'object' && 'src' in iframe ) { @@ -754,12 +779,19 @@ isActive: () => active, wait: ( promise ) => Promise.race( [ promise, cancellation ] ), ownClient: ( nextClient ) => { - client = nextClient; if ( ! active ) { - void lifecycle.releaseClient().catch( () => {} ); + void lifecycle.releaseLateClient( nextClient ).catch( () => {} ); + return nextClient; } + client = nextClient; return nextClient; }, + releaseLateClient: ( lateClient ) => Promise.resolve().then( async () => { + if ( typeof disposeClient === 'function' ) { + return disposeClient( lateClient ); + } + return null; + } ), releaseClient: () => { if ( client === undefined ) { return Promise.resolve( { requested: false, ...browserPreviewClientReleaseEvidence() } ); @@ -823,12 +855,12 @@ } ); return cleanup; }, - cancel: () => { + cancel: ( error = browserPreviewAbortError() ) => { if ( cancelled ) { return; } cancelled = true; - rejectCancellation( browserPreviewAbortError() ); + rejectCancellation( error ); void lifecycle.dispose(); }, }; @@ -850,11 +882,19 @@ const boot = browserPreviewBootConfig( input ); const iframe = options.iframe || input?.iframe || ( typeof document !== 'undefined' && options.iframeSelector ? document.querySelector( options.iframeSelector ) : null ); const scope = String( boot.scope || '' ); + const startupTimeoutMs = browserPreviewStartupTimeoutMs( options ); + let lifecycle; + let startupTimeout; + if ( startupTimeoutMs !== null ) { + startupTimeout = setTimeout( () => { + lifecycle?.cancel( browserPreviewStartupTimeoutError( boot, scope, startupTimeoutMs ) ); + }, startupTimeoutMs ); + } const previousLifecycle = scope ? browserPreviewLifecycles.get( scope ) : null; if ( previousLifecycle ) { - await previousLifecycle.dispose(); + previousLifecycle.cancel( browserPreviewReplacedError() ); } - const lifecycle = createBrowserPreviewLifecycle( scope, iframe, options.signal, options.disposeClient ); + lifecycle = createBrowserPreviewLifecycle( scope, iframe, options.signal, options.disposeClient ); if ( options.signal?.aborted ) { await lifecycle.dispose(); throw browserPreviewAbortError(); @@ -906,8 +946,15 @@ dispose: lifecycle.dispose, }; } catch ( error ) { - await lifecycle.dispose(); + const cleanup = error?.code === 'browser_preview_replaced' ? null : await lifecycle.dispose(); + if ( error?.code === 'browser_preview_startup_timeout' && error.data ) { + error.data = Object.freeze( { ...error.data, cleanup } ); + } throw error; + } finally { + if ( startupTimeout !== undefined ) { + clearTimeout( startupTimeout ); + } } }; diff --git a/tests/browser-sdk-facade.test.ts b/tests/browser-sdk-facade.test.ts index 8164cf1a..1d57dd32 100644 --- a/tests/browser-sdk-facade.test.ts +++ b/tests/browser-sdk-facade.test.ts @@ -22,6 +22,8 @@ const sandbox = { TextDecoder, TextEncoder, URL, + setTimeout, + clearTimeout, } vm.runInNewContext(runtimeSource, sandbox, { filename: "browser-runtime.js" }) @@ -653,6 +655,147 @@ finishLateStart?.({ client: "late-start" }) await new Promise((resolve) => setTimeout(resolve, 0)) assert.equal(lateStartReleased, 1, "an aborted late start releases its returned client") +const timeoutIframe: { src: string } = { src: "https://playground.example/remote.html" } +let timedOutStarts = 0 +await assert.rejects( + () => api.v1.startBrowserPreview({ ...lifecycleBoot, scope: "preview-startup-timeout" }, { + iframe: timeoutIframe, + startupTimeoutMs: 5, + hydrateBlueprintRef: async () => ({ blueprint: { steps: [] } }), + startPlaygroundWeb: () => new Promise(() => {}), + disposeClient: async () => { timedOutStarts += 1 }, + }), + (error: any) => { + assert.equal(error.schema, "wp-codebox/browser-runtime-error/v1") + assert.equal(error.phase, "browser_preview_start") + assert.equal(error.code, "browser_preview_startup_timeout") + assert.deepEqual(plain(error.data), { + schema: "wp-codebox/browser-preview-startup-timeout/v1", + phase: "startup", + timeout_ms: 5, + scope: "preview-startup-timeout", + session_id: "preview-session-1", + cleanup: { + schema: "wp-codebox/browser-preview-dispose-result/v1", + success: true, + status: "disposed", + scope: "preview-startup-timeout", + iframe_reset: true, + listeners_released: true, + pending_work_cancellation_requested: true, + pending_work_cancelled: false, + stale_result_suppression_enabled: true, + client_release_requested: false, + client_released: false, + client_release_error: null, + client_release_evidence: null, + runtime_release_requested: true, + runtime_terminated: false, + lifecycle_released: true, + }, + }) + return true + }, +) +assert.equal(timedOutStarts, 0, "an unresolved startup has no client to release") +assert.equal(timeoutIframe.src, "about:blank", "startup timeout resets the associated iframe") + +for (let index = 0; index < 3; index += 1) { + await assert.rejects( + () => api.v1.startBrowserPreview({ ...lifecycleBoot, scope: "preview-timeout-cycle" }, { + iframe: { src: "https://playground.example/remote.html" }, + startupTimeoutMs: 5, + hydrateBlueprintRef: async () => ({ blueprint: { steps: [] } }), + startPlaygroundWeb: () => new Promise(() => {}), + }), + (error: any) => error.code === "browser_preview_startup_timeout", + ) +} +const timeoutCycleReplacement = await api.v1.startBrowserPreview({ ...lifecycleBoot, scope: "preview-timeout-cycle" }, { + iframe: { src: "https://playground.example/remote.html" }, + startupTimeoutMs: 0, + hydrateBlueprintRef: async () => ({ blueprint: { steps: [] } }), + startPlaygroundWeb: async () => ({ client: "timeout-cycle-replacement" }), +}) +assert.equal(timeoutCycleReplacement.client.client, "timeout-cycle-replacement", "repeated timeouts release scope ownership for a new preview") +await timeoutCycleReplacement.dispose() + +let finishTimedOutStart: ((value: unknown) => void) | undefined +let timedOutLateRelease = 0 +let timedOutLateCallbacks = 0 +const timedOutLateStart = api.v1.startBrowserPreview({ ...lifecycleBoot, scope: "preview-timeout-late-start" }, { + iframe: { src: "https://playground.example/remote.html" }, + startupTimeoutMs: 5, + hydrateBlueprintRef: async () => ({ blueprint: { steps: [] } }), + startOptions: { onClientConnected: () => { timedOutLateCallbacks += 1 } }, + startPlaygroundWeb: (request: Record) => new Promise((resolve) => { + finishTimedOutStart = (client) => { + request.onClientConnected() + resolve(client) + } + }), + disposeClient: async () => { timedOutLateRelease += 1; return true }, +}) +await assert.rejects(timedOutLateStart, (error: any) => error.code === "browser_preview_startup_timeout") +finishTimedOutStart?.({ client: "timed-out-late-start" }) +await new Promise((resolve) => setTimeout(resolve, 0)) +assert.equal(timedOutLateRelease, 1, "a late timeout result releases its client exactly once") +assert.equal(timedOutLateCallbacks, 0, "a timed-out lifecycle suppresses late Playground callbacks") + +let finishReplacedStart: ((value: unknown) => void) | undefined +let replacedLateRelease = 0 +let replacedLateCallbacks = 0 +const replacedStart = api.v1.startBrowserPreview({ ...lifecycleBoot, scope: "preview-replaced-start" }, { + iframe: { src: "https://playground.example/remote.html" }, + startupTimeoutMs: 0, + hydrateBlueprintRef: async () => ({ blueprint: { steps: [] } }), + startOptions: { onClientConnected: () => { replacedLateCallbacks += 1 } }, + startPlaygroundWeb: (request: Record) => new Promise((resolve) => { + finishReplacedStart = (client) => { + request.onClientConnected() + resolve(client) + } + }), + disposeClient: async () => { replacedLateRelease += 1; return true }, +}) +await new Promise((resolve) => setTimeout(resolve, 0)) +const replacementAfterPendingStart = await api.v1.startBrowserPreview({ ...lifecycleBoot, scope: "preview-replaced-start" }, { + iframe: { src: "https://playground.example/remote.html" }, + startupTimeoutMs: 5, + hydrateBlueprintRef: async () => ({ blueprint: { steps: [] } }), + startPlaygroundWeb: async () => ({ client: "replacement-after-pending-start" }), +}) +await assert.rejects(replacedStart, (error: any) => error.code === "browser_preview_replaced") +finishReplacedStart?.({ client: "replaced-late-start" }) +await new Promise((resolve) => setTimeout(resolve, 0)) +assert.equal(replacedLateRelease, 1, "a replaced late start releases its client exactly once") +assert.equal(replacedLateCallbacks, 0, "a replaced lifecycle suppresses late Playground callbacks") +assert.equal(replacementAfterPendingStart.client.client, "replacement-after-pending-start", "same-scope replacement starts without awaiting the prior startup") +await replacementAfterPendingStart.dispose() + +let replacementCleanupAttempted = false +const neverDisposes = await api.v1.startBrowserPreview({ ...lifecycleBoot, scope: "preview-hanging-replacement-cleanup" }, { + iframe: { src: "https://playground.example/remote.html" }, + startupTimeoutMs: 0, + hydrateBlueprintRef: async () => ({ blueprint: { steps: [] } }), + startPlaygroundWeb: async () => ({ client: "hanging-cleanup" }), + disposeClient: () => { + replacementCleanupAttempted = true + return new Promise(() => {}) + }, +}) +await assert.rejects( + () => api.v1.startBrowserPreview({ ...lifecycleBoot, scope: "preview-hanging-replacement-cleanup" }, { + iframe: { src: "https://playground.example/remote.html" }, + startupTimeoutMs: 5, + hydrateBlueprintRef: async () => ({ blueprint: { steps: [] } }), + startPlaygroundWeb: () => new Promise(() => {}), + }), + (error: any) => error.code === "browser_preview_startup_timeout", +) +await new Promise((resolve) => setTimeout(resolve, 0)) +assert.equal(replacementCleanupAttempted, true, "replacement starts the prior client cleanup without waiting for it") + const lifecycleCallbacks: Record unknown> = {} const lifecycleIframe: { src: string } = { src: "https://playground.example/remote.html" } let callbackMutations = 0