All checks were successful
CD / Build and push images (push) Successful in 3m54s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m9s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m36s
CI / Import/export fidelity gate (push) Successful in 47s
A new block plugin bundling the OFFICIAL draw.io editor — nothing ever
loads from diagrams.net; the sandbox CSP pins every request to the
plugin's own version-pinned asset path (zero-external-network verified
live via a request-capture run).
Plugin (packages/plugins/drawio):
- block data { xml, svg }: xml is the draw.io source (document of
record), svg the rendered snapshot as raw markup — render mode,
office/PDF exports (the existing fallback renderer already inlines
data.svg) and the public view all show the diagram without running
diagram code
- edit mode: snapshot + "edit in fullscreen" (an empty block opens the
editor immediately); the bundled editor runs in a child iframe of the
plugin's own assets and speaks draw.io's JSON embed protocol —
Save & Exit exports xmlsvg, persists { xml, svg } via blockData, and
drops back to the inline size
- build.mjs fetches the pinned release (v30.3.6) into a gitignored
vendor/ cache (fonts-build pattern; skipped in CI — plugin.js still
bundles, the installable ZIP needs a dev machine) and packs a trimmed
webapp subset: no dev sources, no embed.diagrams.net integrations
bundle, no standalone viewers, no MathJax/templates/PWA — 27 MiB ZIP,
85 MiB unpacked, de+en editor languages
Host/SDK extensions (generic, not drawio-specific):
- new ui.enterFullscreen()/exitFullscreen(): the surface's frame becomes
a viewport-covering overlay — same sandboxed iframe, only geometry
changes; destroy removes the frame, so a vanished plugin can never
leave the app covered
- sandbox CSP: connect-src/frame-src now allow the plugin's OWN asset
path (was 'none') — bundled apps lazy-load their resources and run in
a child frame, but the api and external hosts stay unreachable; HTML
assets are served with the same CSP so a packaged page cannot widen
the rules, and child frames inherit the sandbox attribute
- plugin size limits raised (ZIP 5→64 MiB, unpacked 20→256 MiB) for
bundled-app plugins; content types for xml/txt/ico assets
Verified end to end against a local stack (9/9): install via dropzone
(85 MiB validation), block insert, fullscreen entry, bundled editor
boots inside the double sandbox (German UI), shape drawn, Save & Exit
persists, snapshot renders inline, survives reload, zero off-origin
requests throughout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
/**
|
|
* Plugin side of the RPC channel. A plugin bundle calls `createPlugin(...)`
|
|
* once; it wires up the message listener, answers the host's lifecycle calls
|
|
* with the supplied handlers, and returns a typed `host` proxy for calling the
|
|
* plugin API back.
|
|
*/
|
|
import { CAPABILITY_METHODS, CAPABILITIES } from './capabilities';
|
|
import { createRpcEndpoint, type RpcRequestOptions, type RpcTransport } from './rpc';
|
|
|
|
/** Page outline entry surfaced by `readCurrentPage.getOutline()` / TOC tools. */
|
|
export interface OutlineEntry {
|
|
id: string;
|
|
level: number;
|
|
text: string;
|
|
}
|
|
|
|
export interface PageMeta {
|
|
id: string;
|
|
title: string;
|
|
pondId: string;
|
|
slug: string;
|
|
}
|
|
|
|
export interface PageSummary {
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
/** Names of the page's labels — the page-index reference plugin filters on
|
|
* these (issue #77). Only pages the viewer may read arrive here at all. */
|
|
labels: string[];
|
|
}
|
|
|
|
/**
|
|
* The plugin API surface, grouped by capability. A plugin may call only the
|
|
* groups it declared in its manifest `permissions`; undeclared calls reject
|
|
* with `capability_not_permitted` at the host. Payload shapes are intentionally
|
|
* loose here (v1) and tightened by the capability endpoints in #74.
|
|
*/
|
|
export interface PluginHost {
|
|
readCurrentPage: {
|
|
getOutline: () => Promise<OutlineEntry[]>;
|
|
getContent: () => Promise<string>;
|
|
getMeta: () => Promise<PageMeta>;
|
|
};
|
|
readPond: {
|
|
listPages: () => Promise<PageSummary[]>;
|
|
getPageOutline: (pageId: string) => Promise<OutlineEntry[]>;
|
|
getPageContent: (pageId: string) => Promise<string>;
|
|
};
|
|
readBlock: {
|
|
getBlock: (pageId: string, blockId: string) => Promise<unknown>;
|
|
};
|
|
blockData: {
|
|
getData: () => Promise<unknown>;
|
|
setData: (data: unknown) => Promise<void>;
|
|
};
|
|
ui: {
|
|
resize: (height: number) => Promise<void>;
|
|
openPage: (pageId: string) => Promise<void>;
|
|
toast: (messageKey: string) => Promise<void>;
|
|
/** Scroll the host page to a heading by its outline id (issue #77). */
|
|
scrollToHeading: (headingId: string) => Promise<void>;
|
|
/**
|
|
* Promote this surface's frame to a viewport-covering overlay — for
|
|
* plugins whose editing UI needs the whole screen (drawio). The frame
|
|
* stays the same sandboxed iframe; only its geometry changes. Always
|
|
* pair with {@link exitFullscreen}; the host also restores normal
|
|
* geometry when the surface is destroyed.
|
|
*/
|
|
enterFullscreen: () => Promise<void>;
|
|
exitFullscreen: () => Promise<void>;
|
|
};
|
|
}
|
|
|
|
/** Params the host passes to lifecycle handlers. */
|
|
export interface RenderContext {
|
|
/** The plugin surface being rendered (extension point id). */
|
|
extensionPointId: string;
|
|
/** Locale to render in, e.g. `"de"`. */
|
|
locale: string;
|
|
/** For a `block` surface: the stored block data (may be undefined on first
|
|
* render of a fresh block). */
|
|
data?: unknown;
|
|
}
|
|
|
|
export interface PluginLifecycle {
|
|
/** Draw the surface. Called on mount and whenever inputs change. */
|
|
onRender?: (context: RenderContext) => void | Promise<void>;
|
|
/** Enter edit mode for a block surface. */
|
|
onEdit?: (context: RenderContext) => void | Promise<void>;
|
|
/** Release resources before the frame is torn down. */
|
|
onDestroy?: () => void | Promise<void>;
|
|
}
|
|
|
|
export interface CreatePluginOptions extends PluginLifecycle {
|
|
transport: RpcTransport;
|
|
/** Default timeout for plugin→host calls, in ms. */
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export interface PluginInstance {
|
|
host: PluginHost;
|
|
dispose: () => void;
|
|
}
|
|
|
|
// A single-argument method sends its argument as `params`; a multi-argument
|
|
// method sends the positional array. This keeps the wire format simple while
|
|
// letting the host implementation destructure as needed.
|
|
function toParams(args: unknown[]): unknown {
|
|
if (args.length === 0) return undefined;
|
|
if (args.length === 1) return args[0];
|
|
return args;
|
|
}
|
|
|
|
/**
|
|
* Initializes a plugin inside its sandboxed frame. Returns the `host` proxy
|
|
* (typed capability calls) and a `dispose()` to detach. The returned proxy
|
|
* issues real RPC calls lazily, so importing the SDK has no side effects until
|
|
* a method is invoked.
|
|
*/
|
|
export function createPlugin(options: CreatePluginOptions): PluginInstance {
|
|
const endpoint = createRpcEndpoint({
|
|
...options.transport,
|
|
timeoutMs: options.timeoutMs,
|
|
handlers: {
|
|
render: (params) => options.onRender?.(params as RenderContext),
|
|
edit: (params) => options.onEdit?.(params as RenderContext),
|
|
destroy: () => options.onDestroy?.(),
|
|
},
|
|
});
|
|
|
|
const call = (method: string, args: unknown[], requestOptions?: RpcRequestOptions) =>
|
|
endpoint.request(method, toParams(args), requestOptions);
|
|
|
|
// Build the grouped `host` proxy from the capability→methods map so it stays
|
|
// in lock-step with the protocol without hand-listing every method.
|
|
const host = {} as Record<string, Record<string, (...args: unknown[]) => Promise<unknown>>>;
|
|
for (const capability of CAPABILITIES) {
|
|
const group: Record<string, (...args: unknown[]) => Promise<unknown>> = {};
|
|
for (const method of CAPABILITY_METHODS[capability]) {
|
|
group[method] = (...args: unknown[]) => call(method, args);
|
|
}
|
|
host[capability] = group;
|
|
}
|
|
|
|
return {
|
|
host: host as unknown as PluginHost,
|
|
dispose: () => endpoint.dispose(),
|
|
};
|
|
}
|