Refactors and tests

This commit is contained in:
Lukasz Ostrowski 2022-08-11 23:14:41 +02:00
parent 983dba1126
commit d445653c2d
6 changed files with 260 additions and 226 deletions

View file

@ -1,4 +1,4 @@
import { Events, EventType, PayloadOfEvent, ThemeType } from "./events";
import { ThemeType } from "./events";
export type AppBridgeState = {
token?: string;
@ -8,43 +8,6 @@ export type AppBridgeState = {
path: string;
theme: ThemeType;
};
type EventCallback<TPayload extends {} = {}> = (data: TPayload) => void;
type SubscribeMap = {
[type in EventType]: Record<symbol, EventCallback<PayloadOfEvent<type>>>;
};
function reducer(state: AppBridgeState, event: Events) {
switch (event.type) {
case EventType.handshake: {
const newState = {
...state,
ready: true,
token: event.payload.token,
};
return newState;
}
case EventType.redirect: {
const newState = {
...state,
path: event.payload.path,
};
return newState;
}
case EventType.theme: {
const newState = {
...state,
theme: event.payload.theme,
};
return newState;
}
default: {
return state;
}
}
}
export class AppBridgeStateContainer {
private state: AppBridgeState = {
@ -55,96 +18,6 @@ export class AppBridgeStateContainer {
theme: "light",
};
private subscribeMap: SubscribeMap = {
handshake: {},
response: {},
redirect: {},
theme: {},
};
private refererOrigin: string | null = null;
constructor() {
try {
this.refererOrigin = new URL(document.referrer).origin;
} catch (e) {
// TODO probably throw
console.warn("document.referrer is empty");
}
this.listenOnMessages();
}
/**
* TODO Move to higher instance
* @private
*/
private listenOnMessages() {
window.addEventListener(
"message",
({ origin, data }: Omit<MessageEvent, "data"> & { data: Events }) => {
// check if event origin matches the document referer
if (origin !== this.refererOrigin) {
// TODO what should happen here - be explicit
return;
}
this.state = reducer(this.state, data);
/**
* TODO Validate and warn/throw
*/
const { type, payload } = data;
if (EventType[type]) {
Object.getOwnPropertySymbols(this.subscribeMap[type]).forEach((key) =>
// @ts-ignore fixme
this.subscribeMap[type][key](payload)
);
}
}
);
}
/**
* Subscribes to an Event.
*
* @param eventType - Event type.
* @param cb - Callback that executes when Event is registered. Called with Event payload object.
* @returns Unsubscribe function. Call to unregister the callback.
*/
subscribe<TEventType extends EventType, TPayload extends PayloadOfEvent<TEventType>>(
eventType: TEventType,
cb: EventCallback<TPayload>
) {
const key = Symbol("Callback token");
// @ts-ignore fixme
this.subscribeMap[eventType][key] = cb;
return () => {
delete this.subscribeMap[eventType][key];
};
}
/**
* Unsubscribe to all Events of type.
* If type not provider, unsubscribe all
*
* @param eventType - (optional) Event type. If empty, all callbacks will be unsubscribed.
*/
unsubscribeAll(eventType?: EventType) {
if (eventType) {
this.subscribeMap[eventType] = {};
} else {
this.subscribeMap = {
handshake: {},
response: {},
redirect: {},
theme: {},
};
}
}
getState() {
return this.state;
}

View file

@ -1,5 +1,5 @@
import { fireEvent } from "@testing-library/dom";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
// mock document.referrer
const origin = "http://example.com";
@ -16,11 +16,23 @@ Object.defineProperty(window, "location", {
});
// eslint-disable-next-line
import { actions, DispatchResponseEvent, createApp } from ".";
import { actions, DispatchResponseEvent, createApp, HandshakeEvent } from ".";
const handshakeEvent: HandshakeEvent = {
payload: {
token: "mock-token",
version: 1,
},
type: "handshake",
};
describe("createApp", () => {
const domain = "saleor.domain.host";
const app = createApp(domain);
let app = createApp(domain);
beforeEach(() => {
app = createApp(domain);
});
it("correctly sets the domain", () => {
expect(app.getState().domain).toEqual(domain);
@ -42,23 +54,19 @@ describe("createApp", () => {
expect(app.getState().token).toEqual(token);
});
it("subscribes to an event and returns unsubcribe function", () => {
// subscribe
it("subscribes to an event and returns unsubscribe function", () => {
const callback = vi.fn();
const unsubscribe = app.subscribe("handshake", callback);
expect(callback).not.toHaveBeenCalled();
const token = "fresh-token";
// correct event
const payload = {
token,
version: 1,
};
// First call proper event
fireEvent(
window,
new MessageEvent("message", {
data: { type: "handshake", payload },
data: handshakeEvent,
origin,
})
);
@ -82,11 +90,10 @@ describe("createApp", () => {
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(payload);
expect(app.getState().token).toEqual(token);
expect(callback).toHaveBeenCalledWith(handshakeEvent.payload);
expect(app.getState().token).toEqual(handshakeEvent.payload.token);
expect(app.getState().id).toEqual("appid");
// unsubscribe
unsubscribe();
fireEvent(
@ -129,4 +136,36 @@ describe("createApp", () => {
it("times out after action response has not been registered", () =>
expect(app.dispatch(actions.Redirect({ to: "/test" }))).rejects.toBeInstanceOf(Error));
it("unsubscribes from all listeners", () => {
const cb1 = vi.fn();
const cb2 = vi.fn();
app.subscribe("handshake", cb1);
app.subscribe("handshake", cb2);
fireEvent(
window,
new MessageEvent("message", {
data: handshakeEvent,
origin,
})
);
expect(cb1).toHaveBeenCalledTimes(1);
expect(cb2).toHaveBeenCalledTimes(1);
app.unsubscribeAll();
fireEvent(
window,
new MessageEvent("message", {
data: handshakeEvent,
origin,
})
);
expect(cb1).toHaveBeenCalledTimes(1);
expect(cb2).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,200 @@
import { Actions } from "./actions";
import { AppBridgeState, AppBridgeStateContainer } from "./app-bridge-state";
import { SSR } from "./constants";
import { Events, EventType, PayloadOfEvent, ThemeType } from "./events";
const DISPATCH_RESPONSE_TIMEOUT = 1000;
type EventCallback<TPayload extends {} = {}> = (data: TPayload) => void;
type SubscribeMap = {
[type in EventType]: Record<symbol, EventCallback<PayloadOfEvent<type>>>;
};
function eventStateReducer(state: AppBridgeState, event: Events) {
switch (event.type) {
case EventType.handshake: {
return {
...state,
ready: true,
token: event.payload.token,
};
}
case EventType.redirect: {
return {
...state,
path: event.payload.path,
};
}
case EventType.theme: {
return {
...state,
theme: event.payload.theme,
};
}
case EventType.response: {
return state;
}
default: {
/**
* Event comes from API, so always assume it can be something not covered by TS
*/
console.warn(`Invalid event received: ${(event as any)?.type}`);
return state;
}
}
}
const createEmptySubscribeMap = (): SubscribeMap => ({
handshake: {},
response: {},
redirect: {},
theme: {},
});
export class AppBridge {
private state = new AppBridgeStateContainer();
private refererOrigin = new URL(document.referrer).origin;
private subscribeMap = createEmptySubscribeMap();
constructor(private targetDomain?: string) {
if (SSR) {
throw new Error(
"AppBridge detected you're running this app in SSR mode. Make sure to call `new AppBridge()` when window object exists."
);
}
if (!targetDomain) {
this.targetDomain = new URL(window.location.href).searchParams.get("domain") || "";
}
if (!this.refererOrigin) {
// TODO probably throw
console.warn("document.referrer is empty");
}
this.setInitialState();
this.listenOnMessages();
}
/**
* Subscribes to an Event.
*
* @param eventType - Event type.
* @param cb - Callback that executes when Event is registered. Called with Event payload object.
* @returns Unsubscribe function. Call to unregister the callback.
*/
subscribe<TEventType extends EventType, TPayload extends PayloadOfEvent<TEventType>>(
eventType: TEventType,
cb: EventCallback<TPayload>
) {
const key = Symbol("Callback token");
// @ts-ignore fixme
this.subscribeMap[eventType][key] = cb;
return () => {
delete this.subscribeMap[eventType][key];
};
}
/**
* Unsubscribe to all Events of type.
* If type not provider, unsubscribe all
*
* @param eventType - (optional) Event type. If empty, all callbacks will be unsubscribed.
*/
unsubscribeAll(eventType?: EventType) {
if (eventType) {
this.subscribeMap[eventType] = {};
} else {
this.subscribeMap = createEmptySubscribeMap();
}
}
/**
* Dispatch event to dashboard
*/
async dispatch<T extends Actions>(action: T) {
return new Promise<void>((resolve, reject) => {
if (!window.parent) {
reject(new Error("Parent window does not exist."));
} else {
window.parent.postMessage(
{
type: action.type,
payload: action.payload,
},
"*"
);
let intervalId: number;
const unsubscribe = this.subscribe(EventType.response, ({ actionId, ok }) => {
if (action.payload.actionId === actionId) {
unsubscribe();
clearInterval(intervalId);
if (ok) {
resolve();
} else {
reject(
new Error(
"Action responded with negative status. This indicates the action method was not used properly."
)
);
}
}
});
intervalId = window.setInterval(() => {
unsubscribe();
reject(new Error("Action response timed out."));
}, DISPATCH_RESPONSE_TIMEOUT);
}
});
}
/**
* Gets current state
*/
getState() {
return this.state.getState();
}
private setInitialState() {
const url = new URL(window.location.href);
const id = url.searchParams.get("id") || "";
const path = window.location.pathname || "";
const theme: ThemeType = url.searchParams.get("theme") === "light" ? "light" : "dark";
this.state.setState({ domain: this.targetDomain, id, path, theme });
}
private listenOnMessages() {
window.addEventListener(
"message",
({ origin, data }: Omit<MessageEvent, "data"> & { data: Events }) => {
if (origin !== this.refererOrigin) {
// TODO what should happen here - be explicit
return;
}
const newState = eventStateReducer(this.state.getState(), data);
this.state.setState(newState);
/**
* TODO Validate and warn/throw
*/
const { type, payload } = data;
if (EventType[type]) {
Object.getOwnPropertySymbols(this.subscribeMap[type]).forEach((key) =>
// @ts-ignore fixme
this.subscribeMap[type][key](payload)
);
}
}
);
}
}

View file

@ -8,6 +8,7 @@ export const EventType = {
redirect: "redirect",
theme: "theme",
} as const;
export type EventType = Values<typeof EventType>;
type Event<Name extends EventType, Payload extends {}> = {

View file

@ -1,85 +1,4 @@
import { Actions } from "./actions";
import { AppBridgeStateContainer } from "./app-bridge-state";
import { SSR } from "./constants";
import { EventType, ThemeType } from "./events";
export function createApp(targetDomain?: string) {
const appBridgeState = new AppBridgeStateContainer();
if (SSR) {
throw new Error(
"AppBridge detected you're running this app in SSR mode. Make sure to call `createApp` when window object exists."
);
}
let domain: string;
const url = new URL(window.location.href);
const id = url.searchParams.get("id") || "";
const path = window.location.pathname || "";
const theme: ThemeType = url.searchParams.get("theme") === "light" ? "light" : "dark";
if (targetDomain) {
domain = targetDomain;
} else {
domain = url.searchParams.get("domain") || "";
}
appBridgeState.setState({ domain, id, path, theme });
/**
* Dispatches Action to Saleor Dashboard.
*
* @param action - Action containing type and payload.
* @returns Promise resolved when Action is successfully completed.
*/
async function dispatch<T extends Actions>(action: T) {
return new Promise<void>((resolve, reject) => {
if (window.parent) {
window.parent.postMessage(
{
type: action.type,
payload: action.payload,
},
"*"
);
let intervalId: NodeJS.Timer;
const unsubscribe = appBridgeState.subscribe(EventType.response, ({ actionId, ok }) => {
if (action.payload.actionId === actionId) {
unsubscribe();
clearInterval(intervalId);
if (ok) {
resolve();
} else {
reject(
new Error(
"Error: Action responded with negative status. This indicates the action method was not used properly."
)
);
}
}
});
// If dashboard doesn't respond within 1 second, reject and unsubscribe
intervalId = setInterval(() => {
unsubscribe();
reject(new Error("Error: Action response timed out."));
}, 1000);
} else {
reject(new Error("Error: Parent window does not exist."));
}
});
}
return {
dispatch,
subscribe: appBridgeState.subscribe.bind(appBridgeState),
unsubscribeAll: appBridgeState.unsubscribeAll.bind(appBridgeState),
getState: appBridgeState.getState.bind(appBridgeState),
};
}
import { AppBridge } from "./app-bridge";
export * from "./actions";
export * from "./events";
@ -87,5 +6,7 @@ export * from "./types";
/**
* @deprecated avoid default functions in SDKs
* TODO: Expose AppBridge()
*/
export const createApp = (targetDomain?: string) => new AppBridge(targetDomain);
export default createApp;

View file

@ -1,5 +1,5 @@
import { createApp } from ".";
import { AppBridge } from "./app-bridge";
import { AppBridgeState } from "./app-bridge-state";
export type App = ReturnType<typeof createApp>;
export type App = typeof AppBridge;
export { AppBridgeState };