From 983dba1126f779187f7b8154e1856136d3cd2a2f Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Thu, 11 Aug 2022 22:19:11 +0200 Subject: [PATCH] Refactor app bridge state container --- src/app-bridge/actions.ts | 10 +- src/app-bridge/app-bridge-state.test.ts | 32 +++++ src/app-bridge/app-bridge-state.ts | 160 +++++++++++++++++++++++ src/app-bridge/app.ts | 165 ------------------------ src/app-bridge/index.ts | 25 +++- src/app-bridge/types.ts | 2 +- 6 files changed, 218 insertions(+), 176 deletions(-) create mode 100644 src/app-bridge/app-bridge-state.test.ts create mode 100644 src/app-bridge/app-bridge-state.ts delete mode 100644 src/app-bridge/app.ts diff --git a/src/app-bridge/actions.ts b/src/app-bridge/actions.ts index c9da8ec..3360507 100644 --- a/src/app-bridge/actions.ts +++ b/src/app-bridge/actions.ts @@ -7,6 +7,7 @@ export const ActionType = { redirect: "redirect", notification: "notification", } as const; + export type ActionType = Values; type Action = { @@ -44,7 +45,8 @@ export type RedirectPayload = { * Redirects Dashboard user. */ export type RedirectAction = ActionWithId<"redirect", RedirectPayload>; -function Redirect(payload: RedirectPayload): RedirectAction { + +function createRedirectAction(payload: RedirectPayload): RedirectAction { return withActionId({ payload, type: "redirect", @@ -65,7 +67,7 @@ export type NotificationAction = ActionWithId<"notification", NotificationPayloa /** * Shows a notification using Dashboard's notification system. */ -function Notification(payload: NotificationPayload): NotificationAction { +function createNotificationAction(payload: NotificationPayload): NotificationAction { return withActionId({ type: "notification", payload, @@ -75,6 +77,6 @@ function Notification(payload: NotificationPayload): NotificationAction { export type Actions = RedirectAction | NotificationAction; export const actions = { - Redirect, - Notification, + Redirect: createRedirectAction, + Notification: createNotificationAction, }; diff --git a/src/app-bridge/app-bridge-state.test.ts b/src/app-bridge/app-bridge-state.test.ts new file mode 100644 index 0000000..cb1cdd5 --- /dev/null +++ b/src/app-bridge/app-bridge-state.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { AppBridgeState, AppBridgeStateContainer } from "./app-bridge-state"; + +describe("app-bridge-state.ts", () => { + it("Creates with default state", () => { + const instance = new AppBridgeStateContainer(); + + expect(instance.getState()).toEqual({ + id: "", + domain: "", + ready: false, + path: "/", + theme: "light", + }); + }); + + it("Can update state", () => { + const instance = new AppBridgeStateContainer(); + + const newState: Partial = { + domain: "https://my-saleor-instance.cloud", + id: "foo-bar", + path: "/", + theme: "light", + }; + + instance.setState(newState); + + expect(instance.getState()).toEqual(expect.objectContaining(newState)); + }); +}); diff --git a/src/app-bridge/app-bridge-state.ts b/src/app-bridge/app-bridge-state.ts new file mode 100644 index 0000000..e95ee4a --- /dev/null +++ b/src/app-bridge/app-bridge-state.ts @@ -0,0 +1,160 @@ +import { Events, EventType, PayloadOfEvent, ThemeType } from "./events"; + +export type AppBridgeState = { + token?: string; + id: string; + ready: boolean; + domain: string; + 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 = { + id: "", + domain: "", + ready: false, + path: "/", + 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; + } + + setState(newState: Partial) { + this.state = { + ...this.state, + ...newState, + }; + + return this.state; + } +} diff --git a/src/app-bridge/app.ts b/src/app-bridge/app.ts deleted file mode 100644 index 2335802..0000000 --- a/src/app-bridge/app.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { SSR } from "./constants"; -import { Events, EventType, PayloadOfEvent, ThemeType } from "./events"; - -export type AppBridgeState = { - token?: string; - id: string; - ready: boolean; - domain: string; - 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 const app = (() => { - if (SSR) { - console.warn( - "@saleor/app-bridge detected you're running this app in SSR mode. Make sure to call `createApp` when window object exists." - ); - return null as never; - } - - let state: AppBridgeState = { - id: "", - domain: "", - ready: false, - path: "/", - theme: "light", - }; - let subscribeMap: SubscribeMap = { - handshake: {}, - response: {}, - redirect: {}, - theme: {}, - }; - let refererOrigin: string; - - try { - refererOrigin = new URL(document.referrer).origin; - } catch (e) { - console.warn("document.referrer is empty"); - } - - window.addEventListener( - "message", - // Generic MessageEvent is not supported by tsdx's TS version - ({ origin, data }: Omit & { data: Events }) => { - // check if event origin matches the document referer - if (origin !== refererOrigin) { - return; - } - - // compute new state - state = reducer(state, data); - - // run callbacks - const { type, payload } = data; - // We know this is valid object, su supress - // eslint-disable-next-line no-prototype-builtins - if (EventType.hasOwnProperty(type)) { - Object.getOwnPropertySymbols(subscribeMap[type]).forEach((key) => - // @ts-ignore - 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. - */ - function subscribe>( - eventType: TEventType, - cb: EventCallback - ) { - const key = Symbol("Callback token") as unknown as string; // https://github.com/Microsoft/TypeScript/issues/24587 - // @ts-ignore - subscribeMap[eventType][key] = cb; - - return () => { - delete subscribeMap[eventType][key]; - }; - } - - /** - * Unsubscribe to all Events of type. - * - * @param eventType - (optional) Event type. If empty, all callbacks will be unsubscribed. - */ - function unsubscribeAll(eventType?: EventType) { - if (eventType) { - subscribeMap[eventType] = {}; - } else { - subscribeMap = { - handshake: {}, - response: {}, - redirect: {}, - theme: {}, - }; - } - } - - /** - * Gets current state. - * - * @returns State object. - */ - function getState() { - return state; - } - - function setState(newState: Partial) { - state = { - ...state, - ...newState, - }; - - return state; - } - return { - subscribe, - unsubscribeAll, - getState, - setState, - }; -})(); diff --git a/src/app-bridge/index.ts b/src/app-bridge/index.ts index fa5ea66..fb8fe71 100644 --- a/src/app-bridge/index.ts +++ b/src/app-bridge/index.ts @@ -1,8 +1,17 @@ import { Actions } from "./actions"; -import { app } from "./app"; +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") || ""; @@ -15,7 +24,7 @@ export function createApp(targetDomain?: string) { domain = url.searchParams.get("domain") || ""; } - app.setState({ domain, id, path, theme }); + appBridgeState.setState({ domain, id, path, theme }); /** * Dispatches Action to Saleor Dashboard. @@ -36,7 +45,7 @@ export function createApp(targetDomain?: string) { let intervalId: NodeJS.Timer; - const unsubscribe = app.subscribe(EventType.response, ({ actionId, ok }) => { + const unsubscribe = appBridgeState.subscribe(EventType.response, ({ actionId, ok }) => { if (action.payload.actionId === actionId) { unsubscribe(); clearInterval(intervalId); @@ -66,13 +75,17 @@ export function createApp(targetDomain?: string) { return { dispatch, - subscribe: app.subscribe, - unsubscribeAll: app.unsubscribeAll, - getState: app.getState, + subscribe: appBridgeState.subscribe.bind(appBridgeState), + unsubscribeAll: appBridgeState.unsubscribeAll.bind(appBridgeState), + getState: appBridgeState.getState.bind(appBridgeState), }; } export * from "./actions"; export * from "./events"; export * from "./types"; + +/** + * @deprecated avoid default functions in SDKs + */ export default createApp; diff --git a/src/app-bridge/types.ts b/src/app-bridge/types.ts index 3ed1995..4e6283a 100644 --- a/src/app-bridge/types.ts +++ b/src/app-bridge/types.ts @@ -1,5 +1,5 @@ import { createApp } from "."; -import { AppBridgeState } from "./app"; +import { AppBridgeState } from "./app-bridge-state"; export type App = ReturnType; export { AppBridgeState };