move app-bridge code and tests to app-sdk

This commit is contained in:
Lukasz Ostrowski 2022-08-11 14:03:51 +02:00
parent a59519aede
commit 8110818251
14 changed files with 3322 additions and 126 deletions

View file

@ -57,7 +57,7 @@
"import/no-cycle": "off", // pathpidia issue "import/no-cycle": "off", // pathpidia issue
"import/prefer-default-export": "off", "import/prefer-default-export": "off",
"@typescript-eslint/no-misused-promises": ["error"], "@typescript-eslint/no-misused-promises": ["error"],
"@typescript-eslint/no-floating-promises": ["error"] "@typescript-eslint/no-floating-promises": ["error"],
}, },
"settings": { "settings": {
"import/parsers": { "import/parsers": {

View file

@ -10,7 +10,8 @@
"build": "tsup-node src/* --format esm,cjs --dts && clear-package-json package.json -o dist/package.json --fields publishConfig", "build": "tsup-node src/* --format esm,cjs --dts && clear-package-json package.json -o dist/package.json --fields publishConfig",
"test": "vitest", "test": "vitest",
"prepare": "husky install", "prepare": "husky install",
"lint": "prettier --loglevel warn --write . && eslint --fix ." "lint": "prettier --loglevel warn --write . && eslint --fix .",
"release": "np"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -19,7 +20,8 @@
"fast-glob": "^3.2.11", "fast-glob": "^3.2.11",
"graphql": "^16.5.0", "graphql": "^16.5.0",
"jose": "^4.8.3", "jose": "^4.8.3",
"retes": "^0.32.0" "retes": "^0.32.0",
"uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^18.6.5", "@types/node": "^18.6.5",
@ -40,7 +42,14 @@
"tsm": "^2.2.2", "tsm": "^2.2.2",
"tsup": "^6.2.1", "tsup": "^6.2.1",
"typescript": "^4.7.4", "typescript": "^4.7.4",
"vitest": "^0.21.1" "vitest": "^0.21.1",
"watchlist": "^0.3.1",
"np": "^7.6.1",
"@types/uuid": "^8.3.4",
"@testing-library/dom": "^8.17.1",
"@vitejs/plugin-react": "^2.0.0",
"jsdom": "^20.0.0",
"vite": "^3.0.5"
}, },
"lint-staged": { "lint-staged": {
"*.{js,ts,tsx}": "eslint --cache --fix", "*.{js,ts,tsx}": "eslint --cache --fix",
@ -63,6 +72,11 @@
"import": "./urls.mjs", "import": "./urls.mjs",
"require": "./urls.js" "require": "./urls.js"
}, },
"./app-bridge": {
"types": "./app-bridge/index.d.ts",
"import": "./app-bridge/index.mjs",
"require": "./app-bridge/index.js"
},
".": { ".": {
"types": "./index.d.ts", "types": "./index.d.ts",
"import": "./index.mjs", "import": "./index.mjs",

File diff suppressed because it is too large Load diff

80
src/app-bridge/actions.ts Normal file
View file

@ -0,0 +1,80 @@
import { v4 as uuidv4 } from "uuid";
import { Values } from "./helpers";
// Using constants over Enums, more info: https://fettblog.eu/tidy-typescript-avoid-enums/
export const ActionType = {
redirect: "redirect",
notification: "notification",
} as const;
export type ActionType = Values<typeof ActionType>;
type Action<Name extends ActionType, Payload extends {}> = {
payload: Payload;
type: Name;
};
type ActionWithId<Name extends ActionType, Payload extends {}> = {
payload: Payload & { actionId: string };
type: Name;
};
function withActionId<Name extends ActionType, Payload extends {}, T extends Action<Name, Payload>>(
action: T
): ActionWithId<Name, Payload> {
const actionId = uuidv4();
return {
...action,
payload: {
...action.payload,
actionId,
},
};
}
export type RedirectPayload = {
/**
* Relative (inside Dashboard) or absolute URL path.
*/
to: string;
newContext?: boolean;
};
/**
* Redirects Dashboard user.
*/
export type RedirectAction = ActionWithId<"redirect", RedirectPayload>;
function Redirect(payload: RedirectPayload): RedirectAction {
return withActionId({
payload,
type: "redirect",
});
}
export type NotificationPayload = {
/**
* Matching Dashboard's notification object.
*/
status?: "info" | "success" | "warning" | "error";
title?: string;
text?: string;
apiMessage?: string;
};
export type NotificationAction = ActionWithId<"notification", NotificationPayload>;
/**
* Shows a notification using Dashboard's notification system.
*/
function Notification(payload: NotificationPayload): NotificationAction {
return withActionId({
type: "notification",
payload,
});
}
export type Actions = RedirectAction | NotificationAction;
export const actions = {
Redirect,
Notification,
};

165
src/app-bridge/app.ts Normal file
View file

@ -0,0 +1,165 @@
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

@ -0,0 +1 @@
export const SSR = typeof window === "undefined";

55
src/app-bridge/events.ts Normal file
View file

@ -0,0 +1,55 @@
import { Values } from "./helpers";
export type Version = 1;
export const EventType = {
handshake: "handshake",
response: "response",
redirect: "redirect",
theme: "theme",
} as const;
export type EventType = Values<typeof EventType>;
type Event<Name extends EventType, Payload extends {}> = {
payload: Payload;
type: Name;
};
export type HandshakeEvent = Event<
"handshake",
{
token: string;
version: Version;
}
>;
export type DispatchResponseEvent = Event<
"response",
{
actionId: string;
ok: boolean;
}
>;
export type RedirectEvent = Event<
"redirect",
{
path: string;
}
>;
export type ThemeType = "light" | "dark";
export type ThemeEvent = Event<
"theme",
{
theme: ThemeType;
}
>;
export type Events = HandshakeEvent | DispatchResponseEvent | RedirectEvent | ThemeEvent;
export type PayloadOfEvent<
TEventType extends EventType,
TEvent extends Events = Events
// @ts-ignore TODO - why this is not working with this tsconfig? Fixme
> = TEvent extends Event<TEventType, any> ? TEvent["payload"] : never;

View file

@ -0,0 +1 @@
export type Values<T> = T[keyof T];

View file

@ -0,0 +1,132 @@
import { fireEvent } from "@testing-library/dom";
import { describe, expect, it, vi } from "vitest";
// mock document.referrer
const origin = "http://example.com";
Object.defineProperty(window.document, "referrer", {
value: origin,
writable: true,
});
Object.defineProperty(window, "location", {
value: {
href: `${origin}?domain=saleor.domain&id=appid`,
},
writable: true,
});
// eslint-disable-next-line
import { actions, DispatchResponseEvent, createApp } from ".";
describe("createApp", () => {
const domain = "saleor.domain.host";
const app = createApp(domain);
it("correctly sets the domain", () => {
expect(app.getState().domain).toEqual(domain);
});
it("authenticates", () => {
expect(app.getState().ready).toBe(false);
const token = "test-token";
fireEvent(
window,
new MessageEvent("message", {
data: { type: "handshake", payload: { token } },
origin,
})
);
expect(app.getState().ready).toBe(true);
expect(app.getState().token).toEqual(token);
});
it("subscribes to an event and returns unsubcribe function", () => {
// subscribe
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,
};
fireEvent(
window,
new MessageEvent("message", {
data: { type: "handshake", payload },
origin,
})
);
// incorrect event type
fireEvent(
window,
new MessageEvent("message", {
data: { type: "invalid", payload: { token: "invalid" } },
origin,
})
);
// incorrect origin
fireEvent(
window,
new MessageEvent("message", {
data: { type: "handshake", payload: { token } },
origin: "http://wrong.origin.com",
})
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(payload);
expect(app.getState().token).toEqual(token);
expect(app.getState().id).toEqual("appid");
// unsubscribe
unsubscribe();
fireEvent(
window,
new MessageEvent("message", {
data: { type: "handshake", payload: { token: "123" } },
origin,
})
);
expect(callback).toHaveBeenCalledTimes(1);
expect(app.getState().token).toEqual("123");
});
it("persists domain", () => {
expect(app.getState().domain).toEqual(domain);
});
it("dispatches valid action", () => {
const target = "/test";
const action = actions.Redirect({ to: target });
window.addEventListener("message", (event) => {
if (event.data.type === action.type) {
fireEvent(
window,
new MessageEvent("message", {
data: {
type: "response",
payload: { ok: true, actionId: action.payload.actionId },
} as DispatchResponseEvent,
origin,
})
);
}
});
return expect(app.dispatch(action)).resolves.toBeUndefined();
});
it("times out after action response has not been registered", () =>
expect(app.dispatch(actions.Redirect({ to: "/test" }))).rejects.toBeInstanceOf(Error));
});

78
src/app-bridge/index.ts Normal file
View file

@ -0,0 +1,78 @@
import { Actions } from "./actions";
import { app } from "./app";
import { EventType, ThemeType } from "./events";
export function createApp(targetDomain?: string) {
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") || "";
}
app.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 = app.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: app.subscribe,
unsubscribeAll: app.unsubscribeAll,
getState: app.getState,
};
}
export * from "./actions";
export * from "./events";
export * from "./types";
export default createApp;

5
src/app-bridge/types.ts Normal file
View file

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

0
src/setup-tests.ts Normal file
View file

View file

@ -13,9 +13,10 @@
/* Language and Environment */ /* Language and Environment */
"target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, "target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [ "lib": [
"dom",
"ES2021" "ES2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
// "jsx": "preserve", /* Specify what JSX code is generated. */ "jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */

12
vitest.config.ts Normal file
View file

@ -0,0 +1,12 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: "./src/setup-tests.ts",
css: false,
},
});