UNPKG

@nasriya/hypercloud

Version:

Nasriya HyperCloud is a lightweight Node.js HTTP2 framework.

391 lines (390 loc) 19.1 kB
import helpers from '../../../utils/helpers.js'; import fs from 'fs'; import path from 'path'; export class Component { #_id = helpers.generateRandom(16, { includeSymbols: false }); /**Component name */ #_name; /**The template */ #_template = { filePath: '', content: '' }; /**The component's stylesheet */ #_stylesheet; /**The component's script */ #_script; /**The template's default locals */ #_locals = { default: {} }; #_cache = Object.seal({ extensions: { css: false, js: false } }); #_helpers = { checkPath: (pathToCheck, type) => { const validity = helpers.checkPathAccessibility(pathToCheck); if (!validity.valid) { if (validity.errors.notString) { return this.#_helpers.createError(`The ${type.toLowerCase()} path that was passed to the ${this.#_name} page is not a valid string`); } if (validity.errors.doesntExist) { return this.#_helpers.createError(`The ${type.toLowerCase()} path (${pathToCheck}) that was passed to the ${this.#_name} page doesn't exist`); } if (validity.errors.notAccessible) { return this.#_helpers.createError(`The ${type.toLowerCase()} path (${pathToCheck}) that was passed to the ${this.#_name} page isn't accessible`); } } const ext = type === 'Template' ? '.ejs' : type === 'CSS' ? '.css' : type === 'JS' ? '.js' : ''; if (!path.basename(pathToCheck).endsWith(ext)) { return this.#_helpers.createError(`The ${type.toLowerCase()} path you provided for the ${this.#_name} page isn't a ${ext.substring(1)} file`); } return true; }, createError: (message) => { const error = new Error(`(${this.#_name}) ${message}`); error.name = `${this.name}Error`; return error; }, validate: { scriptConfigs: (config) => { if ('async' in config) { if (typeof config.async !== 'boolean') { throw this.#_helpers.createError(`The script's "async" property can only be boolean, instead got ${typeof config.async}`); } } if ('crossorigin' in config) { if (typeof config.crossorigin !== 'string') { throw this.#_helpers.createError(`The script's "crossorigin" value can only be a string value, instead got ${typeof config.crossorigin}`); } if (!(config.crossorigin === 'anonymous' || config.crossorigin === 'use-credentials')) { throw this.#_helpers.createError(`The script's "crossorigin" can only be 'anonymous' or 'use-credentials'. You passed: ${config.crossorigin}`); } } if ('defer' in config) { if (typeof config.defer !== 'boolean') { throw this.#_helpers.createError(`The script's "defer" property can only be boolean, instead got ${typeof config.defer}`); } } if ('integrity' in config) { if (typeof config.integrity !== 'string') { throw this.#_helpers.createError(`The script's "integrity" value can only be a string value, instead got ${typeof config.integrity}`); } } if ('nomodule' in config) { if (typeof config.nomodule !== 'boolean') { throw this.#_helpers.createError(`The script's "nomodule" property can only be boolean, instead got ${typeof config.nomodule}`); } } if ('referrerpolicy' in config) { if (typeof config.referrerpolicy !== 'string') { throw this.#_helpers.createError(`The script's "referrerpolicy" value can only be a string value, instead got ${typeof config.referrerpolicy}`); } const pol = ["no-referrer", "no-referrer-when-downgrade", "same-origin", "origin", "strict-origin", "origin-when-cross-origin", "strict-origin-when-cross-origin", "unsafe-url"]; if (!pol.includes(config.referrerpolicy)) { throw this.#_helpers.createError(`The script's "referrerpolicy" value (${config.referrerpolicy}) is not a valid referrer policy`); } } if ('type' in config) { if (typeof config.type !== 'string') { throw this.#_helpers.createError(`The script's "type" value can only be a string value, instead got ${typeof config.type}`); } const possibleOptions = ['text/javascript', 'application/ecmascript', 'text/babel', 'application/ld+json', 'module']; config.type = config.type.toLowerCase(); if (!possibleOptions.includes(config.type)) { throw this.#_helpers.createError(`The script's "type" value is not supported`); } } } } }; #_onRender = null; /** * Create a new component * @param name The component name */ constructor(name) { this.#_name = name; } template = { path: { set: (filePath) => { const checkRes = this.#_helpers.checkPath(filePath, 'Template'); if (checkRes instanceof Error) { throw checkRes; } this.#_template.filePath = filePath; this.#_template.content = fs.readFileSync(filePath, { encoding: 'utf-8' }); }, get: () => { return this.#_template.filePath; } }, content: { get: () => { return this.#_template.content; } } }; stylesheet = { /** * Link an internal `css` file * @param filePath The path to the CSS file */ set: (filePath) => { const validity = helpers.checkPathAccessibility(filePath); if (!validity.valid) { if (validity.errors.notString) { throw this.#_helpers.createError(`The stylesheet path that you passed should be a string, instead got ${typeof filePath}`); } if (validity.errors.doesntExist) { throw this.#_helpers.createError(`The stylesheet path (${filePath}) doesn't exist.`); } if (validity.errors.notAccessible) { throw this.#_helpers.createError(`You don't have enough permissions to access the stylesheet path (${filePath})`); } } const name = path.basename(filePath); this.#_stylesheet = { scope: 'Internal', fileName: name, filePath }; }, get: () => this.#_stylesheet }; script = { set: (config) => { if (helpers.isNot.realObject(config)) { throw this.#_helpers.createError(`The script configs you're trying to add is not a valid object`); } if ('filePath' in config) { const validity = helpers.checkPathAccessibility(config.filePath); if (!validity.valid) { if (validity.errors.notString) { throw this.#_helpers.createError(`The script "filePath" is expecting a string value, instead got ${typeof config.filePath}`); } if (validity.errors.doesntExist) { throw this.#_helpers.createError(`The script "filePath" (${config.filePath}) doesn't exist`); } if (validity.errors.notAccessible) { throw this.#_helpers.createError(`You don't have enough permissions to access the script "filePath" (${config.filePath})`); } } } else { throw this.#_helpers.createError(`Unable to add internal script to component. The config object is missing the "filePath" property`); } this.#_helpers.validate.scriptConfigs(config); this.#_script = { scope: 'Internal', fileName: path.basename(config.filePath), ...config }; }, get: () => this.#_script }; locals = { /** * Add a locale object for your component. If the locale is * language specific, specify the language in the second argument. * * **Notes:** * - The `locals.add` method *adds* the given properties, it doesn't reasign * the locals, which means it's safe to add locals in multiple calls. * - Be aware that adding locals already added will overwrite them, which means * the newer value will replace the previous one. * @example * const component = new Component('LoginBar'); * * // Adds object globally * component.locals.add({ login: 'Login', signup: 'Signup' }); * * // Adds a title specifically under the "ar" language * component.locals.add({ login: 'تسجيل الدخول', signup: 'إنشاء حساب' }, 'ar'); * @param locale The locale object * @param lang A language supported by your server. */ add: (locals, lang) => { if (helpers.isNot.realObject(locals)) { throw this.#_helpers.createError(`An invalid locale has been passed to the ${this.#_name} component's "locals.add()". Expected a real object bust instead got ${typeof locals}`); } if (lang === undefined) { lang = 'default'; } if (helpers.isNot.realObject(this.#_locals[lang])) { this.#_locals[lang] = {}; } for (const prop in locals) { this.#_locals[lang][prop] = locals[prop]; } }, multilingual: { set: (locals) => { if (helpers.isNot.realObject(locals)) { throw this.#_helpers.createError(`An invalid locale has been passed to the ${this.#_name} component's "locals.multilingual.set()" locale. Expected a real object bust instead got ${typeof locals}`); } if ('default' in locals) { if (helpers.isNot.realObject(locals.default)) { throw this.#_helpers.createError(`The object passed to "locals.multilingual.set()" has an invalid value type for "default". Expected a real object but instead got ${typeof locals.default}`); } } else { throw this.#_helpers.createError(`The object passed to "locals.multilingual.set()" is missing the "default" property.`); } for (const lang in locals) { this.#_locals[lang] = locals[lang]; } } }, get: (lang = 'default') => lang in this.#_locals ? this.#_locals[lang] : this.#_locals.default }; /**Component name */ get name() { return this.#_name; } /**Component ID */ get _id() { return this.#_id; } /**Control component caching for different assets */ cache = { /** * Enable caching for this component * @param extensions he extensions you want to enable. Default: All assets * @example * component.cache.enable(); // Enable caching for all assets * component.cache.enable('js'); // Enable caching for JavaScript Files * component.cache.enable(['js', 'css']); // Enable caching for CSS Files */ enable: (extensions) => { try { if (extensions === undefined) { this.#_cache.extensions.css = this.#_cache.extensions.js = true; } else { if (!(typeof extensions === 'string' || Array.isArray(extensions))) { throw new TypeError(`${typeof extensions} is not a valid caching argument.`); } if (typeof extensions === 'string') { extensions = [extensions]; } for (const ext of extensions) { if (typeof ext !== 'string') { throw new TypeError(`Cache extensions are expected to be strings, instead got ${typeof ext}`); } if (!Object.keys(this.#_cache.extensions).includes(ext)) { throw new Error(`${ext} is not a valid caching asset`); } this.#_cache.extensions[ext] = true; } } } catch (error) { if (error instanceof Error) { error.message = `Unable to enable ${this.#_name} component cache: ${error.message}`; } throw error; } }, /** * Disable caching for this component. * @param extensions The extensions you want to disable. Default: All assets * @example * component.cache.disable(); // Disable caching for all assets * component.cache.disable('js'); // Disable caching for JavaScript Files * component.cache.disable(['js', 'css']); // Disable caching for CSS Files */ disable: (extensions) => { try { if (extensions === undefined) { this.#_cache.extensions.css = this.#_cache.extensions.js = false; } else { if (!(typeof extensions === 'string' || Array.isArray(extensions))) { throw new TypeError(`${typeof extensions} is not a valid caching argument.`); } if (typeof extensions === 'string') { extensions = [extensions]; } for (const ext of extensions) { if (helpers.is.validString(ext)) { throw new TypeError(`Cache extensions are expected to be strings, instead got ${typeof ext}`); } if (!Object.keys(this.#_cache.extensions).includes(ext)) { throw new Error(`${ext} is not a valid caching asset`); } this.#_cache.extensions[ext] = false; } } } catch (error) { if (error instanceof Error) { error.message = `Unable to disable ${this.#_name} component cache: ${error.message}`; } throw error; } }, /** * Update component caching state. * - For enabled assets, content will be cached in memory and their eTags generated. * - For disabled assets, content will be cleared from memory and eTags will be removed. */ update: async () => { try { if (!this.#_template.filePath) { return; } const promises = [new Promise((resolve, reject) => { try { this.#_template.content = fs.readFileSync(this.#_template.filePath, { encoding: 'utf-8' }); resolve(); } catch (error) { reject(error); } })]; if (!helpers.is.undefined(this.#_stylesheet)) { if (this.#_cache.extensions.css) { promises.push(new Promise((resolve, reject) => { const stylesheet = this.#_stylesheet; stylesheet.content = fs.readFileSync(stylesheet.filePath, { encoding: 'utf-8' }); helpers.calculateHash(stylesheet.filePath).then(hash => { stylesheet.eTag = hash; resolve(); }).catch(err => reject({ type: 'stylesheet', filePath: stylesheet.filePath, error: err })); })); } else { this.#_stylesheet.content = this.#_stylesheet.eTag = undefined; } } if (!helpers.is.undefined(this.#_script)) { if (this.#_cache.extensions.js) { promises.push(new Promise((resolve, reject) => { const script = this.#_script; script.content = fs.readFileSync(script.filePath, { encoding: 'utf-8' }); helpers.calculateHash(script.filePath).then(hash => { script.eTag = hash; resolve(); }).catch(err => reject({ type: 'stylesheet', filePath: script.filePath, error: err })); })); } else { this.#_script.content = this.#_script.eTag = undefined; } } await Promise.allSettled(promises).then(results => { const failed = results.filter(i => i.status === 'rejected'); if (failed.length > 0) { const errors = failed.map(i => i.reason); console.error(errors); throw new Error(`${errors.length} errors occurred. Read the errors object`); } }); } catch (error) { if (error instanceof Error) { error.message = `Unable to update the ${this.#_name} component cache: ${error.message}`; } throw error; } }, /**Read the caching status of this component */ status: () => this.#_cache.extensions }; onRender = { /** * Set the component's renderer function * @param {OnRenderHandler} callback */ set: (callback) => { if (typeof callback !== 'function') { throw new TypeError(`The component's "onRender" handler must be a function, instead got ${typeof callback}`); } this.#_onRender = callback; }, /**Get the component's render function */ get: () => { return this.#_onRender; } }; } export default Component;