Skip to content

Instantly share code, notes, and snippets.

@pepicrft
Last active March 30, 2023 14:03
Show Gist options
  • Save pepicrft/5cbdb1669a5ba9e515dcdbf5f3c02a68 to your computer and use it in GitHub Desktop.
Save pepicrft/5cbdb1669a5ba9e515dcdbf5f3c02a68 to your computer and use it in GitHub Desktop.
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
(function() {
"use strict";
function toCamelCase(str) {
return str.toLowerCase().replace(/-+(.)/g, (s, char) => char.toUpperCase());
}
function merge(obj, fields) {
if (!fields)
return;
for (let key in fields) {
const value = fields[key];
if (value != null && value !== "")
obj[key] = value;
}
}
function setDifference(a, b) {
const c = new Set(a);
b.forEach((b2) => c.delete(b2));
return c;
}
const hex = (num) => num.toString(16);
const ID_PREFIX$1 = `${hex(Date.now())}-${hex(Math.random() * 1e9 | 0)}-`;
let idCounter$1 = 0;
function uid() {
return ID_PREFIX$1 + hex(++idCounter$1);
}
function stripFunctions(v) {
return Object.fromEntries(
Object.entries(v).filter(([_, v2]) => typeof v2 != "function")
);
}
let injectedStyleSheet = null;
function defineCustomElement(name, constructor) {
if (!injectedStyleSheet) {
injectedStyleSheet = document.createElement("style");
const parent2 = document.head || document.documentElement;
parent2.append(injectedStyleSheet);
}
injectedStyleSheet.append(`${name} { display: none; }`);
customElements.define(name, constructor);
}
const CDN_ORIGIN = "cdn.shopify.com";
const IS_APP_BRIDGE = /\/app\-?bridge[/.-]/i;
function loadConfig() {
var _a;
const params = new URLSearchParams(location.search);
const config = {};
merge(config, fromWindowName());
merge(config, ((_a = window.shopify) == null ? void 0 : _a.config) ?? {});
merge(config, fromScripts());
merge(config, fromMetaTags());
merge(config, fromQueryParameters(params));
validateConfig(config);
return { config, params };
}
function fromWindowName() {
const parts = window.name.match(/^ab=(.+)$/);
if (parts) {
try {
return JSON.parse(atob(parts[1]));
} catch (e) {
}
}
return {};
}
function fromQueryParameters(params) {
const shop = params.get("shop");
const host = params.get("host");
return { shop, host };
}
function fromScripts() {
const scripts = Array.from(document.getElementsByTagName("script"));
if (document.currentScript) {
scripts.unshift(document.currentScript);
}
const config = {};
for (const script of scripts) {
if (script.src) {
try {
const url = new URL(script.src);
if (url.hostname === CDN_ORIGIN && IS_APP_BRIDGE.test(url.pathname)) {
url.searchParams.forEach((value, key) => {
if (value)
config[key] = value;
});
merge(config, script.dataset);
}
} catch (e) {
}
} else if (script.type === "shopify/config") {
try {
merge(config, JSON.parse(script.textContent ?? "{}"));
} catch (err) {
console.warn(`App Bridge: failed to parse configuration. ${err}`);
}
}
}
return config;
}
function fromMetaTags() {
const tags = Array.from(document.querySelectorAll('meta[name^="shopify-"i]'));
const config = {};
for (const tag of tags) {
if (!tag.hasAttribute("name"))
continue;
const name = toCamelCase(
tag.getAttribute("name").replace(/shopify-/i, "")
);
config[name] = tag.getAttribute("content");
}
return config;
}
const requiredKeys = ["host", "apiKey", "shop"];
function validateConfig(config) {
if (!requiredKeys.every((key) => key in config)) {
const presentKeys = new Set(Object.keys(config));
throw Error(
`AppBridge configuration is incomplete. Missing keys: ${Array.from(
setDifference(new Set(requiredKeys), presentKeys)
).join(", ")}`
);
}
return config;
}
const ALLOWED_ORIGINS = /(^admin\.shopify\.com|\.myshopify\.com|\.spin\.dev|localhost)$/;
function createProtocol(target, config, recv = self) {
let originLock = "";
const clientInterface = {
name: "app-bridge-next",
version: "0.0.1"
};
function send(type, payload) {
if (type === "dispatch") {
payload.clientInterface = clientInterface;
payload.version = clientInterface.version;
}
const data = { type, payload, source: config };
target.postMessage(data, originLock || "*");
}
function subscribe2(type, callback, { signal } = {}) {
if (signal == null ? void 0 : signal.aborted)
return;
function wrapListener(ev) {
if (ev.source !== target)
return false;
if (originLock) {
if (ev.origin !== originLock)
return;
} else {
const origin = new URL(ev.origin);
if (!ALLOWED_ORIGINS.test(origin.hostname))
return;
originLock = ev.origin;
}
const data = ev.data;
if (data == null || typeof data !== "object")
return;
if (typeof type === "function" ? type(data.payload.type) : type === data.payload.type) {
callback(data.payload.payload ?? data.payload, data);
}
}
recv.addEventListener("message", wrapListener, { signal });
}
return {
send,
subscribe: subscribe2
};
}
function nextMessage(protocol, type, { predicate = () => true, signal } = {}) {
return new Promise((resolve) => {
const ac = new AbortController();
signal == null ? void 0 : signal.addEventListener("abort", () => ac.abort());
protocol.subscribe(
type,
(payload, data) => {
if (!predicate(payload, data))
return;
ac.abort();
resolve(payload);
},
{ signal: ac.signal }
);
});
}
function notify(protocol, method, params) {
protocol.send("dispatch", createAction(method, params));
}
function subscribe(protocol, method, callback) {
const action = createAction(method);
protocol.send("subscribe", action);
protocol.subscribe(action.type, callback);
}
function call(protocol, method, params, { signal } = {}) {
const action = createAction(method, params);
const sub = `${action.type}::RESPOND`;
action.type += "::REQUEST";
protocol.send("dispatch", action);
return nextMessage(protocol, sub, { signal });
}
const actionMap = {
TITLE_BAR: "TITLEBAR"
};
function createAction(method, params) {
const [group2, ...actions] = method.split(".");
const actionGroup = toShoutCase(group2);
let type = `APP::${actionMap[actionGroup] ?? actionGroup}`;
for (const action2 of actions)
type += `::${toShoutCase(action2)}`;
const action = { group: group2, type };
if (params != null)
action.payload = params;
return action;
}
function toShoutCase(str) {
return str.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase();
}
const EARLY_EXPIRY_BUFFER = 10 * 1e3;
let cachedToken = null;
async function sessionToken(protocol, { signal } = {}) {
if (cachedToken)
return cachedToken;
const result = await call(protocol, "SessionToken", null, { signal });
cachedToken = result.sessionToken;
const { exp } = decodeToken(result.sessionToken);
setTimeout(
() => cachedToken = null,
exp * 1e3 - new Date().getTime() - EARLY_EXPIRY_BUFFER
);
return result.sessionToken;
}
function decodeToken(token) {
return JSON.parse(atob(token.split(".")[1]));
}
function sessionTokenCapability(api, protocol) {
Object.assign(api, {
async sessionToken({ signal } = {}) {
return sessionToken(protocol, { signal });
},
async shop({ signal } = {}) {
const { dest } = decodeToken(await sessionToken(protocol, { signal }));
return dest;
}
});
}
function loadingCapability(api, protocol) {
api.loading = function(isLoading) {
if (isLoading)
notify(protocol, "Loading.start");
else
notify(protocol, "Loading.end");
};
}
const group = "Toast";
const defaults = {
duration: 5e3
};
async function toastCapability(api, protocol) {
api.toast = {
show(message, opts = {}) {
const id = uid();
protocol.send("dispatch", {
group,
type: "APP::TOAST::SHOW",
payload: stripFunctions({
...defaults,
message,
...opts,
action: opts.action ? { content: opts.action } : null,
id
})
});
function hasCorrectId(payload) {
return (payload == null ? void 0 : payload.id) === id;
}
const ac = new AbortController();
protocol.subscribe(
"APP::TOAST::ACTION",
(payload) => {
var _a;
if (!hasCorrectId(payload))
return;
(_a = opts.onAction) == null ? void 0 : _a.call(opts);
},
{ signal: ac.signal }
);
nextMessage(protocol, "APP::TOAST::CLEAR", { predicate: hasCorrectId }).then(
() => {
var _a;
ac.abort();
(_a = opts.onDismiss) == null ? void 0 : _a.call(opts);
}
);
return id;
},
hide(id) {
protocol.send("dispatch", {
group,
type: "APP::TOAST::CLEAR",
payload: {
id
}
});
}
};
}
async function titleBarCapability(api, protocol) {
let onClick;
api.titleBar = {
setState(state = {}) {
onClick = state.onClick;
notify(protocol, "TitleBar.update", {
title: state.title ?? "",
buttons: {
primary: state.primaryButton,
secondary: state.secondaryButtons
},
breadcrumbs: {
id: "breadcrumb",
label: state.breadcrumb
}
});
}
};
protocol.subscribe(
(type) => /:TITLEBAR:.+:CLICK$/.test(type),
(payload) => onClick == null ? void 0 : onClick(payload.id)
);
}
const originalValue = Symbol();
function hijack(object, property, newValue) {
const original = object[property];
Object.defineProperty(object, property, {
enumerable: true,
configurable: true,
value: newValue
});
newValue[originalValue] = original;
return original;
}
function hijackGlobal(name, newValue) {
hijack(globalThis, name, newValue);
}
const ID = Symbol();
async function toastDissolver(api) {
class NewNotification extends EventTarget {
constructor(title, opts) {
var _a, _b, _c, _d;
super();
__publicField(this, "title", "");
__publicField(this, "body", "");
/** @deprecated not supported */
__publicField(this, "data", null);
/** @deprecated not supported */
__publicField(this, "tag", "");
/** @deprecated not supported */
__publicField(this, "lang", "");
/** @deprecated not supported */
__publicField(this, "icon", "");
/** @deprecated not supported */
__publicField(this, "dir", "auto");
__publicField(this, "onclick", null);
__publicField(this, "onclose", null);
__publicField(this, "onshow", null);
__publicField(this, "onerror", null);
Object.assign(this, opts);
if ((((_a = opts == null ? void 0 : opts.actions) == null ? void 0 : _a.length) ?? 0) > 1)
throw Error("Cannot have more than one action");
const abToastOpts = {
onAction: () => this.dispatchEvent(new Event("action")),
onDismiss: () => this.dispatchEvent(new Event("close"))
};
const invokeEventProperty = (e) => {
var _a2;
return (_a2 = this[`on${e.type}`]) == null ? void 0 : _a2.call(this, e);
};
for (const type of ["click", "close", "show", "error"]) {
this.addEventListener(type, invokeEventProperty);
}
if (opts == null ? void 0 : opts.body)
title = `${title} - ${opts.body}`;
if (((_b = opts == null ? void 0 : opts.actions) == null ? void 0 : _b.length) ?? 0 > 0) {
const title2 = (_d = (_c = opts == null ? void 0 : opts.actions) == null ? void 0 : _c[0]) == null ? void 0 : _d.title;
if (title2)
abToastOpts.action = title2;
}
this[ID] = api.toast.show(title, abToastOpts);
}
close() {
api.toast.hide(this[ID]);
}
static requestPermission(cb) {
const result = "granted";
cb == null ? void 0 : cb(result);
return Promise.resolve(result);
}
static get permission() {
return "granted";
}
}
hijackGlobal("Notification", NewNotification);
}
const TAG_NAME = "shopify-title-bar";
const ID_KEY = Symbol();
async function titleBarDissolver(api) {
let titleTagValue = document.title;
let activeTitleBarElement;
updateState();
function getActiveTitle() {
var _a;
return ((_a = activeTitleBarElement == null ? void 0 : activeTitleBarElement.getAttribute) == null ? void 0 : _a.call(activeTitleBarElement, "title")) ?? titleTagValue ?? document.title;
}
function onClick(id) {
const buttons = Array.from(
(activeTitleBarElement == null ? void 0 : activeTitleBarElement.querySelectorAll("button")) ?? []
);
const clickedButton = buttons.find((button) => button[ID_KEY] == id);
if (!clickedButton)
return;
clickedButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
function updateState() {
var _a;
updateActiveTitleBar();
const { primaryButton, secondaryButtons, breadcrumb } = ((_a = activeTitleBarElement == null ? void 0 : activeTitleBarElement.buttons) == null ? void 0 : _a.call(activeTitleBarElement)) ?? {};
const title = getActiveTitle();
api.titleBar.setState({
title,
primaryButton,
secondaryButtons,
breadcrumb,
onClick
});
}
function updateActiveTitleBar() {
var _a;
activeTitleBarElement = (_a = document.documentElement.getElementsByTagName(
TAG_NAME
)) == null ? void 0 : _a[0];
}
Object.defineProperty(document, "title", {
get() {
return titleTagValue;
},
set(v) {
titleTagValue = v;
updateState();
}
});
class ShopifyTitleBarElement extends HTMLElement {
constructor() {
super(...arguments);
__publicField(this, "_mo", null);
}
static get observedAttributes() {
return ["title"];
}
connectedCallback() {
this._mo = new MutationObserver(() => {
this._update();
});
this._mo.observe(this, {
childList: true,
subtree: true,
attributes: true,
characterData: true
});
this._update();
}
_breadcrumbSetup() {
const breadcrumb = this.querySelector(
":scope > button[breadcrumb]"
);
if (breadcrumb) {
breadcrumb[ID_KEY] = "breadcrumb";
}
return breadcrumb;
}
_update() {
updateState();
}
disconnectedCallback() {
this._update();
}
attributeChangedCallback() {
this._update();
}
buttons() {
const breadcrumb = this._breadcrumbSetup();
const primary = this.querySelector(
":scope > button[primary]"
);
const secondary = Array.from(
this.querySelectorAll(
":scope > button:not([primary]):not([breadcrumb]), :scope > menu"
)
);
const processedSecondary = secondary.map(
(secondary2) => {
if (secondary2.nodeName === "BUTTON")
return asButtonPayload(secondary2);
else
return asButtonGroupPayload(secondary2);
}
);
return {
...breadcrumb ? { breadcrumb: breadcrumb.textContent } : {},
...primary ? { primaryButton: asButtonPayload(primary) } : {},
secondaryButtons: processedSecondary
};
}
}
defineCustomElement(TAG_NAME, ShopifyTitleBarElement);
}
function asButtonGroupPayload(menu) {
if (!menu[ID_KEY])
menu[ID_KEY] = uid();
const id = menu[ID_KEY];
const label = menu.getAttribute("title") ?? "Actions";
const buttons = Array.from(
menu.querySelectorAll("button")
).map(asButtonPayload);
return {
id,
label,
buttons
};
}
function asButtonPayload(button) {
if (!button[ID_KEY])
button[ID_KEY] = uid();
const id = button[ID_KEY];
const label = button.textContent ?? "";
return {
id,
label,
disabled: button.disabled
};
}
const TTL = 50 * 1e3;
const TOKEN_HEADER = "x-shopify-session-token";
function fetchDissolver(api) {
let tokenTime = Date.now();
let token = void 0;
function getValidToken() {
if (!token || Date.now() - tokenTime > TTL) {
token = api.sessionToken.get();
tokenTime = Date.now();
}
return token;
}
const fetch2 = self.fetch;
async function augmentedFetch(url, opts) {
const req = new Request(url, opts);
const parsed = new URL(req.url);
if (parsed.origin === location.origin && !req.headers.has(TOKEN_HEADER)) {
req.headers.set(TOKEN_HEADER, await getValidToken());
}
return fetch2(req);
}
hijackGlobal("fetch", augmentedFetch);
}
const embeddedFrameParamsToRemove = [
"hmac",
"locale",
"protocol",
"session",
"session_token",
"shop",
"timestamp",
"host",
"embedded"
];
const ID_PREFIX = Date.now() + "-";
let idCounter = 0;
const nextId = () => ID_PREFIX + ++idCounter;
let skipNext;
function urlDissolver(api, protocol) {
function updateUrl(href, replace = false) {
const url = new URL(href, location.href);
embeddedFrameParamsToRemove.forEach(
(param) => url.searchParams.delete(param)
);
const path = `${url.pathname}${url.search}${url.hash}`;
if (replace) {
notify(protocol, "Navigation.history.replace", { path });
} else {
const id = nextId();
skipNext = id;
notify(protocol, "Navigation.redirect.app", { id, path });
}
}
const pushState = hijack(history, "pushState", function(data, _, url) {
pushState.call(this, data, _, url);
updateUrl(url, false);
});
const replaceState = hijack(history, "replaceState", function(data, _, url) {
replaceState.call(this, data, _, url);
updateUrl(url, true);
});
subscribe(protocol, "Navigation.redirect.app", (payload) => {
let skip = skipNext;
skipNext = null;
if (skip === payload.id)
return;
console.log("got Navigation.redirect.app", payload);
replaceState.call(history, null, null, payload.path);
document.dispatchEvent(new PopStateEvent("popstate", { bubbles: true }));
});
updateUrl(location.href, true);
}
const availableCapabilities = {
sessionToken: sessionTokenCapability,
loading: loadingCapability,
toast: toastCapability,
titleBar: titleBarCapability
};
const availableDissolvers = {
toast: toastDissolver,
titleBar: titleBarDissolver,
fetch: fetchDissolver,
url: urlDissolver
};
function init() {
const { config, params } = loadConfig();
Object.freeze(config);
try {
window.name = `ab=${btoa(JSON.stringify(config))}`;
} catch (e) {
}
const origin = new URL("https://" + atob(config.host)).origin;
const protocol = createProtocol(parent, config);
const api = {
config,
protocol,
origin,
// FIXME: This is a bit icky and could use better typing
ready: Promise.resolve()
};
api.ready = initCapabilitiesAndDissolvers();
Object.defineProperty(self, "shopify", {
configurable: true,
writable: true,
value: api
});
if (top === window) {
return redirectToEmbedded(params, api);
}
if (params.get("redirectTo")) {
return bounce(params.get("redirectTo"), api);
}
async function runFactories(factories, excludeList = []) {
const enabledFactories = Object.entries(factories).filter(
([name]) => !excludeList.includes(name)
);
await Promise.allSettled(
enabledFactories.map(async ([name, factory]) => {
try {
await factory(api, protocol);
} catch (e) {
console.error(`Initializing ${name} failed: ${e == null ? void 0 : e.message}`);
}
})
);
}
async function initCapabilitiesAndDissolvers() {
await runFactories(
availableCapabilities,
config.disabledCapabilities ?? []
);
notify(protocol, "Client.initialize");
notify(protocol, "Loading.stop");
await api.sessionToken();
await runFactories(availableDissolvers, config.disabledDissolvers ?? []);
}
}
function redirectToEmbedded(params, api) {
const parsed = new URL(params.get("redirectTo") ?? "", location.origin);
params.forEach((value, key) => {
if (key === "host" || key === "shop")
return;
if (parsed.searchParams.get(key))
return;
parsed.searchParams.set(key, value);
});
const redirectTo = parsed.pathname + parsed.search;
const url = `https://${api.config.shop}/admin/apps/${api.config.apiKey}${redirectTo}`;
return location.assign(url);
}
async function bounce(redirectTo, api) {
const parsed = new URL(redirectTo, location.origin);
if (parsed.origin !== location.origin)
throw Error("invalid redirectTo");
document.removeChild(document.documentElement);
const token = await api.sessionToken();
parsed.searchParams.delete("redirectTo");
history.replaceState(null, null, parsed.href);
const res = await fetch(parsed.href, {
headers: {
accept: "text/html",
"x-shopify-session-token": token
},
window: null
});
const html = await res.text();
document.write(html);
document.dispatchEvent(new Event("DOMContentLoaded", { bubbles: true }));
document.dispatchEvent(new Event("load", { bubbles: true }));
}
init();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment