UNPKG

@open-condo/miniapp-utils

Version:

A set of helper functions / components / hooks used to build new condo apps fast

546 lines (536 loc) 19.9 kB
// src/helpers/messaging/provider.tsx import React, { createContext, useState, useEffect, useContext, useMemo } from "react"; // src/helpers/messaging/controller.ts import { z as z3 } from "zod"; // src/helpers/messaging/errors.ts function getClientErrorMessage(reason, code, message, requestId, eventName) { return { type: eventName ? `${eventName}Error` : "CondoWebAppCommonError", data: { errorType: "client", errorCode: code, errorReason: reason, errorMessage: message, ...typeof requestId !== "undefined" ? { requestId } : null } }; } // src/helpers/messaging/events/bridge.ts import { z as z2 } from "zod"; // src/helpers/ip/utils.ts var v4Seg = "(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"; var v4Str = `(${v4Seg}[.]){3}${v4Seg}`; var IPv4Reg = new RegExp(`^${v4Str}$`); var v6Seg = "(?:[0-9a-fA-F]{1,4})"; var IPv6Reg = new RegExp( `^((?:${v6Seg}:){7}(?:${v6Seg}|:)|(?:${v6Seg}:){6}(?:${v4Str}|:${v6Seg}|:)|(?:${v6Seg}:){5}(?::${v4Str}|(:${v6Seg}){1,2}|:)|(?:${v6Seg}:){4}(?:(:${v6Seg}){0,1}:${v4Str}|(:${v6Seg}){1,3}|:)|(?:${v6Seg}:){3}(?:(:${v6Seg}){0,2}:${v4Str}|(:${v6Seg}){1,4}|:)|(?:${v6Seg}:){2}(?:(:${v6Seg}){0,3}:${v4Str}|(:${v6Seg}){1,5}|:)|(?:${v6Seg}:){1}(?:(:${v6Seg}){0,4}:${v4Str}|(:${v6Seg}){1,6}|:)|(?::((?::${v6Seg}){0,5}:${v4Str}|(?::${v6Seg}){1,7}|:)))(%[0-9a-zA-Z]{1,})?$` ); // src/helpers/urls.ts var REGEXP_ESCAPE_CHARS = /[\\^$.*+?()[\]{}|]/g; var WILDCARD_REGEXP_PART = "([a-zA-Z0-9-]{1,63})"; var WILDCARD_REGEXP_PART_ESCAPED = _escapeRegexp(WILDCARD_REGEXP_PART); function isSafeUrl(url) { if (!url || typeof url !== "string") return false; let decodedUrl; try { decodedUrl = decodeURI(url); } catch (error) { return false; } const normalizedUrl = decodedUrl.replace(/[\u0000-\u001F\s]/g, "").toLowerCase(); return !normalizedUrl.includes("javascript:"); } function _escapeRegexp(source) { return source.replace(REGEXP_ESCAPE_CHARS, "\\$&"); } // src/helpers/uuid.ts import { randomBytes } from "crypto"; function generateUUIDv4() { let randomValues; if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } else if (typeof window !== "undefined" && window.crypto && window.crypto.getRandomValues) { randomValues = new Uint8Array(16); window.crypto.getRandomValues(randomValues); } else { randomValues = randomBytes(16); } randomValues[6] = randomValues[6] & 15 | 64; randomValues[8] = randomValues[8] & 63 | 128; return [...randomValues].map((value, index) => { const hex = value.toString(16).padStart(2, "0"); if (index === 4 || index === 6 || index === 8 || index === 10) { return `-${hex}`; } return hex; }).join(""); } // src/helpers/messaging/utils.ts import { z } from "zod"; function typeCheckerToValidator(typeChecker) { return (params) => { if (!typeChecker(params)) { return { success: false, error: "Invalid params" }; } return { success: true, data: params }; }; } function zodSchemaToValidator(schema) { return (params) => { const result = schema.safeParse(params); if (!result.success) { return { success: false, error: z.prettifyError(result.error) }; } return { success: true, data: result.data }; }; } function sortedMiddlewares(middlewares) { return middlewares.sort((a, b) => { const orderDiff = (a.order || 0) - (b.order || 0); if (orderDiff !== 0) return orderDiff; const aIsGlobal = a.scope === "*" ? 1 : 0; const bIsGlobal = b.scope === "*" ? 1 : 0; if (aIsGlobal !== bIsGlobal) return aIsGlobal - bIsGlobal; const aHasEventName = a.eventName ? 0 : 1; const bHasEventName = b.eventName ? 0 : 1; if (aHasEventName !== bHasEventName) return bHasEventName - aHasEventName; const aHasEventType = a.eventType ? 0 : 1; const bHasEventType = b.eventType ? 0 : 1; return bHasEventType - aHasEventType; }); } function isServiceWorker(source) { return typeof ServiceWorker !== "undefined" && source instanceof ServiceWorker; } function sendResponseMessage({ data, target, origin }) { if (isServiceWorker(target)) { target.postMessage(data); } else { target.postMessage(data, origin); } } // src/helpers/messaging/events/bridge.ts function registerBridgeEvents({ addHandler, router, notificationsApi, modalsApi }) { addHandler("condo-bridge", "CondoWebAppResizeWindow", "*", zodSchemaToValidator(z2.strictObject({ height: z2.number() })), ({ params, source }) => { if (source.type !== "frame") { throw new Error("Forbidden source type. Resize window is only available for registered iframes"); } source.ref.height = `${params.height}px`; return { height: params.height }; }); addHandler("condo-bridge", "CondoWebAppGetFragment", "*", zodSchemaToValidator(z2.strictObject({})), () => { const hash = window.location.hash; const rawFragment = hash.startsWith("#") ? hash.substring(1) : hash; const fragment = decodeURIComponent(rawFragment); return { fragment }; }); addHandler("condo-bridge", "CondoWebAppRedirect", "*", zodSchemaToValidator(z2.strictObject({ url: z2.url(), target: z2.union([z2.literal("_blank"), z2.literal("_self")]) })), async ({ params: { url, target } }) => { if (!isSafeUrl(url)) { throw new Error("Forbidden url. Your url is probably injected"); } if (target === "_blank") { window.open(url, target); } else { const urlOrigin = new URL(url).origin; if (window.origin !== urlOrigin) { throw new Error("The redirect url must have the same origin as parent window, if target is not _blank"); } if (router) { router.push(url); } else { window.open(url, target); } } return { success: true }; }); addHandler("condo-bridge", "CondoWebAppRequestAuth", "*", zodSchemaToValidator(z2.strictObject({ url: z2.url() })), async ({ params: { url } }) => { if (!isSafeUrl(url)) { throw new Error("Forbidden url. Your url is probably injected"); } const response = await fetch(url, { credentials: "include" }); const body = await response.text(); return { response: { status: response.status, body, url: response.url } }; }); if (notificationsApi) { addHandler("condo-bridge", "CondoWebAppShowNotification", "*", zodSchemaToValidator(z2.strictObject({ message: z2.string(), description: z2.string().optional(), type: z2.enum(["success", "error", "warning", "info"]) })), ({ params }) => { notificationsApi(params); return { success: true }; }); } if (modalsApi) { addHandler("condo-bridge", "CondoWebAppShowModalWindow", "*", zodSchemaToValidator(z2.strictObject({ title: z2.string(), url: z2.url(), size: z2.enum(["big", "small"]).optional(), initialHeight: z2.number().optional() })), ({ source, params, storage }) => { if (source.type === "worker") { throw new Error("Forbidden source type. Modals cannot be opened from service workers"); } const modalId = generateUUIDv4(); if (!isSafeUrl(params.url)) { throw new Error("Forbidden url. Your url is probably injected"); } const originalSrc = new URL(params.url); originalSrc.searchParams.set("modalId", modalId); const sourceOrigin = new URL(source.type === "frame" ? source.ref.src : window.location.href).origin; const sourceTarget = source.type === "frame" ? source.ref.contentWindow : source.ref; if (sourceOrigin && originalSrc.origin !== sourceOrigin) { throw new Error("Forbidden url. Url must have same origin as sender"); } const closeEventData = { type: "CondoWebAppCloseModalWindowResult", data: { success: true, modalId } }; const onCancel = () => { storage.events.delete(`modals:${modalId}`); if (sourceTarget) { sendResponseMessage({ data: closeEventData, target: sourceTarget, origin: sourceOrigin }); } }; storage.events.set(`modals:${modalId}`, modalsApi({ ...params, url: originalSrc.toString(), onCancel, metadata: source.type === "frame" ? source.metadata : void 0 })); return { modalId }; }); addHandler("condo-bridge", "CondoWebAppUpdateModalWindow", "*", zodSchemaToValidator(z2.strictObject({ modalId: z2.string(), data: z2.strictObject({ title: z2.string().optional(), size: z2.enum(["big", "small"]).optional() }) })), ({ params, storage }) => { const modalActions = storage.events.get(`modals:${params.modalId}`); if (!modalActions) { return { updated: false }; } modalActions.update(params.data); return { updated: true }; }); addHandler("condo-bridge", "CondoWebAppCloseModalWindow", "*", zodSchemaToValidator(z2.strictObject({ modalId: z2.string() })), ({ params, storage }) => { const modalActions = storage.events.get(`modals:${params.modalId}`); if (!modalActions) { return { modalId: params.modalId, success: false }; } modalActions.destroy(); storage.events.delete(`modals:${params.modalId}`); return { modalId: params.modalId, success: true }; }); } } // src/helpers/messaging/controller.ts var SEMVER_REGEXP = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; var MESSAGE_SCHEMA = z3.object({ handler: z3.string(), params: z3.record(z3.string(), z3.unknown()).and(z3.object({ requestId: z3.union([z3.string(), z3.number()]).optional() })), type: z3.string(), version: z3.string().regex(SEMVER_REGEXP) }).strict(); var PostMessageController = class extends EventTarget { constructor() { super(); this.#registeredFrames = {}; this.#registeredHandlers = {}; this.#registeredMiddlewares = {}; this.#middlewaresIdsMap = {}; this.#storage = {}; this.state = { isBridgeReady: false }; this.addFrame = this.addFrame.bind(this); this.removeFrame = this.removeFrame.bind(this); this.addHandler = this.addHandler.bind(this); this.eventListener = this.eventListener.bind(this); this.registerBridgeEvents = this.registerBridgeEvents.bind(this); this.addMiddleware = this.addMiddleware.bind(this); this.removeMiddleware = this.removeMiddleware.bind(this); } #registeredFrames; #registeredHandlers; #registeredMiddlewares; #middlewaresIdsMap; #storage; // ---- PRIVATE UTILITIES METHODS ---- updateState(state) { this.state = { ...this.state, ...state }; this.dispatchEvent(new CustomEvent("statechange", { detail: this.state })); } getWrappedHandler(eventType, eventName, scope, handler) { const globalMiddlewares = (this.#registeredMiddlewares["*"] ?? []).filter( (mw) => (!mw.eventType || mw.eventType === eventType) && (!mw.eventName || mw.eventName === eventName) && mw.scope === "*" ); const scopedMiddlewares = (this.#registeredMiddlewares[scope] ?? []).filter( (mw) => (!mw.eventType || mw.eventType === eventType) && (!mw.eventName || mw.eventName === eventName) && mw.scope === scope ); const middlewares = sortedMiddlewares([...globalMiddlewares, ...scopedMiddlewares]); return middlewares.reduceRight( (nextHandler, mw) => { return async (args) => { const nextMiddlewareFn = (nextArgs) => { return nextHandler({ ...args, params: (nextArgs == null ? void 0 : nextArgs.params) ?? args.params }); }; return mw.fn({ ...args, next: nextMiddlewareFn }); }; }, handler ); } getMessageSource(source) { if (isServiceWorker(source)) { if (source !== navigator.serviceWorker.controller) return null; return { ref: source, id: "worker", type: "worker" }; } if (source === window) { return { ref: source, id: "parent", type: "window" }; } const registeredFrame = Object.entries(this.#registeredFrames).find(([, frame]) => frame.ref.contentWindow === source); if (!registeredFrame) return null; return { ref: registeredFrame[1].ref, id: registeredFrame[0], type: "frame", metadata: registeredFrame[1].metadata }; } // ---- SOURCE REGISTRATION METHODS ---- addFrame(frame, metadata) { const registeredFrame = Object.entries(this.#registeredFrames).find(([, existingFrame]) => existingFrame.ref === frame); if (registeredFrame) { this.#registeredFrames[registeredFrame[0]].metadata = metadata; return registeredFrame[0]; } const frameId = generateUUIDv4(); this.#registeredFrames[frameId] = { ref: frame, metadata }; return frameId; } removeFrame(frameId) { (this.#registeredMiddlewares[frameId] ?? []).forEach((mw) => { delete this.#middlewaresIdsMap[mw.id]; }); delete this.#registeredFrames[frameId]; delete this.#registeredHandlers[frameId]; delete this.#registeredMiddlewares[frameId]; } // ---- HANDLER REGISTRATION METHODS ---- addHandler(eventType, eventName, handlerScope, validator, handler) { if (!this.#registeredHandlers[handlerScope]) { this.#registeredHandlers[handlerScope] = {}; } const scopedHandlers = this.#registeredHandlers[handlerScope]; if (!scopedHandlers[eventType]) { scopedHandlers[eventType] = {}; } const eventHandlers = scopedHandlers[eventType]; eventHandlers[eventName] = { validator, handler }; } addMiddleware(mw) { const { scope } = mw; if (!this.#registeredMiddlewares[scope]) { this.#registeredMiddlewares[scope] = []; } const id = generateUUIDv4(); this.#registeredMiddlewares[scope].push({ ...mw, fn: mw.fn, id }); this.#middlewaresIdsMap[id] = scope; return id; } removeMiddleware(id) { const scope = this.#middlewaresIdsMap[id]; if (scope && this.#registeredMiddlewares[scope]) { this.#registeredMiddlewares[scope] = this.#registeredMiddlewares[scope].filter((mw) => mw.id !== id); } delete this.#middlewaresIdsMap[id]; } // ---- EVENT LISTENERS ---- async eventListener(event) { var _a, _b, _c, _d; if (typeof window === "undefined") return; if (!event.isTrusted || !event.source || !("self" in event.source) && !isServiceWorker(event.source)) return; const { success: isValidMessage, data: message } = MESSAGE_SCHEMA.safeParse(event.data); if (!isValidMessage) return; const { handler: eventName, params: { requestId, ...handlerParams }, type: eventType } = message; const messageSource = this.getMessageSource(event.source); if (!messageSource) { return sendResponseMessage({ data: getClientErrorMessage("ACCESS_DENIED", 0, "Message was received from unregistered origin / iframe", requestId, eventName), target: event.source, origin: event.origin }); } const handlerMethods = ((_b = (_a = this.#registeredHandlers[messageSource.id]) == null ? void 0 : _a[eventType]) == null ? void 0 : _b[eventName]) ?? ((_d = (_c = this.#registeredHandlers["*"]) == null ? void 0 : _c[eventType]) == null ? void 0 : _d[eventName]) ?? {}; const { handler, validator } = handlerMethods; if (!handler || !validator) { return sendResponseMessage({ data: getClientErrorMessage("UNKNOWN_METHOD", 2, "Unknown method was provided. Make sure your runtime environment supports it.", requestId), origin: event.origin, target: event.source }); } const validationResult = validator(handlerParams); if (!validationResult.success) { return sendResponseMessage({ data: getClientErrorMessage("INVALID_PARAMETERS", 3, validationResult.error, requestId, eventName), origin: event.origin, target: event.source }); } const validatedParams = validationResult.data; this.#storage[eventType] ??= /* @__PURE__ */ new Map(); const eventsStorage = this.#storage[eventType]; const wrappedHandler = this.getWrappedHandler(eventType, eventName, messageSource.id, handler); try { const result = await wrappedHandler({ eventType, eventName, params: validatedParams, storage: { events: eventsStorage }, source: messageSource }); return sendResponseMessage({ data: { type: `${eventName}Result`, data: { ...result, requestId } }, target: event.source, origin: event.origin }); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); return sendResponseMessage({ data: getClientErrorMessage("HANDLER_ERROR", 4, errorMessage, requestId, eventName), target: event.source, origin: event.origin }); } } // ---- COMMON HANDLERS METHODS ---- registerBridgeEvents(options) { registerBridgeEvents({ ...options, addHandler: this.addHandler }); this.updateState({ isBridgeReady: true }); } }; // src/helpers/messaging/provider.tsx function useControllerState(controller) { const [controllerState, setControllerState] = useState(controller.state); useEffect(() => { const handleBridgeReadyChange = (event) => { setControllerState(event.detail); }; controller.addEventListener("statechange", handleBridgeReadyChange); return () => { controller.removeEventListener("statechange", handleBridgeReadyChange); }; }, [controller]); return controllerState; } var PostMessageContext = createContext({ addFrame: () => "", removeFrame: () => { }, addHandler: () => { }, addMiddleware: () => "", removeMiddleware: () => { }, isBridgeReady: false }); var PostMessageProvider = ({ children, router, notificationsApi, modalsApi }) => { const [controller] = useState(() => new PostMessageController()); const controllerState = useControllerState(controller); useEffect(() => { controller.registerBridgeEvents({ router, notificationsApi, modalsApi }); }, [controller, modalsApi, notificationsApi, router]); useEffect(() => { window.addEventListener("message", controller.eventListener); return () => { window.removeEventListener("message", controller.eventListener); }; }, [controller.eventListener]); useEffect(() => { var _a, _b; (_b = (_a = window == null ? void 0 : window.navigator) == null ? void 0 : _a.serviceWorker) == null ? void 0 : _b.addEventListener("message", controller.eventListener); return () => { var _a2, _b2; (_b2 = (_a2 = window == null ? void 0 : window.navigator) == null ? void 0 : _a2.serviceWorker) == null ? void 0 : _b2.removeEventListener("message", controller.eventListener); }; }, [controller.eventListener]); const contextValue = useMemo(() => ({ ...controllerState, addFrame: controller.addFrame, removeFrame: controller.removeFrame, addHandler: controller.addHandler, addMiddleware: controller.addMiddleware, removeMiddleware: controller.removeMiddleware }), [controller, controllerState]); return /* @__PURE__ */ React.createElement(PostMessageContext.Provider, { value: contextValue }, children); }; function usePostMessageContext() { return useContext(PostMessageContext); } export { PostMessageController, PostMessageProvider, typeCheckerToValidator, usePostMessageContext, zodSchemaToValidator }; //# sourceMappingURL=index.mjs.map