rspace-online/lib/ecosystem-bridge.ts

344 lines
9.9 KiB
TypeScript

/**
* EcosystemBridge — Loads external r-ecosystem app manifests and dynamically
* imports their Web Component modules for canvas embedding.
*
* Two embedding modes:
* 1. Trusted: dynamic import() → registers Web Component → shares CRDT + ports + events
* 2. Sandboxed: creates iframe with sandbox attr → postMessage bridge → origin-validated
*
* Usage:
* const bridge = EcosystemBridge.getInstance();
* const manifest = await bridge.loadManifest("rwallet");
* await bridge.loadModule(manifest); // registers shapes in shapeRegistry
* // or
* const iframe = bridge.createSandboxedEmbed(manifest, shapeDesc, container);
*/
import type {
EcosystemManifest,
ResolvedManifest,
EcosystemShapeDescriptor,
} from "../shared/ecosystem-manifest";
import { ECOSYSTEM_PROTOCOL_VERSION } from "../shared/ecosystem-manifest";
import { shapeRegistry } from "./shape-registry";
// ── Types ──
interface CachedManifest {
manifest: ResolvedManifest;
fetchedAt: number;
}
interface SandboxedEmbed {
iframe: HTMLIFrameElement;
appId: string;
origin: string;
destroy: () => void;
}
// ── Singleton ──
let instance: EcosystemBridge | null = null;
export class EcosystemBridge {
/** Cached manifests keyed by appId */
#manifests = new Map<string, CachedManifest>();
/** Loaded module URLs (to avoid double-import) */
#loadedModules = new Set<string>();
/** Active sandboxed embeds */
#sandboxedEmbeds = new Map<string, SandboxedEmbed>();
/** postMessage handler bound reference for cleanup */
#messageHandler: ((e: MessageEvent) => void) | null = null;
/** Cache TTL for manifests (1 hour) */
static MANIFEST_TTL = 3600_000;
static getInstance(): EcosystemBridge {
if (!instance) {
instance = new EcosystemBridge();
}
return instance;
}
constructor() {
// Listen for postMessage from sandboxed iframes
this.#messageHandler = this.#handleSandboxMessage.bind(this);
if (typeof window !== "undefined") {
window.addEventListener("message", this.#messageHandler);
}
}
// ── Manifest loading ──
/**
* Load an ecosystem app's manifest via the server proxy (avoids CORS).
* Results are cached for MANIFEST_TTL.
*/
async loadManifest(appId: string): Promise<ResolvedManifest> {
// Check cache
const cached = this.#manifests.get(appId);
if (cached && Date.now() - cached.fetchedAt < EcosystemBridge.MANIFEST_TTL) {
return cached.manifest;
}
const res = await fetch(`/api/ecosystem/${encodeURIComponent(appId)}/manifest`);
if (!res.ok) {
throw new Error(`Failed to load manifest for ${appId}: ${res.status}`);
}
const data: ResolvedManifest = await res.json();
// Validate protocol version
if (data.minProtocolVersion && data.minProtocolVersion > ECOSYSTEM_PROTOCOL_VERSION) {
throw new Error(
`App ${appId} requires protocol v${data.minProtocolVersion}, ` +
`but this rSpace supports v${ECOSYSTEM_PROTOCOL_VERSION}`
);
}
this.#manifests.set(appId, { manifest: data, fetchedAt: Date.now() });
return data;
}
/** Get a cached manifest without fetching. */
getCachedManifest(appId: string): ResolvedManifest | null {
return this.#manifests.get(appId)?.manifest ?? null;
}
/** Get all cached manifests. */
getAllCachedManifests(): ResolvedManifest[] {
return Array.from(this.#manifests.values()).map((c) => c.manifest);
}
// ── Module loading (Trusted mode) ──
/**
* Dynamically import an ecosystem module and register its shapes.
* Uses the server module proxy to avoid CORS.
*
* The imported module is expected to:
* - Define and register custom elements (Web Components)
* - Export shape classes that extend FolkShape or HTMLElement
*/
async loadModule(manifest: ResolvedManifest): Promise<void> {
const moduleUrl = `/api/ecosystem/${encodeURIComponent(manifest.appId)}/module`;
if (this.#loadedModules.has(manifest.appId)) {
return; // Already loaded
}
try {
// Dynamic import via blob URL to execute the proxied JS as a module
const res = await fetch(moduleUrl);
if (!res.ok) throw new Error(`Module fetch failed: ${res.status}`);
const js = await res.text();
const blob = new Blob([js], { type: "application/javascript" });
const blobUrl = URL.createObjectURL(blob);
try {
await import(/* @vite-ignore */ blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
this.#loadedModules.add(manifest.appId);
// Register shapes in the shape registry (if not already registered by the module itself)
this.registerShapes(manifest);
// Notify service worker to cache the module
this.#notifySwCache(manifest);
} catch (err) {
console.error(`[EcosystemBridge] Failed to load module for ${manifest.appId}:`, err);
throw err;
}
}
/**
* Register manifest shapes in the shape registry.
* Called automatically by loadModule(), but can also be called manually
* if the module was loaded via another mechanism (e.g. <script> tag).
*/
registerShapes(manifest: ResolvedManifest): void {
for (const shape of manifest.shapes) {
if (shapeRegistry.has(shape.tagName)) continue; // Already registered
// The module should have called customElements.define() already
const elementClass = customElements.get(shape.tagName);
if (elementClass) {
shapeRegistry.register(shape.tagName, elementClass as any);
}
}
}
/** Check if a module has been loaded. */
isModuleLoaded(appId: string): boolean {
return this.#loadedModules.has(appId);
}
// ── Sandboxed mode (iframe) ──
/**
* Create a sandboxed iframe embed for untrusted ecosystem apps.
* The iframe gets a restricted sandbox and communicates via validated postMessage.
*/
createSandboxedEmbed(
manifest: ResolvedManifest,
shapeDesc: EcosystemShapeDescriptor,
container: HTMLElement,
config?: { shapeId?: string; space?: string }
): SandboxedEmbed {
const iframe = document.createElement("iframe");
iframe.className = "ecosystem-sandbox";
// Sandboxed: allow scripts + same-origin forms, but no top-nav, popups, etc.
iframe.sandbox.add("allow-scripts");
iframe.sandbox.add("allow-forms");
// The iframe loads the app's dedicated embed page (or homepage with embed param)
const embedUrl = new URL(manifest.homepage);
embedUrl.searchParams.set("embed", "true");
embedUrl.searchParams.set("shape", shapeDesc.tagName);
if (config?.shapeId) embedUrl.searchParams.set("shapeId", config.shapeId);
if (config?.space) embedUrl.searchParams.set("space", config.space);
iframe.src = embedUrl.toString();
iframe.style.cssText = "width: 100%; height: 100%; border: none;";
iframe.loading = "lazy";
// Send context once loaded
iframe.addEventListener("load", () => {
this.#sendSandboxContext(iframe, manifest, shapeDesc, config);
});
container.appendChild(iframe);
const embed: SandboxedEmbed = {
iframe,
appId: manifest.appId,
origin: manifest.origin,
destroy: () => {
iframe.remove();
this.#sandboxedEmbeds.delete(embedKey);
},
};
const embedKey = `${manifest.appId}:${config?.shapeId || "default"}`;
this.#sandboxedEmbeds.set(embedKey, embed);
return embed;
}
/** Send context to a sandboxed iframe with origin validation. */
#sendSandboxContext(
iframe: HTMLIFrameElement,
manifest: ResolvedManifest,
shapeDesc: EcosystemShapeDescriptor,
config?: { shapeId?: string; space?: string }
) {
if (!iframe.contentWindow) return;
try {
iframe.contentWindow.postMessage(
{
source: "rspace-parent",
type: "context",
protocol: ECOSYSTEM_PROTOCOL_VERSION,
appId: manifest.appId,
shapeId: config?.shapeId || null,
space: config?.space || null,
shapeName: shapeDesc.tagName,
portDescriptors: shapeDesc.portDescriptors,
eventDescriptors: shapeDesc.eventDescriptors,
},
manifest.origin // Origin-validated — NOT "*"
);
} catch {
// Cross-origin or iframe not ready
}
}
/** Handle postMessage from sandboxed iframes with origin validation. */
#handleSandboxMessage(e: MessageEvent) {
const msg = e.data;
if (!msg || typeof msg !== "object") return;
if (msg.source !== "rspace-ecosystem-embed") return;
// Validate origin against known ecosystem apps
const embed = Array.from(this.#sandboxedEmbeds.values()).find(
(em) => em.origin === e.origin
);
if (!embed) {
console.warn(`[EcosystemBridge] Rejected message from unknown origin: ${e.origin}`);
return;
}
switch (msg.type) {
case "port-update":
// Sandboxed app wants to update a port value
// Dispatch as a CustomEvent on the parent document
window.dispatchEvent(
new CustomEvent("ecosystem-port-update", {
detail: {
appId: embed.appId,
shapeId: msg.shapeId,
portName: msg.portName,
value: msg.value,
},
})
);
break;
case "event-emit":
// Sandboxed app wants to emit an event bus channel
window.dispatchEvent(
new CustomEvent("ecosystem-event-emit", {
detail: {
appId: embed.appId,
shapeId: msg.shapeId,
channel: msg.channel,
payload: msg.payload,
},
})
);
break;
case "ready":
// Sandboxed embed signaling it's initialized
console.log(`[EcosystemBridge] ${embed.appId} embed ready`);
break;
}
}
// ── Service Worker cache notification ──
#notifySwCache(manifest: ResolvedManifest) {
if (!("serviceWorker" in navigator) || !navigator.serviceWorker.controller) return;
navigator.serviceWorker.controller.postMessage({
type: "cache-ecosystem-module",
appId: manifest.appId,
moduleUrl: manifest.resolvedModuleUrl,
origin: manifest.origin,
});
}
// ── Cleanup ──
destroy() {
if (this.#messageHandler && typeof window !== "undefined") {
window.removeEventListener("message", this.#messageHandler);
}
for (const embed of this.#sandboxedEmbeds.values()) {
embed.iframe.remove();
}
this.#sandboxedEmbeds.clear();
this.#manifests.clear();
this.#loadedModules.clear();
instance = null;
}
}