alonso.torres cf1fbb4d7c 🐛 Close plugins whose load is cancelled before they run
Unloading or replacing a plugin while its code was still being
fetched could leave a late instance running, or block reopening a
plugin that closed during startup.

Each load now carries an AbortController. Unloading, a newer load
of the same plugin or logout aborts it, and the plugin manager
closes itself on abort, also while it fetches its code.
createPlugin returns nothing for a plugin closed before its
sandbox exists, and failed loads clear their pending state so the
plugin can be loaded again.
2026-10-07 14:58:32 +02:00

147 lines
4.2 KiB
TypeScript

import type { Context } from '@penpot/plugin-types';
import { loadManifest } from './parse-manifest.js';
import { Manifest } from './models/manifest.model.js';
import { createPlugin } from './create-plugin.js';
type Plugin = NonNullable<Awaited<ReturnType<typeof createPlugin>>>;
let plugins: Plugin[] = [];
const pendingPlugins = new Map<Manifest['pluginId'], AbortController>();
export type ContextBuilder = (id: string) => Context;
let contextBuilder: ContextBuilder | null = null;
export function setContextBuilder(builder: ContextBuilder) {
contextBuilder = builder;
}
export const getPlugins = () => plugins;
const closeAllPlugins = () => {
// Background and global plugins keep running, so they must stay registered:
// the registry routes their UI messages and lets them be unloaded later.
plugins = plugins.filter((pluginApi) => {
/* eslint-disable @typescript-eslint/no-explicit-any */
if (
pluginApi.manifest?.scope === 'global' ||
(pluginApi.manifest as any)?.allowBackground
) {
return true;
}
pluginApi.plugin.close();
return false;
});
};
window.addEventListener('message', (event) => {
try {
const senderPlugin = plugins.find((it) => it.iframeWindow === event.source);
if (senderPlugin) {
senderPlugin.plugin.sendMessage(event.data);
}
} catch (err) {
console.error(err);
}
});
export const loadPlugin = async function (
manifest: Manifest,
closeCallback?: () => void,
apiExtensions?: object,
) {
const load = new AbortController();
try {
const context = contextBuilder && contextBuilder(manifest.pluginId);
if (!context) {
return;
}
if (manifest.scope === 'global') {
if (
pendingPlugins.has(manifest.pluginId) ||
plugins.some(
(plugin) => plugin.manifest?.pluginId === manifest.pluginId,
)
) {
return;
}
} else {
closeAllPlugins();
}
// The host context is not deeply frozen at this load stage.
//
// The context still contains host-internal function objects and shared
// prototypes that the host may legitimately extend after plugin load
// (for example, by assigning custom properties). Deep-freezing here
// would freeze those prototypes before SES override taming completes,
// preventing later host-side mutations with a "Cannot assign to read
// only property" TypeError.
//
// Responsibility boundary: this function forwards the context to the
// sandbox layer without deep-freezing it. The public API that plugins
// consume is constructed by the API module (`api/index.ts`), and
// `createSandbox`'s proxy handler applies `ses.safeReturn` to values
// crossing into the sandbox. Compartment isolation and intrinsics
// hardening are performed by createSandbox, not here.
pendingPlugins.get(manifest.pluginId)?.abort();
pendingPlugins.set(manifest.pluginId, load);
let plugin: Plugin | undefined = undefined;
plugin = await createPlugin(
context,
manifest,
() => {
load.abort();
if (pendingPlugins.get(manifest.pluginId) === load) {
pendingPlugins.delete(manifest.pluginId);
}
plugins = plugins.filter((api) => api !== plugin);
if (closeCallback) {
closeCallback();
}
},
apiExtensions,
load.signal,
);
if (!plugin || load.signal.aborted) {
plugin?.plugin.close();
return;
}
plugins.push(plugin);
} catch (error) {
if (manifest.scope !== 'global') closeAllPlugins();
throw error;
} finally {
if (pendingPlugins.get(manifest.pluginId) === load) {
pendingPlugins.delete(manifest.pluginId);
}
}
};
export const ɵloadPlugin = async function (
manifest: Manifest,
closeCallback?: () => void,
apiExtensions?: object,
) {
await loadPlugin(manifest, closeCallback, apiExtensions);
};
export const ɵloadPluginByUrl = async function (manifestUrl: string) {
const manifest = await loadManifest(manifestUrl);
await ɵloadPlugin(manifest);
};
export const ɵunloadPlugin = function (id: Manifest['pluginId']) {
pendingPlugins.get(id)?.abort();
const plugin = plugins.find((plugin) => plugin.manifest.pluginId === id);
if (plugin) {
plugin.plugin.close();
}
};