mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-28 15:46:58 +09:00
Fixes https://github.com/webstudio-is/webstudio/issues/5491 ## Description - When pasting an instance with tokens, we can compare the name and the contents. If they are identical, we can choose the existing one and avoid having any conflicts. - When pasting a token and the name is already used, add a numeric suffix to it. - find duplicate tokens command that will go based on content even if name is different ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
206 lines
6.1 KiB
TypeScript
206 lines
6.1 KiB
TypeScript
import { createNanoEvents } from "nanoevents";
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
import { batchUpdate } from "./raf-queue";
|
|
import { useEffectEvent } from "../hook-utils/effect-event";
|
|
import invariant from "tiny-invariant";
|
|
|
|
const apiTokenKey = "__webstudio__$__api_token";
|
|
|
|
declare global {
|
|
interface Window {
|
|
[apiTokenKey]: string | undefined;
|
|
}
|
|
}
|
|
|
|
const getRandomToken = () => {
|
|
const randomBytes = new Uint8Array(10);
|
|
window.crypto.getRandomValues(randomBytes);
|
|
return btoa(String.fromCharCode(...randomBytes));
|
|
};
|
|
|
|
export const createPubsub = <PublishMap>() => {
|
|
type Action<Type extends keyof PublishMap> =
|
|
PublishMap[Type] extends undefined
|
|
? { type: Type; payload?: undefined }
|
|
: { type: Type; payload: PublishMap[Type] };
|
|
|
|
if (typeof window === "undefined") {
|
|
return {
|
|
publish: () => {
|
|
throw new Error("publish is not available in this environment");
|
|
},
|
|
usePublish: () => {
|
|
throw new Error("usePublish is not available in this environment");
|
|
},
|
|
useSubscribe: () => {
|
|
throw new Error("useSubscribe is not available in this environment");
|
|
},
|
|
subscribe: () => {
|
|
throw new Error("subscribe is not available in this environment");
|
|
},
|
|
} as never; // Prevent type exposure
|
|
}
|
|
|
|
/**
|
|
* To avoid postMessage interception from the canvas, i.e., `globalThis.postMessage = () => console.log('INTERCEPTED');`,
|
|
*/
|
|
const postMessageInternal = window.postMessage;
|
|
const parentPostMessageInternal = window.parent.postMessage;
|
|
|
|
/**
|
|
* Similar to a CSRF token, we use a token to ensure that the postMessage is coming from a trusted source.
|
|
*/
|
|
let token =
|
|
window.self === window.top ? getRandomToken() : window.top?.[apiTokenKey];
|
|
|
|
if (window.top) {
|
|
// Initialize token at the Builder, reset it on the Canvas after reading
|
|
window.top[apiTokenKey] = window.self === window.top ? token : undefined;
|
|
}
|
|
|
|
// Use a fixed token in development to handle HMR updates consistently
|
|
if (process.env.NODE_ENV !== "production") {
|
|
token = "development-token";
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const emitter = createNanoEvents<Record<any, any>>();
|
|
|
|
const wrapAction = (action: unknown) => {
|
|
return { action, token };
|
|
};
|
|
|
|
const unwrapAction = (payload: unknown) => {
|
|
if (typeof payload !== "object" || payload === null) {
|
|
if (process.env.IS_STROYBOOK) {
|
|
return { type: "storybook", payload: payload } as Action<
|
|
keyof PublishMap
|
|
>;
|
|
}
|
|
console.error("Invalid payload", payload);
|
|
throw new Error("Invalid payload");
|
|
}
|
|
|
|
if (false === "token" in payload) {
|
|
throw new Error("Invalid payload, not wrapped");
|
|
}
|
|
|
|
if (payload.token !== token) {
|
|
throw new Error("Invalid token");
|
|
}
|
|
|
|
if (false === "action" in payload) {
|
|
throw new Error("Invalid payload, not wrapped");
|
|
}
|
|
|
|
// Hide the token from the subsequent subscribers
|
|
payload.token = undefined;
|
|
return payload.action as Action<keyof PublishMap>;
|
|
};
|
|
|
|
const handleMessage = (event: MessageEvent) => {
|
|
const action = unwrapAction(event.data);
|
|
const type = action.type;
|
|
// Execute all updates within a single batch to improve performance
|
|
batchUpdate(() => {
|
|
emitter.emit(type, action.payload);
|
|
|
|
// Also emit command-specific events for type-safe subscriptions
|
|
if (
|
|
type === "command" &&
|
|
action.payload &&
|
|
typeof action.payload === "object" &&
|
|
"name" in action.payload
|
|
) {
|
|
const commandName = (action.payload as { name: string }).name;
|
|
// Pass the full payload to command-specific subscribers
|
|
emitter.emit(
|
|
`command:${commandName}` as keyof PublishMap,
|
|
action.payload
|
|
);
|
|
}
|
|
});
|
|
};
|
|
|
|
window.addEventListener("message", handleMessage, false);
|
|
|
|
return {
|
|
/**
|
|
* To publish a postMessage event on the current window and parent window from the iframe.
|
|
*/
|
|
publish<Type extends keyof PublishMap>(action: Action<Type>) {
|
|
invariant(
|
|
window.self !== window.top,
|
|
"publish is not available in the Builder environment"
|
|
);
|
|
|
|
parentPostMessageInternal(wrapAction(action), "*");
|
|
postMessageInternal(wrapAction(action), "*");
|
|
},
|
|
|
|
/**
|
|
* To publish a postMessage event on the iframe and parent window from the parent window.
|
|
*/
|
|
usePublish() {
|
|
const postMessageRef = useRef<undefined | typeof window.postMessage>(
|
|
undefined
|
|
);
|
|
|
|
const iframeRefCallback = useCallback(
|
|
(element: HTMLIFrameElement | null) => {
|
|
if (element == null) {
|
|
postMessageRef.current = undefined;
|
|
return;
|
|
}
|
|
/**
|
|
* To avoid postMessage interception from the canvas, i.e., `globalThis.postMessage = () => console.log('INTERCEPTED');`,
|
|
*/
|
|
postMessageRef.current = element.contentWindow!.postMessage;
|
|
},
|
|
[]
|
|
);
|
|
|
|
const publish = useCallback(
|
|
<Type extends keyof PublishMap>(action: Action<Type>) => {
|
|
invariant(
|
|
window.self === window.top,
|
|
"publish is not available in the Canvas environment"
|
|
);
|
|
|
|
if (postMessageRef.current === undefined) {
|
|
return;
|
|
}
|
|
// This ensures the method is called with the correct context.
|
|
const postMessageIframe = postMessageRef.current;
|
|
|
|
postMessageIframe(wrapAction(action), "*");
|
|
postMessageInternal(wrapAction(action), "*");
|
|
},
|
|
[]
|
|
);
|
|
return [publish, iframeRefCallback] as const;
|
|
},
|
|
|
|
/**
|
|
* To subscribe a message event on the current window.
|
|
*/
|
|
useSubscribe<Type extends keyof PublishMap>(
|
|
type: Type,
|
|
onAction: (payload: PublishMap[Type]) => void
|
|
) {
|
|
const handleOnAction = useEffectEvent(onAction);
|
|
|
|
useEffect(() => {
|
|
return emitter.on(type, handleOnAction);
|
|
}, [type, handleOnAction]);
|
|
},
|
|
|
|
subscribe<Type extends keyof PublishMap>(
|
|
type: Type,
|
|
onAction: (payload: PublishMap[Type]) => void
|
|
) {
|
|
return emitter.on(type, onAction);
|
|
},
|
|
};
|
|
};
|