UNPKG

@lionrockjs/mvc

Version:

Controller and View module for LionRockJS

263 lines (223 loc) 7.53 kB
/** * Copyright (c) 2024 Kojin Nakana * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ export default class Controller { //controller states static STATE_CLIENT = 'client'; static STATE_ACTION = 'action'; static STATE_FULL_ACTION_NAME = 'fullActionName'; static STATE_EXITED = 'exited'; static STATE_BODY = 'body'; //web controller states static STATE_REQUEST = 'request'; static STATE_REQUEST_BODY = 'requestBody'; static STATE_REQUEST_HEADERS = 'requestHeader'; static STATE_REQUEST_COOKIES = 'requestCookie'; static STATE_HEADERS = 'headers'; static STATE_COOKIES = 'cookies'; static STATE_HOSTNAME = 'hostname'; static STATE_QUERY = 'query'; static STATE_STATUS = 'status'; //web application states static STATE_PARAMS = 'params'; static STATE_CLIENT_IP = 'clientIP'; static STATE_USER_AGENT = 'userAgent'; static STATE_CHECKPOINT = 'checkpoint'; static STATE_LANGUAGE = 'language'; /** * * @type {ControllerMixin[]} */ static mixins = []; static suppressActionNotFound = false; // properties error = null; state = new Map(); /** * * @param {Request} request * @param {Map} state */ constructor(request, state = new Map()) { const query = request.query || {}; const params = request.params || {}; const raw = request.raw || {}; this.state.set(Controller.STATE_CLIENT, this); this.state.set(Controller.STATE_ACTION, params.action); this.state.set(Controller.STATE_EXITED, false); this.state.set(Controller.STATE_BODY, ''); this.state.set(Controller.STATE_REQUEST, request); this.state.set(Controller.STATE_REQUEST_BODY, request.body); this.state.set(Controller.STATE_REQUEST_HEADERS, request.headers); this.state.set(Controller.STATE_REQUEST_COOKIES, request.cookies); this.state.set(Controller.STATE_HEADERS, { "X-Content-Type-Options": "nosniff" }); this.state.set(Controller.STATE_QUERY, query); /** * cookie to set * @type {{name: String, value: String, options: {secure:Boolean, maxAge:Number}}[]} cookies */ const cookies = []; const reqHeaders = request?.headers || {}; this.state.set(Controller.STATE_COOKIES, cookies); this.state.set(Controller.STATE_STATUS, 200); this.state.set(Controller.STATE_PARAMS, params); this.state.set(Controller.STATE_LANGUAGE, params.language || query.language); this.state.set(Controller.STATE_CLIENT_IP, (!request?.headers) ? '0.0.0.0' : ( reqHeaders['cf-connecting-ip'] || reqHeaders['x-real-ip'] || reqHeaders['x-real_ip'] || reqHeaders['x-forwarded-for'] || reqHeaders['remote_addr'] || request.ip || '0.0.0.0' )); this.state.set(Controller.STATE_USER_AGENT, reqHeaders['user-agent'] || ''); this.state.set(Controller.STATE_HOSTNAME, raw.hostname); this.state.set(Controller.STATE_CHECKPOINT, query.checkpoint || query.cp || null); state.forEach((value, key) => { this.state.set(key, value); }); this.constructor.mixins.forEach(mixin => mixin.init(this.state)); } /** * * @param {string | null} actionName * @param {boolean} retainState * @returns {object} */ async execute(actionName = null, retainState = false) { try { // guard check function action_* exist const action = `action_${actionName || this.state.get(Controller.STATE_ACTION) || 'index'}`; this.state.set(Controller.STATE_FULL_ACTION_NAME, action); if (this[action] === undefined) await this.#handleActionNotFound(action); // stage 0 : setup if (!this.state.get(Controller.STATE_EXITED)) await this.#mixinsSetup(); // stage 1 : before if (!this.state.get(Controller.STATE_EXITED)) await this.#mixinsBefore(); if (!this.state.get(Controller.STATE_EXITED)) await this.before(); // stage 2 : action if (!this.state.get(Controller.STATE_EXITED)) await this.mixinsAction(action); if (!this.state.get(Controller.STATE_EXITED)) await this[action](); // stage 3 : after if (!this.state.get(Controller.STATE_EXITED)) await this.#mixinsAfter(); if (!this.state.get(Controller.STATE_EXITED)) await this.after(); } catch (err) { await this.#serverError(err); } const result = { status: this.state.get(Controller.STATE_STATUS), body: this.state.get(Controller.STATE_BODY), headers: this.state.get(Controller.STATE_HEADERS), cookies: this.state.get(Controller.STATE_COOKIES), }; //clear state to prevent memory leak if(!retainState) this.state.clear(); return result; } async #handleActionNotFound(action) { if (this.constructor.suppressActionNotFound) { this[action] = async () => {/***/}; return; } await this.#notFound(`${this.constructor.name}::${action} not found`); } /** * @async * @callback MixinCallback * @param {ControllerMixin} mixin */ /** * * @param {MixinCallback} lambda * @returns {Promise<void>} */ async #loopMixins(lambda) { const { mixins } = this.constructor; for (let i = 0; i < mixins.length; i++) { if (this.state.get(Controller.STATE_EXITED)) break; // eslint-disable-next-line no-await-in-loop await lambda(mixins[i]); } } async #mixinsSetup() { await this.#loopMixins(async mixin => mixin.setup(this.state)); } async #mixinsBefore() { await this.#loopMixins(async mixin => mixin.before(this.state)); } async before() {/***/} async mixinsAction(fullActionName) { await this.#loopMixins(async mixin => mixin.execute(fullActionName, this.state)); } async action_index() {/***/} async #mixinsAfter() { await this.#loopMixins(async mixin => mixin.after(this.state)); } async after() {/***/} /** * * @param {string} msg */ async #notFound(msg) { this.state.set(Controller.STATE_BODY, `404 / ${msg}`); await this.exit(404); } /** * * @param {Error} err */ async #serverError(err) { this.error = err; const body = this.state.get(Controller.STATE_BODY); if (!body) this.state.set(Controller.STATE_BODY, err.message || '500 / Internal Server Error'); await this.exit(500); } /** * * @param {string} location * @param {boolean} keepQueryString */ async redirect(location, keepQueryString= false) { const headers = this.state.get(Controller.STATE_HEADERS); if(!keepQueryString){ headers.location = location; }else{ const query = new URLSearchParams(this.state.get(Controller.STATE_QUERY)); const qs = query.toString(); const delimiter = /\?/.test(location) ? '&' : '?'; headers.location = qs ? `${location}${delimiter}${qs}` : location; } this.state.set(Controller.STATE_HEADERS, headers); await this.exit(302); } /** * * @param {string} msg */ async forbidden(msg = '') { this.state.set(Controller.STATE_BODY, `403 / ${msg}`); await this.exit(403); } /** * * @param {Number} code */ async exit(code) { this.state.set(Controller.STATE_STATUS, code); this.state.set(Controller.STATE_EXITED, true); //exit all mixins const { mixins } = this.constructor; await Promise.all(mixins.map(async mixin => mixin.exit(this.state))); //run delegate onExit await this.onExit(); } async onExit(){} } Object.freeze(Controller.prototype);