mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
Cause: - Disabling MCP during script loading could leave a late plugin instance attached to a stopped controller, blocking connection after re-enabling. Fix: - Track pending loads and discard instances cancelled by unload. - Prevent duplicate global loads and preserve newer replacement instances. - Clear pending state after failures so a subsequent load can retry. Validation: - Pass 84 runtime tests, the runtime build, lint and formatting checks. - Reproduce the race with a delayed script in the browser, then verify re-enabling restores the session ID, themed indicator and MCP execution. AI-assisted-by: gpt-6 Signed-off-by: Dominik Jain <dominik.jain@oraios-ai.de>
140 lines
4.0 KiB
TypeScript
140 lines
4.0 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';
|
|
|
|
let plugins: Awaited<ReturnType<typeof createPlugin>>[] = [];
|
|
const pendingPlugins = new Map<Manifest['pluginId'], symbol>();
|
|
|
|
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 loadId = Symbol();
|
|
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.set(manifest.pluginId, loadId);
|
|
let plugin: Awaited<ReturnType<typeof createPlugin>> | undefined =
|
|
undefined;
|
|
plugin = await createPlugin(
|
|
context,
|
|
manifest,
|
|
() => {
|
|
plugins = plugins.filter((api) => api !== plugin);
|
|
|
|
if (closeCallback) {
|
|
closeCallback();
|
|
}
|
|
},
|
|
apiExtensions,
|
|
);
|
|
if (pendingPlugins.get(manifest.pluginId) !== loadId) {
|
|
plugin.plugin.close();
|
|
return;
|
|
}
|
|
plugins.push(plugin);
|
|
} catch (error) {
|
|
if (manifest.scope !== 'global') closeAllPlugins();
|
|
throw error;
|
|
} finally {
|
|
if (pendingPlugins.get(manifest.pluginId) === loadId) {
|
|
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.delete(id);
|
|
const plugin = plugins.find((plugin) => plugin.manifest.pluginId === id);
|
|
|
|
if (plugin) {
|
|
plugin.plugin.close();
|
|
}
|
|
};
|