Files
dbx/apps/desktop/src/lib/plugins/pluginHostBridge.ts
T

571 lines
28 KiB
TypeScript

import type { InstalledPlugin, PluginBinaryEvent, PluginEvent, PluginUiAssetPayload, PluginWorkbenchContribution } from "@/types/database";
import { clonePluginData, snapshotPluginWorkbenchContext } from "./pluginData";
const PLUGIN_MESSAGE_SOURCE = "dbx-plugin";
const HOST_MESSAGE_SOURCE = "dbx-host";
const BRIDGE_VERSION = 1;
const MAX_BRIDGE_PAYLOAD_BYTES = 2 * 1024 * 1024;
const MAX_BRIDGE_BINARY_BYTES = 8 * 1024 * 1024;
export interface PluginBridgeTheme {
appearance: "light" | "dark";
/** Resolved DBX design tokens (`--color-*`, `--radius-*`, ...) for the current theme. */
tokens: Record<string, string>;
}
export interface PluginWorkbenchContext {
connectionId?: string;
database?: string;
schema?: string;
values?: Record<string, unknown>;
[key: string]: unknown;
}
export interface PluginHostBridgeApi {
invoke<T = unknown>(pluginId: string, method: string, params?: unknown, timeoutMs?: number): Promise<T>;
notify(pluginId: string, method: string, params?: unknown): Promise<void>;
sendBinary(pluginId: string, channel: string, dataBase64: string): Promise<void>;
readAsset(pluginId: string, path: string): Promise<PluginUiAssetPayload>;
openWorkbench?(pluginId: string, contributionId: string, context?: PluginWorkbenchContext): Promise<void> | void;
openFilesystem?(pluginId: string, providerId: string, context?: PluginWorkbenchContext): Promise<void> | void;
closeTab?(): Promise<void> | void;
}
interface PluginRequestMessage {
source: typeof PLUGIN_MESSAGE_SOURCE;
version: typeof BRIDGE_VERSION;
type: "request";
id: string;
method: string;
params?: unknown;
/** Optional zero-copy binary payload transferred with the request. */
data?: ArrayBuffer;
}
export class PluginHostBridge {
private context: PluginWorkbenchContext;
private locale: string;
private theme?: PluginBridgeTheme;
constructor(
private readonly plugin: InstalledPlugin,
private readonly workbench: PluginWorkbenchContribution,
context: PluginWorkbenchContext,
private readonly targetWindow: () => Window | null,
private readonly api: PluginHostBridgeApi,
locale = "en",
theme?: PluginBridgeTheme,
) {
this.context = snapshotPluginWorkbenchContext(context);
this.locale = locale;
this.theme = theme ? clonePluginData(theme) : undefined;
}
handleWindowMessage(event: MessageEvent): boolean {
const target = this.targetWindow();
if (!target || event.source !== target || !isRecord(event.data)) return false;
if (event.data.source !== PLUGIN_MESSAGE_SOURCE || event.data.version !== BRIDGE_VERSION) return false;
if (event.data.type === "ready") {
this.sendInit();
return true;
}
if (event.data.type === "shortcut" && event.data.shortcut === "closeTab") {
void this.api.closeTab?.();
return true;
}
if (event.data.type !== "request" || !validRequestMessage(event.data)) return false;
void this.handleRequest(event.data, target);
return true;
}
sendInit(): void {
this.post({
source: HOST_MESSAGE_SOURCE,
version: BRIDGE_VERSION,
type: "init",
pluginId: this.plugin.manifest.id,
contributionId: this.workbench.id,
locale: this.locale,
theme: this.theme ? clonePluginData(this.theme) : undefined,
permissions: [...(this.plugin.manifest.permissions || [])],
context: snapshotPluginWorkbenchContext(this.context),
});
}
/**
* Push a new workbench context into the already-loaded plugin UI instead of
* rebuilding the iframe. Identity changes (plugin/contribution) still require
* a full reload; context-only changes must not lose plugin state.
*/
updateContext(context: PluginWorkbenchContext): void {
this.context = snapshotPluginWorkbenchContext(context);
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "context", context: snapshotPluginWorkbenchContext(this.context) });
}
/** Notify the plugin UI about a locale change without a reload. */
updateLocale(locale: string): void {
this.locale = locale;
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "env", locale });
}
/** Push resolved theme tokens so the plugin UI can follow DBX light/dark and palette changes. */
updateTheme(theme: PluginBridgeTheme): void {
this.theme = clonePluginData(theme);
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "env", locale: this.locale, theme: this.theme });
}
forwardEvent(event: PluginEvent): void {
if (event.pluginId !== this.plugin.manifest.id || !this.hasPermission("host.events")) return;
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "event", method: event.method, params: event.params });
}
forwardBinary(event: PluginBinaryEvent): void {
if (event.pluginId !== this.plugin.manifest.id || !this.hasPermission("host.binary")) return;
const target = this.targetWindow();
if (!target) return;
const buffer = base64ToBytes(event.dataBase64);
target.postMessage({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "binary", channel: event.channel, data: buffer }, "*", [buffer]);
}
private async handleRequest(request: PluginRequestMessage, target: Window): Promise<void> {
try {
enforcePayloadLimit(request.params);
const result = await this.dispatch(request.method, request.params, request.data);
this.respond(target, request.id, { result: result ?? null });
} catch (error) {
this.respond(target, request.id, { error: error instanceof Error ? error.message : String(error) });
}
}
private async dispatch(method: string, params: unknown, binary?: ArrayBuffer): Promise<unknown> {
if (method === "host.getContext") return snapshotPluginWorkbenchContext(this.context);
if (method === "backend.invoke") {
const input = requireRecord(params, "backend.invoke params");
const backendMethod = requireProtocolName(input.method, "backend method");
const timeoutMs = input.timeoutMs === undefined ? undefined : requireTimeout(input.timeoutMs);
return this.api.invoke(this.plugin.manifest.id, backendMethod, input.params ?? null, timeoutMs);
}
if (method === "backend.notify") {
const input = requireRecord(params, "backend.notify params");
await this.api.notify(this.plugin.manifest.id, requireProtocolName(input.method, "backend method"), input.params ?? null);
return null;
}
if (method === "backend.sendBinary") {
this.requirePermission("host.binary");
const input = requireRecord(params, "backend.sendBinary params");
const channel = requireProtocolName(input.channel, "binary channel");
if (binary instanceof ArrayBuffer) {
if (binary.byteLength > MAX_BRIDGE_BINARY_BYTES) throw new Error("Plugin binary payload exceeds 8 MiB; chunk the transfer");
await this.api.sendBinary(this.plugin.manifest.id, channel, bytesToBase64(new Uint8Array(binary)));
return null;
}
await this.api.sendBinary(this.plugin.manifest.id, channel, requireBase64(input.dataBase64));
return null;
}
if (method === "ui.readAsset") {
const input = requireRecord(params, "ui.readAsset params");
return this.api.readAsset(this.plugin.manifest.id, requireSafeAssetPath(input.path));
}
if (method === "host.openWorkbench") {
this.requirePermission("host.workbench");
if (!this.api.openWorkbench) throw new Error("Host workbench navigation is unavailable");
const input = requireRecord(params, "host.openWorkbench params");
await this.api.openWorkbench(this.plugin.manifest.id, requireProtocolName(input.contributionId, "workbench contribution"), isRecord(input.context) ? input.context : undefined);
return null;
}
if (method === "host.openFilesystem") {
this.requirePermission("host.filesystem");
if (!this.api.openFilesystem) throw new Error("Host filesystem navigation is unavailable");
const input = requireRecord(params, "host.openFilesystem params");
await this.api.openFilesystem(this.plugin.manifest.id, requireProtocolName(input.providerId, "filesystem provider"), isRecord(input.context) ? input.context : undefined);
return null;
}
throw new Error(`Unsupported plugin host method '${method}'`);
}
private requirePermission(permission: string): void {
if (!this.hasPermission(permission)) throw new Error(`Plugin has not declared permission '${permission}'`);
}
private hasPermission(permission: string): boolean {
return (this.plugin.manifest.permissions || []).includes(permission);
}
private respond(target: Window, id: string, payload: { result?: unknown; error?: string }): void {
target.postMessage({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "response", id, ...payload }, "*");
}
private post(message: Record<string, unknown>): void {
this.targetWindow()?.postMessage(message, "*");
}
}
/**
* Parse `host.network:<origin>` permission entries into CSP connect-src
* origins. Must stay aligned with `parse_host_network_permission` in
* crates/dbx-core/src/plugins/manifest.rs.
*/
export function pluginNetworkOrigins(permissions: readonly string[] | undefined): string[] {
const origins = new Set<string>();
for (const permission of permissions || []) {
if (!permission.startsWith("host.network:")) continue;
const origin = permission.slice("host.network:".length);
if (!/^https:\/\/[A-Za-z0-9._-]+(?::[0-9]+)?$/.test(origin)) continue;
origins.add(origin);
if (origins.size >= 8) break;
}
return [...origins];
}
export function pluginSandboxDocument(html: string, permissions?: readonly string[], theme?: PluginBridgeTheme): string {
const networkOrigins = pluginNetworkOrigins(permissions);
const connectSrc = networkOrigins.length > 0 ? `connect-src ${networkOrigins.join(" ")};` : "connect-src 'none';";
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline' blob:; img-src data: blob:; font-src data: blob:; ${connectSrc} media-src data: blob:;">`;
const sdk = `<script>${pluginSdkSource(theme)}</script>`;
const uiKit = `<style>${pluginUiKitCss()}</style>`;
// Placed after the uiKit so the boot `color-scheme` wins the cascade: the
// bridge init message (and the SDK's applyTheme) only runs once the iframe
// has loaded, and the uiKit's token fallbacks would otherwise paint the
// first frame white on dark hosts.
const bootTheme = pluginBootThemeCss(theme);
const injection = `${csp}${uiKit}${bootTheme ? `<style>${bootTheme}</style>` : ""}${sdk}`;
if (/<head(?:\s[^>]*)?>/i.test(html)) return html.replace(/<head(?:\s[^>]*)?>/i, (head) => `${head}${injection}`);
return `<!doctype html><html><head>${injection}</head><body>${html}</body></html>`;
}
/**
* Pre-paint theme seed for the sandbox document. The bridge init message only
* arrives after the iframe load event, so without this style the first frame
* renders with the uiKit fallbacks (white background) before the real tokens
* land — the white flash when opening a plugin workbench on a dark host.
*/
export function pluginBootThemeCss(theme?: PluginBridgeTheme): string {
if (!theme || (theme.appearance !== "dark" && theme.appearance !== "light")) return "";
const declarations: string[] = [`color-scheme: ${theme.appearance}`];
const tokens = theme.tokens && typeof theme.tokens === "object" ? theme.tokens : {};
for (const [name, value] of Object.entries(tokens)) {
// Same name validation as the SDK's applyTheme; values must stay inside a
// single CSS declaration so they cannot break out of the style element.
if (!/^--[a-z0-9-]+$/i.test(name) || typeof value !== "string" || !value.trim()) continue;
if (!/^[^"{}<>;]*$/.test(value)) continue;
declarations.push(`${name}: ${value}`);
}
return `:root{${declarations.join(";")}}`;
}
/**
* Minimal official component kit for plugin workbenches. Every class is built
* on the DBX design tokens the host pushes through the bridge, so plugin UI
* follows light/dark and palette changes without any plugin-side logic.
*/
export function pluginUiKitCss(): string {
return `
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif);
font-size: 13px;
line-height: 1.5;
color: var(--color-foreground, #18181b);
background: var(--color-background, #ffffff);
}
.dbx-card {
border: 1px solid var(--color-border, #e4e4e7);
border-radius: var(--radius-lg, 10px);
background: var(--color-card, #ffffff);
padding: 14px 16px;
}
.dbx-section-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-muted-foreground, #71717a);
margin: 0 0 10px;
}
.dbx-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 30px;
padding: 0 12px;
border-radius: var(--radius-md, 8px);
border: 1px solid var(--color-border, #d4d4d8);
background: var(--color-background, #ffffff);
color: var(--color-foreground, #18181b);
font-size: 13px;
cursor: pointer;
}
.dbx-btn:hover { background: var(--color-muted, #f4f4f5); }
.dbx-btn--primary { background: var(--color-primary, #2563eb); border-color: var(--color-primary, #2563eb); color: var(--color-primary-foreground, #ffffff); }
.dbx-btn--primary:hover { background: var(--color-primary, #2563eb); opacity: 0.9; }
.dbx-btn--danger { background: var(--color-destructive, #dc2626); border-color: var(--color-destructive, #dc2626); color: var(--color-destructive-foreground, #ffffff); }
.dbx-btn--ghost { border-color: transparent; background: transparent; }
.dbx-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.dbx-label { display: inline-flex; font-size: 12px; font-weight: 500; color: var(--color-foreground, #18181b); }
.dbx-input, .dbx-select, .dbx-textarea {
width: 100%;
height: 30px;
padding: 0 10px;
border-radius: var(--radius-md, 8px);
border: 1px solid var(--color-input, #d4d4d8);
background: var(--color-background, #ffffff);
color: var(--color-foreground, #18181b);
font-size: 13px;
font-family: inherit;
}
.dbx-textarea { height: auto; min-height: 64px; padding: 6px 10px; resize: vertical; }
.dbx-input:focus, .dbx-select:focus, .dbx-textarea:focus { outline: 2px solid var(--color-ring, #93c5fd); outline-offset: 1px; border-color: var(--color-ring, #93c5fd); }
.dbx-hint { font-size: 12px; color: var(--color-muted-foreground, #71717a); }
.dbx-row { display: grid; grid-template-columns: minmax(96px, auto) minmax(0, 1fr); gap: 8px 12px; align-items: center; margin-bottom: 10px; }
.dbx-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.dbx-table th { text-align: left; font-weight: 600; color: var(--color-muted-foreground, #71717a); border-bottom: 1px solid var(--color-border, #e4e4e7); padding: 6px 8px; }
.dbx-table td { border-bottom: 1px solid var(--color-border, #e4e4e7); padding: 6px 8px; }
.dbx-badge { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: 999px; background: var(--color-primary-alpha, rgba(37, 99, 235, 0.12)); color: var(--color-primary, #2563eb); font-size: 11px; font-weight: 500; }
.dbx-link { color: var(--color-primary, #2563eb); text-decoration: none; cursor: pointer; }
.dbx-link:hover { text-decoration: underline; }
`.trim();
}
export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
const safeTokens = Object.fromEntries(Object.entries(initialTheme?.tokens || {}).filter(([name, value]) => /^--[a-z0-9-]+$/i.test(name) && typeof value === "string" && !!value.trim() && /^[^"{}<>;]*$/.test(value)));
const safeInitialTheme = initialTheme && (initialTheme.appearance === "dark" || initialTheme.appearance === "light") ? { appearance: initialTheme.appearance, tokens: safeTokens } : null;
const serializedInitialTheme = JSON.stringify(safeInitialTheme)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
return `(() => {
const pending = new Map();
const listeners = { event: new Set(), binary: new Set(), init: new Set(), context: new Set() };
let sequence = 0;
let context;
let locale = 'en';
let theme;
let resolveReady;
const initialTheme = ${serializedInitialTheme};
const applyTheme = (value) => {
if (!value || typeof value !== 'object') return;
theme = value;
const root = document.documentElement;
root.dataset.dbxTheme = theme.appearance === "dark" ? "dark" : "light";
root.style.colorScheme = theme.appearance === "dark" ? "dark" : "light";
const tokens = value.tokens && typeof value.tokens === 'object' ? value.tokens : {};
for (const [name, tokenValue] of Object.entries(tokens)) {
if (/^--[a-z0-9-]+$/i.test(name) && typeof tokenValue === 'string') root.style.setProperty(name, tokenValue);
}
};
const ready = new Promise((resolve) => { resolveReady = resolve; });
if (initialTheme) applyTheme(initialTheme);
// Plugin UIs routinely hand reactive state (Vue Proxy arrays/objects)
// straight to invoke(); postMessage cannot structured-clone a Proxy and
// WebKit rejects with "The object can not be cloned.". Mirror the host's
// structuredCloneSafe: clone when possible, otherwise recover the plain
// data with a JSON round-trip (the sidecar transport is JSON anyway).
const toPlain = (value) => {
if (!value || typeof value !== 'object') return value;
if (typeof structuredClone === 'function') {
try { return structuredClone(value); } catch {}
}
return JSON.parse(JSON.stringify(value));
};
const request = (method, params, options = {}) => new Promise((resolve, reject) => {
const id = String(++sequence);
pending.set(id, { resolve, reject });
const message = { source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'request', id, method, params: toPlain(params) };
if (options.transfer) {
message.data = options.transfer;
parent.postMessage(message, '*', [options.transfer]);
} else {
parent.postMessage(message, '*');
}
});
const decode = (value) => Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
const encode = (value) => {
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
};
const stream = async (method, params = {}, options = {}) => {
const streamId = options.streamId || (globalThis.crypto?.randomUUID?.() || 'stream-' + Date.now() + '-' + (++sequence));
const closeMethod = options.closeMethod || 'filesystem/stream/close';
let removeListener;
let closeRequested = false;
let resolveOpen;
let rejectOpen;
const metadata = {};
const opened = new Promise((resolve, reject) => { resolveOpen = resolve; rejectOpen = reject; });
const readable = new ReadableStream({
start(controller) {
const onEvent = (message) => {
if (message?.method !== 'host.stream.chunk' && message?.method !== 'host.stream.end' && message?.method !== 'host.stream.error') return;
const event = message.params || {};
if (event.streamId !== streamId) return;
if (message.method === 'host.stream.chunk') {
try { controller.enqueue(decode(event.dataBase64 || '')); } catch (error) { controller.error(error); }
return;
}
removeListener?.();
removeListener = undefined;
if (message.method === 'host.stream.error') {
const error = new Error(event.message || 'Plugin stream failed');
rejectOpen(error);
controller.error(error);
} else {
Object.assign(metadata, event);
controller.close();
}
};
removeListener = () => listeners.event.delete(onEvent);
listeners.event.add(onEvent);
request('backend.invoke', { method, params: { ...(params || {}), streamId }, timeoutMs: options.timeoutMs }).then(resolveOpen, (error) => {
removeListener?.();
removeListener = undefined;
rejectOpen(error);
controller.error(error);
});
},
cancel() {
removeListener?.();
removeListener = undefined;
if (closeRequested) return undefined;
closeRequested = true;
return request('backend.invoke', { method: closeMethod, params: { streamId } }).catch(() => undefined);
},
});
Object.assign(metadata, await opened);
return { stream: readable, metadata };
};
window.dbxPlugin = Object.freeze({
ready,
get context() { return context; },
get locale() { return locale; },
get theme() { return theme; },
request,
invoke: (method, params, options = {}) => request('backend.invoke', { method, params, timeoutMs: options.timeoutMs }),
stream,
notify: (method, params) => request('backend.notify', { method, params }),
sendBinary: (channel, data) => {
if (typeof data === 'string') return request('backend.sendBinary', { channel, dataBase64: data });
const bytes = data instanceof ArrayBuffer ? data : (data instanceof Uint8Array ? data.buffer : new Uint8Array(data).buffer);
return request('backend.sendBinary', { channel }, { transfer: bytes });
},
readAsset: (path) => request('ui.readAsset', { path }),
readAssetUrl: async (path) => {
const asset = await request('ui.readAsset', { path });
return URL.createObjectURL(new Blob([decode(asset.dataBase64)], { type: asset.contentType }));
},
openWorkbench: (contributionId, childContext) => request('host.openWorkbench', { contributionId, context: childContext }),
openFilesystem: (providerId, childContext) => request('host.openFilesystem', { providerId, context: childContext }),
onEvent: (listener) => { listeners.event.add(listener); return () => listeners.event.delete(listener); },
onBinary: (listener) => { listeners.binary.add(listener); return () => listeners.binary.delete(listener); },
onContext: (listener) => { listeners.context.add(listener); return () => listeners.context.delete(listener); },
onInit: (listener) => { listeners.init.add(listener); if (context !== undefined) listener(context); return () => listeners.init.delete(listener); },
decodeBase64: decode,
encodeBase64: encode,
});
addEventListener('message', (event) => {
if (event.source !== parent || !event.data || event.data.source !== '${HOST_MESSAGE_SOURCE}' || event.data.version !== ${BRIDGE_VERSION}) return;
const message = event.data;
if (message.type === 'response') {
const handler = pending.get(message.id);
if (!handler) return;
pending.delete(message.id);
if (message.error) handler.reject(new Error(message.error)); else handler.resolve(message.result);
} else if (message.type === 'init') {
context = message.context;
locale = typeof message.locale === 'string' ? message.locale : 'en';
applyTheme(message.theme);
resolveReady(context);
listeners.init.forEach((listener) => listener(context));
// Plugin listeners register on the document (onHostThemeChange);
// bare dispatchEvent targets window, which document listeners never
// receive — env theme pushes were silently lost.
document.dispatchEvent(new CustomEvent('dbx-plugin-init', { detail: message }));
} else if (message.type === 'context') {
context = message.context;
listeners.context.forEach((listener) => listener(context));
document.dispatchEvent(new CustomEvent('dbx-plugin-context', { detail: context }));
} else if (message.type === 'env') {
if (typeof message.locale === 'string') locale = message.locale;
if (message.theme) applyTheme(message.theme);
listeners.event.forEach((listener) => listener(message));
document.dispatchEvent(new CustomEvent('dbx-plugin-env', { detail: message }));
} else if (message.type === 'event') {
listeners.event.forEach((listener) => listener(message));
document.dispatchEvent(new CustomEvent('dbx-plugin-event', { detail: message }));
} else if (message.type === 'binary') {
const payload = { channel: message.channel, data: message.data ? new Uint8Array(message.data) : new Uint8Array(0) };
listeners.binary.forEach((listener) => listener(payload));
document.dispatchEvent(new CustomEvent('dbx-plugin-binary', { detail: payload }));
}
});
addEventListener('keydown', (event) => {
const key = typeof event.key === 'string' ? event.key.toLowerCase() : '';
if (key !== 'w' || (!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
parent.postMessage({ source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'shortcut', shortcut: 'closeTab' }, '*');
}, true);
parent.postMessage({ source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'ready' }, '*');
})();`;
}
function validRequestMessage(value: Record<string, unknown>): value is Record<string, unknown> & PluginRequestMessage {
return typeof value.id === "string" && value.id.length > 0 && value.id.length <= 128 && typeof value.method === "string" && value.method.length > 0 && value.method.length <= 128;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${label} must be an object`);
return value;
}
function requireProtocolName(value: unknown, label: string): string {
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value)) throw new Error(`${label} is invalid`);
return value;
}
function requireTimeout(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("timeoutMs must be a number");
return Math.min(120_000, Math.max(1, Math.round(value)));
}
function base64ToBytes(value: string): ArrayBuffer {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes.buffer;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
function requireBase64(value: unknown): string {
if (typeof value !== "string" || value.length > MAX_BRIDGE_PAYLOAD_BYTES * 2 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error("Binary payload must be base64");
return value;
}
function requireSafeAssetPath(value: unknown): string {
if (typeof value !== "string" || !value || value.startsWith("/") || value.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("Plugin asset path is invalid");
return value;
}
function enforcePayloadLimit(value: unknown): void {
if (value === undefined) return;
const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
if (bytes > MAX_BRIDGE_PAYLOAD_BYTES) throw new Error("Plugin bridge request is too large");
}
function isRecord(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}