mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Every plugin iframe loaded /plugin-sandbox, a force-dynamic Next page. Each frame then fetched, parsed and hydrated Next's client router on top of React and the runtime, and the server rendered the page once per frame, one after another when a dozen plugins started together. Sandboxed frames have opaque origins, so the browser's code cache never helps, and they run on the host page's main thread: with 12 plugins the frames took 2.5-3 s to come up after the app had loaded. The sandbox routes now return a small HTML page with the UI kit's stylesheet and the runtime as an ES module, both built ahead of time: - `scripts/build-plugin-sandbox.mjs` bundles the runtime with esbuild and compiles `plugin-kit.css` with the app's Tailwind setup into `public/plugin-sandbox-assets/`, with a manifest. Named Tabler imports are pointed at the icons' own files, so the full icon set stays in a chunk that only loads for an icon name the kit doesn't ship. - `npm run build`, `npm run dev`, the Dockerfile and the perf-guard workflow run it before `next build`. - `lib/plugin-sandbox/sandbox-document.ts` writes the page; proxy.ts still sets the sandbox CSP and the script carries its nonce. - The assets are served from public/ with CORS and immutable caching. A frame on an idle page is ready in 255 ms instead of 362 ms, eleven at once in 1.0 s instead of 2.1 s.
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
// Shared message-protocol types for host ↔ sandbox postMessage RPC.
|
|
//
|
|
// 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, PluginTier } from '../plugin-types';
|
|
import type { ThemeSnapshot } from './host-theme';
|
|
|
|
/** Resolved presentation/regional settings mirrored into plugin runtimes. */
|
|
export interface RegionalSnapshot {
|
|
/** The effective UI language, e.g. 'en', 'de', 'fr'. */
|
|
locale: string;
|
|
/** Resolved BCP-47 tag for date rendering (respects dateLocale override, e.g. 'en-CA' for iso). */
|
|
dateLocale: string;
|
|
/** True for 12-hour clock (AM/PM), false for 24-hour clock. */
|
|
hour12: boolean;
|
|
/** The user's effective time zone, e.g. 'Europe/Berlin'. */
|
|
timeZone: string;
|
|
/** First day of week (0 = Sunday, 1 = Monday, 6 = Saturday). */
|
|
firstDayOfWeek: 0 | 1 | 6;
|
|
}
|
|
|
|
/** Initialisation payload for a background-instance iframe (one per plugin). */
|
|
export interface BackgroundInit {
|
|
mode: 'background';
|
|
pluginId: string;
|
|
/**
|
|
* Execution tier. 'privileged' iframes are same-origin (real WebCrypto +
|
|
* IndexedDB); 'untrusted' iframes are null-origin. Decided host-side by
|
|
* `resolvePluginTier`; the sandbox itself does not act on this field.
|
|
*/
|
|
tier: PluginTier;
|
|
/** Trimmed manifest visible to the plugin. No host secrets. */
|
|
manifest: {
|
|
id: string;
|
|
version: string;
|
|
permissions: string[];
|
|
settings: Record<string, unknown>;
|
|
locales?: Record<string, Record<string, string>>;
|
|
httpOrigins?: string[];
|
|
};
|
|
/** UTF-8 plugin bundle source (CommonJS). */
|
|
code: string;
|
|
/** Initial app locale; host pushes updates via 'locale-change'. */
|
|
locale: string;
|
|
/** Resolved presentation/regional preferences (time format, date locale, time zone). */
|
|
regional?: RegionalSnapshot;
|
|
}
|
|
|
|
/** Initialisation payload for a slot-instance iframe (one per slot mount). */
|
|
export interface SlotInit {
|
|
mode: 'slot';
|
|
pluginId: string;
|
|
/** Execution tier (mirrors `BackgroundInit.tier`). */
|
|
tier: PluginTier;
|
|
/** Slot name the iframe should render a component for. */
|
|
slot: SlotName;
|
|
/**
|
|
* The frame fills its panel instead of growing with its content: the
|
|
* plugin's root is as tall as the panel and scrolls inside, so it can pin
|
|
* controls at the bottom (a chat input). Only for panel slots.
|
|
*/
|
|
fill?: boolean;
|
|
/** Same bundle code as the background instance. */
|
|
code: string;
|
|
/**
|
|
* Trimmed manifest (mirrors `BackgroundInit.manifest`). Slot iframes get the
|
|
* same fields so `api.plugin.settings` and `httpOrigins` work identically
|
|
* to the background context.
|
|
*/
|
|
manifest: {
|
|
id: string;
|
|
version: string;
|
|
permissions: string[];
|
|
settings: Record<string, unknown>;
|
|
locales?: Record<string, Record<string, string>>;
|
|
httpOrigins?: string[];
|
|
};
|
|
/**
|
|
* Initial props the host passes through from `PluginSlot` `extraProps`.
|
|
* Function values are pre-encoded by the host as
|
|
* `{ __pluginCallback: '<id>' }` markers and rehydrated to stub functions
|
|
* by the runtime; the stubs round-trip to the host via 'callback-invoke'.
|
|
*/
|
|
extraProps: Record<string, unknown>;
|
|
locale: string;
|
|
/** Resolved presentation/regional preferences (time format, date locale, time zone). */
|
|
regional?: RegionalSnapshot;
|
|
/**
|
|
* Resolved host theme (colour tokens, font stack, dark flag). The sandbox
|
|
* can't load globals.css/fonts cross-origin, so the runtime replays this as
|
|
* injected CSS + a `.dark` class. Host pushes updates via 'theme-change'.
|
|
*/
|
|
theme: ThemeSnapshot;
|
|
}
|
|
|
|
export type InitPayload = BackgroundInit | SlotInit;
|
|
|
|
// ─── Sandbox → Host messages ─────────────────────────────────
|
|
|
|
export interface ReadyMsg { type: 'sandbox-ready'; }
|
|
|
|
export interface InitDoneMsg {
|
|
type: 'init-done';
|
|
/** Hook names the plugin registered. The host installs proxy handlers. */
|
|
hooks: string[];
|
|
/** Slots the plugin claims. Used by the host to know when a slot is offered. `fill`: a panel slot that fills its panel (see slot-layout.ts). */
|
|
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean; startFolded?: boolean }>;
|
|
/**
|
|
* Keyboard shortcuts the plugin declares. The host installs a global
|
|
* keydown listener that dispatches to the `shortcut:<id>` hook when the
|
|
* combo matches. `keys` is a `+`-separated string like "Ctrl+Shift+L".
|
|
*/
|
|
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>;
|
|
}
|
|
|
|
export interface InitErrorMsg { type: 'init-error'; error: string; }
|
|
|
|
export interface ApiRequestMsg {
|
|
type: 'api-request';
|
|
id: string;
|
|
/** Dotted method path, e.g. "http.post", "storage.get", "admin.getConfig". */
|
|
method: string;
|
|
args: unknown[];
|
|
}
|
|
|
|
/** Sandbox → host: invoke a function the host passed in via `extraProps`. */
|
|
export interface CallbackInvokeMsg {
|
|
type: 'callback-invoke';
|
|
/** Round-trip id so the host can return a value if the caller awaits. */
|
|
id: string;
|
|
/** The callback marker id (matches `__pluginCallback`). */
|
|
callbackId: string;
|
|
args: unknown[];
|
|
}
|
|
|
|
/** Host → sandbox: response to a callback-invoke. */
|
|
export interface CallbackResponseMsg {
|
|
type: 'callback-response';
|
|
id: string;
|
|
ok: boolean;
|
|
result?: unknown;
|
|
error?: string;
|
|
}
|
|
|
|
export interface HookResultMsg {
|
|
type: 'hook-result';
|
|
id: string;
|
|
ok: boolean;
|
|
result?: unknown;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* A slot frame has applied a theme-change. The host sets the iframe element's
|
|
* color-scheme only now, so element and document never disagree (see
|
|
* SandboxInstance.setTheme).
|
|
*/
|
|
export interface ThemeAppliedMsg { type: 'theme-applied'; dark: boolean; }
|
|
|
|
export interface SlotResizeMsg {
|
|
type: 'slot-resize';
|
|
height: number;
|
|
/** Natural content width, sent by inline slots (see slot-layout.ts). */
|
|
width?: number;
|
|
}
|
|
|
|
export interface SlotShouldShowResultMsg {
|
|
type: 'slot-should-show-result';
|
|
id: string;
|
|
show: boolean;
|
|
}
|
|
|
|
export interface FloatingDropdownRect {
|
|
left: number;
|
|
top: number;
|
|
bottom: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export interface FloatingDropdownOption {
|
|
value: string;
|
|
label: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface FloatingDropdownOpenMsg {
|
|
type: 'floating-dropdown-open';
|
|
id: string;
|
|
rect: FloatingDropdownRect;
|
|
options: FloatingDropdownOption[];
|
|
multiple?: boolean;
|
|
selectedValues: string[];
|
|
singleValue?: string;
|
|
highlightedIndex: number;
|
|
ariaLabel?: string;
|
|
}
|
|
|
|
export interface FloatingDropdownUpdateMsg {
|
|
type: 'floating-dropdown-update';
|
|
id: string;
|
|
rect?: FloatingDropdownRect;
|
|
options?: FloatingDropdownOption[];
|
|
multiple?: boolean;
|
|
selectedValues?: string[];
|
|
singleValue?: string;
|
|
highlightedIndex?: number;
|
|
}
|
|
|
|
export interface FloatingDropdownCloseMsg {
|
|
type: 'floating-dropdown-close';
|
|
id: string;
|
|
}
|
|
|
|
export type SandboxToHost =
|
|
| ReadyMsg
|
|
| InitDoneMsg
|
|
| InitErrorMsg
|
|
| ApiRequestMsg
|
|
| CallbackInvokeMsg
|
|
| HookResultMsg
|
|
| SlotResizeMsg
|
|
| ThemeAppliedMsg
|
|
| SlotShouldShowResultMsg
|
|
| FloatingDropdownOpenMsg
|
|
| FloatingDropdownUpdateMsg
|
|
| FloatingDropdownCloseMsg;
|
|
|
|
// ─── Host → Sandbox messages ─────────────────────────────────
|
|
|
|
export interface InitMsg { type: 'init'; payload: InitPayload; }
|
|
|
|
export interface ApiResponseMsg {
|
|
type: 'api-response';
|
|
id: string;
|
|
ok: boolean;
|
|
result?: unknown;
|
|
error?: string;
|
|
}
|
|
|
|
export interface HookInvokeMsg {
|
|
type: 'hook-invoke';
|
|
id: string;
|
|
hookName: string;
|
|
args: unknown[];
|
|
}
|
|
|
|
export interface LocaleChangeMsg {
|
|
type: 'locale-change';
|
|
locale: string;
|
|
regional?: RegionalSnapshot;
|
|
/** The plugin's tables for the new locale (init carries only the current ones). */
|
|
locales?: Record<string, Record<string, string>>;
|
|
}
|
|
|
|
/** Host → sandbox: the resolved theme changed; re-inject the slot's theme CSS. */
|
|
export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; }
|
|
|
|
/** Host → sandbox: the user changed this plugin's settings. */
|
|
export interface SettingsChangeMsg { type: 'settings-change'; settings: Record<string, unknown>; }
|
|
|
|
export interface PropsUpdateMsg { type: 'props-update'; props: Record<string, unknown>; }
|
|
|
|
export interface SlotShouldShowMsg {
|
|
type: 'slot-should-show';
|
|
id: string;
|
|
slot: SlotName;
|
|
context: unknown;
|
|
}
|
|
|
|
/** Host → sandbox: a piece of a streamed api.proxy.fetch response. */
|
|
export interface StreamChunkMsg { type: 'stream-chunk'; streamId: string; chunk: ArrayBuffer; }
|
|
/** Host → sandbox: a streamed response ended (error set when it broke off). */
|
|
export interface StreamEndMsg { type: 'stream-end'; streamId: string; error?: string; }
|
|
/** Host → sandbox: upload progress of an api.proxy.fetch request. */
|
|
export interface StreamProgressMsg { type: 'stream-progress'; streamId: string; loaded: number; total: number; }
|
|
|
|
export interface FloatingDropdownSelectMsg {
|
|
type: 'floating-dropdown-select';
|
|
id: string;
|
|
option: FloatingDropdownOption;
|
|
}
|
|
|
|
export interface FloatingDropdownHighlightMsg {
|
|
type: 'floating-dropdown-highlight';
|
|
id: string;
|
|
index: number;
|
|
}
|
|
|
|
export interface FloatingDropdownDismissMsg {
|
|
type: 'floating-dropdown-dismiss';
|
|
id: string;
|
|
}
|
|
|
|
export type HostToSandbox =
|
|
| InitMsg
|
|
| StreamChunkMsg
|
|
| StreamEndMsg
|
|
| StreamProgressMsg
|
|
| ApiResponseMsg
|
|
| CallbackResponseMsg
|
|
| HookInvokeMsg
|
|
| LocaleChangeMsg
|
|
| ThemeChangeMsg
|
|
| SettingsChangeMsg
|
|
| PropsUpdateMsg
|
|
| SlotShouldShowMsg
|
|
| FloatingDropdownSelectMsg
|
|
| FloatingDropdownHighlightMsg
|
|
| FloatingDropdownDismissMsg;
|
|
|
|
// ─── Constants ───────────────────────────────────────────────
|
|
|
|
/** Path used for the untrusted (null-origin) sandbox iframe `src`. Matched in
|
|
* `proxy.ts` for CSP. */
|
|
export const SANDBOX_PATH = '/plugin-sandbox';
|
|
|
|
/**
|
|
* Path used for the privileged (same-origin) sandbox iframe `src`. A distinct
|
|
* route so the iframe gets `allow-same-origin` (real WebCrypto + IndexedDB)
|
|
* while keeping the same CSP relaxations as the untrusted sandbox. Matched in
|
|
* `proxy.ts`. Serves the same document (sandbox-document.ts).
|
|
*/
|
|
export const SANDBOX_PRIVILEGED_PATH = '/plugin-sandbox-privileged';
|