mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 22:10:34 +00:00
Global plugin lifecycle:
- Add optional manifest scope "global" for plugins that remain active
across dashboard and workspace navigation within one browser tab.
Existing plugins retain workspace scope by default.
- Preserve global sandboxes when a workspace ends or another plugin
loads, including when that other plugin fails to initialize.
- Avoid duplicate instances of an already-running global plugin.
- Close global plugins on explicit closure or logout, with idempotent
cleanup of listeners, timers and UI resources. Global scope does not
imply automatic startup or persistence across a browser reload.
Plugin API and navigation:
- Keep a permanently live penpot facade whose getters follow the current
workspace, while user, theme and UI facilities remain available on
the dashboard.
- Expose penpotMgmt to global plugins with workspace metadata,
none/loading/ready status and workspacechange subscriptions.
- Add awaitable openFile(fileId, {teamId}) navigation in the current tab,
defaulting to the current team and resolving only once the requested
file and its active page are ready.
- Reject invalid identifiers, failed or superseded navigation and waits
exceeding 30 seconds; release navigation watchers on completion.
- Guard workspace-bound operations while no workspace is ready and
return null from root, currentFile and currentPage in that state.
- Require content:read for the initial management API. Callers must
reacquire file, page and shape references after changing files.
MCP lifecycle and connection controls:
- Make MCP a global plugin and initialize its integrated controller per
authenticated application session instead of per workspace.
- Load the enabled integration with a valid token on the dashboard as
well as in a file, retaining its existing hidden-iframe implementation.
- Keep enabling and connecting separate: an enabled plugin starts idle,
and each tab connects explicitly through dashboard or workspace
controls.
- Preserve the iframe, WebSocket, heartbeat and reconnect machinery
during navigation; disconnect on request and close on disable/logout.
MCP sessions and execution:
- Derive the short MCP session ID solely from currentUser.sessionId,
excluding the file ID. Retain the SHA-256-based, 50-bit Base32 encoding
to produce a stable, copyable 10-character identifier.
- Allow sessions without an open file and update workspace metadata over
the existing connection without replacing its routing identity.
- Refresh context after the connection handshake so initialization does
not leave stale file metadata.
- Expose penpotMgmt to execute_code, preserve execution storage across
navigation and skip layout waits when no workspace is ready.
- Update API types, tool guidance, documentation and the changelog.
Validation:
- Cover authenticated startup, workspace survival, logout cleanup,
readiness, navigation failure, duplicate loads and session routing.
- Verify that short IDs remain stable across files and reconnects and
that disconnect cancels pending asynchronous ID initialization.
- Pass 16 focused frontend tests, 73 runtime tests, 30 MCP plugin tests,
builds, TypeScript checks and frontend/runtime lint.
- Pass all 73 MCP server tests with --test-force-exit; the normal runner
still stays alive after assertions finish.
- Verify a live dashboard-to-file-to-dashboard round trip without a
document or iframe reload, preserving facade identity and storage.
AI-assisted-by: gpt-6
224 lines
6.5 KiB
TypeScript
224 lines
6.5 KiB
TypeScript
import type { createPluginManager } from './plugin-manager';
|
|
import { createApi } from './api';
|
|
import { ses } from './ses.js';
|
|
|
|
/**
|
|
* WeakMap used to track errors originating from plugin code.
|
|
* Using a WeakMap is safer than extending error objects because:
|
|
* 1. It works even if the error object is frozen (SES hardened environment)
|
|
* 2. It doesn't require modifying the error object
|
|
* 3. It allows garbage collection of error objects when no longer referenced
|
|
*/
|
|
const pluginErrors = new WeakMap<object, true>();
|
|
|
|
/**
|
|
* Checks if an error originated from plugin code.
|
|
*/
|
|
export function isPluginError(error: unknown): boolean {
|
|
if (error !== null && typeof error === 'object') {
|
|
return pluginErrors.has(error as object);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Marks an error as originating from plugin code.
|
|
* Uses a WeakMap so it works even if the error object is frozen.
|
|
*/
|
|
export function markPluginError(error: unknown): void {
|
|
if (error !== null && typeof error === 'object') {
|
|
pluginErrors.set(error as object, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wraps a handler function to mark any thrown errors as plugin errors.
|
|
* Errors are marked and re-thrown so they propagate to the global error handler,
|
|
* where they can be identified and handled appropriately.
|
|
*/
|
|
function wrapHandler<T extends (...args: unknown[]) => unknown>(
|
|
handler: T,
|
|
): (...args: Parameters<T>) => ReturnType<T> {
|
|
return function (...args: Parameters<T>) {
|
|
try {
|
|
const result = handler(...args);
|
|
// Handle async functions - mark errors in the returned promise
|
|
if (result instanceof Promise) {
|
|
return result.catch((error: unknown) => {
|
|
markPluginError(error);
|
|
throw error;
|
|
}) as ReturnType<T>;
|
|
}
|
|
return result as ReturnType<T>;
|
|
} catch (error) {
|
|
markPluginError(error);
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
export function createSandbox(
|
|
plugin: Awaited<ReturnType<typeof createPluginManager>>,
|
|
apiExtensions?: object,
|
|
) {
|
|
ses.hardenIntrinsics();
|
|
|
|
const pluginApi = createApi(plugin);
|
|
|
|
const safeHandler = {
|
|
get(target: object, prop: string, receiver: unknown) {
|
|
const originalValue = Reflect.get(target, prop, receiver);
|
|
|
|
if (typeof originalValue === 'function') {
|
|
return function (...args: unknown[]) {
|
|
const result = originalValue.apply(target, args);
|
|
|
|
return ses.safeReturn(result);
|
|
};
|
|
}
|
|
|
|
return ses.safeReturn(originalValue);
|
|
},
|
|
};
|
|
|
|
const proxyApi = new Proxy(pluginApi.penpot, safeHandler);
|
|
|
|
const safeFetch = (url: string, options: RequestInit) => {
|
|
const sanitizedOptions: RequestInit = {
|
|
...options,
|
|
credentials: 'omit',
|
|
headers: {
|
|
...options?.headers,
|
|
Authorization: '',
|
|
},
|
|
};
|
|
|
|
return fetch(url, sanitizedOptions).then((response) => {
|
|
const safeResponse = {
|
|
ok: response.ok,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
url: response.url,
|
|
text: response.text.bind(response),
|
|
json: response.json.bind(response),
|
|
};
|
|
|
|
return ses.safeReturn(safeResponse);
|
|
});
|
|
};
|
|
|
|
let publicPluginApi = {
|
|
penpot: proxyApi,
|
|
fetch: ses.harden(safeFetch),
|
|
setTimeout: ses.harden(
|
|
(...[handler, timeout]: Parameters<typeof setTimeout>) => {
|
|
const wrappedHandler = wrapHandler(
|
|
typeof handler === 'function' ? handler : () => {},
|
|
);
|
|
const timeoutId = setTimeout(wrappedHandler, timeout);
|
|
|
|
plugin.timeouts.add(timeoutId);
|
|
|
|
return ses.safeReturn(timeoutId);
|
|
},
|
|
) as typeof setTimeout,
|
|
clearTimeout: ses.harden((id: ReturnType<typeof setTimeout>) => {
|
|
clearTimeout(id);
|
|
|
|
plugin.timeouts.delete(id);
|
|
}),
|
|
setInterval: ses.harden(
|
|
(...[handler, interval]: Parameters<typeof setInterval>) => {
|
|
const wrappedHandler = wrapHandler(
|
|
typeof handler === 'function' ? handler : () => {},
|
|
);
|
|
const intervalId = setInterval(wrappedHandler, interval);
|
|
|
|
plugin.intervals.add(intervalId);
|
|
|
|
return ses.safeReturn(intervalId);
|
|
},
|
|
) as typeof setInterval,
|
|
clearInterval: ses.harden((id: ReturnType<typeof setInterval>) => {
|
|
clearInterval(id);
|
|
|
|
plugin.intervals.delete(id);
|
|
}),
|
|
|
|
/**
|
|
* GLOBAL FUNCTIONS ACCESIBLE TO PLUGINS
|
|
**/
|
|
isFinite: ses.harden(isFinite),
|
|
isNaN: ses.harden(isNaN),
|
|
parseFloat: ses.harden(parseFloat),
|
|
parseInt: ses.harden(parseInt),
|
|
decodeURI: ses.harden(decodeURI),
|
|
decodeURIComponent: ses.harden(decodeURIComponent),
|
|
encodeURI: ses.harden(encodeURI),
|
|
encodeURIComponent: ses.harden(encodeURIComponent),
|
|
Object: ses.harden(Object),
|
|
Boolean: ses.harden(Boolean),
|
|
Symbol: ses.harden(Symbol),
|
|
Number: ses.harden(Number),
|
|
BigInt: ses.harden(BigInt),
|
|
Math: ses.harden(Math),
|
|
Date: ses.harden(Date),
|
|
String: ses.harden(String),
|
|
RegExp: ses.harden(RegExp),
|
|
Array: ses.harden(Array),
|
|
Int8Array: ses.harden(Int8Array),
|
|
Uint8Array: ses.harden(Uint8Array),
|
|
Uint8ClampedArray: ses.harden(Uint8ClampedArray),
|
|
Int16Array: ses.harden(Int16Array),
|
|
Uint16Array: ses.harden(Uint16Array),
|
|
Int32Array: ses.harden(Int32Array),
|
|
Uint32Array: ses.harden(Uint32Array),
|
|
BigInt64Array: ses.harden(BigInt64Array),
|
|
BigUint64Array: ses.harden(BigUint64Array),
|
|
Float32Array: ses.harden(Float32Array),
|
|
Float64Array: ses.harden(Float64Array),
|
|
Map: ses.harden(Map),
|
|
Set: ses.harden(Set),
|
|
WeakMap: ses.harden(WeakMap),
|
|
WeakSet: ses.harden(WeakSet),
|
|
ArrayBuffer: ses.harden(ArrayBuffer),
|
|
DataView: ses.harden(DataView),
|
|
Atomics: ses.harden(Atomics),
|
|
JSON: ses.harden(JSON),
|
|
Promise: ses.harden(Promise),
|
|
Proxy: ses.harden(Proxy),
|
|
Intl: ses.harden(Intl),
|
|
|
|
// Window properties
|
|
console: ses.harden(window.console),
|
|
devicePixelRatio: window.devicePixelRatio,
|
|
atob: ses.harden(window.atob.bind(null)),
|
|
btoa: ses.harden(window.btoa.bind(null)),
|
|
structuredClone: ses.harden(window.structuredClone),
|
|
};
|
|
|
|
if (apiExtensions) {
|
|
publicPluginApi = Object.assign(publicPluginApi, apiExtensions);
|
|
}
|
|
|
|
if (plugin.manifest.scope === 'global') {
|
|
Object.assign(publicPluginApi, {
|
|
penpotMgmt: new Proxy(pluginApi.penpotMgmt, safeHandler),
|
|
});
|
|
}
|
|
|
|
const compartment = ses.createCompartment(publicPluginApi);
|
|
|
|
return {
|
|
evaluate: () => {
|
|
compartment.evaluate(plugin.code);
|
|
},
|
|
cleanGlobalThis: () => {
|
|
Object.keys(publicPluginApi).forEach((key) => {
|
|
delete compartment.globalThis[key];
|
|
});
|
|
},
|
|
compartment,
|
|
};
|
|
}
|