UNPKG

@saleor/app-sdk

Version:
516 lines (505 loc) 14.1 kB
import { extractUserFromJwt } from "./chunk-NHGUNOYT.mjs"; // 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" }; 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. Please ensure you are using https or localhost"); } } 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 } }); } var actions = { Redirect: createRedirectAction, Notification: createNotificationAction, UpdateRouting: createUpdateRoutingAction, NotifyReady: createNotifyReadyAction, RequestPermissions: createRequestPermissionsAction }; // 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" }; 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 } }; } }; // 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" }; 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 = 1e3; 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.response: { return state; } default: { console.warn(`Invalid event received: ${event?.type}`); return state; } } } var createEmptySubscribeMap = () => ({ handshake: {}, response: {}, redirect: {}, theme: {}, localeChanged: {}, tokenRefresh: {} }); 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) ]; } return () => { debug2("Provider unmounted, will clean up listeners"); unsubscribes.forEach((unsubscribe) => unsubscribe()); }; }, [appBridge, updateState]); return { appBridge, appBridgeState }; }; export { ActionType, actions, AppIframeParams, EventType, DashboardEventFactory, AppBridge, AppContext, AppBridgeProvider, useAppBridge };