Files
webstudio/apps/builder/app/shared/builder-api.ts
Oleg Isonen d440e02990 feat: Add safer MCP authoring and draft-page verification (#5905)
- **Restore points for agent work**: Create named restore points before
major edits, review changes with a dry run, and safely revert project
content if needed.

- **Shared Slot authoring**: Agents can turn existing sections into
shared Slots and insert linked copies across pages, including valid
nested Slots.

- **DTCG and Figma token imports**: Import design tokens and CSS
variables through CLI, MCP, or direct paste, with aliases, modes,
composite values, and mappings supported.

- **Consistent token conflict handling**: Every paste workflow now
offers the same preserve, rename, or merge choices instead of silently
overwriting existing design tokens.

- **Draft-page previews**: Agents can generate and inspect previews of
draft pages without publishing them or exposing drafts in production
builds.

- **Safer MCP mutations**: Destructive operations require explicit
confirmation, invalid raw patches and invented record IDs are rejected,
and restore operations commit atomically.

- **More dependable agent contracts**: New tools have complete runtime
schemas, clearer validation errors, and consistent behavior across CLI,
MCP, Builder, and server APIs.
2026-07-17 23:16:39 +02:00

121 lines
3.0 KiB
TypeScript

import { createRecursiveProxy } from "@trpc/server/shared";
import invariant from "tiny-invariant";
import { toast } from "@webstudio-is/design-system";
import { uploadAssets } from "~/builder/shared/assets/upload-assets";
import { showTokenConflictDialog } from "./token-conflict-dialog";
import { showDesignTokenImportDialog } from "./design-token-import-dialog";
const apiWindowNamespace = "__webstudio__$__builderApi";
type ToastHandler = (message: string) => void;
const isSafeMode = (() => {
if (typeof window === "undefined") {
return false;
}
return new URLSearchParams(window.location.search).get("safemode") === "true";
})();
const _builderApi = {
isInitialized: () => true,
isSafeMode: () => isSafeMode,
toast: {
info: toast.info as ToastHandler,
warn: toast.warn as ToastHandler,
error: toast.error as ToastHandler,
success: toast.success as ToastHandler,
},
uploadImages: async (srcs: string[]) => {
const urlToIds = await uploadAssets(
"image",
srcs.map((src) => new URL(src))
);
return new Map([...urlToIds.entries()].map(([url, id]) => [url.href, id]));
},
showDesignTokenImportDialog,
showTokenConflictDialog,
};
declare global {
interface Window {
[apiWindowNamespace]: typeof _builderApi;
}
}
const isInTop = () => {
try {
return window.self === window.top;
} catch {
return true;
}
};
const getTopApi = () => {
if (isInTop()) {
// Inside the iframe, use the local window.api
return _builderApi;
} else {
// Find first iframe with the API
invariant(window.top);
return window.top[apiWindowNamespace];
}
};
const isKeyOf = <T>(key: unknown, obj: T): key is keyof T => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return key in obj;
};
/**
* Forwards the call from the builder to the iframe, invoking the original API in the iframe.
*/
export const builderApi = createRecursiveProxy((options) => {
const api = getTopApi();
if (api == null) {
if (
options.path.join(".") ===
("isInitialized" satisfies keyof typeof _builderApi)
) {
return false;
}
console.warn(
`API not found in the iframe, skipping ${options.path.join(".")} call, iframe probably not loaded yet`
);
return null;
}
let currentMethod = api as unknown;
for (const key of options.path) {
invariant(
isKeyOf(key, currentMethod),
`API method ${options.path.join(".")} not found`
);
invariant(typeof currentMethod === "object");
invariant(currentMethod != null);
currentMethod = currentMethod[key];
}
invariant(
typeof currentMethod === "function",
`API method ${options.path.join(".")} is not a function`
);
return currentMethod.call(null, ...options.args);
}) as typeof _builderApi;
/**
* Initializes the builder API in the window. Must be called in the builder context.
*/
export const initBuilderApi = () => {
if (isInTop()) {
window[apiWindowNamespace] = _builderApi;
}
return () => {};
};