mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
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.
147 lines
4.2 KiB
TypeScript
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();
|
|
}
|
|
};
|