fix: split app into (main)/(sandbox) route groups so plugin iframe hydrates properly
This commit is contained in:
@@ -118,7 +118,15 @@ export class SandboxInstance {
|
||||
});
|
||||
|
||||
this.iframe = document.createElement('iframe');
|
||||
this.iframe.setAttribute('sandbox', 'allow-scripts');
|
||||
// Dev-only: Next's HMR/dev runtime refuses requests from the opaque
|
||||
// ("null") origin a strict sandbox produces, so the iframe never
|
||||
// hydrates and `sandbox-ready` is never posted. Add allow-same-origin
|
||||
// in dev so the iframe shares the host's origin and HMR works.
|
||||
// Production keeps the strict opaque-origin sandbox.
|
||||
const sandboxFlags = process.env.NODE_ENV === 'development'
|
||||
? 'allow-scripts allow-same-origin'
|
||||
: 'allow-scripts';
|
||||
this.iframe.setAttribute('sandbox', sandboxFlags);
|
||||
this.iframe.setAttribute('referrerpolicy', 'no-referrer');
|
||||
this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`;
|
||||
this.iframe.style.border = 'none';
|
||||
|
||||
@@ -65,20 +65,44 @@ async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
|
||||
|
||||
// ─── Load ─────────────────────────────────────────────────────
|
||||
|
||||
// Bound on how long the sandbox iframe may take to send back init-done.
|
||||
// Without this a single misbehaving plugin can hang the whole load loop.
|
||||
// 30s accommodates Next.js dev-mode per-iframe compile + SSR + hydrate on
|
||||
// slower machines, while still catching truly stuck plugins.
|
||||
const INIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${ms}ms`));
|
||||
}, ms);
|
||||
promise.then(
|
||||
(v) => { clearTimeout(timer); resolve(v); },
|
||||
(e) => { clearTimeout(timer); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
let background: ReturnType<typeof createBackgroundInstance> | null = null;
|
||||
try {
|
||||
const code = await getBundleCode(plugin);
|
||||
const background = createBackgroundInstance({
|
||||
background = createBackgroundInstance({
|
||||
plugin,
|
||||
code,
|
||||
locale: currentLocale,
|
||||
});
|
||||
|
||||
// Wait for the background runtime to evaluate the bundle, register hooks,
|
||||
// and enumerate slots.
|
||||
const info = await background.initPromise;
|
||||
// and enumerate slots. Bounded so a stuck iframe doesn't hang activation.
|
||||
const bg = background;
|
||||
const info = await withTimeout(
|
||||
bg.initPromise,
|
||||
INIT_TIMEOUT_MS,
|
||||
`[plugin-sandbox] "${plugin.id}" init`,
|
||||
);
|
||||
|
||||
// Wire hook proxies: every hookName the plugin registered gets a HookBus
|
||||
// entry whose handler dispatches into the sandbox. `shortcut:<id>` hooks
|
||||
@@ -93,7 +117,7 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
}
|
||||
const proxy = async (...args: unknown[]) => {
|
||||
try {
|
||||
return await background.invokeHook(hookName, args);
|
||||
return await bg.invokeHook(hookName, args);
|
||||
} catch (err) {
|
||||
pluginErrorTracker.record(plugin.id, err);
|
||||
throw err;
|
||||
@@ -103,13 +127,13 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
}
|
||||
|
||||
// Install plugin-declared keyboard shortcuts.
|
||||
const shortcutDispose = registerShortcuts(background, info.shortcuts ?? []);
|
||||
const shortcutDispose = registerShortcuts(bg, info.shortcuts ?? []);
|
||||
hookDisposables.push({ dispose: shortcutDispose });
|
||||
|
||||
registerActive({
|
||||
plugin,
|
||||
code,
|
||||
background,
|
||||
background: bg,
|
||||
slotOffers: info.slots,
|
||||
hookDisposables,
|
||||
});
|
||||
@@ -120,6 +144,11 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
storeAccessor?.setPluginStatus(plugin.id, 'error', msg);
|
||||
console.error(`[plugin-sandbox] Failed to load "${plugin.id}":`, err);
|
||||
// Tear down the hung/failed iframe so it can't keep posting messages or
|
||||
// occupy DOM and resources after we've given up on it.
|
||||
if (background) {
|
||||
try { background.destroy(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// Shared message-protocol types for host ↔ sandbox postMessage RPC.
|
||||
//
|
||||
// The sandbox iframe is null-origin (`sandbox="allow-scripts"`), so postMessage
|
||||
// events arrive with `event.origin === "null"`. The host pins messages by the
|
||||
// iframe's `contentWindow` reference instead. All values crossing the boundary
|
||||
// must be structured-cloneable: no functions, no DOM nodes, no class instances.
|
||||
// In production the sandbox iframe is null-origin (`sandbox="allow-scripts"`),
|
||||
// so postMessage events arrive with `event.origin === "null"`. In development
|
||||
// the iframe also gets `allow-same-origin` so Next's HMR/dev runtime works;
|
||||
// `event.origin` is then the host's actual origin. The host pins messages by
|
||||
// the iframe's `contentWindow` reference in either case. All values crossing
|
||||
// the boundary must be structured-cloneable: no functions, no DOM nodes, no
|
||||
// class instances.
|
||||
|
||||
import type { SlotName } from '../plugin-types';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// 5. In slot mode: look up `slots[slot].component`, render it into the
|
||||
// iframe body, push height back via ResizeObserver.
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import * as ReactJSXRuntime from 'react/jsx-runtime';
|
||||
@@ -54,6 +54,9 @@ let pluginExports: PluginExports | null = null;
|
||||
let mode: 'background' | 'slot' | null = null;
|
||||
let slotName: SlotName | null = null;
|
||||
let bootDone = false;
|
||||
// Guards the initial sandbox-ready post against React strict mode's double
|
||||
// useEffect invocation; the parent only needs to be pinged once per iframe.
|
||||
let readyPosted = false;
|
||||
|
||||
const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
||||
const pendingCallbacks = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
||||
@@ -436,13 +439,13 @@ function handleHostMessage(ev: MessageEvent): void {
|
||||
// ─── React entry ─────────────────────────────────────────────
|
||||
|
||||
export function SandboxRuntime(): React.JSX.Element {
|
||||
const inited = useRef(false);
|
||||
useEffect(() => {
|
||||
if (inited.current) return;
|
||||
inited.current = true;
|
||||
window.addEventListener('message', handleHostMessage);
|
||||
// Initial ping. We don't know parent origin yet, so '*' is required.
|
||||
if (window.parent && window.parent !== window) {
|
||||
// Guard at module scope so React strict mode's double-invoke doesn't
|
||||
// re-post (and so a re-post can't race with the parent's init reply).
|
||||
if (!readyPosted && window.parent && window.parent !== window) {
|
||||
readyPosted = true;
|
||||
window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*');
|
||||
}
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user