fix: split app into (main)/(sandbox) route groups so plugin iframe hydrates properly
This commit is contained in:
@@ -16,11 +16,11 @@ import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
|
|||||||
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import MailPage from "@/app/[locale]/page";
|
import MailPage from "@/app/(main)/[locale]/page";
|
||||||
import CalendarPage from "@/app/[locale]/calendar/page";
|
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
|
||||||
import ContactsPage from "@/app/[locale]/contacts/page";
|
import ContactsPage from "@/app/(main)/[locale]/contacts/page";
|
||||||
import FilesPage from "@/app/[locale]/files/page";
|
import FilesPage from "@/app/(main)/[locale]/files/page";
|
||||||
import SettingsPage from "@/app/[locale]/settings/page";
|
import SettingsPage from "@/app/(main)/[locale]/settings/page";
|
||||||
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
|
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
|
||||||
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
|
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@ import { getLocale } from "next-intl/server";
|
|||||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
import { configManager } from "@/lib/admin/config-manager";
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
import "./globals.css";
|
import "../globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||||
|
|
||||||
|
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||||
|
// Next's injected hydration/chunk scripts. With force-static, those scripts
|
||||||
|
// render without a nonce and the strict sandbox CSP blocks them.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function PluginSandboxPage() {
|
||||||
|
return <SandboxRuntime />;
|
||||||
|
}
|
||||||
@@ -240,10 +240,19 @@ export async function PATCH(request: NextRequest) {
|
|||||||
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||||
|
|
||||||
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
||||||
const updated = await updatePluginMeta(id, updates);
|
let updated = await updatePluginMeta(id, updates);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
// Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but
|
||||||
|
// forceEnabled is canonical-stored in policy.forceEnabledPlugins on the
|
||||||
|
// client. Skip the registry write and return the live dev plugin so the
|
||||||
|
// policy save path can proceed.
|
||||||
|
const devEntries = await listDevPlugins();
|
||||||
|
const devEntry = devEntries.find(e => e.plugin.id === id);
|
||||||
|
if (!devEntry) {
|
||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
updated = { ...devEntry.plugin, ...updates };
|
||||||
|
}
|
||||||
|
|
||||||
// Enable/disable changes the set of plugins contributing frame origins.
|
// Enable/disable changes the set of plugins contributing frame origins.
|
||||||
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
|
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,6 +12,10 @@ import { logger } from '@/lib/logger';
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const policy = configManager.getPolicy();
|
||||||
|
const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []);
|
||||||
|
|
||||||
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
|
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
|
||||||
getPluginRegistry(),
|
getPluginRegistry(),
|
||||||
getThemeRegistry(),
|
getThemeRegistry(),
|
||||||
@@ -34,7 +39,11 @@ export async function GET() {
|
|||||||
type: p.type,
|
type: p.type,
|
||||||
permissions: p.permissions,
|
permissions: p.permissions,
|
||||||
entrypoint: p.entrypoint,
|
entrypoint: p.entrypoint,
|
||||||
forceEnabled: p.forceEnabled || false,
|
// Policy is the canonical source for force-enable. The per-plugin field
|
||||||
|
// can drift for dev plugins (manifest always loads forceEnabled:false)
|
||||||
|
// and during pending policy saves; OR'ing here unifies the signal so
|
||||||
|
// the client's auto-enable path triggers consistently.
|
||||||
|
forceEnabled: p.forceEnabled || policyForceEnabledIds.has(p.id),
|
||||||
// Content hash + updatedAt let clients detect re-uploads even when
|
// Content hash + updatedAt let clients detect re-uploads even when
|
||||||
// the manifest version is unchanged.
|
// the manifest version is unchanged.
|
||||||
bundleHash: p.bundleHash,
|
bundleHash: p.bundleHash,
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
|
||||||
|
|
||||||
export const dynamic = 'force-static';
|
|
||||||
|
|
||||||
export default function PluginSandboxPage() {
|
|
||||||
return <SandboxRuntime />;
|
|
||||||
}
|
|
||||||
@@ -118,7 +118,15 @@ export class SandboxInstance {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.iframe = document.createElement('iframe');
|
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.setAttribute('referrerpolicy', 'no-referrer');
|
||||||
this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`;
|
this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`;
|
||||||
this.iframe.style.border = 'none';
|
this.iframe.style.border = 'none';
|
||||||
|
|||||||
@@ -65,20 +65,44 @@ async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
|
|||||||
|
|
||||||
// ─── Load ─────────────────────────────────────────────────────
|
// ─── 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> {
|
export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
let background: ReturnType<typeof createBackgroundInstance> | null = null;
|
||||||
try {
|
try {
|
||||||
const code = await getBundleCode(plugin);
|
const code = await getBundleCode(plugin);
|
||||||
const background = createBackgroundInstance({
|
background = createBackgroundInstance({
|
||||||
plugin,
|
plugin,
|
||||||
code,
|
code,
|
||||||
locale: currentLocale,
|
locale: currentLocale,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for the background runtime to evaluate the bundle, register hooks,
|
// Wait for the background runtime to evaluate the bundle, register hooks,
|
||||||
// and enumerate slots.
|
// and enumerate slots. Bounded so a stuck iframe doesn't hang activation.
|
||||||
const info = await background.initPromise;
|
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
|
// Wire hook proxies: every hookName the plugin registered gets a HookBus
|
||||||
// entry whose handler dispatches into the sandbox. `shortcut:<id>` hooks
|
// 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[]) => {
|
const proxy = async (...args: unknown[]) => {
|
||||||
try {
|
try {
|
||||||
return await background.invokeHook(hookName, args);
|
return await bg.invokeHook(hookName, args);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pluginErrorTracker.record(plugin.id, err);
|
pluginErrorTracker.record(plugin.id, err);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -103,13 +127,13 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Install plugin-declared keyboard shortcuts.
|
// Install plugin-declared keyboard shortcuts.
|
||||||
const shortcutDispose = registerShortcuts(background, info.shortcuts ?? []);
|
const shortcutDispose = registerShortcuts(bg, info.shortcuts ?? []);
|
||||||
hookDisposables.push({ dispose: shortcutDispose });
|
hookDisposables.push({ dispose: shortcutDispose });
|
||||||
|
|
||||||
registerActive({
|
registerActive({
|
||||||
plugin,
|
plugin,
|
||||||
code,
|
code,
|
||||||
background,
|
background: bg,
|
||||||
slotOffers: info.slots,
|
slotOffers: info.slots,
|
||||||
hookDisposables,
|
hookDisposables,
|
||||||
});
|
});
|
||||||
@@ -120,6 +144,11 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
|||||||
const msg = (err as Error).message ?? String(err);
|
const msg = (err as Error).message ?? String(err);
|
||||||
storeAccessor?.setPluginStatus(plugin.id, 'error', msg);
|
storeAccessor?.setPluginStatus(plugin.id, 'error', msg);
|
||||||
console.error(`[plugin-sandbox] Failed to load "${plugin.id}":`, err);
|
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.
|
// Shared message-protocol types for host ↔ sandbox postMessage RPC.
|
||||||
//
|
//
|
||||||
// The sandbox iframe is null-origin (`sandbox="allow-scripts"`), so postMessage
|
// In production the sandbox iframe is null-origin (`sandbox="allow-scripts"`),
|
||||||
// events arrive with `event.origin === "null"`. The host pins messages by the
|
// so postMessage events arrive with `event.origin === "null"`. In development
|
||||||
// iframe's `contentWindow` reference instead. All values crossing the boundary
|
// the iframe also gets `allow-same-origin` so Next's HMR/dev runtime works;
|
||||||
// must be structured-cloneable: no functions, no DOM nodes, no class instances.
|
// `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';
|
import type { SlotName } from '../plugin-types';
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
// 5. In slot mode: look up `slots[slot].component`, render it into the
|
// 5. In slot mode: look up `slots[slot].component`, render it into the
|
||||||
// iframe body, push height back via ResizeObserver.
|
// iframe body, push height back via ResizeObserver.
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect } from 'react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as ReactDOM from 'react-dom/client';
|
import * as ReactDOM from 'react-dom/client';
|
||||||
import * as ReactJSXRuntime from 'react/jsx-runtime';
|
import * as ReactJSXRuntime from 'react/jsx-runtime';
|
||||||
@@ -54,6 +54,9 @@ let pluginExports: PluginExports | null = null;
|
|||||||
let mode: 'background' | 'slot' | null = null;
|
let mode: 'background' | 'slot' | null = null;
|
||||||
let slotName: SlotName | null = null;
|
let slotName: SlotName | null = null;
|
||||||
let bootDone = false;
|
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 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 }>();
|
const pendingCallbacks = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
||||||
@@ -436,13 +439,13 @@ function handleHostMessage(ev: MessageEvent): void {
|
|||||||
// ─── React entry ─────────────────────────────────────────────
|
// ─── React entry ─────────────────────────────────────────────
|
||||||
|
|
||||||
export function SandboxRuntime(): React.JSX.Element {
|
export function SandboxRuntime(): React.JSX.Element {
|
||||||
const inited = useRef(false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inited.current) return;
|
|
||||||
inited.current = true;
|
|
||||||
window.addEventListener('message', handleHostMessage);
|
window.addEventListener('message', handleHostMessage);
|
||||||
// Initial ping. We don't know parent origin yet, so '*' is required.
|
// 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, '*');
|
window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*');
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ export async function proxy(request: NextRequest) {
|
|||||||
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
|
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
|
||||||
const isProtocolRoute = pathname === '/protocol' || pathname.startsWith('/protocol/');
|
const isProtocolRoute = pathname === '/protocol' || pathname.startsWith('/protocol/');
|
||||||
const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/');
|
const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/');
|
||||||
|
// The plugin sandbox lives in its own root layout under app/(sandbox)/ and
|
||||||
|
// is not part of the localized tree. Letting next-intl rewrite the path to
|
||||||
|
// /en/plugin-sandbox 404s, which kills the iframe and disables every plugin.
|
||||||
|
const isSandboxRoute = isSandboxPath;
|
||||||
|
|
||||||
// When localePrefix is 'always', paths that already have a locale prefix
|
// When localePrefix is 'always', paths that already have a locale prefix
|
||||||
// (e.g. /en/settings) should not be re-processed by the intl middleware -
|
// (e.g. /en/settings) should not be re-processed by the intl middleware -
|
||||||
@@ -129,7 +133,7 @@ export async function proxy(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
|
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
|
||||||
if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !hasLocalePrefix) {
|
if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !isSandboxRoute && !hasLocalePrefix) {
|
||||||
try {
|
try {
|
||||||
intlResponse = intlMiddleware(request);
|
intlResponse = intlMiddleware(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -262,11 +262,11 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
// Sync server-managed plugins before loading
|
// Sync server-managed plugins before loading
|
||||||
await syncServerPlugins(get, set);
|
await syncServerPlugins(get, set);
|
||||||
|
|
||||||
// Load all enabled plugins
|
// Load all enabled plugins in parallel. Sequential `await` made one
|
||||||
|
// hung/slow plugin block every subsequent one; loadSandboxedPlugin
|
||||||
|
// catches its own errors so allSettled is just for tidy completion.
|
||||||
const enabledPlugins = get().plugins.filter(p => p.enabled && p.status !== 'error');
|
const enabledPlugins = get().plugins.filter(p => p.enabled && p.status !== 'error');
|
||||||
for (const plugin of enabledPlugins) {
|
await Promise.allSettled(enabledPlugins.map(plugin => loadPlugin(plugin)));
|
||||||
await loadPlugin(plugin);
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ initialized: true });
|
set({ initialized: true });
|
||||||
})();
|
})();
|
||||||
@@ -474,6 +474,9 @@ async function syncServerPlugins(
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
} else if (local.managed !== true || local.forceEnabled !== sp.forceEnabled) {
|
} else if (local.managed !== true || local.forceEnabled !== sp.forceEnabled) {
|
||||||
|
// When forceEnabled flips on, enable the plugin in the same pass so
|
||||||
|
// the user doesn't need a second refresh for it to run.
|
||||||
|
const shouldAutoEnable = sp.forceEnabled && !local.enabled;
|
||||||
set(state => ({
|
set(state => ({
|
||||||
plugins: state.plugins.map(p =>
|
plugins: state.plugins.map(p =>
|
||||||
p.id === sp.id
|
p.id === sp.id
|
||||||
@@ -482,6 +485,7 @@ async function syncServerPlugins(
|
|||||||
managed: true,
|
managed: true,
|
||||||
forceEnabled: sp.forceEnabled,
|
forceEnabled: sp.forceEnabled,
|
||||||
settingsSchema: sp.settingsSchema,
|
settingsSchema: sp.settingsSchema,
|
||||||
|
...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}),
|
||||||
}
|
}
|
||||||
: p
|
: p
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user