Refactor app bridge state container

This commit is contained in:
Lukasz Ostrowski 2022-08-11 22:19:11 +02:00
parent af68bedfb3
commit 983dba1126
6 changed files with 218 additions and 176 deletions

View file

@ -7,6 +7,7 @@ export const ActionType = {
redirect: "redirect",
notification: "notification",
} as const;
export type ActionType = Values<typeof ActionType>;
type Action<Name extends ActionType, Payload extends {}> = {
@ -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,
};

View file

@ -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<AppBridgeState> = {
domain: "https://my-saleor-instance.cloud",
id: "foo-bar",
path: "/",
theme: "light",
};
instance.setState(newState);
expect(instance.getState()).toEqual(expect.objectContaining(newState));
});
});

View file

@ -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<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 = {
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<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;
}
setState(newState: Partial<AppBridgeState>) {
this.state = {
...this.state,
...newState,
};
return this.state;
}
}

View file

@ -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<TPayload extends {} = {}> = (data: TPayload) => void;
type SubscribeMap = {
[type in EventType]: Record<any, 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 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<MessageEvent, "data"> & { 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<TEventType extends EventType, TPayload extends PayloadOfEvent<TEventType>>(
eventType: TEventType,
cb: EventCallback<TPayload>
) {
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<AppBridgeState>) {
state = {
...state,
...newState,
};
return state;
}
return {
subscribe,
unsubscribeAll,
getState,
setState,
};
})();

View file

@ -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;

View file

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