UNPKG

oc-client-browser

Version:
771 lines (692 loc) 19.5 kB
/* globals __CLIENT_VERSION__, __REGISTERED_TEMPLATES_PLACEHOLDER__, __DEFAULT_RETRY_INTERVAL__, __DEFAULT_RETRY_LIMIT__, __DEFAULT_DISABLE_LOADER__, __DISABLE_LEGACY_TEMPLATES__, __EXTERNALS__, __IMPORTS__ */ import { decode } from "@rdevis/turbo-stream"; function createErrorFromObject(o) { const e = new Error(o.message || o); if (o.stack) e.stack = o.stack; return Object.assign(e, o.originalError, o); } export function createOc(oc) { // If oc client is already inside the page, we do nothing. if (oc.status) { return oc; } oc.status = "loading"; oc.conf = oc.conf || {}; oc.cmd = oc.cmd || []; oc.renderedComponents = oc.renderedComponents || {}; oc.clientVersion = __CLIENT_VERSION__; let isRequired = (name, value) => { if (!value) { throw name + " parameter is required"; } }; // The code let $document = document; let $window = window; let noop = () => {}; let initialised = false; let initialising = false; let retries = {}; let isBool = (a) => typeof a == "boolean"; let timeout = setTimeout; let ocCmd = oc.cmd; let ocConf = oc.conf; let renderedComponents = oc.renderedComponents; let dataRenderedAttribute = "data-rendered"; let dataRenderingAttribute = "data-rendering"; let nonce = $document.currentScript?.nonce; let logError = (msg) => console.log(msg); let logInfo = (msg) => ocConf.debug && console.log(msg); let handleFetchResponse = (response) => { if (response.headers.get("Content-Type") !== "x-text/stream") return response.json(); return decode(response.body).then((decoded) => decoded.value); }; // Constants let RETRY_INTERVAL = ocConf.retryInterval || Number(__DEFAULT_RETRY_INTERVAL__); let RETRY_LIMIT = ocConf.retryLimit || Number(__DEFAULT_RETRY_LIMIT__); let DISABLE_LOADER = isBool(ocConf.disableLoader) ? ocConf.disableLoader : __DEFAULT_DISABLE_LOADER__; let RETRY_SEND_NUMBER = ocConf.retrySendNumber || true; let POLLING_INTERVAL = ocConf.pollingInterval || 500; let OC_TAG = ocConf.tag || "oc-component"; let DISABLE_LIFECYCLES = isBool(ocConf.disableLifecycles) ? ocConf.disableLifecycles : false; let MESSAGES_ERRORS_HREF_MISSING = "Href parameter missing"; let MESSAGES_ERRORS_RETRY_FAILED = "Failed to load % component " + RETRY_LIMIT + " times. Giving up"; let MESSAGES_ERRORS_LOADING_COMPILED_VIEW = "Error getting compiled view: %"; let MESSAGES_ERRORS_RENDERING = "Error rendering component: %, error: "; let MESSAGES_ERRORS_RETRIEVING = "Failed to retrieve the component. Retrying in " + RETRY_INTERVAL / 1000 + " seconds..."; let MESSAGES_ERRORS_VIEW_ENGINE_NOT_SUPPORTED = 'Error loading component: view engine "%" not supported'; let MESSAGES_LOADING_COMPONENT = ocConf.loadingMessage || ""; let MESSAGES_RENDERED = "Component '%' correctly rendered"; let MESSAGES_RETRIEVING = "Unrendered component found. Trying to retrieve it..."; let interpolate = (str, value) => str.replace("%", value); let registeredTemplates = __REGISTERED_TEMPLATES_PLACEHOLDER__; let externals = __EXTERNALS__; let imports = __IMPORTS__; let registerTemplates = (templates, overwrite) => { templates = Array.isArray(templates) ? templates : [templates]; templates.map((template) => { if (overwrite || !registeredTemplates[template.type]) { registeredTemplates[template.type] = { externals: template.externals, }; } }); }; if (ocConf.templates) { registerTemplates(ocConf.templates, true); } let retry = (component, cb, failedRetryCb) => { if (retries[component] == undefined) { retries[component] = RETRY_LIMIT; } if (retries[component] <= 0) { failedRetryCb(); } else { timeout(() => { cb(RETRY_LIMIT - retries[component] + 1); }, RETRY_INTERVAL); retries[component]--; } }; let addParametersToHref = (href, parameters) => { return ( href + (~href.indexOf("?") ? "&" : "?") + new URLSearchParams(parameters) ); }; let reanimateScripts = (component) => { for (let script of Array.from(component.querySelectorAll("script"))) { let newScript = $document.createElement("script"); newScript.textContent = script.textContent; for (let attribute of Array.from(script.attributes)) { newScript.setAttribute(attribute.name, attribute.value); } if (nonce) { newScript.setAttribute("nonce", nonce); } script.parentNode?.replaceChild(newScript, script); } }; let getHeaders = () => { let globalHeaders = ocConf.globalHeaders; return { Accept: "application/vnd.oc.unrendered+json", "Content-Type": "application/json", ...(typeof globalHeaders == "function" ? globalHeaders() : globalHeaders), }; }; oc.addStylesToHead = (styles) => { let style = $document.createElement("style"); style.textContent = styles; if (nonce) { style.setAttribute("nonce", nonce); } $document.head.appendChild(style); }; let loadAfterReady = () => { oc.ready(oc.renderUnloadedComponents); }; oc.registerTemplates = (templates) => { registerTemplates(templates); loadAfterReady(); return registeredTemplates; }; // A minimal require.js-ish that uses l.js oc.require = (nameSpace, url, callback) => { if (!callback) { callback = url; url = nameSpace; nameSpace = undefined; } if (typeof nameSpace == "string") { nameSpace = [nameSpace]; } let getObj = () => { let base = $window; if (nameSpace == undefined) { return undefined; } for (let i in nameSpace) { base = base[nameSpace[i]]; if (!base) { return undefined; } } return base; }; let cbGetObj = () => { callback(getObj()); }; if (!getObj()) { ljs.load(url, cbGetObj); } else { cbGetObj(); } }; let asyncRequireForEach = (toLoad, loaded, callback) => { if (!callback) { callback = loaded; loaded = []; } if (!toLoad.length) { callback(loaded); } else { let loading = toLoad[0]; oc.require(loading.global, loading.url, (resolved) => { asyncRequireForEach(toLoad.slice(1), loaded.concat(resolved), callback); }); } }; oc.requireSeries = asyncRequireForEach; let processHtml = (component, data, callback) => { let setAttribute = component.setAttribute.bind(component); let dataName = data.name; let dataVersion = data.version; setAttribute("id", data.id); setAttribute(dataRenderedAttribute, true); setAttribute(dataRenderingAttribute, false); setAttribute("data-version", dataVersion); setAttribute("data-id", data.ocId); if (typeof data.html === "string") { component.innerHTML = data.html; } // If the html contains <scripts> tags, innerHTML will not execute them. // So we need to do it manually. reanimateScripts(component); if (data.key) { setAttribute("data-hash", data.key); } if (dataName) { setAttribute("data-name", dataName); renderedComponents[dataName] = { version: dataVersion }; if (data.baseUrl) { renderedComponents[dataName].baseUrl = data.baseUrl; } data.element = component; oc.events.fire("oc:rendered", { ...data, id: data.ocId }); } callback(); }; let getData = (options, cb) => { cb = cb || noop; let version = options.version, baseUrl = options.baseUrl, name = options.name; isRequired("version", version); isRequired("baseUrl", baseUrl); isRequired("name", name); if (options.action) { baseUrl = `${baseUrl}~actions/${options.action}/${options.name}/${ options.version || "" }`; } let parameters = { ...ocConf.globalParameters, ...options.parameters }; let data = options.action ? parameters : { components: [ { action: options.action, name: name, version: version, parameters, }, ], }; let headers = getHeaders(); fetch(baseUrl, { method: "POST", headers: headers, body: JSON.stringify(data), signal: options.signal, }) .then(handleFetchResponse) .then((apiResponse) => { if (!options.action) { let response = apiResponse[0].response; let err = response.error ? createErrorFromObject(response.details || response.error) : null; cb(err, response.data, apiResponse[0]); } else { let err = apiResponse.error ? createErrorFromObject(apiResponse.details || apiResponse.error) : null; cb(err, apiResponse.data); } }) .catch(cb); }; oc.getData = getData; oc.getAction = (options) => { return new Promise((resolve, reject) => { let name = options.component; getData( { json: true, name: name, ...renderedComponents[name], ...options, }, (err, data) => { if (err) { reject(err); } else { if (data.component) { let props = data.component.props; delete props._staticPath; delete props._baseUrl; delete props._componentName; delete props._componentVersion; resolve(props); } else { resolve(); } } }, ); }); }; oc.build = (options) => { isRequired("baseUrl", options.baseUrl); isRequired("name", options.name); let withFinalSlash = (s) => { if (!s) return ""; return s.match(/\/$/) ? s : s + "/"; }; let href = withFinalSlash(options.baseUrl) + withFinalSlash(options.name) + withFinalSlash(options.version); if (options.parameters) { href += "?"; for (let [key, value] of Object.entries(options.parameters)) { if (/[+&=]/.test(value)) { value = encodeURIComponent(value); } href += key + "=" + value + "&"; } href = href.slice(0, -1); } return "<" + OC_TAG + ' href="' + href + '"></' + OC_TAG + ">"; }; oc.ready = (callback) => { if (initialised) { callback(); } else if (initialising) { ocCmd.push(callback); } else { initialising = true; let done = () => { initialised = true; initialising = false; oc.events = (() => { let listeners = {}; return { fire(key, data) { logInfo(`OC event fired: "${key}"`); if (listeners[key]) { for (let cb of listeners[key]) { cb({ type: key }, data); } } }, on(key, cb) { if (!cb) { throw new Error("Callback is required"); } if (!listeners[key]) { listeners[key] = []; } listeners[key].push(cb); }, off(events, handler) { if (typeof events === "string") { events = [events]; } for (let event of events) { if (listeners[event]) { if (handler) { listeners[event] = listeners[event].filter( (cb) => cb !== handler, ); } else { delete listeners[event]; } } } }, reset() { listeners = {}; }, }; })(); if (Object.keys(imports).length > 0) { $document.head.appendChild( Object.assign($document.createElement("script"), { type: "importmap", textContent: JSON.stringify({ imports }), }), ); } callback(); oc.events.fire("oc:ready", oc); oc.status = "ready"; ocCmd.map((cmd) => { cmd(oc); }); oc.cmd = { push: (f) => f(oc), }; }; oc.requireSeries(externals, done); } }; const renderOc = (template, apiResponse, callback) => { const isEsm = !!apiResponse.data?.component?.esm; if (isEsm) { renderEsm(apiResponse.data, callback); } else { oc.render(template, apiResponse.data, callback); } }; const renderEsm = async (data, callback) => { try { const { _staticPath, _componentName, _componentVersion } = data.component.props; window.oc._esm = window.oc._esm || {}; window.oc._esm[`${_componentName}@${_componentVersion}`] = (args) => { return _staticPath + "public/" + args; }; const { mount } = await import(data.component.src); let context = {}; if (data.component.development) context.development = data.component.development; mount(data.element, data.component.props, context); callback(null); } catch (error) { console.error("Error rendering ESM component", error); callback(error); } }; oc.render = (compiledViewInfo, model, callback) => { oc.ready(() => { // TODO: integrate with oc-empty-response-handler module if (model && model.__oc_emptyResponse == true) { return callback(null, ""); } let type = compiledViewInfo.type; if (!__DISABLE_LEGACY_TEMPLATES__) { if (type == "jade" || type == "handlebars") { type = "oc-template-" + type; } } let template = registeredTemplates[type]; if (template) { oc.require( ["oc", "components", compiledViewInfo.key], compiledViewInfo.src, (compiledView) => { if (!compiledView) { callback( interpolate( MESSAGES_ERRORS_LOADING_COMPILED_VIEW, compiledViewInfo.src, ), ); } else { asyncRequireForEach(template.externals, () => { try { callback( null, !__DISABLE_LEGACY_TEMPLATES__ && type == "oc-template-handlebars" ? $window.Handlebars.template(compiledView, [])(model) : compiledView(model), ); } catch (e) { callback("" + e); } }); } }, ); } else { callback( interpolate( MESSAGES_ERRORS_VIEW_ENGINE_NOT_SUPPORTED, compiledViewInfo.type, ), ); } }); }; oc.renderNestedComponent = (component, callback) => { oc.ready(() => { // If the component is a jQuery object, we need to get the first element component = component[0] || component; let getAttribute = component.getAttribute.bind(component); let setAttribute = component.setAttribute.bind(component); let dataRendering = getAttribute(dataRenderingAttribute); let dataRendered = getAttribute(dataRenderedAttribute); let isRendering = dataRendering == "true"; let isRendered = dataRendered == "true"; if (isRendered) { callback(); return; } if (isRendering) { timeout(() => { oc.renderNestedComponent(component, callback); }, POLLING_INTERVAL); return; } logInfo(MESSAGES_RETRIEVING); setAttribute(dataRenderingAttribute, true); if (!DISABLE_LOADER) { component.innerHTML = '<div class="oc-loading">' + MESSAGES_LOADING_COMPONENT + "</div>"; } oc.renderByHref( { href: getAttribute("href"), id: getAttribute("id"), element: component, }, (err, data) => { if (err || !data) { setAttribute(dataRenderingAttribute, false); setAttribute(dataRenderedAttribute, false); setAttribute("data-failed", true); component.innerHTML = ""; oc.events.fire("oc:failed", { originalError: err, data: data, component, }); logError(err); callback(); } else { processHtml(component, data, callback); } }, ); }); }; oc.renderByHref = (hrefOrOptions, retryNumberOrCallback, callback) => { callback = callback || retryNumberOrCallback; let ocId = Math.floor(Math.random() * 9999999999); let retryNumber = hrefOrOptions.retryNumber || +retryNumberOrCallback || 0; let href = hrefOrOptions.href || hrefOrOptions; let id = hrefOrOptions.id || ocId; let element = hrefOrOptions.element; oc.ready(() => { if (!href) { callback(MESSAGES_ERRORS_RENDERING + MESSAGES_ERRORS_HREF_MISSING); } else { fetch( addParametersToHref(href, { ...ocConf.globalParameters, ...(RETRY_SEND_NUMBER ? { __oc_Retry: retryNumber } : {}), }), { headers: getHeaders(), }, ) .then(handleFetchResponse) .then((apiResponse) => { if (apiResponse.error) { throw apiResponse; } let template = apiResponse.template; apiResponse.data.id = ocId; apiResponse.data.element = element; renderOc(template, apiResponse, (err, html) => { if (err) { callback( interpolate(MESSAGES_ERRORS_RENDERING, apiResponse.href) + err, ); } else { logInfo(interpolate(MESSAGES_RENDERED, template.src)); callback(null, { id: id, ocId: ocId, html: html, baseUrl: apiResponse.baseUrl, key: template.key, version: apiResponse.version, name: apiResponse.name, }); } }); }) .catch((err) => { if (err && err.status == 429) { retries[href] = 0; } logError(MESSAGES_ERRORS_RETRIEVING); window.oc.events.fire("oc:error", err); retry( href, (requestNumber) => { oc.renderByHref( { href: href, retryNumber: requestNumber, id: id, element: element, }, callback, ); }, () => { callback(interpolate(MESSAGES_ERRORS_RETRY_FAILED, href)); }, ); }); } }); }; oc.renderUnloadedComponents = () => { oc.ready(() => { let unloadedComponents = $document.querySelectorAll( `${OC_TAG}:not([data-rendered="true"]):not([data-failed="true"])`, ); unloadedComponents.forEach((unloadedComponent, idx) => { oc.renderNestedComponent(unloadedComponent, () => { if (idx == unloadedComponents.length - 1) { oc.renderUnloadedComponents(); } }); }); }); }; oc.load = (placeholder, href, callback) => { oc.ready(() => { callback = callback || noop; if (placeholder) { placeholder = placeholder[0] || placeholder; placeholder.innerHTML = "<" + OC_TAG + ' href="' + href + '" />'; let newComponent = placeholder.querySelector(OC_TAG); oc.renderNestedComponent(newComponent, () => { callback(newComponent); }); } }); }; // render the components loadAfterReady(); if (window.customElements) { window.customElements.define( OC_TAG, class extends HTMLElement { #connected = false; #manageLifecycle = !DISABLE_LIFECYCLES; // biome-ignore lint/complexity/noUselessConstructor: <explanation> constructor() { super(); } connectedCallback() { this.#connected = true; if ( this.getAttribute("disable-lifecycle") == "true" || this.getAttribute("disable-lifecycle") == "" ) { this.#manageLifecycle = false; } else if (this.getAttribute("disable-lifecycle") == "false") { this.#manageLifecycle = true; } if (this.#manageLifecycle) { if (this.getAttribute("loading") === "lazy") { const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) { observer.disconnect(); oc.renderNestedComponent(this, () => {}); break; } } }); observer.observe(this); } else { oc.renderNestedComponent(this, () => {}); } } } disconnectedCallback() { if (this.#connected) { this.#connected = false; const id = this.getAttribute("id"); if (id) { oc.events.fire("oc:unrendered", { element: this, id, }); } const shouldUnmount = this.#manageLifecycle && this.unmount && this.getAttribute("data-rendered") == "true"; if (shouldUnmount) { this.unmount(); this.removeAttribute("data-rendered"); } } } }, ); } return oc; }