Merge pull request #25 from saleor/merge-bridge
Include app-bridge package into app-sdk
This commit is contained in:
commit
d087bbf2ed
20 changed files with 3643 additions and 127 deletions
|
@ -57,7 +57,7 @@
|
|||
"import/no-cycle": "off", // pathpidia issue
|
||||
"import/prefer-default-export": "off",
|
||||
"@typescript-eslint/no-misused-promises": ["error"],
|
||||
"@typescript-eslint/no-floating-promises": ["error"]
|
||||
"@typescript-eslint/no-floating-promises": ["error"],
|
||||
},
|
||||
"settings": {
|
||||
"import/parsers": {
|
||||
|
|
6
.github/workflows/main.yml
vendored
6
.github/workflows/main.yml
vendored
|
@ -7,7 +7,7 @@ jobs:
|
|||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7.9.0
|
||||
version: 7.5.0 # Use 7.5.0 like other Saleor packages, to avoid pnpm version conflicts.
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.x
|
||||
|
@ -23,7 +23,7 @@ jobs:
|
|||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7.9.0
|
||||
version: 7.5.0
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.x
|
||||
|
@ -39,7 +39,7 @@ jobs:
|
|||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7.9.0
|
||||
version: 7.5.0
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.x
|
||||
|
|
|
@ -21,6 +21,10 @@ Feel free to play with SDK and to move its code to your app directly
|
|||
npm i @saleor/app-sdk
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
- [AppBridge](./docs/app-bridge.md) - communication layer between Saleor App and Dashboard
|
||||
|
||||
## Development
|
||||
|
||||
### How to link development version to your project
|
||||
|
|
109
docs/app-bridge.md
Normal file
109
docs/app-bridge.md
Normal file
|
@ -0,0 +1,109 @@
|
|||
# AppBridge
|
||||
|
||||
App bridge is an interface that connects App (running inside Dashboard) with Dashboard itself.
|
||||
|
||||
## Setup
|
||||
|
||||
Create instance of AppBridge by running following code
|
||||
|
||||
```js
|
||||
import { AppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
const appBridge = new AppBridge(options);
|
||||
```
|
||||
|
||||
## Access app state:
|
||||
|
||||
```js
|
||||
const { token, domain, ready, id } = appBridge.getState();
|
||||
```
|
||||
|
||||
Available state represents `AppBridgeState`:
|
||||
|
||||
```typescript
|
||||
type AppBridgeState = {
|
||||
token?: string;
|
||||
id: string;
|
||||
ready: boolean;
|
||||
domain: string;
|
||||
path: string;
|
||||
theme: ThemeType;
|
||||
};
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
Events are messages that originate in Saleor Dashboard. AppBridge can subscribe on events and app can react on them
|
||||
|
||||
### Subscribing to events
|
||||
|
||||
`subscribe(eventType, callback)` - can be used to listen to particular [event type](#available-event-types). It returns an unsubscribe function, which unregisters the callback.
|
||||
|
||||
Example:
|
||||
|
||||
```typescript
|
||||
const unsubscribe = appBridge.subscribe("handshake", (payload) => {
|
||||
setToken(payload.token); // do something with event payload
|
||||
|
||||
const { token } = appState.getState(); // you can also get app's current state here
|
||||
});
|
||||
|
||||
// unsubscribe when callback is no longer needed
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
### Unsubscribing multiple listeners
|
||||
|
||||
`unsubscribeAll(eventType?)` - unregisters all callbacks of provided type. If no type was provided, it will remove all event callbacks.
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
// unsubscribe from all handshake events
|
||||
appBridge.unsubscribeAll("handshake");
|
||||
|
||||
// unsubscribe from all events
|
||||
appBridge.unsubscribeAll();
|
||||
```
|
||||
|
||||
### Available event types
|
||||
|
||||
| Event type | Description |
|
||||
| :---------- | :--------------------------------------------------------------------------- |
|
||||
| `handshake` | Fired when iFrame containing the App is initialized or new token is assigned |
|
||||
| `response` | Fired when Dashboard responds to an Action |
|
||||
| `redirect` | Fired when Dashboard changes a subpath within the app path |
|
||||
| `theme` | Fired when Dashboard changes the theme |
|
||||
|
||||
## Actions
|
||||
|
||||
Actions expose a high-level API to communicate with Saleor Dashboard. They're exported under an `actions` namespace.
|
||||
|
||||
### Available methods
|
||||
|
||||
**`dispatch(action)`** - dispatches an Action. Returns a promise which resolves when action is successfully completed.
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
import { actions } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
const handleRedirect = async () => {
|
||||
await appBridge.dispatch(actions.Redirect({ to: "/orders" }));
|
||||
|
||||
console.log("Redirect complete!");
|
||||
};
|
||||
|
||||
handleRedirect();
|
||||
```
|
||||
|
||||
### Available actions
|
||||
|
||||
| Action | Arguments | Description |
|
||||
| :------------- | :--------------------------------------------------------------- | :---------- |
|
||||
| `Redirect` | `to` (string) - relative (inside Dashboard) or absolute URL path | |
|
||||
| | `newContext` (boolean) - should open in a new browsing context | |
|
||||
| `Notification` | `status` (`info` / `success` / `warning` / `error` / undefined) | |
|
||||
| | `title` (string / undefined) - title of the notification | |
|
||||
| | `text` (string / undefined) - content of the notification | |
|
||||
| | `apiMessage` (string / undefined) - error log from api | |
|
22
package.json
22
package.json
|
@ -10,21 +10,28 @@
|
|||
"build": "tsup-node src/* --format esm,cjs --dts && clear-package-json package.json -o dist/package.json --fields publishConfig",
|
||||
"test": "vitest",
|
||||
"prepare": "husky install",
|
||||
"lint": "prettier --loglevel warn --write . && eslint --fix ."
|
||||
"lint": "prettier --loglevel warn --write . && eslint --fix .",
|
||||
"release": "np"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.2.11",
|
||||
"graphql": "^16.5.0",
|
||||
"jose": "^4.8.3",
|
||||
"retes": "^0.32.0"
|
||||
"retes": "^0.32.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^8.17.1",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/node": "^18.6.5",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.33.0",
|
||||
"@typescript-eslint/parser": "^5.33.0",
|
||||
"@vitejs/plugin-react": "^2.0.0",
|
||||
"clean-publish": "^4.0.1",
|
||||
"eslint": "8.21.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
|
@ -36,11 +43,15 @@
|
|||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||
"husky": "^8.0.1",
|
||||
"jsdom": "^20.0.0",
|
||||
"np": "^7.6.1",
|
||||
"prettier": "2.7.1",
|
||||
"tsm": "^2.2.2",
|
||||
"tsup": "^6.2.1",
|
||||
"typescript": "^4.7.4",
|
||||
"vitest": "^0.21.1"
|
||||
"vite": "^3.0.5",
|
||||
"vitest": "^0.21.1",
|
||||
"watchlist": "^0.3.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||
|
@ -68,6 +79,11 @@
|
|||
"import": "./urls.mjs",
|
||||
"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",
|
||||
"import": "./index.mjs",
|
||||
|
|
2904
pnpm-lock.yaml
2904
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
37
src/app-bridge/actions.test.ts
Normal file
37
src/app-bridge/actions.test.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { actions, NotificationPayload, RedirectPayload } from "./actions";
|
||||
|
||||
describe("actions.ts", () => {
|
||||
describe("actions.Notification", () => {
|
||||
it("Constructs action with \"notification\" type, random id and payload", () => {
|
||||
const payload: NotificationPayload = {
|
||||
apiMessage: "test-api-message",
|
||||
status: "info",
|
||||
text: "test-text",
|
||||
title: "test-title",
|
||||
};
|
||||
|
||||
const action = actions.Notification(payload);
|
||||
|
||||
expect(action.type).toBe("notification");
|
||||
expect(action.payload.actionId).toEqual(expect.any(String));
|
||||
expect(action.payload).toEqual(expect.objectContaining(payload));
|
||||
});
|
||||
});
|
||||
|
||||
describe("actions.Redirect", () => {
|
||||
it("Constructs action with \"redirect\" type, random id and payload", () => {
|
||||
const payload: RedirectPayload = {
|
||||
newContext: true,
|
||||
to: "/foo/bar",
|
||||
};
|
||||
|
||||
const action = actions.Redirect(payload);
|
||||
|
||||
expect(action.type).toBe("redirect");
|
||||
expect(action.payload.actionId).toEqual(expect.any(String));
|
||||
expect(action.payload).toEqual(expect.objectContaining(payload));
|
||||
});
|
||||
});
|
||||
});
|
82
src/app-bridge/actions.ts
Normal file
82
src/app-bridge/actions.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
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 createRedirectAction(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 createNotificationAction(payload: NotificationPayload): NotificationAction {
|
||||
return withActionId({
|
||||
type: "notification",
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
export type Actions = RedirectAction | NotificationAction;
|
||||
|
||||
export const actions = {
|
||||
Redirect: createRedirectAction,
|
||||
Notification: createNotificationAction,
|
||||
};
|
32
src/app-bridge/app-bridge-state.test.ts
Normal file
32
src/app-bridge/app-bridge-state.test.ts
Normal 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));
|
||||
});
|
||||
});
|
33
src/app-bridge/app-bridge-state.ts
Normal file
33
src/app-bridge/app-bridge-state.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { ThemeType } from "./events";
|
||||
|
||||
export type AppBridgeState = {
|
||||
token?: string;
|
||||
id: string;
|
||||
ready: boolean;
|
||||
domain: string;
|
||||
path: string;
|
||||
theme: ThemeType;
|
||||
};
|
||||
|
||||
export class AppBridgeStateContainer {
|
||||
private state: AppBridgeState = {
|
||||
id: "",
|
||||
domain: "",
|
||||
ready: false,
|
||||
path: "/",
|
||||
theme: "light",
|
||||
};
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
setState(newState: Partial<AppBridgeState>) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
...newState,
|
||||
};
|
||||
|
||||
return this.state;
|
||||
}
|
||||
}
|
180
src/app-bridge/app-bridge.test.ts
Normal file
180
src/app-bridge/app-bridge.test.ts
Normal file
|
@ -0,0 +1,180 @@
|
|||
import { fireEvent } from "@testing-library/dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// mock document.referrer
|
||||
const origin = "http://example.com";
|
||||
const domain = "saleor.domain.host";
|
||||
|
||||
Object.defineProperty(window.document, "referrer", {
|
||||
value: origin,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
href: `${origin}?domain=${domain}&id=appid`,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line
|
||||
import { actions, DispatchResponseEvent, HandshakeEvent, AppBridge } from ".";
|
||||
|
||||
const handshakeEvent: HandshakeEvent = {
|
||||
payload: {
|
||||
token: "mock-token",
|
||||
version: 1,
|
||||
},
|
||||
type: "handshake",
|
||||
};
|
||||
|
||||
describe("AppBridge", () => {
|
||||
let appBridge = new AppBridge();
|
||||
|
||||
beforeEach(() => {
|
||||
appBridge = new AppBridge();
|
||||
});
|
||||
|
||||
it("correctly sets the default domain, if not set in constructor", () => {
|
||||
expect(appBridge.getState().domain).toEqual(domain);
|
||||
});
|
||||
|
||||
it("authenticates", () => {
|
||||
expect(appBridge.getState().ready).toBe(false);
|
||||
|
||||
const token = "test-token";
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent("message", {
|
||||
data: { type: "handshake", payload: { token } },
|
||||
origin,
|
||||
})
|
||||
);
|
||||
|
||||
expect(appBridge.getState().ready).toBe(true);
|
||||
expect(appBridge.getState().token).toEqual(token);
|
||||
});
|
||||
|
||||
it("subscribes to an event and returns unsubscribe function", () => {
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = appBridge.subscribe("handshake", callback);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
const token = "fresh-token";
|
||||
|
||||
// First call proper event
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent("message", {
|
||||
data: handshakeEvent,
|
||||
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(handshakeEvent.payload);
|
||||
expect(appBridge.getState().token).toEqual(handshakeEvent.payload.token);
|
||||
expect(appBridge.getState().id).toEqual("appid");
|
||||
|
||||
unsubscribe();
|
||||
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent("message", {
|
||||
data: { type: "handshake", payload: { token: "123" } },
|
||||
origin,
|
||||
})
|
||||
);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(appBridge.getState().token).toEqual("123");
|
||||
});
|
||||
|
||||
it("persists domain", () => {
|
||||
expect(appBridge.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(appBridge.dispatch(action)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("times out after action response has not been registered", () =>
|
||||
expect(appBridge.dispatch(actions.Redirect({ to: "/test" }))).rejects.toBeInstanceOf(Error));
|
||||
|
||||
it("unsubscribes from all listeners", () => {
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
|
||||
appBridge.subscribe("handshake", cb1);
|
||||
appBridge.subscribe("handshake", cb2);
|
||||
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent("message", {
|
||||
data: handshakeEvent,
|
||||
origin,
|
||||
})
|
||||
);
|
||||
|
||||
expect(cb1).toHaveBeenCalledTimes(1);
|
||||
expect(cb2).toHaveBeenCalledTimes(1);
|
||||
|
||||
appBridge.unsubscribeAll();
|
||||
|
||||
fireEvent(
|
||||
window,
|
||||
new MessageEvent("message", {
|
||||
data: handshakeEvent,
|
||||
origin,
|
||||
})
|
||||
);
|
||||
|
||||
expect(cb1).toHaveBeenCalledTimes(1);
|
||||
expect(cb2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("attaches domain from options in constructor", () => {
|
||||
appBridge = new AppBridge({
|
||||
targetDomain: "https://foo.bar",
|
||||
});
|
||||
|
||||
expect(appBridge.getState().domain).toEqual("https://foo.bar");
|
||||
});
|
||||
});
|
264
src/app-bridge/app-bridge.ts
Normal file
264
src/app-bridge/app-bridge.ts
Normal file
|
@ -0,0 +1,264 @@
|
|||
import { debug as createDebug } from "debug";
|
||||
|
||||
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>>>;
|
||||
};
|
||||
|
||||
const debug = createDebug("AppBridge");
|
||||
|
||||
function eventStateReducer(state: AppBridgeState, event: Events) {
|
||||
debug("Event reducer received event: %j", event);
|
||||
|
||||
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 type AppBridgeOptions = {
|
||||
targetDomain?: string;
|
||||
};
|
||||
|
||||
const getDefaultOptions = (): AppBridgeOptions => ({
|
||||
targetDomain: new URL(window.location.href).searchParams.get("domain") || "",
|
||||
});
|
||||
|
||||
export class AppBridge {
|
||||
private state = new AppBridgeStateContainer();
|
||||
|
||||
private refererOrigin = document.referrer ? new URL(document.referrer).origin : undefined;
|
||||
|
||||
private subscribeMap = createEmptySubscribeMap();
|
||||
|
||||
private combinedOptions = getDefaultOptions();
|
||||
|
||||
constructor(options: AppBridgeOptions = {}) {
|
||||
debug("Constructor called with options: %j", options);
|
||||
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
this.combinedOptions = {
|
||||
...this.combinedOptions,
|
||||
...options,
|
||||
};
|
||||
|
||||
debug("Resolved combined AppBridge options: %j", this.combinedOptions);
|
||||
|
||||
if (!this.refererOrigin) {
|
||||
// TODO probably throw
|
||||
console.warn("document.referrer is empty");
|
||||
}
|
||||
|
||||
if (!this.combinedOptions.targetDomain) {
|
||||
console.error(
|
||||
"No domain set, ensure ?domain param in iframe exist or provide in AppBridge constructor"
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
) {
|
||||
debug("subscribe() called with event %s and callback %s", eventType, cb.name);
|
||||
|
||||
const key = Symbol("Callback token");
|
||||
// @ts-ignore fixme
|
||||
this.subscribeMap[eventType][key] = cb;
|
||||
|
||||
return () => {
|
||||
debug("unsubscribe called with event %s and callback %s", eventType, cb.name);
|
||||
|
||||
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) {
|
||||
debug("unsubscribeAll called with event: %s", eventType);
|
||||
|
||||
this.subscribeMap[eventType] = {};
|
||||
} else {
|
||||
debug("unsubscribeAll called without argument");
|
||||
|
||||
this.subscribeMap = createEmptySubscribeMap();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch event to dashboard
|
||||
*/
|
||||
async dispatch<T extends Actions>(action: T) {
|
||||
debug("dispatch called with action argument: %j", action);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!window.parent) {
|
||||
debug("window.parent doesnt exist, will throw");
|
||||
|
||||
reject(new Error("Parent window does not exist."));
|
||||
} else {
|
||||
debug("Calling window.parent.postMessage with %j", action);
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: action.type,
|
||||
payload: action.payload,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
|
||||
let intervalId: number;
|
||||
|
||||
const unsubscribe = this.subscribe(EventType.response, ({ actionId, ok }) => {
|
||||
debug(
|
||||
"Subscribing to %s with action id: %s and status 'ok' is: %s",
|
||||
EventType.response,
|
||||
actionId,
|
||||
ok
|
||||
);
|
||||
|
||||
if (action.payload.actionId === actionId) {
|
||||
debug("Received matching action id: %s. Will unsubscribe", 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() {
|
||||
debug("getState() called and will return %j", this.state.getState());
|
||||
|
||||
return this.state.getState();
|
||||
}
|
||||
|
||||
private setInitialState() {
|
||||
debug("setInitialState() called");
|
||||
|
||||
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";
|
||||
|
||||
const state = { domain: this.combinedOptions.targetDomain, id, path, theme };
|
||||
|
||||
debug("setInitialState() will setState with %j", state);
|
||||
|
||||
this.state.setState(state);
|
||||
}
|
||||
|
||||
private listenOnMessages() {
|
||||
debug("listenOnMessages() called");
|
||||
|
||||
window.addEventListener(
|
||||
"message",
|
||||
({ origin, data }: Omit<MessageEvent, "data"> & { data: Events }) => {
|
||||
debug("Received message from origin: %s and data: %j", origin, data);
|
||||
|
||||
if (origin !== this.refererOrigin) {
|
||||
debug("Origin from message doesnt match refererOrigin. Function will return now");
|
||||
// TODO what should happen here - be explicit
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = eventStateReducer(this.state.getState(), data);
|
||||
debug("Computed new state: %j. Will be set with setState", newState);
|
||||
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);
|
||||
debug("Setting listener for event: %s and payload %j", type, payload);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
1
src/app-bridge/constants.ts
Normal file
1
src/app-bridge/constants.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const SSR = typeof window === "undefined";
|
56
src/app-bridge/events.ts
Normal file
56
src/app-bridge/events.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
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;
|
1
src/app-bridge/helpers.ts
Normal file
1
src/app-bridge/helpers.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type Values<T> = T[keyof T];
|
14
src/app-bridge/index.ts
Normal file
14
src/app-bridge/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { AppBridge } from "./app-bridge";
|
||||
|
||||
export { AppBridge };
|
||||
|
||||
export * from "./actions";
|
||||
export * from "./events";
|
||||
export * from "./types";
|
||||
|
||||
/**
|
||||
* @deprecated use new AppBridge(), createApp will be removed
|
||||
*/
|
||||
export const createApp = (targetDomain?: string) =>
|
||||
new AppBridge(targetDomain ? { targetDomain } : undefined);
|
||||
export default createApp;
|
8
src/app-bridge/types.ts
Normal file
8
src/app-bridge/types.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { AppBridge } from "./app-bridge";
|
||||
import { AppBridgeState } from "./app-bridge-state";
|
||||
|
||||
/**
|
||||
* @deprecated Use AppBridge instead
|
||||
*/
|
||||
export type App = AppBridge;
|
||||
export { AppBridgeState };
|
0
src/setup-tests.ts
Normal file
0
src/setup-tests.ts
Normal file
|
@ -13,9 +13,10 @@
|
|||
/* Language and Environment */
|
||||
"target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"lib": [
|
||||
"dom",
|
||||
"ES2021"
|
||||
] /* 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. */
|
||||
// "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'. */
|
||||
|
|
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
Loading…
Reference in a new issue