Last active
March 30, 2023 14:03
-
-
Save pepicrft/5cbdb1669a5ba9e515dcdbf5f3c02a68 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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