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
4 changes: 4 additions & 0 deletions packages/runtime-playground/src/preview-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,10 @@ function proxyResponseHeaders(headers: IncomingHttpHeaders, requestTarget: Previ
delete forwarded.connection
delete forwarded["transfer-encoding"]

if (new URL(requestTarget.path, "http://localhost").pathname.endsWith("/sw.js")) {
forwarded["service-worker-allowed"] = "/"
}

if (typeof forwarded.location === "string") {
try {
const location = new URL(forwarded.location, target)
Expand Down
92 changes: 79 additions & 13 deletions packages/wordpress-plugin/assets/browser-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -945,7 +945,7 @@

const resolvePlaygroundClientModule = async ( boot, options = {} ) => {
if ( typeof options.startPlaygroundWeb === 'function' ) {
return { startPlaygroundWeb: options.startPlaygroundWeb, zipWpContent: options.zipWpContent };
return { startPlaygroundWeb: options.startPlaygroundWeb, zipWpContent: options.zipWpContent, disposePlaygroundClient: options.disposePlaygroundClient };
}

const moduleUrl = String( options.clientModuleUrl || boot.client_module_url || '' );
Expand All @@ -959,10 +959,13 @@
throw runtimeError( 'browser_preview_start', 'browser_preview_start_unavailable', 'Codebox browser preview client module does not export startPlaygroundWeb.' );
}

return { startPlaygroundWeb: module.startPlaygroundWeb, zipWpContent: module.zipWpContent };
return { startPlaygroundWeb: module.startPlaygroundWeb, zipWpContent: module.zipWpContent, disposePlaygroundClient: module.disposePlaygroundClient };
};

const browserPreviewLifecycles = new Map();
const browserPreviewSlotRoots = new WeakMap();
const browserPreviewSlotIframes = new WeakMap();
const browserPreviewSlotLifecycles = new WeakMap();
const browserPreviewReplaySources = new WeakMap();
const defaultBrowserPreviewStartupTimeoutMs = 30000;
const browserPreviewAbortError = () => runtimeError( 'browser_preview_start', 'browser_preview_aborted', 'Browser preview start was aborted.' );
Expand Down Expand Up @@ -991,8 +994,44 @@
}
);

