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: 1 addition & 1 deletion docs/browser-contained-site-facade.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 10 additions & 4 deletions docs/public-api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 54 additions & 7 deletions packages/wordpress-plugin/assets/browser-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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() } );
Expand Down Expand Up @@ -823,12 +855,12 @@
} );
return cleanup;
},
cancel: () => {
cancel: ( error = browserPreviewAbortError() ) => {
if ( cancelled ) {
return;
}
cancelled = true;
rejectCancellation( browserPreviewAbortError() );
rejectCancellation( error );
void lifecycle.dispose();
},
};
Expand All @@ -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();
Expand Down Expand Up @@ -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 );
}
}
};

Expand Down
143 changes: 143 additions & 0 deletions tests/browser-sdk-facade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const sandbox = {
TextDecoder,
TextEncoder,
URL,
setTimeout,
clearTimeout,
}

vm.runInNewContext(runtimeSource, sandbox, { filename: "browser-runtime.js" })
Expand Down Expand Up @@ -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<string, any>) => 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<string, any>) => 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<string, (...args: any[]) => unknown> = {}
const lifecycleIframe: { src: string } = { src: "https://playground.example/remote.html" }
let callbackMutations = 0
Expand Down
Loading