UNPKG

webcm

Version:

Demonstrative implementation of a web-based manager for utilising Managed Components

351 lines (350 loc) 14.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Manager = exports.ManagerGeneric = exports.MCEvent = void 0; const fs_1 = require("fs"); const jsdom_1 = require("jsdom"); const pacote_1 = __importDefault(require("pacote")); const path_1 = __importDefault(require("path")); const index_1 = require("./cache/index"); const constants_1 = require("./constants"); const kv_storage_1 = require("./storage/kv-storage"); const manifest_1 = require("./manifest"); const compConfig_1 = require("./compConfig"); const find_package_json_1 = __importDefault(require("find-package-json")); class MCEvent extends Event { name; payload; client; type; constructor(type, req) { super(type); this.type = type; this.payload = req.body.payload || { timestamp: new Date().getTime() }; // because pageviews are symbolic requests without a payload this.name = type === 'ecommerce' ? this.payload.name : undefined; } } exports.MCEvent = MCEvent; const EXTS = ['.mjs', '.js', '.mts', '.ts']; class ManagerGeneric { components; trackPath; name; componentsFolderPath; requiredSnippets; mappedEndpoints; proxiedEndpoints; staticFiles; listeners; clientListeners; registeredEmbeds; registeredWidgets; permissions; constructor(Context) { this.componentsFolderPath = Context.componentsFolderPath || path_1.default.join(__dirname, '..', 'components'); this.requiredSnippets = ['track', 'embedHeight']; this.registeredWidgets = []; this.registeredEmbeds = {}; this.listeners = {}; this.permissions = {}; this.clientListeners = {}; this.mappedEndpoints = {}; this.proxiedEndpoints = {}; this.staticFiles = {}; this.name = 'WebCM'; this.trackPath = Context.trackPath; this.components = Context.components; } route(component, path, callback) { const fullPath = '/webcm/' + component + path; this.mappedEndpoints[fullPath] = callback; return fullPath; } proxy(component, path, target) { this.proxiedEndpoints[component] ||= {}; this.proxiedEndpoints[component][path] = target; return '/webcm/' + component + path; } serve(component, path, target) { const fullPath = '/webcm/' + component + path; this.staticFiles[fullPath] = component + '/' + target; return fullPath; } addEventListener(component, type, callback) { if (!this.requiredSnippets.includes(type)) { this.requiredSnippets.push(type); } this.listeners[type] ||= {}; this.listeners[type][component] ||= []; this.listeners[type][component].push(callback); } async initComponent(component, name, settings, permissions) { if (component) { try { // save component permissions in memory this.permissions[name] = permissions; console.info(':: Initialising component', name); await component.default(new Manager(name, this), settings); } catch (error) { console.error(':: Error initialising component', component, error); } } } async loadComponentManifest(basePath) { let manifest; const manifestPath = path_1.default.join(basePath, 'manifest.json'); if ((0, fs_1.existsSync)(manifestPath)) { manifest = JSON.parse((0, fs_1.readFileSync)(manifestPath, 'utf8')); const parseResult = manifest_1.ManifestShape.safeParse(manifest); if (!parseResult.success) { console.error(parseResult.error); console.error(parseResult.error.format()); throw new Error('Invalid component manifest'); } else { manifest = manifest; } } else { manifest = (0, manifest_1.mockManifest)(basePath); } return manifest; } async fetchLocalComponent(basePath) { let component; const pkgPath = path_1.default.join(basePath, 'package.json'); if ((0, fs_1.existsSync)(pkgPath)) { const pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, 'utf8')); const main = pkg.main; const mainPath = path_1.default.join(basePath, main); if ((0, fs_1.existsSync)(mainPath)) { console.info('FOUND LOCAL MC:', mainPath); component = mainPath.endsWith('.mjs') ? await import(mainPath) : require(mainPath); } else { console.error(`No executable file for component at ${mainPath}`); } } else { for (const ext of EXTS) { const componentPath = path_1.default.join(basePath, 'index' + ext); if ((0, fs_1.existsSync)(componentPath)) { console.info('FOUND LOCAL MC:', componentPath); component = ext === '.mjs' ? await import(componentPath) : require(componentPath); break; } } if (!component) { console.error(`No executable file for component in ${basePath}`); } } const manifest = await this.loadComponentManifest(basePath); return { component, manifest }; } async fetchRemoteComponent(basePath, name) { let component; const componentPath = path_1.default.join(this.componentsFolderPath, name); try { await pacote_1.default.extract(`@managed-components/${name}`, componentPath); component = await this.fetchLocalComponent(basePath); } catch (error) { console.error(':: Error fetching remote component', name, error); (0, fs_1.rmdir)(componentPath, () => console.info(':::: Removed empty component folder', componentPath)); return { component: null, manifest: null }; } return component; } async loadComponent(name) { const localPathBase = path_1.default.join(this.componentsFolderPath, name); return (0, fs_1.existsSync)(localPathBase) ? this.fetchLocalComponent(localPathBase) : this.fetchRemoteComponent(localPathBase, name); } async loadComponentByPath(componentPath) { const component = require(componentPath); const manifest = (0, manifest_1.mockManifest)(componentPath); return { component, manifest }; } async hasRequiredPermissions(component, requiredPermissions, givenPermissions) { let hasPermissions = true; const missingPermissions = []; for (const [key, permission] of Object.entries(requiredPermissions || {})) { if (permission.required && !givenPermissions.includes(key)) { hasPermissions = false; missingPermissions.push(key); } } !hasPermissions && console.error('\x1b[31m', `\n🔒 MISSING REQUIRED PERMISSIONS :: ${component} component requires additional permissions:\n`, '\x1b[33m', `\t${JSON.stringify(missingPermissions)} \n`); !hasPermissions && process.exit(1); return hasPermissions; } async init() { for (const compConfig of this.components) { if (!(0, compConfig_1.isComponentConfig)(compConfig)) { (0, compConfig_1.explainProblemsWithConfig)(compConfig); throw new Error('Bad config shape'); } let name; let settings; let permissions; let component; let manifest; if ('path' in compConfig) { name = (0, find_package_json_1.default)(compConfig.path).next().value?.name || 'customComponent'; settings = {}; permissions = (compConfig.permissions || []); const result = (await this.loadComponentByPath(compConfig.path)) || {}; if (!result.component || !result.manifest) { console.warn(`Failed to load component by path: '${path_1.default}'`); return; } component = result.component; manifest = result.manifest; } else { name = compConfig.name; settings = compConfig.settings || {}; permissions = compConfig.permissions; const result = (await this.loadComponent(name)) || {}; if (!result.component || !result.manifest) { console.warn(`Failed to load component by name: '${name}'`); return; } component = result.component; manifest = result.manifest; } await this.initComponent(component, name, settings, permissions); this.hasRequiredPermissions(name, manifest.permissions, permissions); } } getInjectedScript(clientGeneric) { let injectedScript = ''; const clientListeners = new Set(Object.values(clientGeneric.webcmPrefs.listeners).flat()); for (const snippet of [...this.requiredSnippets, ...clientListeners]) { if (clientGeneric.pageVars.__client[snippet]) continue; const snippetPath = path_1.default.join(__dirname, 'browser', `${snippet}.js`); if ((0, fs_1.existsSync)(snippetPath)) { injectedScript += (0, fs_1.readFileSync)(snippetPath) .toString() .replace('TRACK_PATH', this.trackPath); } } return injectedScript; } async processEmbeds(response) { const dom = new jsdom_1.JSDOM(response); for (const div of dom.window.document.querySelectorAll('div[data-component-embed]')) { const parameters = Object.fromEntries(Array.prototype.slice .call(div.attributes) .map(attr => [attr.nodeName.replace('data-', ''), attr.nodeValue])); const name = parameters['component-embed']; if (this.registeredEmbeds[name]) { const embed = await this.registeredEmbeds[name]({ parameters }); const uuid = 'embed-' + crypto.randomUUID(); div.innerHTML = `<iframe id="${uuid}" style="width: 100%; border: 0;" src="data:text/html;charset=UTF-8,${encodeURIComponent(embed + `<script> const webcmUpdateHeight = () => parent.postMessage({webcmUpdateHeight: true, id: '${uuid}', h: document.body.scrollHeight }, '*'); addEventListener('load', webcmUpdateHeight); addEventListener('resize', webcmUpdateHeight); </script>`)}"></iframe> `; } } return dom.serialize(); } async processWidgets(response) { const dom = new jsdom_1.JSDOM(response); for (const fn of this.registeredWidgets) { const widget = await fn(); const div = dom.window.document.createElement('div'); div.innerHTML = widget; dom.window.document.body.appendChild(div); } return dom.serialize(); } checkPermissions(component, method) { const componentPermissions = this.permissions[component] || []; if (!componentPermissions.includes(method)) { console.error(`⚠️ ${component} component: ${method?.toLocaleUpperCase()} - permissions not granted `); return false; } return true; } } exports.ManagerGeneric = ManagerGeneric; class Manager { #generic; #component; name; constructor(component, generic) { this.#generic = generic; this.#component = component; this.name = this.#generic.name; } addEventListener(type, callback) { this.#generic.addEventListener(this.#component, type, callback); return true; } createEventListener(type, callback) { this.#generic.clientListeners[`${type}__${this.#component}`] = callback; return true; } get(key) { return (0, kv_storage_1.get)(this.#component + '__' + key); } async set(key, value) { return (0, kv_storage_1.set)(this.#component + '__' + key, value); } route(path, callback) { if (this.#generic.checkPermissions(this.#component, constants_1.PERMISSIONS.route)) { return this.#generic.route(this.#component, path, callback); } } proxy(path, target) { if (this.#generic.checkPermissions(this.#component, constants_1.PERMISSIONS.proxy)) { return this.#generic.proxy(this.#component, path, target); } } serve(path, target) { if (this.#generic.checkPermissions(this.#component, constants_1.PERMISSIONS.serve)) { return this.#generic.serve(this.#component, path, target); } } fetch(path, options) { if (this.#generic.checkPermissions(this.#component, constants_1.PERMISSIONS.managerFetch)) { return fetch(path, options); } } // eslint-disable-next-line @typescript-eslint/ban-types async useCache(key, callback, expiry) { return await (0, index_1.useCache)(this.#component + '__' + key, callback, expiry); } async invalidateCache(key) { (0, index_1.invalidateCache)(this.#component + '__' + key); } registerEmbed(name, callback) { this.#generic.registeredEmbeds[this.#component + '-' + name] = callback; return true; } registerWidget(callback) { if (this.#generic.checkPermissions(this.#component, constants_1.PERMISSIONS.widget)) { this.#generic.registeredWidgets.push(callback); return true; } } } exports.Manager = Manager;