const resetBrowserPreviewIframe = ( iframe ) => {
const browserPreviewSlot = ( iframe ) => {
if ( ! iframe || ( typeof iframe !== 'object' && typeof iframe !== 'function' ) ) return null;
const slot = browserPreviewSlotRoots.get( iframe ) || iframe;
browserPreviewSlotRoots.set( iframe, slot );
if ( ! browserPreviewSlotIframes.has( slot ) ) browserPreviewSlotIframes.set( slot, iframe );
return slot;
};
const currentBrowserPreviewIframe = ( slot, fallback ) => slot ? ( browserPreviewSlotIframes.get( slot ) || fallback ) : fallback;
const resetBrowserPreviewIframe = async ( iframe, slot ) => {
if ( iframe && typeof iframe === 'object' && 'src' in iframe ) {
if ( iframe.parentNode && typeof iframe.cloneNode === 'function' && typeof iframe.parentNode.replaceChild === 'function' ) {
const replacement = iframe.cloneNode( false );
replacement.src = 'about:blank';
iframe.parentNode.replaceChild( replacement, iframe );
if ( slot ) {
browserPreviewSlotRoots.set( replacement, slot );
browserPreviewSlotIframes.set( slot, replacement );
}
return true;
}
if ( typeof iframe.addEventListener === 'function' && typeof iframe.removeEventListener === 'function' ) {
await new Promise( ( resolve ) => {
let settled = false;
const finish = () => {
if ( settled ) {
return;
}
settled = true;
iframe.removeEventListener( 'load', finish );
clearTimeout( timeout );
resolve();
};
const timeout = setTimeout( finish, 5000 );
iframe.addEventListener( 'load', finish, { once: true } );
iframe.src = 'about:blank';
} );
return true;
}
iframe.src = 'about:blank';
return true;
}
Expand All @@ -1004,27 +1043,31 @@
if ( typeof value === 'boolean' ) {
return {
client_released: value,
runtime_termination_requested: false,
runtime_terminated: false,
client_release_evidence: null,
};
}
if ( value && typeof value === 'object' && ! Array.isArray( value ) ) {
const client_released = value.client_released === true;
const runtime_termination_requested = value.runtime_termination_requested === true;
const runtime_terminated = value.runtime_terminated === true;
return {
client_released,
runtime_termination_requested,
runtime_terminated,
client_release_evidence: Object.freeze( { client_released, runtime_terminated } ),
client_release_evidence: Object.freeze( { client_released, runtime_termination_requested, runtime_terminated } ),
};
}
return {
client_released: false,
runtime_termination_requested: false,
runtime_terminated: false,
client_release_evidence: null,
};
};

const createBrowserPreviewLifecycle = ( scope, iframe, signal, disposeClient ) => {
const createBrowserPreviewLifecycle = ( scope, slot, iframe, signal, disposeClient ) => {
let active = true;
let cancelled = false;
let client;
Expand All @@ -1037,6 +1080,9 @@
} );
const lifecycle = {
isActive: () => active,
setDisposeClient: ( nextDisposeClient ) => {
if ( typeof nextDisposeClient === 'function' ) disposeClient = nextDisposeClient;
},
wait: ( promise ) => Promise.race( [ promise, cancellation ] ),
ownClient: ( nextClient ) => {
if ( ! active ) {
Expand Down Expand Up @@ -1079,21 +1125,26 @@
if ( scope && browserPreviewLifecycles.get( scope ) === lifecycle ) {
browserPreviewLifecycles.delete( scope );
}
const iframe_reset = resetBrowserPreviewIframe( iframe );
if ( slot && browserPreviewSlotLifecycles.get( slot ) === lifecycle ) {
browserPreviewSlotLifecycles.delete( slot );
}
let client_release_requested = false;
let client_released = false;
let runtime_termination_requested = false;
let runtime_terminated = false;
let client_release_evidence = null;
let client_release_error = null;
try {
const clientRelease = await lifecycle.releaseClient();
client_release_requested = clientRelease.requested;
client_released = clientRelease.client_released;
runtime_termination_requested = clientRelease.runtime_termination_requested;
runtime_terminated = clientRelease.runtime_terminated;
client_release_evidence = clientRelease.client_release_evidence;
} catch ( error ) {
client_release_error = error?.message || 'Browser preview client cleanup failed.';
}
const iframe_reset = await resetBrowserPreviewIframe( iframe, slot );
return Object.freeze( {
schema: 'wp-codebox/browser-preview-dispose-result/v1',
success: true,
Expand All @@ -1109,6 +1160,7 @@
client_release_error,
client_release_evidence,
runtime_release_requested: iframe_reset,
runtime_termination_requested,
runtime_terminated,
lifecycle_released: true,
} );
Expand Down Expand Up @@ -1140,31 +1192,45 @@
throw browserPreviewAbortError();
}
const boot = browserPreviewBootConfig( input );
const iframe = options.iframe || input?.iframe || ( typeof document !== 'undefined' && options.iframeSelector ? document.querySelector( options.iframeSelector ) : null );
const requestedIframe = options.iframe || input?.iframe || ( typeof document !== 'undefined' && options.iframeSelector ? document.querySelector( options.iframeSelector ) : null );
const slot = browserPreviewSlot( requestedIframe );
let iframe = currentBrowserPreviewIframe( slot, requestedIframe );
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 ) {
previousLifecycle.cancel( browserPreviewReplacedError() );
}
lifecycle = createBrowserPreviewLifecycle( scope, iframe, options.signal, options.disposeClient );
const previousSlotLifecycle = slot ? browserPreviewSlotLifecycles.get( slot ) : null;
if ( previousSlotLifecycle && previousSlotLifecycle !== previousLifecycle ) {
previousSlotLifecycle.cancel( browserPreviewReplacedError() );
}
if ( previousSlotLifecycle ) {
await previousSlotLifecycle.dispose();
iframe = currentBrowserPreviewIframe( slot, iframe );
}
lifecycle = createBrowserPreviewLifecycle( scope, slot, iframe, options.signal, options.disposeClient );
if ( options.signal?.aborted ) {
await lifecycle.dispose();
throw browserPreviewAbortError();
}
if ( scope ) {
browserPreviewLifecycles.set( scope, lifecycle );
}
if ( slot ) {
browserPreviewSlotLifecycles.set( slot, lifecycle );
}
if ( startupTimeoutMs !== null ) {
startupTimeout = setTimeout( () => {
lifecycle.cancel( browserPreviewStartupTimeoutError( boot, scope, startupTimeoutMs ) );
}, startupTimeoutMs );
}

try {
const playgroundModule = await lifecycle.wait( resolvePlaygroundClientModule( boot, options ) );
if ( typeof options.disposeClient !== 'function' ) lifecycle.setDisposeClient( playgroundModule.disposePlaygroundClient );
const blueprint = await lifecycle.wait( hydrateBrowserPreviewBlueprint( boot, options ) );
const startOptions = options.startOptions && typeof options.startOptions === 'object' ? options.startOptions : {};
const guardedStartOptions = { ...startOptions };
Expand Down
25 changes: 23 additions & 2 deletions tests/browser-sdk-facade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,7 @@ await assert.rejects(
client_release_error: null,
client_release_evidence: null,
runtime_release_requested: true,
runtime_termination_requested: false,
runtime_terminated: false,
lifecycle_released: true,
},
Expand Down Expand Up @@ -958,7 +959,7 @@ const lifecyclePreview = await api.v1.startBrowserPreview(lifecycleBoot, {
assert.equal(client.client, "lifecycle-playground")
lifecycleClientReleased = true
releasedClients += 1
return { client_released: true, runtime_terminated: false }
return { client_released: true, runtime_termination_requested: false, runtime_terminated: false }
},
})
lifecycleCallbacks.onBlueprintStepCompleted()
Expand All @@ -979,8 +980,9 @@ assert.deepEqual(plain(disposed), {
client_release_requested: true,
client_released: true,
client_release_error: null,
client_release_evidence: { client_released: true, runtime_terminated: false },
client_release_evidence: { client_released: true, runtime_termination_requested: false, runtime_terminated: false },
runtime_release_requested: true,
runtime_termination_requested: false,
runtime_terminated: false,
lifecycle_released: true,
})
Expand All @@ -1004,6 +1006,25 @@ assert.equal(defaultDispose.client_released, false, "default disposal does not v
assert.equal(defaultDispose.runtime_release_requested, true, "iframe reset requests runtime release")
assert.equal(defaultDispose.runtime_terminated, false, "default disposal does not verify runtime termination")

let moduleDisposedClient: unknown
const moduleDisposePreview = await api.v1.startBrowserPreview({ ...lifecycleBoot, client_module_url: "https://playground.example/client/index.js", scope: "preview-module-dispose-test" }, {
iframe: { src: "https://playground.example/remote.html" },
hydrateBlueprintRef: async () => ({ blueprint: { steps: [] } }),
importModule: async () => ({
startPlaygroundWeb: async () => lifecycleClient,
disposePlaygroundClient: async (client: unknown) => {
moduleDisposedClient = client
return { client_released: true, runtime_termination_requested: true, runtime_terminated: false }
},
}),
})
const moduleDisposeResult = await moduleDisposePreview.dispose()
assert.equal(moduleDisposedClient, lifecycleClient, "default disposal delegates runtime teardown to the imported Playground module")
assert.equal(moduleDisposeResult.client_release_requested, true, "module-owned runtime teardown is reported as requested")
assert.equal(moduleDisposeResult.client_released, true, "module-owned client release evidence is preserved")
assert.equal(moduleDisposeResult.runtime_termination_requested, true, "module-owned runtime termination request evidence is preserved")
assert.equal(moduleDisposeResult.runtime_terminated, false, "module-owned teardown does not overstate confirmed runtime termination")

let replacementReleased = 0
const replacementCallbacks: Record<string, () => unknown> = {}
const firstReplacement = await api.v1.startBrowserPreview({ ...lifecycleBoot, scope: "preview-replacement-test" }, {
Expand Down
Loading
Loading