344 lines
9.9 KiB
TypeScript
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;
|
|
}
|
|
}
|