mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-09 00:19:13 +08:00
253 lines
6.9 KiB
TypeScript
253 lines
6.9 KiB
TypeScript
import { window, document, location } from 'global';
|
|
import * as EVENTS from '@storybook/core-events';
|
|
import Channel, { ChannelEvent, ChannelHandler } from '@storybook/channels';
|
|
import { logger, pretty } from '@storybook/client-logger';
|
|
import { isJSON, parse, stringify } from 'telejson';
|
|
import qs from 'qs';
|
|
|
|
interface Config {
|
|
page: 'manager' | 'preview';
|
|
}
|
|
|
|
interface BufferedEvent {
|
|
event: ChannelEvent;
|
|
resolve: (value?: any) => void;
|
|
reject: (reason?: any) => void;
|
|
}
|
|
|
|
export const KEY = 'storybook-channel';
|
|
|
|
// TODO: we should export a method for opening child windows here and keep track of em.
|
|
// that way we can send postMessage to child windows as well, not just iframe
|
|
// https://stackoverflow.com/questions/6340160/how-to-get-the-references-of-all-already-opened-child-windows
|
|
|
|
export class PostmsgTransport {
|
|
private buffer: BufferedEvent[];
|
|
|
|
private handler: ChannelHandler;
|
|
|
|
private connected: boolean;
|
|
|
|
constructor(private readonly config: Config) {
|
|
this.buffer = [];
|
|
this.handler = null;
|
|
window.addEventListener('message', this.handleEvent.bind(this), false);
|
|
|
|
// Check whether the config.page parameter has a valid value
|
|
if (config.page !== 'manager' && config.page !== 'preview') {
|
|
throw new Error(`postmsg-channel: "config.page" cannot be "${config.page}"`);
|
|
}
|
|
}
|
|
|
|
setHandler(handler: ChannelHandler): void {
|
|
this.handler = (...args) => {
|
|
handler.apply(this, args);
|
|
|
|
if (!this.connected && this.getLocalFrame().length) {
|
|
this.flush();
|
|
this.connected = true;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sends `event` to the associated window. If the window does not yet exist
|
|
* the event will be stored in a buffer and sent when the window exists.
|
|
* @param event
|
|
*/
|
|
send(event: ChannelEvent, options?: any): Promise<any> {
|
|
let depth = 25;
|
|
let allowFunction = true;
|
|
let target;
|
|
|
|
if (options && typeof options.allowFunction === 'boolean') {
|
|
allowFunction = options.allowFunction;
|
|
}
|
|
if (options && Number.isInteger(options.depth)) {
|
|
depth = options.depth;
|
|
}
|
|
if (options && typeof options.target === 'string') {
|
|
target = options.target;
|
|
}
|
|
|
|
const frames = this.getFrames(target);
|
|
|
|
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
|
|
|
const data = stringify(
|
|
{
|
|
key: KEY,
|
|
event,
|
|
refId: query.refId,
|
|
},
|
|
{ maxDepth: depth, allowFunction }
|
|
);
|
|
|
|
if (!frames.length) {
|
|
return new Promise((resolve, reject) => {
|
|
this.buffer.push({ event, resolve, reject });
|
|
});
|
|
}
|
|
if (this.buffer.length) {
|
|
this.flush();
|
|
}
|
|
|
|
frames.forEach((f) => {
|
|
try {
|
|
f.postMessage(data, '*');
|
|
} catch (e) {
|
|
console.error('sending over postmessage fail');
|
|
}
|
|
});
|
|
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
private flush(): void {
|
|
const { buffer } = this;
|
|
this.buffer = [];
|
|
buffer.forEach((item) => {
|
|
this.send(item.event).then(item.resolve).catch(item.reject);
|
|
});
|
|
}
|
|
|
|
private getFrames(target?: string): Window[] {
|
|
if (this.config.page === 'manager') {
|
|
const nodes: HTMLIFrameElement[] = [
|
|
...document.querySelectorAll('iframe[data-is-storybook][data-is-loaded]'),
|
|
];
|
|
|
|
const list = nodes
|
|
.filter((e) => {
|
|
try {
|
|
return !!e.contentWindow && e.dataset.isStorybook !== undefined && e.id === target;
|
|
} catch (er) {
|
|
return false;
|
|
}
|
|
})
|
|
.map((e) => e.contentWindow);
|
|
|
|
return list.length ? list : this.getCurrentFrames();
|
|
}
|
|
if (window && window.parent && window.parent !== window) {
|
|
return [window.parent];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private getCurrentFrames(): Window[] {
|
|
if (this.config.page === 'manager') {
|
|
const list: HTMLIFrameElement[] = [
|
|
...document.querySelectorAll('[data-is-storybook="true"]'),
|
|
];
|
|
return list.map((e) => e.contentWindow);
|
|
}
|
|
if (window && window.parent) {
|
|
return [window.parent];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private getLocalFrame(): Window[] {
|
|
if (this.config.page === 'manager') {
|
|
const list: HTMLIFrameElement[] = [...document.querySelectorAll('#storybook-preview-iframe')];
|
|
return list.map((e) => e.contentWindow);
|
|
}
|
|
if (window && window.parent) {
|
|
return [window.parent];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private handleEvent(rawEvent: MessageEvent): void {
|
|
try {
|
|
const { data } = rawEvent;
|
|
const { key, event, refId } = typeof data === 'string' && isJSON(data) ? parse(data) : data;
|
|
|
|
if (key === KEY) {
|
|
const pageString =
|
|
this.config.page === 'manager'
|
|
? `<span style="color: #37D5D3; background: black"> manager </span>`
|
|
: `<span style="color: #1EA7FD; background: black"> preview </span>`;
|
|
|
|
const eventString = Object.values(EVENTS).includes(event.type)
|
|
? `<span style="color: #FF4785">${event.type}</span>`
|
|
: `<span style="color: #FFAE00">${event.type}</span>`;
|
|
|
|
if (refId) {
|
|
event.refId = refId;
|
|
}
|
|
|
|
event.source =
|
|
this.config.page === 'preview' ? rawEvent.origin : getEventSourceUrl(rawEvent);
|
|
|
|
if (!event.source) {
|
|
pretty.error(
|
|
`${pageString} received ${eventString} but was unable to determine the source of the event`
|
|
);
|
|
|
|
return;
|
|
}
|
|
pretty.debug(
|
|
location.origin !== event.source
|
|
? `${pageString} received ${eventString}`
|
|
: `${pageString} received ${eventString} <span style="color: gray">(on ${location.origin} from ${event.source})</span>`,
|
|
...event.args
|
|
);
|
|
|
|
this.handler(event);
|
|
}
|
|
} catch (error) {
|
|
logger.error(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
const getEventSourceUrl = (event: MessageEvent) => {
|
|
const frames = [...document.querySelectorAll('iframe[data-is-storybook]')];
|
|
// try to find the originating iframe by matching it's contentWindow
|
|
// This might not be cross-origin safe
|
|
const [frame, ...remainder] = frames.filter((element) => {
|
|
try {
|
|
return element.contentWindow === event.source;
|
|
} catch (err) {
|
|
// continue
|
|
}
|
|
|
|
const src = element.getAttribute('src');
|
|
let origin;
|
|
|
|
try {
|
|
({ origin } = new URL(src, document.location));
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
return origin === event.origin;
|
|
});
|
|
|
|
if (frame && remainder.length === 0) {
|
|
const src = frame.getAttribute('src');
|
|
const { origin, pathname } = new URL(src, document.location);
|
|
return origin + pathname;
|
|
}
|
|
|
|
if (remainder.length > 0) {
|
|
// If we found multiple matches, there's going to be trouble
|
|
logger.error('found multiple candidates for event source');
|
|
}
|
|
|
|
// If we found no frames of matches
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Creates a channel which communicates with an iframe or child window.
|
|
*/
|
|
export default function createChannel({ page }: Config): Channel {
|
|
const transport = new PostmsgTransport({ page });
|
|
return new Channel({ transport });
|
|
}
|