@saleor/app-sdk
Version:
SDK for building great Saleor Apps
570 lines (558 loc) • 15.7 kB
JavaScript
import {
extractUserFromJwt
} from "./chunk-NHGUNOYT.mjs";
// src/app-bridge/form-payload.ts
var formPayloadUpdateActionName = "formPayloadUpdate";
var formPayloadEventName = "formPayload";
// src/app-bridge/actions.ts
var ActionType = {
/**
* Ask Dashboard to redirect - either internal or external route
*/
redirect: "redirect",
/**
* Ask Dashboard to send a notification toast
*/
notification: "notification",
/**
* Ask Dashboard to update deep URL to preserve app route after refresh
*/
updateRouting: "updateRouting",
/**
* Inform Dashboard that AppBridge is ready
*/
notifyReady: "notifyReady",
/**
* Request one or more permissions from the Dashboard
*
* Available from 3.15
*/
requestPermission: "requestPermissions",
/**
* Apply form fields in active context.
*
* EXPERIMENTAL
*/
formPayloadUpdate: formPayloadUpdateActionName,
/**
* Ask Dashboard to close the popup (if the app is running in a popup).
* If not in a popup, it does nothing and responds ok: true.
*/
popupClose: "popupClose"
};
function withActionId(action) {
try {
const actionId = globalThis.crypto.randomUUID();
return {
...action,
payload: {
...action.payload,
actionId
}
};
} catch (e) {
throw new Error(
"Failed to generate action ID, likely as your browser doesn't consider current session as Secure Context. Please ensure you are using https or localhost, or current IP/domain is in 'dom.securecontext.allowlist'/'#unsafely-treat-insecure-origin-as-secure' if you trust it."
);
}
}
function createRedirectAction(payload) {
return withActionId({
payload,
type: "redirect"
});
}
function createNotificationAction(payload) {
return withActionId({
type: "notification",
payload
});
}
function createUpdateRoutingAction(payload) {
return withActionId({
type: "updateRouting",
payload
});
}
function createNotifyReadyAction() {
return withActionId({
type: "notifyReady",
payload: {}
});
}
function createRequestPermissionsAction(permissions, redirectPath) {
return withActionId({
type: "requestPermissions",
payload: {
permissions,
redirectPath
}
});
}
function createPopupCloseAction() {
return withActionId({
type: "popupClose",
payload: {}
});
}
function createFormPayloadUpdateAction(payload) {
return withActionId({
type: formPayloadUpdateActionName,
// @ts-ignore - TODO: For some reason TS is failing here, but this is internal implementation so it doesn't change the public API
payload
});
}
var actions = {
Redirect: createRedirectAction,
Notification: createNotificationAction,
UpdateRouting: createUpdateRoutingAction,
NotifyReady: createNotifyReadyAction,
RequestPermissions: createRequestPermissionsAction,
FormPayloadUpdate: createFormPayloadUpdateAction,
PopupClose: createPopupCloseAction
};
// src/app-bridge/app-iframe-params.ts
var AppIframeParams = {
APP_ID: "id",
THEME: "theme",
DOMAIN: "domain",
SALEOR_API_URL: "saleorApiUrl",
LOCALE: "locale"
};
// src/app-bridge/events.ts
var EventType = {
handshake: "handshake",
response: "response",
redirect: "redirect",
theme: "theme",
localeChanged: "localeChanged",
tokenRefresh: "tokenRefresh",
formPayload: formPayloadEventName
};
var DashboardEventFactory = {
createThemeChangeEvent(theme) {
return {
payload: {
theme
},
type: "theme"
};
},
createRedirectEvent(path) {
return {
type: "redirect",
payload: {
path
}
};
},
createDispatchResponseEvent(actionId, ok) {
return {
type: "response",
payload: {
actionId,
ok
}
};
},
createHandshakeEvent(token, version = 1, saleorVersions) {
return {
type: "handshake",
payload: {
token,
version,
saleorVersion: saleorVersions?.core,
dashboardVersion: saleorVersions?.dashboard
}
};
},
createLocaleChangedEvent(newLocale) {
return {
type: "localeChanged",
payload: {
locale: newLocale
}
};
},
createTokenRefreshEvent(newToken) {
return {
type: "tokenRefresh",
payload: {
token: newToken
}
};
},
// EXPERIMENTAL
createFormEvent(formPayload) {
return {
type: formPayloadEventName,
payload: formPayload
};
}
};
// src/app-bridge/app-bridge.ts
import debugPkg from "debug";
// src/util/extract-app-permissions-from-jwt.ts
import * as jose from "jose";
var extractAppPermissionsFromJwt = (jwtToken) => {
const tokenDecoded = jose.decodeJwt(jwtToken);
return tokenDecoded.permissions;
};
// src/app-bridge/app-bridge-state.ts
var AppBridgeStateContainer = class {
constructor(options = {}) {
this.state = {
id: "",
saleorApiUrl: "",
ready: false,
path: "/",
theme: "light",
locale: "en",
formContext: {}
};
this.state.locale = options.initialLocale ?? this.state.locale;
this.state.theme = options.initialTheme ?? this.state.theme;
}
getState() {
return this.state;
}
setState(newState) {
this.state = {
...this.state,
...newState
};
return this.state;
}
};
// src/app-bridge/constants.ts
var SSR = typeof window === "undefined";
// src/app-bridge/app-bridge.ts
var DISPATCH_RESPONSE_TIMEOUT = 1e4;
var debug = debugPkg.debug("app-sdk:AppBridge");
function eventStateReducer(state, event) {
switch (event.type) {
case EventType.handshake: {
const userJwtPayload = extractUserFromJwt(event.payload.token);
const appPermissions = extractAppPermissionsFromJwt(event.payload.token);
return {
...state,
ready: true,
token: event.payload.token,
saleorVersion: event.payload.saleorVersion,
dashboardVersion: event.payload.dashboardVersion,
user: {
email: userJwtPayload.email,
permissions: userJwtPayload.userPermissions
},
appPermissions
};
}
case EventType.redirect: {
return {
...state,
path: event.payload.path
};
}
case EventType.theme: {
return {
...state,
theme: event.payload.theme
};
}
case EventType.localeChanged: {
return {
...state,
locale: event.payload.locale
};
}
case EventType.tokenRefresh: {
return {
...state,
token: event.payload.token
};
}
case EventType.formPayload: {
return {
...state,
formContext: {
...state.formContext,
[event.payload.form]: event.payload
}
};
}
case EventType.response: {
return state;
}
default: {
console.warn(`Invalid event received: ${event?.type}`);
return state;
}
}
}
var createEmptySubscribeMap = () => ({
handshake: {},
response: {},
redirect: {},
theme: {},
localeChanged: {},
tokenRefresh: {},
formPayload: {}
});
var getLocaleFromUrl = () => new URL(window.location.href).searchParams.get(AppIframeParams.LOCALE) || void 0;
var getSaleorApiUrlFromUrl = () => new URL(window.location.href).searchParams.get(AppIframeParams.SALEOR_API_URL) || "";
var getThemeFromUrl = () => {
const value = new URL(window.location.href).searchParams.get(AppIframeParams.THEME);
switch (value) {
case "dark":
case "light":
return value;
default:
return void 0;
}
};
var getDefaultOptions = () => ({
saleorApiUrl: getSaleorApiUrlFromUrl(),
initialLocale: getLocaleFromUrl() ?? "en",
autoNotifyReady: true,
initialTheme: getThemeFromUrl() ?? void 0
});
var AppBridge = class {
constructor(options = {}) {
this.refererOrigin = document.referrer ? new URL(document.referrer).origin : void 0;
this.subscribeMap = createEmptySubscribeMap();
this.combinedOptions = getDefaultOptions();
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
};
this.state = new AppBridgeStateContainer({
initialLocale: this.combinedOptions.initialLocale
});
debug("Resolved combined AppBridge options: %j", this.combinedOptions);
if (!this.refererOrigin) {
console.warn("document.referrer is empty");
}
if (!this.combinedOptions.saleorApiUrl) {
debug("?saleorApiUrl was not found in iframe url");
}
if (!this.combinedOptions.saleorApiUrl) {
console.error("saleorApiUrl param was not found in iframe url");
}
this.setInitialState();
this.listenOnMessages();
if (this.combinedOptions.autoNotifyReady) {
this.sendNotifyReadyAction();
}
}
/**
* 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, cb) {
debug("subscribe() called with event %s and callback %s", eventType, cb.name);
const key = Symbol("Callback token");
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) {
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(action) {
debug("dispatch called with action argument: %j", action);
return new Promise((resolve, reject) => {
if (!window.parent) {
debug("window.parent doesn't exist, will throw");
reject(new Error("Parent window does not exist."));
return;
}
debug("Calling window.parent.postMessage with %j", action);
window.parent.postMessage(
{
type: action.type,
payload: action.payload
},
"*"
);
let timeoutId;
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();
clearTimeout(timeoutId);
if (ok) {
resolve();
} else {
reject(
new Error(
"Action responded with negative status. This indicates the action method was not used properly."
)
);
}
}
});
timeoutId = window.setTimeout(() => {
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();
}
sendNotifyReadyAction() {
this.dispatch(actions.NotifyReady()).catch((e) => {
console.error("notifyReady action failed");
console.error(e);
});
}
setInitialState() {
debug("setInitialState() called");
const url = new URL(window.location.href);
const id = url.searchParams.get(AppIframeParams.APP_ID) || "";
const path = window.location.pathname || "";
const state = {
id,
path,
theme: this.combinedOptions.initialTheme,
saleorApiUrl: this.combinedOptions.saleorApiUrl,
locale: this.combinedOptions.initialLocale
};
debug("setInitialState() will setState with %j", state);
this.state.setState(state);
}
listenOnMessages() {
debug("listenOnMessages() called");
window.addEventListener(
"message",
({ origin, data }) => {
debug("Received message from origin: %s and data: %j", origin, data);
if (origin !== this.refererOrigin) {
debug("Origin from message doesn't match refererOrigin. Function will return now");
return;
}
const newState = eventStateReducer(this.state.getState(), data);
debug("Computed new state: %j. Will be set with setState", newState);
this.state.setState(newState);
const { type, payload } = data;
if (EventType[type]) {
Object.getOwnPropertySymbols(this.subscribeMap[type]).forEach((key) => {
debug("Executing listener for event: '%s' and payload %j", type, payload);
this.subscribeMap[type][key](payload);
});
}
}
);
}
};
// src/app-bridge/app-bridge-provider.tsx
import debugPkg2 from "debug";
import * as React from "react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
var debug2 = debugPkg2.debug("app-sdk:AppBridgeProvider");
var AppContext = React.createContext({
appBridge: void 0,
mounted: false
});
function AppBridgeProvider({ appBridgeInstance, ...props }) {
debug2("Provider mounted");
const [appBridge, setAppBridge] = useState(appBridgeInstance);
useEffect(() => {
if (!appBridge) {
debug2("AppBridge not defined, will create new instance");
setAppBridge(appBridgeInstance ?? new AppBridge());
} else {
debug2("AppBridge provided in props, will use this one");
}
}, []);
const contextValue = useMemo(
() => ({
appBridge,
mounted: true
}),
[appBridge]
);
return /* @__PURE__ */ React.createElement(AppContext.Provider, { value: contextValue, ...props });
}
var useAppBridge = () => {
const { appBridge, mounted } = useContext(AppContext);
const [appBridgeState, setAppBridgeState] = useState(
() => appBridge ? appBridge.getState() : null
);
if (typeof window !== "undefined" && !mounted) {
throw new Error("useAppBridge used outside of AppBridgeProvider");
}
const updateState = useCallback(() => {
if (appBridge?.getState()) {
debug2("Detected state change in AppBridge, will set new state");
setAppBridgeState(appBridge.getState());
}
}, [appBridge]);
useEffect(() => {
let unsubscribes = [];
if (appBridge) {
debug2("Provider mounted, will set up listeners");
unsubscribes = [
appBridge.subscribe("handshake", updateState),
appBridge.subscribe("theme", updateState),
appBridge.subscribe("response", updateState),
appBridge.subscribe("redirect", updateState),
appBridge.subscribe("formPayload", updateState)
];
}
return () => {
debug2("Provider unmounted, will clean up listeners");
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
}, [appBridge, updateState]);
return { appBridge, appBridgeState };
};
export {
formPayloadUpdateActionName,
formPayloadEventName,
ActionType,
actions,
AppIframeParams,
EventType,
DashboardEventFactory,
AppBridge,
AppContext,
AppBridgeProvider,
useAppBridge
};