UNPKG

@ikaru5/heimdall-controller

Version:

Structure your REST requests by communicating with contracts.

464 lines (415 loc) 17.1 kB
import Package from "./package.js" import HeimdallController from "./heimdall-controller.js" import ControllerBase from "./controller-base.js" /** * Base Class * Needs to be initialized before usage! * Import in your index.js (or your equivalent) with at least heimdall.init() */ class HeimdallAPI { /** * Create empty controller map. The Controller will use the _registerController Method to populate it. * Also set the default simple connection error handler (Just logs an error to the console). It should be replaced if you want real error handling! */ constructor() { this._controller = {} this._onDispatchCallbacks = [] this._onInboundCallbacks = [] this.connectionFailureCallback = function (error) { console.error(`Network Error: ${error}`) } } /** * Subscribe to every outgoing dispatch. Called with {messageId, receiver} * right before the package leaves - the place to register in-flight commands. * @param {function} callback * @returns {function} unsubscribe */ onDispatch(callback) { this._onDispatchCallbacks.push(callback) return () => { this._onDispatchCallbacks = this._onDispatchCallbacks.filter(cb => cb !== callback) } } /** * Subscribe to every inbound package (including synthetic transport failures). * Called with the received Package BEFORE routing - fires even when no * controller is registered for the receiver. The place to clear in-flight * commands via receivedPackage.correlatesTo. * @param {function} callback * @returns {function} unsubscribe */ onInbound(callback) { this._onInboundCallbacks.push(callback) return () => { this._onInboundCallbacks = this._onInboundCallbacks.filter(cb => cb !== callback) } } _emit(callbacks, argument) { for (const callback of callbacks) { try { callback(argument) } catch (error) { console.error(`Heimdall subscriber threw: ${error}`) } } } /** * Load defaults and gives the options to set things up. * Will also read default host information if nothing provided. (Not on React Native) * If it is a simple webapp its completely fine to provide nothing if it fits you. * @param {string} path * @param {number} port * @param {string} protocol - TODO better name? * @param {string} host * @param {boolean} handleCSRF * @param {undefined | function} afterCSRF * @param {undefined | function} decorateForReceiver - optional function to decorate the payload before sending it out * @param {boolean} useCustomConnection - use some custom protocol like websockets for example * @param {undefined | function} connectCustomConnection - callback to connect to a custom connection (e.g. websockets) * @param {undefined | function} disconnectAllCustomConnections - callback to disconnect from all custom connections (e.g. websockets) * @param {undefined | function} disconnectCustomConnection - callback to disconnect from a custom connection (e.g. websockets) * @returns {HeimdallAPI} - no need to use your configuration output, since its singleton. */ init({ path = "/api", port, protocol = "HTTP", host, handleCSRF = false, afterCSRF, decorateForReceiver, useCustomConnection = false, connectCustomConnection, disconnectAllCustomConnections, disconnectCustomConnection }) { // simply default values, can be changed for a package this.path = path this.protocol = protocol this.host = host this.handleCSRF = handleCSRF if (typeof window !== "undefined") { if (undefined === this.host && undefined === this.port) { if (0 === window.location.port.length) { this.host = window.location.origin } else { this.host = window.location.origin.slice(0, -(window.location.port.length + 1)) } this.port = window.location.port } else if (undefined === this.host) { if (0 === window.location.port.length) { this.host = window.location.origin } else { this.host = window.location.origin.slice(0, -(window.location.port.length + 1)) } } } this.port = this.port || 80 this.decorateForReceiver = decorateForReceiver if (this.handleCSRF) { this.csrfToken = undefined this._getCSRFToken() this._afterCSRF = afterCSRF } if (useCustomConnection) { this.useCustomConnection = useCustomConnection this._connectCustomConnection = connectCustomConnection this._customCreateReturnCollection = [] if (disconnectAllCustomConnections) this._disconnectAllCustomConnections = disconnectAllCustomConnections if (disconnectCustomConnection) this._disconnectCustomConnection = disconnectCustomConnection } return this } listenToCustom(params) { if (!this.useCustomConnection || !this._connectCustomConnection) { console.error( "Could not define channel listener because not enabled or connectCustomConnection not defined." + "Make sure you used the init method with useCustomConnection set to true and connectCustomConnection defined." ) return } const connection = this._connectCustomConnection( { params, onConnected: this._connectedToCustom, onDisconnected: this._disconnectedFromCustom, onReceive: this._onCustomReceive } ) if (connection) this._customCreateReturnCollection.push({connection, params}) return connection } stopListenToCustom(params) { console.info("Disconnecting from custom channel with params:") console.info(params) this._disconnectCustomConnection({params, collection: this._customCreateReturnCollection}) } disconnectCustoms() { this._disconnectAllCustomConnections() } _connectedToCustom = (params) => { console.info("Connected to custom channel with params:") console.info(params) } _disconnectedFromCustom = (params) => { this._customCreateReturnCollection = this._customCreateReturnCollection.filter( e => JSON.stringify(e.params) !== JSON.stringify(params) ) console.info("Disconnected from custom channel with params:") console.info(params) } _onCustomReceive = ({data, protocol}) => { this._receivePackage(JSON.parse(data), protocol) } /** * Use this to handle network errors. * @param {function} callback */ setConnectionFailureCallback(callback) { this.connectionFailureCallback = callback } /** * @typedef DispatchOptions * @property {string} receiver - Backend Endpoint like User.Show * @property {Array} [files] - files to send (picture, video, ...) * @property {string} [path] - override the default path * @property {string} [port] - override default port * @property {string} [host] - override default host * @property {string} [protocol] - override default protocol */ /** * Build a heimdall formed package with provided payload and dispatch it to the backend. * Payload can be a contract and heimdall will extract the JSON itself. ;) * @param {Object} payload * @param {DispatchOptions} options */ dispatch(payload, {receiver, path, files, port, host, protocol}) { const sendPackage = Package.buildSend(payload, { receiver: receiver, path: path, files: files, port: port, host: host, protocol: protocol } ) this._emit(this._onDispatchCallbacks, {messageId: sendPackage.messageId, receiver: sendPackage.receiver}) sendPackage.sendOut() return sendPackage.messageId } /** * Get the first CSRF Token. TODO maybe extract it to an external file make it overrideable to make the handling customizable. * @private */ _getCSRFToken() { Package.buildSend({}, {receiver: "Heimdall.CSRF", protocol: "GET"}).sendOut() } /** * Controller must register itself, so Heimdall knows where to route incoming packages. * @param controllerInstance * @param controllerName * @param {Array<string|Action>} actions * @private */ _registerController(controllerInstance, controllerName, actions) { if (!this._controller[controllerName]) this._controller[controllerName] = {} this._controller[controllerName].instance = controllerInstance actions?.forEach(action => this._registerAction(action.controller ?? controllerName, action)) } /** * @typedef ContractLike * Heimdall never imports a contract library - it duck-types against three methods. * Any class implementing them works (e.g. @ikaru5/heimdall-contract in any version). * @property {function(Object): void} assign - fills the instance with the received payload * @property {function(string=): boolean} isValid - validates the instance after assignment * @property {function(): Object} toObject - serializes the instance for dispatching */ /** * @typedef Action * @property {string} name - action name * @property {string} [controller] - overwrite name of controller * @property {Function} [contract] - contract-like class (see ContractLike); received payload is assigned to a fresh instance * @property {function} [to] - callback where action should be passed to * @property {function} [onInvalid] - callback where action should be passed to if contract is invalid * @property {boolean} [validate] - validate contract */ /** * Register action and the controller, if not already happened. * BUT! without instance -> if its stays without instance, action must have a "to" callback defined! * @param controllerName * @param {string|Action} action * @private */ _registerAction(controllerName, action) { if (!this._controller[controllerName]) this._controller[controllerName] = {} if (!this._controller[controllerName].actions) this._controller[controllerName].actions = [] if ("string" === typeof action) { this._controller[controllerName].actions.push({name: action}) } else { this._controller[controllerName].actions.push(action) } } /** * This is the sender method. It will make the technical decisions. * Remember: Target of Heimdall is to take away thoughts about this stuff from the developer. * @param payloadJSON * @param protocol * @param host * @param path * @param port * @private */ _sendOut(payloadJSON, protocol = this.protocol, host = this.host, path = this.path, port = this.port, files, meta = {}) { switch (protocol) { case "HTTP": this._dispatchPOST(payloadJSON, host, path, port, files, meta) break case "POST": this._dispatchPOST(payloadJSON, host, path, port, files, meta) break case "GET": if (undefined !== files) console.warn(`Unexpected File Input for protocol: ${protocol}`) this._dispatchGET(payloadJSON, host, path, port, meta) break default: console.error(`Unknown protocol: ${protocol}`) this.connectionFailureCallback(`Unknown protocol: ${protocol}`) } } /** * The HTTP POST communication request. * @param payloadJSON * @param host * @param path * @param port * @param files * @returns {Promise<void>} * @private */ async _dispatchPOST(payloadJSON, host, path, port, files, meta = {}) { let data try { let url = 80 === port ? `${host}${path}` : `${host}:${port}${path}` let headers = {} if (this.handleCSRF) headers["X-CSRF-TOKEN"] = this.csrfToken let response if (undefined === files) { headers['Content-Type'] = 'application/json' response = await fetch(url, {method: "POST", body: payloadJSON, headers: headers}) } else { let payloadData = new FormData() payloadData.append('file-count', files.length) payloadData.append('_json', payloadJSON) for (let fileId in files) { payloadData.append(`file_${1 + parseInt(fileId)}`, files[fileId]) } response = await fetch(url, {method: "POST", body: payloadData, headers: headers}) } data = await response.json() } catch (error) { this.connectionFailureCallback(error) this._receiveTransportFailure(meta, error) return } // deliberately OUTSIDE the transport try/catch: routing, contract and // handler errors are application failures - reporting them as // Heimdall.transportFailed would misdirect every debugging session this._receivePackage(data, "HTTP") } async _dispatchGET(payloadJSON, host, path, port, meta = {}) { let data try { let url = 80 === port ? `${host}${path}` : `${host}:${port}${path}` let encodedPayload = encodeURIComponent(payloadJSON) url = `${url}?_json=${encodedPayload}` let response = await fetch(url, {method: "GET", headers: {'Content-Type': 'application/json'}}) data = await response.json() } catch (error) { this.connectionFailureCallback(error) this._receiveTransportFailure(meta, error) return } this._receivePackage(data, "HTTP") } /** * Transport failures become inbound messages: the app handles network * problems with the same mechanics as everything else. correlatesTo carries * the failed command's messageId so in-flight tracking can settle it. * @private */ _receiveTransportFailure(meta, error) { if (undefined === meta.messageId) return try { this._receivePackage({ receiver: "Heimdall.transportFailed", correlatesTo: meta.messageId, payload: {message: String(error), originalReceiver: meta.receiver} }, "INTERNAL") } catch (routingError) { console.error(`Could not route transport failure: ${routingError}`) } } _receivePackage(rawData, protocol) { const parseData = (rawData, protocol, priority = 0) => { const receivedPackage = Package.buildReceive(rawData, {protocol: protocol}) // subscribers see every inbound package, even ones no controller handles this._emit(this._onInboundCallbacks, receivedPackage) const controllerName = receivedPackage.receiver.split(".").slice(0, -1).join(".") + "Controller" const action = receivedPackage.receiver.split(".").slice(-1)[0] let controller = this._controller[controllerName] if (!controller) throw `No controller found for ${receivedPackage.receiver}. Maybe you forgot to register it or minification changed the name? You can use constant names for controllers to prevent minification changes. See the documentation for more information.` // lookup definition for action const actionDef = controller.actions.find(a => a.name === action) if (!actionDef?.silent) console.info(`Received package priority: ${priority} to ${controllerName}->${action}`) // VALIDATE ACTION DEFINITION const isActionDefValid = actionDef && ( (!!controller.instance && actionDef.name) || !!actionDef.to ) if (!isActionDefValid) { console.error(`Path for ${receivedPackage.receiver}, which was interpreted as ${controllerName}->${action} not found.`) return } // PREPARE DATA FOR ACTION const data = {} data.receivedPackage = receivedPackage // if contract defined build instance if (actionDef.contract) { data.contract = new actionDef.contract() data.contract.assign(receivedPackage.payload) if (actionDef.validate !== false) { // set validate explicit to false if you don't want to validate your contract if (!data.contract.isValid(actionDef.context)) { console.error(`Path for ${receivedPackage.receiver}, which was interpreted as ${controllerName}->${action} received invalid data for contract ${data.contract.constructor?.name}.`) console.info(data.contract.errors) if (actionDef.onInvalid) actionDef.onInvalid(data) return } } } // CALL DEFINED ACTION if (!!actionDef.to) { actionDef.to(data) } else { controller.instance._callAction(actionDef.name, data) } } if (undefined === rawData.length) { parseData(rawData, protocol) } else { // sort by priority if provided: higher priority first, missing priority // sorts to the front. Equal priorities return 0 so their relative order // stays stable - the old comparator returned -1 for ties, leaving the // order of equal messages undefined. const sortedData = rawData.sort( (lE, rE) => (rE.priority ?? Number.MAX_SAFE_INTEGER) - (lE.priority ?? Number.MAX_SAFE_INTEGER) ) for (const data of sortedData) { parseData(data, protocol, data.priority) } } } } const heimdallAPI = new HeimdallAPI() Package.api = heimdallAPI // late binding - avoids a circular package.js <-> index.js import export default heimdallAPI export {HeimdallController, ControllerBase}