import type {FileRenderPlugin} from '../plugin.ts'; import {globCompile} from '../../utils/glob.ts'; type RemotePluginMeta = { schemaVersion: number; id: string; name: string; version: string; description: string; entryUrl: string; assetsBaseUrl: string; filePatterns: string[]; permissions?: string[]; }; type RemotePluginModule = { render: (container: HTMLElement, fileUrl: string, options?: any) => void | Promise; }; const moduleCache = new Map>(); const SUPPORTED_SCHEMA_VERSION = 1; async function fetchRemoteMetadata(): Promise { const base = window.config.appSubUrl || ''; const response = await window.fetch(`${base}/assets/render-plugins/index.json`, {headers: {'Accept': 'application/json'}}); if (!response.ok) { throw new Error(`Failed to load render plugin metadata (${response.status})`); } return response.json() as Promise; } async function loadRemoteModule(meta: RemotePluginMeta): Promise { let cached = moduleCache.get(meta.id); if (!cached) { cached = (async () => { try { const mod = await import(/* webpackIgnore: true */ meta.entryUrl); const exported = (mod?.default ?? mod) as RemotePluginModule | undefined; if (!exported || typeof exported.render !== 'function') { throw new Error(`Plugin ${meta.id} does not export a render() function`); } return exported; } catch (err) { moduleCache.delete(meta.id); throw err; } })(); moduleCache.set(meta.id, cached); } return cached; } function createMatcher(patterns: string[]) { const compiled = patterns.map((pattern) => { const normalized = pattern.toLowerCase(); try { return globCompile(normalized); } catch (err) { console.error('Failed to compile render plugin glob pattern', pattern, err); return null; } }).filter(Boolean) as ReturnType[]; return (filename: string) => { const lower = filename.toLowerCase(); return compiled.some((glob) => glob.regexp.test(lower)); }; } function wrapRemotePlugin(meta: RemotePluginMeta): FileRenderPlugin { const matcher = createMatcher(meta.filePatterns); return { name: meta.name, canHandle(filename: string, _mimeType: string, _headChunk?: Uint8Array | null) { return matcher(filename); }, async render(container, fileUrl, options) { const allowedHosts = collectAllowedHosts(meta, fileUrl); await withNetworkRestrictions(allowedHosts, async () => { const remote = await loadRemoteModule(meta); await remote.render(container, fileUrl, options); }); }, }; } type RestoreFn = () => void; function collectAllowedHosts(meta: RemotePluginMeta, fileUrl: string): Set { const hosts = new Set(); const addHost = (value?: string | null) => { if (!value) return; hosts.add(value.toLowerCase()); }; addHost(parseHost(fileUrl)); for (const perm of meta.permissions ?? []) { addHost(normalizeHost(perm)); } return hosts; } function normalizeHost(host: string | null | undefined): string | null { if (!host) return null; return host.trim().toLowerCase(); } function parseHost(value: string | URL | null | undefined): string | null { if (!value) return null; try { const url = value instanceof URL ? value : new URL(value, window.location.href); return normalizeHost(url.host); } catch { return null; } } function ensureAllowedHost(kind: string, url: URL, allowedHosts: Set): void { const host = normalizeHost(url.host); if (!host || allowedHosts.has(host)) { return; } throw new Error(`Render plugin network request for ${kind} blocked: ${host} is not in the declared permissions`); } function resolveRequestURL(input: RequestInfo | URL): URL { if (typeof Request !== 'undefined' && input instanceof Request) { return new URL(input.url, window.location.href); } if (input instanceof URL) { return new URL(input.toString(), window.location.href); } return new URL(input as string, window.location.href); } async function withNetworkRestrictions(allowedHosts: Set, fn: () => Promise): Promise { const restoreFns: RestoreFn[] = []; const register = (restorer: RestoreFn | null | undefined) => { if (restorer) { restoreFns.push(restorer); } }; register(patchFetch(allowedHosts)); register(patchXHR(allowedHosts)); register(patchSendBeacon(allowedHosts)); register(patchWebSocket(allowedHosts)); register(patchEventSource(allowedHosts)); try { await fn(); } finally { while (restoreFns.length > 0) { const restore = restoreFns.pop(); restore?.(); } } } function patchFetch(allowedHosts: Set): RestoreFn { const originalFetch = window.fetch; const guarded = (input: RequestInfo | URL, init?: RequestInit) => { const target = resolveRequestURL(input); ensureAllowedHost('fetch', target, allowedHosts); return originalFetch.call(window, input as any, init); }; window.fetch = guarded as typeof window.fetch; return () => { window.fetch = originalFetch; }; } function patchXHR(allowedHosts: Set): RestoreFn { const originalOpen = XMLHttpRequest.prototype.open; function guardedOpen(this: XMLHttpRequest, method: string, url: string | URL, async?: boolean, user?: string | null, password?: string | null) { const target = url instanceof URL ? url : new URL(url, window.location.href); ensureAllowedHost('XMLHttpRequest', target, allowedHosts); return originalOpen.call(this, method, url as any, async ?? true, user ?? undefined, password ?? undefined); } XMLHttpRequest.prototype.open = guardedOpen; return () => { XMLHttpRequest.prototype.open = originalOpen; }; } function patchSendBeacon(allowedHosts: Set): RestoreFn | null { if (typeof navigator.sendBeacon !== 'function') { return null; } const original = navigator.sendBeacon; const bound = original.bind(navigator); navigator.sendBeacon = ((url: string | URL, data?: BodyInit | null) => { const target = url instanceof URL ? url : new URL(url, window.location.href); ensureAllowedHost('sendBeacon', target, allowedHosts); return bound(url as any, data); }) as typeof navigator.sendBeacon; return () => { navigator.sendBeacon = original; }; } function patchWebSocket(allowedHosts: Set): RestoreFn { const OriginalWebSocket = window.WebSocket; const GuardedWebSocket = function(url: string | URL, protocols?: string | string[]) { const target = url instanceof URL ? url : new URL(url, window.location.href); ensureAllowedHost('WebSocket', target, allowedHosts); return new OriginalWebSocket(url as any, protocols); } as unknown as typeof WebSocket; GuardedWebSocket.prototype = OriginalWebSocket.prototype; Object.setPrototypeOf(GuardedWebSocket, OriginalWebSocket); window.WebSocket = GuardedWebSocket; return () => { window.WebSocket = OriginalWebSocket; }; } function patchEventSource(allowedHosts: Set): RestoreFn | null { if (typeof window.EventSource !== 'function') { return null; } const OriginalEventSource = window.EventSource; const GuardedEventSource = function(url: string | URL, eventSourceInitDict?: EventSourceInit) { const target = url instanceof URL ? url : new URL(url, window.location.href); ensureAllowedHost('EventSource', target, allowedHosts); return new OriginalEventSource(url as any, eventSourceInitDict); } as unknown as typeof EventSource; GuardedEventSource.prototype = OriginalEventSource.prototype; Object.setPrototypeOf(GuardedEventSource, OriginalEventSource); window.EventSource = GuardedEventSource; return () => { window.EventSource = OriginalEventSource; }; } export async function loadDynamicRenderPlugins(): Promise { try { const metadata = await fetchRemoteMetadata(); return metadata.filter((meta) => { if (meta.schemaVersion !== SUPPORTED_SCHEMA_VERSION) { console.warn(`Render plugin ${meta.id} ignored due to incompatible schemaVersion ${meta.schemaVersion}`); return false; } return true; }).map((meta) => wrapRemotePlugin(meta)); } catch (err) { console.error('Failed to load dynamic render plugins', err); return []; } }