Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483import { EventTarget } from "/js/eventEmitter.js";import { SimpleUUID, isDev } from "/js/utils.js";import { SignalMap, ComputedMap, untrack } from "/js/signals.js";
const SANDBOX_URL = "/plugin-sandbox.html";
export class Logger { static LEVELS = { info: 10, warn: 20, error: 30, silent: 40 };
constructor(prefix, logLevel = "warn") { this.prefix = prefix; this.logLevel = logLevel; } _enabled(level) { return Logger.LEVELS[level] >= Logger.LEVELS[this.logLevel]; } info(...args) { if (this._enabled("info")) console.info(this.prefix, ...args); } warn(...args) { if (this._enabled("warn")) console.warn(this.prefix, ...args); } error(...args) { if (this._enabled("error")) console.error(this.prefix, ...args); }}
const logger = new Logger("[plugins]", isDev() ? "info" : "warn");
// Has same API as Worker, but runs code in a sandboxed iframeexport class SandboxedWorker extends EventTarget { constructor(wrappedSource) { super(); this.frame = this._createSandboxFrame(); this._messageTarget = this.frame.contentWindow; this._handleWindowMessage = this._handleWindowMessage.bind(this); window.addEventListener("message", this._handleWindowMessage); this.frame.addEventListener("load", () => { this.frame.contentWindow.postMessage( { type: "init", workerSource: wrappedSource }, "*", ); }); document.body.appendChild(this.frame); }
_createSandboxFrame() { const frame = document.createElement("iframe"); frame.setAttribute("sandbox", "allow-scripts"); frame.setAttribute("aria-hidden", "true"); frame.style.display = "none"; frame.src = SANDBOX_URL; return frame; }
postMessage(payload) { this.frame.contentWindow.postMessage({ type: "send", payload }, "*"); }
terminate() { window.removeEventListener("message", this._handleWindowMessage); this.frame.remove(); this.dispatchEvent({ type: "terminate" }); }
_handleWindowMessage(event) { if (event.source !== this.frame.contentWindow) return; const message = event.data; if (!message || typeof message !== "object") return; switch (message.type) { case "fromWorker": this.dispatchEvent({ type: "message", data: message.payload }); return; case "workerError": this.dispatchEvent({ type: "error", message: message.error }); return; } }}
export class PluginSdkError extends Error {}
let __sdkSourcePromise = null;
function getSdkSource() { if (__sdkSourcePromise == null) { __sdkSourcePromise = fetch(`/plugin-sdk/${window.env.pluginSdkFileName}`) .then((response) => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }) .catch((error) => { __sdkSourcePromise = null; throw new PluginSdkError( `Could not fetch plugin SDK: ${error.message}`, ); }); } return __sdkSourcePromise;}
export async function wrapWorkerSource(source) { const sdkSource = await getSdkSource(); return /* js */ ` delete self.BroadcastChannel; delete self.SharedWorker;
${sdkSource}
self.module = {};
self.require = (name) => { if (name === "@impro.social/impro-plugin") return ImproPlugin; throw new Error("Cannot find module \\"" + name + "\\""); };
${source}
const pluginClass = self.module.exports?.default; if (pluginClass) { pluginClass.register(); } `;}
function createSandboxedWorker(wrappedSource) { const worker = new SandboxedWorker(wrappedSource); // in the future, we could add a handshake here to ensure worker has loaded return worker;}
// Direct (unsandboxed) Worker for e2e testsfunction createDirectWorker(wrappedSource) { const blob = new Blob([wrappedSource], { type: "text/javascript", }); return new Worker(URL.createObjectURL(blob));}
export class PluginInstance { constructor(pluginId, manifest, worker, { onRegister, onHostCall }) { this.pluginId = pluginId; this.manifest = manifest; this.worker = worker; this._onRegister = onRegister; this._onHostCall = onHostCall; this.disposers = []; this._pendingCalls = new Map(); this.callUuid = new SimpleUUID(); this.worker.addEventListener("message", (event) => this._handleWorkerMessage(event), ); this.worker.addEventListener("error", (event) => logger.error(`"${this.pluginId}" worker error:`, event.message), ); this._readyPromise = new Promise((resolve, reject) => { this._setReady = () => resolve(); this._setFailed = (e) => reject(e); }); }
_handleWorkerMessage(event) { const message = event.data; if (!message || typeof message !== "object") return; switch (message.type) { case "ready": { message.error ? this._setFailed(message.error) : this._setReady(); return; } case "register": { const dispose = this._onRegister(this, message); if (dispose) this.disposers.push(dispose); return; } case "result": { this._handleCallResult(message); return; } case "hostCall": { this._onHostCall(this, message); return; } default: return; } }
static async loadFromSource(pluginId, manifest, source, callbacks) { const wrappedSource = await wrapWorkerSource(source); const worker = !window.env.playwright // don't sandbox in e2e tests ? createSandboxedWorker(wrappedSource) : createDirectWorker(wrappedSource); const instance = new PluginInstance(pluginId, manifest, worker, callbacks); try { return await instance.waitForReady(2000); } catch (err) { instance.unload(); throw err; } }
async waitForReady(timeout) { const timeoutPromise = new Promise((resolve, reject) => setTimeout(() => reject(new Error("Timed out")), timeout), ); await Promise.race([this._readyPromise, timeoutPromise]); return this; }
async call(handlerId, ...args) { const callId = this.callUuid.create(); return new Promise((resolve, reject) => { this._pendingCalls.set(callId, { resolve, reject }); this.worker.postMessage({ type: "call", callId, handlerId, args, }); }); }
async sendEvent(event, data) { this.worker.postMessage({ type: "event", event, data, }); }
_handleCallResult(message) { const pending = this._pendingCalls.get(message.callId); if (!pending) return; this._pendingCalls.delete(message.callId); if (message.error) pending.reject(new Error(message.error)); else pending.resolve(message.value); }
unload() { this.disposers.forEach((dispose) => dispose()); this.worker.terminate(); }}
export class PluginBridge { constructor( sourceProvider, pluginStylesLoader, loadPluginInstance = PluginInstance.loadFromSource, ) { this._provider = sourceProvider; this._pluginStylesLoader = pluginStylesLoader; this._loadPluginInstance = loadPluginInstance; this._registrationTargets = new Map(); this._inFlightLoads = new Map(); this._hostCallHandlers = new Map(); // reactive loading state this.$loadedPlugins = new SignalMap(); this.$loading = new SignalMap(); this.$pluginLoadingErrors = new SignalMap(); this.$loadStatuses = new ComputedMap((pluginId) => ({ loading: this.$loading.get(pluginId) ?? false, error: this.$pluginLoadingErrors.get(pluginId) ?? null, })); }
isLoaded(pluginId) { return this.$loadedPlugins.has(pluginId); }
getInstance(pluginId) { return this.$loadedPlugins.get(pluginId) ?? null; }
addRegistrationTarget(target, handler) { this._registrationTargets.set(target, handler); }
_handleRegistration(pluginInstance, message) { const handler = this._registrationTargets.get(message.target); if (!handler) { logger.warn( `"${pluginInstance.pluginId}" attempted to register unknown target "${message.target}"`, ); return null; }
return handler(pluginInstance, message); }
// Request: {id, version, repo?} async loadPlugins(pluginRequests) { const loadedPlugins = []; const erroredPlugins = []; await Promise.all( pluginRequests.map(async ({ id, version, repo }) => { try { const plugin = await this.loadPlugin(id, version, repo); loadedPlugins.push(plugin); } catch (error) { erroredPlugins.push({ pluginId: id, version, error }); } }), ); return { loadedPlugins, erroredPlugins, }; }
async loadPlugin(pluginId, version, repo) { if (untrack(() => this.$loadedPlugins.has(pluginId))) return; const inFlightLoad = this._inFlightLoads.get(pluginId); if (inFlightLoad) return inFlightLoad; const load = (async () => { this.$loading.set(pluginId, true); this.$pluginLoadingErrors.set(pluginId, null); try { const instance = await this._loadPlugin(pluginId, version, repo); return instance; } catch (error) { this.$pluginLoadingErrors.set(pluginId, error); throw error; } finally { this.$loading.set(pluginId, false); this._inFlightLoads.delete(pluginId); } })(); this._inFlightLoads.set(pluginId, load); return load; }
async _loadPlugin(pluginId, version, repo) { let manifest; try { manifest = await this._provider.getManifest(pluginId, version, repo); } catch (error) { logger.warn(`failed to load "${pluginId}": invalid manifest`, error); throw new Error("Could not fetch plugin manifest"); } let source; try { source = await this._provider.getSource(pluginId, version, repo); } catch (error) { logger.error( `failed to load "${pluginId}": could not fetch main.js`, error, ); throw new Error("Could not fetch plugin source"); } let cssText; try { cssText = await this._provider.getStyles(pluginId, version, repo); } catch (error) { logger.error( `failed to load "${pluginId}": could not fetch styles.css`, error, ); throw new Error("Failed to load plugin styles"); } if (cssText != null) { try { this._pluginStylesLoader.mount(pluginId, cssText); } catch (error) { logger.error(`failed to load "${pluginId}": invalid styles.css`, error); throw new Error("Plugin styles failed validation"); } } if (manifest.fonts?.length) { try { const descriptors = await Promise.all( manifest.fonts.map(async (font) => ({ ...font, blob: await this._provider.getFont( pluginId, version, repo, font.file, ), })), ); this._pluginStylesLoader.mountFonts(pluginId, descriptors); } catch (error) { this._pluginStylesLoader.unmount(pluginId); logger.error( `failed to load "${pluginId}": could not load fonts`, error, ); throw new Error("Failed to load plugin fonts"); } } try { const pluginInstance = await this._loadPluginInstance( pluginId, manifest, source, { onRegister: (instance, message) => this._handleRegistration(instance, message), onHostCall: (instance, message) => this._handleHostCall(instance, message), }, ); this.$loadedPlugins.set(pluginId, pluginInstance); logger.info(`loaded "${pluginId}" v${manifest.version}`); return pluginInstance; } catch (error) { this._pluginStylesLoader.unmount(pluginId); if (error instanceof PluginSdkError) { logger.error(`could not load "${pluginId}": plugin SDK unavailable`); throw new Error("Plugin system failed to load"); } logger.error(`"${pluginId}" failed during initialization:`, error); throw new Error("Plugin failed during initialization"); } }
addHostMethod(method, handler) { this._hostCallHandlers.set(method, handler); }
_handleHostCall(pluginInstance, message) { const handler = this._hostCallHandlers.get(message.method); const hostCallId = message.hostCallId; const sendResult = (result) => { if (hostCallId == null) return; pluginInstance.worker.postMessage({ type: "hostResult", hostCallId, ...result, }); }; if (!handler) { logger.warn( `"${pluginInstance.pluginId}" called unknown host method "${message.method}"`, ); sendResult({ error: `unknown host method "${message.method}"` }); return; } const args = message.args ?? []; Promise.resolve() .then(() => handler(pluginInstance, ...args)) .then( (value) => sendResult({ value }), (error) => { logger.error( `"${pluginInstance.pluginId}" host method "${message.method}" threw:`, error, ); sendResult({ error: error?.message ?? String(error) }); }, ); }
handleNodeEvent(pluginId, handlerId, virtualEvent) { const instance = this.$loadedPlugins.get(pluginId); if (!instance) { logger.warn( `received event for unknown plugin "${pluginId}", handler "${handlerId}"`, ); return; } instance.call(handlerId, virtualEvent).catch((error) => { logger.warn(`[plugins] "${pluginId}" event handler threw:`, error); }); }
unloadPlugin(pluginId) { const instance = untrack(() => this.$loadedPlugins.get(pluginId)); if (!instance) return; instance.unload(); this.$loadedPlugins.delete(pluginId); this.$loading.delete(pluginId); this.$pluginLoadingErrors.delete(pluginId); this._pluginStylesLoader.unmount(pluginId); }
async reloadPlugin(pluginId, version, repo) { this.unloadPlugin(pluginId); return this.loadPlugin(pluginId, version, repo); }}