From d445653c2de27cbbfb981c1859e7e030d1e332c4 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Thu, 11 Aug 2022 23:14:41 +0200 Subject: [PATCH] Refactors and tests --- src/app-bridge/app-bridge-state.ts | 129 +---------- .../{index.test.ts => app-bridge.test.ts} | 67 ++++-- src/app-bridge/app-bridge.ts | 200 ++++++++++++++++++ src/app-bridge/events.ts | 1 + src/app-bridge/index.ts | 85 +------- src/app-bridge/types.ts | 4 +- 6 files changed, 260 insertions(+), 226 deletions(-) rename src/app-bridge/{index.test.ts => app-bridge.test.ts} (68%) create mode 100644 src/app-bridge/app-bridge.ts diff --git a/src/app-bridge/app-bridge-state.ts b/src/app-bridge/app-bridge-state.ts index e95ee4a..d61c57a 100644 --- a/src/app-bridge/app-bridge-state.ts +++ b/src/app-bridge/app-bridge-state.ts @@ -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 = (data: TPayload) => void; -type SubscribeMap = { - [type in EventType]: Record>>; -}; - -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 & { 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>( - eventType: TEventType, - cb: EventCallback - ) { - 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; } diff --git a/src/app-bridge/index.test.ts b/src/app-bridge/app-bridge.test.ts similarity index 68% rename from src/app-bridge/index.test.ts rename to src/app-bridge/app-bridge.test.ts index acc28e0..295d5f6 100644 --- a/src/app-bridge/index.test.ts +++ b/src/app-bridge/app-bridge.test.ts @@ -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); + }); }); diff --git a/src/app-bridge/app-bridge.ts b/src/app-bridge/app-bridge.ts new file mode 100644 index 0000000..36b6287 --- /dev/null +++ b/src/app-bridge/app-bridge.ts @@ -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 = (data: TPayload) => void; +type SubscribeMap = { + [type in EventType]: Record>>; +}; + +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>( + eventType: TEventType, + cb: EventCallback + ) { + 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(action: T) { + return new Promise((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 & { 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) + ); + } + } + ); + } +} diff --git a/src/app-bridge/events.ts b/src/app-bridge/events.ts index 5d230e9..e66c615 100644 --- a/src/app-bridge/events.ts +++ b/src/app-bridge/events.ts @@ -8,6 +8,7 @@ export const EventType = { redirect: "redirect", theme: "theme", } as const; + export type EventType = Values; type Event = { diff --git a/src/app-bridge/index.ts b/src/app-bridge/index.ts index fb8fe71..062b683 100644 --- a/src/app-bridge/index.ts +++ b/src/app-bridge/index.ts @@ -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(action: T) { - return new Promise((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; diff --git a/src/app-bridge/types.ts b/src/app-bridge/types.ts index 4e6283a..9be66a8 100644 --- a/src/app-bridge/types.ts +++ b/src/app-bridge/types.ts @@ -1,5 +1,5 @@ -import { createApp } from "."; +import { AppBridge } from "./app-bridge"; import { AppBridgeState } from "./app-bridge-state"; -export type App = ReturnType; +export type App = typeof AppBridge; export { AppBridgeState };