├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── app.js ├── cli.js ├── config.d.ts ├── config.js ├── contexts ├── base.d.ts ├── base.js ├── http.d.ts ├── http.js ├── websocket.d.ts └── websocket.js ├── db.d.ts ├── db.js ├── errors.d.ts ├── errors.js ├── index.d.ts ├── index.js ├── install.js ├── log.d.ts ├── log.js ├── package.json ├── router.d.ts ├── router.js ├── session.d.ts ├── session.js ├── test ├── config.json ├── controllers │ ├── Socket.js │ └── TestEndpoint.js ├── jest.config.js ├── package.json ├── startup.js └── tasks │ ├── controller-context.test.js │ ├── routing.test.js │ ├── setup.js │ ├── teardown.js │ ├── utils.js │ └── websockets.test.js ├── typescript ├── ValidationError.js ├── build.js ├── db-typings.js ├── index.js ├── preprocessor.js ├── tsconfig_build.json ├── tsconfig_run.json └── validator.ts └── utils ├── cors.js ├── http.js ├── index.d.ts ├── index.js ├── loader.js └── parsers.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | package-lock.json 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | yarn.lock 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2021 DimaCrafter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple API core for your projects 2 | 3 | [![NPM](https://nodei.co/npm/dc-api-core.png)](https://npmjs.com/package/dc-api-core) 4 | 5 | ## Useful links 6 | 7 | ![Documentation image](https://user-images.githubusercontent.com/10772852/116776513-d4a0ba00-aa79-11eb-99c5-a42592b0bd2d.png) 8 | 9 | * [Documentation](http://dimacrafter.github.io/dc-api-core) 10 | * [deema](https://github.com/mayerdev/deema) - CLI toolkit 11 | * [dc-api-client](https://github.com/DimaCrafter/dc-api-client) - API client 12 | * [dc-api-mongo](https://github.com/DimaCrafter/dc-api-mongo) - Mongoose based MongoDB driver 13 | * [Examples](https://github.com/mayerdev/dc-api-examples) 14 | 15 | ## Dependencies 16 | 17 | * [jwa](https://github.com/auth0/node-jwa) 18 | * [vercel/ms](https://github.com/vercel/ms) 19 | * [μWebSockets.js](https://github.com/uNetworking/uWebSockets.js) 20 | * [watch](https://github.com/mikeal/watch) 21 | * [ts-node](https://github.com/TypeStrong/ts-node) (optional) 22 | * [typescript](https://github.com/Microsoft/TypeScript) (optional) 23 | 24 | --- 25 | 26 | ## Structure 27 | 28 | ```txt 29 | 📙 30 | ├── ⚙️ controllers Request controllers 31 | ├── 🗃️ models Models for working with DB 32 | │ └── 📁 Database driver name (Optional) 33 | │ └── 📜 Model name (js or json) 34 | ├── ️📃 config.json Configuration file 35 | └── ⏱ startup.js Script, that was started before starting API server 36 | ``` 37 | 38 | --- 39 | 40 | ## Installation with [Deema CLI](https://github.com/mayerdev/deema) (recommended) 41 | 42 | **1)** You can use `deema gen project ` to create project. 43 | 44 | You can also optionally use arguments: 45 | - `--ts` or `--typescript` to create typescript project; 46 | - you can use `--install` to install packages immediately after creating a project. 47 | 48 | **2)** Run `npm install` or `yarn` (skip if you created project with `--install`) 49 | 50 | **3)** Run `deema serve` 51 | 52 | **4)** Done! 53 | 54 | ## Installation (manually) 55 | 56 | **0)** Run `npm init` or `yarn init` 57 | 58 | **1)** Install package - `npm i dc-api-core --save` or `yarn add dc-api-core` 59 | 60 | **2)** Run `npm exec dc-api-core init` or `yarn dc-api-core init` 61 | 62 | **3)** Run `npm run dc-init` or `yarn dc-init` 63 | 64 | **4)** Run `npm start` or `yarn start` 65 | 66 | **5)** Done! 67 | 68 | --- 69 | 70 | ## `config.json` 71 | 72 | | Field | Default | Description | 73 | |-----------------------|---------------------|------------------------------------------------------------| 74 | | `db` | Optional | Object | 75 | | `db[driverName]` | | Code of [database driver](#db-module) | 76 | | `db[driverName].name` | Required | Database name | 77 | | `db[driverName].port` | Defined by plugin | Database port | 78 | | `db[driverName].user` | Optional | Database username | 79 | | `db[driverName].pass` | | and password | 80 | | `db[driverName].srv` | Optional for mongo | Boolean, `true` - use `srv` | 81 | | | | | 82 | | `session.secret` | Required | Private string for cookie | 83 | | `session.store` | Required | Database config name | 84 | | `session.ttl` | `3d` (3 days) | Session lifetime in [vercel/ms] format, `false` - infinite | 85 | | | | | 86 | | `ssl` | Optional | Enables HTTPS mode if filled | 87 | | `ssl.*` | Optional | Any `μWS.SSLApp` options field | 88 | | `ssl.key` | Required | Local path to private key | 89 | | `ssl.cert` | Required | Local path to certificate file | 90 | | | | | 91 | | `plugins` | `[]` | Array of plugin packages names | 92 | | `origin` | `Origin` header | Accept requests only from this origin | 93 | | `port` | `8081` | API listing port | 94 | | `ws_timeout` | `60` | WebSocket request waiting timeout in seconds | 95 | | | | | 96 | | `ignore` | `[]` | Excluded directories in development mode | 97 | | `isDev` | Read-only | `true` if using `--dev` argument | 98 | | `dev` | `{}` | Config to merge if `isDev` is `true` | 99 | | `ttl` | `0` | WebSocket TTL in seconds, `0` - disabled | 100 | | `typescript` | `false` | TypeScript-support | 101 | 102 | [vercel/ms]: https://github.com/vercel/ms 103 | 104 | Example: 105 | 106 | ```js 107 | { 108 | "port": "$env", // Equals value of process.env.PORT 109 | "db": { 110 | "mongo": { 111 | "host": "localhost", 112 | "name": "test-db" 113 | } 114 | }, 115 | "plugins": ["dc-api-mongo"], 116 | "session": { 117 | "secret": "super secret string", 118 | "store": "mongo" 119 | }, 120 | "ssl": { 121 | "cert": "/etc/letsencrypt/live/awesome.site/cert.pem", 122 | "key": "/etc/letsencrypt/live/awesome.site/privkey.pem" 123 | }, 124 | "dev": { 125 | "port": 8081, 126 | "db": { 127 | "mongo": { "name": "test-dev-db" } 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | --- 134 | 135 | ## MongoDB (recommended) 136 | 137 | Example: 138 | 139 | ```js 140 | // JS 141 | const db = require('dc-api-mongo').connect(); 142 | 143 | // TS 144 | import db from 'dc-api-mongo/mongo'; 145 | 146 | async function main() { 147 | const result = await db.Model.findOne(); 148 | console.log(result); 149 | } 150 | 151 | main(); 152 | ``` 153 | 154 | Where `Model` is your model name. 155 | 156 | ## MySQL 157 | 158 | If you're using MySQL, use `mysql` as database driver (don't forget to apply plugin first). 159 | 160 | ```js 161 | const db = require('dc-api-mysql').connect(); 162 | 163 | async function main() { 164 | const result = await db.Model.findOne(); 165 | console.log(result); 166 | } 167 | 168 | main(); 169 | ``` 170 | 171 | Where `Model` is your model name. 172 | 173 | ## Plugins 174 | 175 | For first, install plugin package via `npm` or `yarn`. 176 | After this add name of plugin package to `plugins` array in `config.json`. 177 | 178 | Example `config.json`: 179 | 180 | ```js 181 | { 182 | // ... 183 | "plugins": ["dc-api-mongo"] 184 | } 185 | ``` 186 | 187 | If you want create your own plugin, read 188 | [plugin development documentation](https://dimacrafter.github.io/dc-api-core/en/plugins/basics.html) 189 | 190 | --- 191 | 192 | ## Sessions 193 | 194 | ### Functions 195 | 196 | | Function | Example | Description | 197 | |--------------------------|--------------------------------|------------------------| 198 | | `this.session.` | `this.session.name = 'User'` | Set session data | 199 | | `this.session.save()` | `await this.session.save()` | Save session data | 200 | | `this.session.destroy()` | `await this.session.destroy()` | Clear all session data | 201 | 202 | #### Example 203 | 204 | ```js 205 | module.exports = class Controller { 206 | async test () { 207 | this.session.name = 'test'; 208 | await this.session.save(); 209 | this.send('saved'); 210 | } 211 | } 212 | ``` 213 | 214 | ## Request hooks 215 | 216 | ### onLoad 217 | 218 | Will be executed before calling action method in controller. 219 | 220 | If the `onLoad` function returns false, the request will be rejected. 221 | 222 | #### Example 223 | 224 | ```js 225 | module.exports = class Test { 226 | onLoad () { 227 | if (!this.session.user) return false; 228 | } 229 | } 230 | ``` 231 | 232 | ## Working with config.json 233 | 234 | ### Require 235 | 236 | Require config module: 237 | 238 | ```js 239 | // JS 240 | const config = require('dc-api-core/config'); 241 | 242 | // TS 243 | import config from 'dc-api-core/config'; 244 | ``` 245 | 246 | Get data: 247 | 248 | ```js 249 | config. 250 | ``` 251 | 252 | #### Example 253 | 254 | ```js 255 | const config = require('dc-api-core/config'); 256 | 257 | module.exports = class Test { 258 | index() { 259 | this.send(config.myParam); 260 | } 261 | } 262 | ``` 263 | 264 | ## Routing 265 | 266 | Register route in startup script: 267 | 268 | ```js 269 | // startup.js 270 | const Router = require('dc-api-core/router'); 271 | Router.register('/testing/files/${id}/${file}.jpg', 'Test.getFile'); 272 | ``` 273 | 274 | Now requests like `/testing/files/some-id/secret_file.jpg` will call `getFile` method of `Test` controller. 275 | 276 | ```js 277 | // controllers/Test.js 278 | class Test { 279 | async getFile () { 280 | this.send(this.params); 281 | // Will send { "id": "some-id", "file": "secret_file" } 282 | } 283 | } 284 | 285 | module.exports = Test; 286 | ``` 287 | 288 | ## My TODOs 289 | 290 | * [ ] Document new `config.cors.headers` 291 | 292 | * [ ] Support for glibc < 2.18 293 | * [ ] Typing (`.d.ts`) files 294 | * [ ] Automatic package publication when all tests are passed 295 | * [ ] More functionality tests 296 | * [ ] Clusterization/multi-threading support 297 | * [ ] Edit pages "API" > "Database driver" and "Plugins" > "Basics" of docs 298 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const uWS = require('uWebSockets.js'); 2 | 3 | const config = require('./config'); 4 | const cors = require('./utils/cors'); 5 | 6 | 7 | /** @type {uWS.TemplatedApp} */ 8 | let app; 9 | if (config.ssl) { 10 | const opts = { ...config.ssl }; 11 | opts.cert_file_name = opts.cert_file_name || opts.cert; 12 | opts.key_file_name = opts.key_file_name || opts.key; 13 | app = uWS.SSLApp(opts); 14 | } else { 15 | app = uWS.App(); 16 | } 17 | 18 | // CORS preflight request 19 | app.options('/*', (res, req) => { 20 | cors.preflight(req, res); 21 | res.writeStatus('200 OK'); 22 | res.end(); 23 | }); 24 | 25 | module.exports = app; 26 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * @import { ChildProcessByStdio } from 'child_process' 4 | * @import { Readable } from 'stream' 5 | */ 6 | 7 | const log = require('./log'); 8 | const config = require('./config'); 9 | const { getFlag } = require('./utils'); 10 | 11 | 12 | if (config.isDev) { 13 | const ROOT = process.cwd(); 14 | 15 | /** @type {ChildProcessByStdio} */ 16 | let core; 17 | 18 | /** @return {Promise | void} */ 19 | function stopCore () { 20 | if (!core || core.exitCode !== null) return; 21 | 22 | return new Promise(resolve => { 23 | core.kill(); 24 | 25 | let timeout; 26 | core.once('exit', () => { 27 | clearTimeout(timeout); 28 | resolve(); 29 | }); 30 | 31 | timeout = setTimeout(() => core.kill(9), 500); 32 | }); 33 | } 34 | 35 | let restarting = false; 36 | const { spawn } = require('child_process'); 37 | const start = async reason => { 38 | if (restarting) return; 39 | 40 | restarting = true; 41 | await stopCore(); 42 | 43 | log.text(''); 44 | log.info('Starting API...'); 45 | 46 | core = spawn( 47 | 'node', 48 | [__dirname + '/index.js', ...process.argv.slice(2), '--restart-reason', reason], 49 | { cwd: ROOT, env: process.env, stdio: ['ignore', 'pipe', 'inherit'] } 50 | ); 51 | 52 | core.stdout.pipe(process.stdout); 53 | core.stdout.once('readable', () => restarting = false); 54 | 55 | core.once('exit', code => { 56 | if (code) { 57 | // Converting i64 (JS Number) to i32 code 58 | log.error('API server process crushed with code ' + (code | 0)); 59 | } else if (!restarting) { 60 | log.info('API server process exited'); 61 | } 62 | 63 | restarting = false; 64 | }); 65 | }; 66 | 67 | log.text('API will be restarted after saving any file'); 68 | log.text('You can submit `rs` to restart server manually'); 69 | start('@initial'); 70 | 71 | process.stdin.on('data', line => { 72 | if (line.toString().trim() == 'rs') start('@manual'); 73 | }); 74 | 75 | const watch = require('watch'); 76 | watch.watchTree(ROOT, { 77 | ignoreDotFiles: true, 78 | filter (file) { 79 | file = file.replace(/\\/g, '/').slice(file.lastIndexOf('/') + 1); 80 | 81 | if (config.ignore.indexOf(file) != -1) { 82 | return false; 83 | } else if (file.includes('/node_modules/')) { 84 | return false; 85 | } else { 86 | return true; 87 | } 88 | }, 89 | interval: 0.075 90 | }, (path, curr, prev) => { 91 | if (typeof path == 'object' && !prev && !curr) return; 92 | start(path); 93 | }); 94 | 95 | process.on('SIGINT', async () => { 96 | restarting = true; 97 | stopCore(); 98 | process.exit(); 99 | }); 100 | } else if (getFlag('--ts-build')) { 101 | if (!config.typescript) { 102 | log.warn('Typescript is not enabled'); 103 | process.exit(); 104 | } 105 | 106 | require('./typescript/build'); 107 | } else { 108 | require('.'); 109 | } 110 | -------------------------------------------------------------------------------- /config.d.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import baseConfig from '../../config.json' 3 | 4 | interface IConfigAdditions { 5 | /** Readonly, true if development mode enabled */ 6 | readonly isDev: boolean; 7 | } 8 | 9 | const contents: typeof baseConfig & IConfigAdditions = baseConfig; 10 | export = contents; 11 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | const Path = require('path'); 2 | const ms = require('ms'); 3 | const { existsSync } = require('fs'); 4 | const { mergeObj, getArg, getFlag } = require('./utils'); 5 | const log = require('./log'); 6 | 7 | const ROOT = process.cwd(); 8 | function load (path) { 9 | try { 10 | return require(path); 11 | } catch (error) { 12 | log.error('Config loading error', error); 13 | process.exit(-1); 14 | } 15 | } 16 | 17 | let config; 18 | let configPath = getArg('--cfg'); 19 | if (configPath) { 20 | if (configPath[0] != '/') configPath = Path.join(ROOT, configPath); 21 | 22 | if (!existsSync(configPath)) { 23 | log.error('Config file not found'); 24 | process.exit(-1); 25 | } 26 | 27 | config = load(configPath); 28 | } else { 29 | configPath = require.resolve(Path.join(ROOT, 'config')); 30 | config = existsSync(configPath) ? load(configPath) : {}; 31 | } 32 | 33 | if (config.port) { 34 | if (config.port == '$env') config.port = process.env.PORT; 35 | } else { 36 | config.port = 8081; 37 | } 38 | 39 | config.db = config.db || {}; 40 | config.isDev = getFlag('--dev'); 41 | 42 | if (config.isDev) { 43 | if (config.dev) { 44 | mergeObj(config, config.dev); 45 | } 46 | 47 | config.ignore = config.ignore || []; 48 | if (!~config.ignore.indexOf('node_modules')) config.ignore.push('node_modules'); 49 | } 50 | 51 | delete config.dev; 52 | 53 | if (config.session) { 54 | config.session.ttl = config.session.ttl || '3d'; 55 | if (typeof config.session.ttl == 'string') config.session.ttl = ms(config.session.ttl); 56 | } 57 | 58 | module.exports = config; 59 | -------------------------------------------------------------------------------- /contexts/base.d.ts: -------------------------------------------------------------------------------- 1 | type Session = object & { 2 | _id: { toString (): string }; 3 | /** Save current session data */ 4 | save (): Promise; 5 | /** Remove current session */ 6 | destroy (): Promise; 7 | 8 | [key: string]: any; 9 | }; 10 | 11 | export class ControllerBase { 12 | /** Information about client IP-address */ 13 | address: { 14 | type: 'ipv4' | 'ipv6', 15 | value: string 16 | } 17 | 18 | /** Parsed query string */ 19 | query: Record; 20 | /** Get request header */ 21 | header (name: string): string; 22 | /** Set response header value */ 23 | header (name: string, value: string): void; 24 | 25 | /** Contains all fiels and methods of current controller */ 26 | controller: { [key: string]: any }; 27 | session: Session; 28 | } 29 | 30 | export class ControllerBaseContext { 31 | protected _req: In; 32 | protected _res: Out; 33 | constructor (req: In, res: Out); 34 | 35 | public type: string; 36 | public get session (): Session; 37 | protected _session: Session | undefined; 38 | /** Contains all fiels and methods of current controller */ 39 | controller: { [key: string]: any }; 40 | 41 | /** You can store custom data attached to request or connection in controller's context */ 42 | [key: string]: any; 43 | } 44 | -------------------------------------------------------------------------------- /contexts/base.js: -------------------------------------------------------------------------------- 1 | const { HttpError } = require('../errors'); 2 | const Session = require('../session'); 3 | const { parseIPv6Part } = require('../utils/parsers'); 4 | 5 | class ControllerBase {} 6 | 7 | function isIpProxied (value, isV4) { 8 | if (isV4) { 9 | // Loopback, local subnets 10 | if (value.startsWith('127.') || value.startsWith('192.168.') || value.startsWith('10.') || value.startsWith('100.64.')) { 11 | return true; 12 | } 13 | 14 | // RFC 1918: Private network 15 | if (value.startsWith('172.')) { 16 | const secondPart = +value.slice(4, value.indexOf('.', 4)); 17 | return secondPart >= 16 && secondPart < 32; 18 | } 19 | 20 | return false; 21 | } else { 22 | // Loopback and Unique Local Address 23 | return value == ':::::::0001' || value.startsWith('fd'); 24 | } 25 | } 26 | 27 | class ControllerBaseContext { 28 | constructor (req, res) { 29 | this._req = req; 30 | this._res = res; 31 | this.query = req.query; 32 | } 33 | 34 | /** 35 | * @param {string} name 36 | * @param {any} value 37 | */ 38 | header (name, value) { 39 | name = name.toLowerCase(); 40 | if (value === undefined) { 41 | return this._req.headers[name]; 42 | } else { 43 | if (value == null) delete this._res.headers[name]; 44 | else this._res.headers[name] = value; 45 | } 46 | } 47 | 48 | get address () { 49 | if (this._address) return this._address; 50 | const buf = Buffer.from(this._res.getRemoteAddress()); 51 | 52 | let value = ''; 53 | let isV4 = true; 54 | 55 | let last = []; 56 | for (let i = 0; i < buf.length; i++) { 57 | const current = buf[i]; 58 | if (i < 10) { 59 | if (current != 0) { 60 | isV4 = false; 61 | } 62 | } else if (i < 12) { 63 | if (current != 0xFF) { 64 | isV4 = false; 65 | } 66 | } else if (isV4) { 67 | if (i == 12) value = current.toString(); 68 | else value += '.' + current; 69 | } 70 | 71 | if (i < 12 || !isV4) { 72 | last.push(current); 73 | if (i % 2 != 0) { 74 | if (last[0] == 0 && last[1] == 0) { 75 | } else { 76 | value += parseIPv6Part(last[0]); 77 | value += parseIPv6Part(last[1]); 78 | } 79 | 80 | if (i != 15) value += ':'; 81 | last = []; 82 | } 83 | } 84 | } 85 | 86 | let result; 87 | if (isIpProxied(value, isV4)) { 88 | const realValue = this._req.headers['x-real-ip']; 89 | if (realValue) { 90 | result = { 91 | type: ~realValue.indexOf('.') ? 'ipv4' : 'ipv6', 92 | value: realValue 93 | }; 94 | } 95 | } 96 | 97 | if (!result) { 98 | result = { 99 | type: isV4 ? 'ipv4' : 'ipv6', 100 | value 101 | }; 102 | } 103 | 104 | /** @private */ 105 | this._address = result; 106 | return result; 107 | } 108 | 109 | get controller () { 110 | return this._controllerProxy; 111 | } 112 | set controller (controller) { 113 | /** @private */ 114 | this._controllerProxy = {}; 115 | const { prototype } = controller.constructor; 116 | for (const key of Object.getOwnPropertyNames(prototype)) { 117 | if (key == 'constructor') continue; 118 | 119 | const prop = prototype[key]; 120 | if (typeof prop == 'function') { 121 | this._controllerProxy[key] = prop.bind(this); 122 | } else { 123 | this._controllerProxy[key] = controller[key] || prop; 124 | } 125 | } 126 | } 127 | 128 | get session () { 129 | if (!Session.enabled) { 130 | throw new HttpError('Trying to access session when it is disabled', 500); 131 | } 132 | 133 | if (!this._session) { 134 | this._session = Session.init(); 135 | } 136 | 137 | return this._session; 138 | } 139 | } 140 | 141 | module.exports = { 142 | ControllerBase, 143 | ControllerBaseContext 144 | }; 145 | -------------------------------------------------------------------------------- /contexts/http.d.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequest, HttpResponse } from 'uWebSockets.js' 2 | 3 | import { ControllerBase, ControllerBaseContext } from './base' 4 | import { Validated, ValidatedCtor } from '../typescript/validator' 5 | 6 | 7 | export class HttpController extends ControllerBase { 8 | /** Parsed request payload */ 9 | data?: any; 10 | /** Parameters parsed from route path */ 11 | params?: { [param: string]: string }; 12 | 13 | /** Drop the connection without responding to the request */ 14 | drop (): void; 15 | /** Redirect user to specified URL */ 16 | redirect (url: string, code?: number): void; 17 | /** 18 | * Send response to client and close connection 19 | * @param data Payload to send 20 | * @param code HTTP response code, by default 200 21 | * @param isPure If true, then data will sended without transformations, otherwise data will be serialized, by default false 22 | */ 23 | send (data: any, code?: number, isPure?: boolean): void; 24 | 25 | __validateData (TypeClass: ValidatedCtor): Promise; 26 | __validateQuery (TypeClass: ValidatedCtor): Promise; 27 | } 28 | 29 | type Request = HttpRequest & { 30 | query?: object, 31 | body?: any, 32 | headers: { [key: string]: string } 33 | }; 34 | 35 | export class HttpControllerContext extends ControllerBaseContext { 36 | constructor (req: Request, res: HttpResponse); 37 | init (): Promise; 38 | 39 | send (data: any, code?: number, isPure?: boolean): void; 40 | drop (): void; 41 | redirect (url: string): void; 42 | } 43 | 44 | export function registerHttpController (path: string, controller: HttpController): void; 45 | 46 | export function dispatchHttp (req: HttpRequest, res: HttpResponse, handler: (ctx: HttpControllerContext) => void): Promise; 47 | -------------------------------------------------------------------------------- /contexts/http.js: -------------------------------------------------------------------------------- 1 | const { getResponseStatus, fetchBody, prepareHttpConnection } = require('../utils/http'); 2 | const { ControllerBase, ControllerBaseContext } = require('./base'); 3 | const { emitError, HttpError } = require('../errors'); 4 | const Session = require('../session'); 5 | const CORS = require('../utils/cors'); 6 | const config = require('../config'); 7 | const ValidationError = require('../typescript/ValidationError'); 8 | const { camelToKebab } = require('../utils'); 9 | const { getActionCaller } = require('../utils/loader'); 10 | const app = require('../app'); 11 | 12 | class HttpController extends ControllerBase {} 13 | 14 | class HttpControllerContext extends ControllerBaseContext { 15 | /** 16 | * @param {import('./http').Request} req 17 | * @param {import("uWebSockets.js").HttpResponse} res 18 | */ 19 | constructor (req, res) { 20 | super(req, res); 21 | 22 | this.type = 'http'; 23 | this.data = req.body; 24 | } 25 | 26 | async init () { 27 | if (Session.enabled && this._req.headers.session) { 28 | try { 29 | this._session = await Session.parse(JSON.parse(this._req.headers.session)); 30 | } catch (error) { 31 | emitError({ 32 | isSystem: true, 33 | type: 'SessionError', 34 | code: 500, 35 | message: error.message, 36 | error 37 | }); 38 | 39 | throw error; 40 | } 41 | } 42 | } 43 | 44 | /** 45 | * @param {any} data 46 | * @param {number} code 47 | * @param {boolean} isPure 48 | */ 49 | send (data, code = 200, isPure = false) { 50 | if (this._res.aborted) return; 51 | this._res.aborted = true; 52 | 53 | this._res.cork(async () => { 54 | this._res.writeStatus(getResponseStatus(code)); 55 | CORS.normal(this._req, this._res); 56 | for (const header in this._res.headers) { 57 | this._res.writeHeader(header, this._res.headers[header]); 58 | } 59 | 60 | if (this._session?._init) { 61 | this._res.writeHeader('session', JSON.stringify(await this._session._init)); 62 | } 63 | 64 | if (isPure) { 65 | if (!this._res.headers['content-type']) { 66 | if (typeof data === 'string') { 67 | this._res.writeHeader('Content-Type', 'text/plain'); 68 | } else if (data instanceof Buffer) { 69 | this._res.writeHeader('Content-Type', 'application/octet-stream'); 70 | } 71 | } 72 | 73 | this._res.end(data); 74 | } else { 75 | this._res.writeHeader('Content-Type', 'application/json'); 76 | this._res.end(JSON.stringify(data)); 77 | } 78 | }); 79 | } 80 | 81 | drop () { 82 | if (this._res.aborted) return; 83 | this._res.close(); 84 | } 85 | 86 | /** 87 | * @param {import("uWebSockets.js").RecognizedString} url 88 | */ 89 | redirect (url, code = 302) { 90 | if (this._res.aborted) return; 91 | this._res.aborted = true; 92 | 93 | this._res.cork(async () => { 94 | this._res.writeStatus(getResponseStatus(code)); 95 | CORS.normal(this._req, this._res); 96 | this._res.writeHeader('Location', url); 97 | this._res.writeHeader('Content-Type', 'text/plain'); 98 | this._res.end(); 99 | }); 100 | } 101 | 102 | // todo: inline 103 | // todo: add difference between "Data" and "Query" for ValidationError 104 | __validateData (TypeClass) { 105 | if (!this.data) { 106 | throw new ValidationError('Payload required'); 107 | } 108 | 109 | return new TypeClass(this.data).validate(); 110 | } 111 | 112 | __validateQuery (TypeClass) { 113 | if (!this.query) { 114 | throw new ValidationError('Query required'); 115 | } 116 | 117 | return new TypeClass(this.query).validate(); 118 | } 119 | } 120 | 121 | function registerHttpController (path, controller) { 122 | for (const action of Object.getOwnPropertyNames(controller.constructor.prototype)) { 123 | if (action[0] == '_' || action == 'onLoad' || action == 'constructor') { 124 | continue; 125 | } 126 | 127 | const handler = getActionCaller(controller, controller[action]); 128 | const requestHandler = async (res, req) => { 129 | prepareHttpConnection(req, res); 130 | if (res.aborted) return; 131 | 132 | if (req.getMethod() == 'post') await fetchBody(req, res); 133 | if (res.aborted) return; 134 | 135 | await dispatch(req, res, handler); 136 | }; 137 | 138 | const routePath = path + '/' + camelToKebab(action); 139 | // TODO: get request method through vanilla decorators 140 | app.get(routePath, requestHandler); 141 | app.post(routePath, requestHandler); 142 | } 143 | } 144 | 145 | async function dispatch (req, res, handler) { 146 | const ctx = new HttpControllerContext(req, res); 147 | try { 148 | // Throws session parsing errors 149 | await ctx.init(); 150 | } catch (error) { 151 | return ctx.send(error.toString(), 500); 152 | } 153 | 154 | try { 155 | const result = await handler(ctx); 156 | if (!res.aborted && result !== undefined) { 157 | ctx.send(result); 158 | } 159 | } catch (error) { 160 | if (error instanceof HttpError) { 161 | ctx.send(error.message, error.code); 162 | emitError({ 163 | isSystem: true, 164 | type: 'DispatchError', 165 | message: error.message, 166 | code: error.code, 167 | error 168 | }); 169 | } else { 170 | if (config.isDev) { 171 | ctx.send(error.toString(), 500); 172 | } else { 173 | ctx.send('InternalError', 500); 174 | } 175 | 176 | emitError({ 177 | isSystem: true, 178 | type: 'DispatchError', 179 | code: 500, 180 | message: error.message, 181 | error 182 | }); 183 | } 184 | } 185 | } 186 | 187 | module.exports = { 188 | HttpController, 189 | HttpControllerContext, 190 | registerHttpController, 191 | dispatchHttp: dispatch 192 | }; 193 | -------------------------------------------------------------------------------- /contexts/websocket.d.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket } from 'uWebSockets.js' 2 | import { ControllerBase, ControllerBaseContext } from './base' 3 | 4 | /** 5 | * This class marks controller as a WebSocket handler. 6 | * It also helps IDE to show code suggestions. 7 | */ 8 | export class SocketController extends ControllerBase { 9 | /** Connection open hook, overridable */ 10 | open (): void; 11 | /** Connection close hook, overridable */ 12 | close (): void; 13 | /** 14 | * Hook for handling WebSocket errors, overridable 15 | * @param code WebSocket error code 16 | * @param msg Error description (can be empty) 17 | */ 18 | error (code: number, msg: string): void; 19 | 20 | /** 21 | * Send event with payload to current socket 22 | * @param event Event name 23 | * @param args Any JSON-serializable arguments for handler function 24 | */ 25 | emit (event: string, ...args: any[]): void; 26 | /** 27 | * Send event with payload to first matched socket 28 | * @param filter Socket matcher function 29 | * @param event Event name 30 | * @param args Any JSON-serializable arguments for handler function 31 | */ 32 | emitFirst (filter: (socket: SocketControllerContext) => boolean, event: string, ...args: any[]): void; 33 | /** 34 | * Makes current connection subscribed to specified channel 35 | * @param channel Channel name 36 | */ 37 | subscribe (channel: string): void; 38 | /** 39 | * Removes subscription on specified channel for current connection, otherwise removes all subscriptions 40 | * @param channel Channel name 41 | */ 42 | unsubscribe (channel: string): void; 43 | /** 44 | * Emits event for all connections that have subscription on specified channel. 45 | * If channel name is null, event will be emitted for all active WebSocket connections. 46 | * @param channel Channel name 47 | * @param event Event name 48 | * @param args Any JSON-serializable arguments for handler function 49 | */ 50 | broadcast (channel: string | null, event: string, ...args: any[]): void; 51 | /** 52 | * Close socket connection 53 | * @param message empty string by default 54 | * @param code by default 1000 (closed without errors) 55 | */ 56 | end (message?: string, code?: number): void; 57 | } 58 | 59 | type Socket = WebSocket & { 60 | isClosed: boolean, 61 | send (msg: string): void; 62 | end (code: number, msg: string): void; 63 | }; 64 | 65 | export class SocketControllerContext extends ControllerBaseContext { 66 | constructor (ws: Socket); 67 | 68 | init (sessionHeader: string): Promise; 69 | emit (event: string, ...args: any[]): void; 70 | end (msg?: string, code?: number): void; 71 | protected _destroy (): void; 72 | 73 | subscribe (channel: string): void; 74 | unsubscribe (channel: string): void; 75 | broadcast (channel: string, ...args: any[]): void; 76 | } 77 | 78 | export function registerSocketController (path: string, controller: SocketController): void; 79 | 80 | /** 81 | * Send event with payload to first matched socket 82 | * @param filter Socket matcher function 83 | * @param event Event name 84 | * @param args Any JSON-serializable arguments for handler function 85 | */ 86 | export function emitFirst (filter: (socket: SocketControllerContext) => boolean, event: string, ...args: any[]): void; 87 | /** 88 | * Emits event for all connections that have subscription on specified channel. 89 | * If channel name is null, event will be emitted for all active WebSocket connections. 90 | * @param channel Channel name 91 | * @param event Event name 92 | * @param args Any JSON-serializable arguments for handler function 93 | */ 94 | export function broadcast (channel: string | null, event: string, ...args: any[]): void; 95 | /** 96 | * Returns all WebSocket connections 97 | */ 98 | export function getConnections (): SocketControllerContext[]; 99 | /** 100 | * Returns all WebSocket connections subscribed to specified channel 101 | * @param isUnique Enables sockets dedupe by session id 102 | */ 103 | export function getConnections (channel: string, isUnique?: boolean): Generator; 104 | -------------------------------------------------------------------------------- /contexts/websocket.js: -------------------------------------------------------------------------------- 1 | const { ControllerBase, ControllerBaseContext } = require('./base'); 2 | const { parseRequest } = require('../utils/http'); 3 | const { emitError, HttpError } = require('../errors'); 4 | const config = require('../config'); 5 | const log = require('../log'); 6 | const Session = require('../session'); 7 | const app = require('../app'); 8 | 9 | class SocketController extends ControllerBase {} 10 | 11 | let connected = []; 12 | function* getFilteredConnections (filter) { 13 | for (let i = 0; i < connected.length; i++) { 14 | const socket = connected[i]; 15 | if (filter(socket)) yield socket; 16 | } 17 | } 18 | 19 | function getConnections (channel = null, isUnique = false) { 20 | if (channel) { 21 | if (isUnique) { 22 | const listed = []; 23 | return getFilteredConnections(socket => { 24 | if (socket._channels.has(channel)) { 25 | const id = socket.session._id.toString(); 26 | if (~listed.indexOf(id)) return false; 27 | else { 28 | listed.push(id); 29 | return true; 30 | } 31 | } else { 32 | return false; 33 | } 34 | }); 35 | } else { 36 | return getFilteredConnections(socket => socket._channels.has(channel)); 37 | } 38 | } else { 39 | return connected; 40 | } 41 | } 42 | 43 | function emitFirst (filter, ...args) { 44 | for (const ctx of connected) { 45 | if (filter(ctx)) { 46 | ctx._res.send(JSON.stringify(args)); 47 | break; 48 | } 49 | } 50 | } 51 | 52 | function broadcast (channel, ...args) { 53 | const payload = JSON.stringify(args); 54 | if (channel) { 55 | for (const ctx of connected) { 56 | if (ctx._channels.has(channel)) ctx._req.send(payload); 57 | } 58 | } else { 59 | for (const ctx of connected) { 60 | ctx._req.send(payload); 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * @extends {ControllerBaseContext} 67 | */ 68 | class SocketControllerContext extends ControllerBaseContext { 69 | /** 70 | * @param {import('./websocket').Socket} ws 71 | */ 72 | constructor (ws) { 73 | // `req` and `res` in `getBase` used only to get 74 | // request/response values, that combined in `ws` 75 | super(ws, ws); 76 | 77 | this.type = 'ws'; 78 | this._channels = new Set(); 79 | 80 | this.emitFirst = emitFirst; 81 | this.broadcast = broadcast; 82 | } 83 | 84 | _destroy () { 85 | const i = connected.indexOf(this); 86 | if (i != -1) connected.splice(i, 1); 87 | } 88 | 89 | /** 90 | * @param {string} sessionHeader 91 | */ 92 | async init (sessionHeader) { 93 | if (Session.enabled) { 94 | try { 95 | // Throws session parsing errors 96 | this._session = await Session.parse(sessionHeader); 97 | } catch (error) { 98 | emitError({ 99 | isSystem: true, 100 | type: 'SessionError', 101 | code: 500, 102 | message: error.message, 103 | error 104 | }); 105 | 106 | throw error; 107 | } 108 | } 109 | 110 | connected.push(this); 111 | } 112 | 113 | get session () { 114 | var session = super.session; 115 | if (this._session._init) { 116 | this._session._init.then(token => { 117 | if (this._req.isClosed) return; 118 | this.emit('session', token); 119 | }); 120 | } 121 | 122 | return session; 123 | } 124 | 125 | // emit(event, ...arguments); 126 | emit (...args) { 127 | if (this._req.isClosed) { 128 | return log.warn('Trying to send message via closed socket'); 129 | } 130 | 131 | this._res.send(JSON.stringify(args)); 132 | } 133 | 134 | subscribe (channel) { this._channels.add(channel); } 135 | unsubscribe (channel) { 136 | if (channel) this._channels.delete(channel); 137 | else this._channels.clear(); 138 | } 139 | 140 | end (msg = '', code = 1000) { 141 | if (!this._req.isClosed) { 142 | this._res.end(code, msg); 143 | } 144 | } 145 | } 146 | 147 | /** 148 | * @param {string} path 149 | * @param {import('./websocket').SocketController} controller 150 | */ 151 | function registerSocketController (path, controller) { 152 | app.ws(path, { 153 | maxPayloadLength: 16 * 1024 * 1024, // 16 Mb 154 | idleTimeout: config.ttl || 0, 155 | upgrade (res, req, context) { 156 | const ws = { isClosed: false }; 157 | parseRequest(req, ws); 158 | 159 | res.upgrade( 160 | ws, 161 | ws.headers['sec-websocket-key'], 162 | ws.headers['sec-websocket-protocol'], 163 | ws.headers['sec-websocket-extensions'], 164 | context 165 | ); 166 | }, 167 | async open (ws) { 168 | try { 169 | ws.dispatch = dispatch(ws, controller); 170 | } catch (error) { 171 | log.error('WebSocket request dispatch error', error); 172 | } 173 | }, 174 | message (ws, msg, isBinary) { ws.dispatch.message(msg, isBinary); }, 175 | close (ws, code, msg) { 176 | ws.isClosed = true; 177 | ws.dispatch.close(code, msg); 178 | } 179 | }); 180 | } 181 | 182 | function catchError (ctx, error) { 183 | if (error instanceof HttpError) { 184 | ctx.emit('error', error.message, error.code); 185 | 186 | emitError({ 187 | isSystem: true, 188 | type: 'DispatchError', 189 | ...error, 190 | error 191 | }); 192 | } else { 193 | if (config.isDev) { 194 | ctx.emit('error', error.toString(), 500); 195 | } 196 | 197 | emitError({ 198 | isSystem: true, 199 | type: 'DispatchError', 200 | code: 500, 201 | message: error.message, 202 | error 203 | }); 204 | } 205 | } 206 | 207 | const WS_SYSTEM_EVENTS = ['open', 'close', 'error']; 208 | /** 209 | * @param {import('./websocket').SocketController} controller 210 | */ 211 | function dispatch (ws, controller) { 212 | const obj = {}; 213 | const ctx = new SocketControllerContext(ws); 214 | ctx.controller = controller; 215 | 216 | let initProgress; 217 | const init = async session => { 218 | try { 219 | await ctx.init(session); 220 | } catch (error) { 221 | return ctx.emit('error', error.toString(), 500); 222 | } 223 | 224 | try { 225 | if (controller.open) await controller.open.call(ctx); 226 | } catch (error) { 227 | return catchError(ctx, error); 228 | } 229 | 230 | initProgress = undefined; 231 | }; 232 | 233 | if (!Session.enabled) initProgress = init(); 234 | 235 | obj.message = async msg => { 236 | msg = Buffer.from(msg).toString(); 237 | if (initProgress) await initProgress; 238 | try { 239 | const parsed = JSON.parse(msg); 240 | if (parsed[0] == 'session') { 241 | initProgress = init(parsed[1]); 242 | return; 243 | } else { 244 | await initProgress; 245 | } 246 | 247 | if (~parsed[0].indexOf(WS_SYSTEM_EVENTS)) return; 248 | if (parsed[0] in controller) { 249 | controller[parsed[0]].apply(ctx, parsed.slice(1)); 250 | } 251 | } catch (error) { 252 | return catchError(ctx, error); 253 | } 254 | } 255 | 256 | obj.close = async (code, message) => { 257 | // @ts-ignore 258 | ctx._destroy(); 259 | if (controller.close) controller.close.call(ctx); 260 | 261 | // 0 or 1000 - Clear close 262 | // 1001 - Page closed 263 | // 1005 - Expected close status, received none 264 | // 1006 & !message - Browser ended connection with no close frame. 265 | // In most cases it means "normal" close when page reloaded or browser closed 266 | if (code == 0 || code == 1000 || code == 1001 || (code == 1005 || code == 1006) && message.byteLength == 0) { 267 | return; 268 | } 269 | 270 | message = Buffer.from(message).toString(); 271 | 272 | if (controller.error) { 273 | await controller.error.call(ctx, code, message); 274 | } else { 275 | log.error('Unhandled socket error', `WebSocket disconnected with code ${code}\nDriver message: ${message}`); 276 | emitError({ 277 | isSystem: true, 278 | type: 'SocketUnhandledError', 279 | code, 280 | message 281 | }); 282 | } 283 | } 284 | 285 | return obj; 286 | } 287 | 288 | module.exports = { 289 | SocketController, 290 | SocketControllerContext, 291 | registerSocketController, 292 | dispatchSocket: dispatch, 293 | 294 | emitFirst, broadcast, getConnections 295 | }; 296 | -------------------------------------------------------------------------------- /db.d.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | 3 | type Class = new (...args: any[]) => T; 4 | 5 | interface ConnectionOptions { 6 | identifier: string 7 | } 8 | 9 | interface DatabaseDriverStatic { 10 | /** Returns database connection by its name */ 11 | connect (connection?: string, options?: ConnectionOptions): DriverType; 12 | new (options: ConnectionOptions): DriverType; 13 | } 14 | 15 | type FindQuery = { 16 | [Key in keyof Doc]?: Doc[Key] | FindQueryOp; 17 | } 18 | 19 | declare interface FindQueryOp { 20 | /** Lower that value */ 21 | $lt?: T; 22 | /** Lower that value or equal */ 23 | $lte?: T; 24 | /** Greater that value */ 25 | $gt?: T; 26 | /** Greater that value or equal */ 27 | $gte?: T; 28 | /** Not equal to value */ 29 | $ne?: T; 30 | } 31 | 32 | declare abstract class Model> { 33 | public init: (() => Promise) | undefined; 34 | 35 | public create (values: DocLike): Promise; 36 | 37 | public find (query: FindQuery): Promise; 38 | public findOne (query: FindQuery): Promise; 39 | public findById (id: any): Promise; 40 | 41 | public delete (query: FindQuery): Promise; 42 | public deleteOne (query: FindQuery): Promise; 43 | public deleteById (id: any): Promise; 44 | 45 | public update (query: FindQuery, values: DocLike): Promise; 46 | public updateOne (query: FindQuery, values: DocLike): Promise; 47 | public updateById (id: any, values: DocLike): Promise; 48 | } 49 | 50 | export abstract class DatabaseDriver extends EventEmitter { 51 | public readonly [modelName: string]: ModelType; 52 | public readonly _self: DatabaseDriverStatic; 53 | public readonly _name: string; 54 | 55 | protected constructor (); 56 | public abstract constructor (options: object); 57 | public abstract connect (): Promise; 58 | public getModel (basePath: string, name: string): ModelType; 59 | public makeModel (name: string, schema: any): ModelType; 60 | 61 | emit (event: 'disconnected'): void; 62 | } 63 | 64 | type ExtractModelType = InstanceType extends DatabaseDriver ? ModelType : never; 65 | export function registerDriver (driver: DriverClass, driverName: string): DatabaseDriverStatic>; 66 | export function connectDatabase (configKey: string, options?: ConnectionOptions): DatabaseDriver; 67 | export function makeModel (connector: DatabaseDriver, modelName: string, schema: Record): Model; 68 | 69 | interface ModelFieldInfo { 70 | type: 'string' | 'text' | 'int' | 'long' | 'enum' | 'json' 71 | } 72 | 73 | class ModelField { 74 | readonly info: ModelFieldInfo; 75 | get required (): this; 76 | default (value: T): this; 77 | } 78 | 79 | class ModelString extends ModelField {} 80 | export function string (length?: number): ModelString; 81 | 82 | class ModelText extends ModelField {} 83 | export function text (): ModelText; 84 | 85 | class ModelJson extends ModelField {} 86 | export function json (): ModelJson; 87 | 88 | class ModelInt extends ModelField {} 89 | export function int (): ModelInt; 90 | 91 | class ModelLong extends ModelField {} 92 | export function long (): ModelLong; 93 | 94 | class ModelEnum extends ModelField {} 95 | export function enumerable (...values: V): ModelEnum; 96 | -------------------------------------------------------------------------------- /db.js: -------------------------------------------------------------------------------- 1 | const Path = require('path'); 2 | const { emitError } = require('./errors'); 3 | const config = require('./config'); 4 | const log = require('./log'); 5 | const DbTypesGenerator = require('./typescript/db-typings'); 6 | 7 | async function connect (connector, attempt = 0) { 8 | try { 9 | await connector.connect(); 10 | log.success(`Connected to ${connector._name} database`); 11 | attempt = 0; 12 | } catch (error) { 13 | log.error(`Connection to ${connector._name} database was failed`, error); 14 | emitError({ 15 | isSystem: true, 16 | type: 'DatabaseConnectionError', 17 | name: connector._name, 18 | error 19 | }); 20 | 21 | setTimeout(() => { 22 | log.info(`Reconnecting to ${connector._name}... Attempt #${attempt}`); 23 | connect(connector, attempt + 1); 24 | }, 5000); 25 | } 26 | } 27 | 28 | function makeModel (connector, modelName, schema) { 29 | for (const field in schema) { 30 | const descriptor = schema[field]; 31 | if (descriptor instanceof ModelField) { 32 | schema[field] = descriptor.info; 33 | } 34 | } 35 | 36 | const model = connector.makeModel(modelName, schema); 37 | if (model.init) model.init(); 38 | 39 | Object.defineProperty(connector, modelName, { value: model, writable: false }); 40 | return model; 41 | } 42 | 43 | exports.makeModel = makeModel; 44 | 45 | function loadModel (connector, modelName, schema) { 46 | if ('default' in schema) { 47 | schema = schema.default; 48 | } 49 | 50 | if (connector.makeModel) { 51 | makeModel(connector, modelName, schema); 52 | } else { 53 | // todo? deprecate 54 | const model = connector.getModel(modelName, schema); 55 | Object.defineProperty(connector, modelName, { value: model, writable: false }); 56 | } 57 | } 58 | 59 | // #paste :modelsMap 60 | 61 | function* iterModels (dbConfigName) { 62 | // #region :iterModels 63 | const MODELS_BASE_PATH = Path.join(process.cwd(), 'models', dbConfigName); 64 | for (const entry of readdirSync(MODELS_BASE_PATH)) { 65 | if (!entry.endsWith('.js')) continue; 66 | 67 | const name = entry.slice(0, -3); 68 | const path = Path.join(MODELS_BASE_PATH, entry); 69 | if (!existsSync(path)) { 70 | log.warn(`Database model "${name}" not found for "${dbConfigName}" configuration`); 71 | return; 72 | } 73 | 74 | yield { name, path }; 75 | } 76 | // #endregion 77 | } 78 | 79 | function maintainConnector (connector, dbConfig) { 80 | if (getFlag('--ts-build')) { 81 | return connector; 82 | } 83 | 84 | connect(connector); 85 | const types = new DbTypesGenerator(connector); 86 | 87 | for (const { name, path, schema } of iterModels(dbConfig._name)) { 88 | try { 89 | // todo: schema types 90 | loadModel(connector, name, schema || require(path)); 91 | } catch (error) { 92 | log.error(`Cannot load "${name}" model for "${dbConfig._name}" configuration`, error); 93 | process.exit(-1); 94 | } 95 | 96 | types.add(name, connector[name]); 97 | } 98 | 99 | types.write(dbConfig._name); 100 | 101 | return connector; 102 | } 103 | 104 | const connections = {}; 105 | const drivers = {}; 106 | exports.registerDriver = (DriverClass, driverName) => { 107 | // todo: DRY 108 | for (const key in config.db) { 109 | if (key == driverName || key.startsWith(driverName + '.')) { 110 | const dbConfig = config.db[key]; 111 | // todo! write docs 112 | if (dbConfig.template) continue; 113 | 114 | const connector = new DriverClass(dbConfig); 115 | connector._self = DriverClass; 116 | connector._name = key; 117 | dbConfig._name = key; 118 | connections[key] = maintainConnector(connector, dbConfig); 119 | } 120 | } 121 | 122 | DriverClass.connect = (configKey, options) => { 123 | if (options && !options.identifier && !options.name) { 124 | return log.warn('Templated database connection must have `identifier` field'); 125 | } 126 | 127 | // Key of configuration in config.db object 128 | configKey = driverName + (configKey ? ('.' + configKey) : ''); 129 | 130 | // Unique name of current connection (equals configKey when not templated) 131 | const connectionName = options 132 | ? (driverName + '.' + (options.identifier || options.name)) 133 | : configKey; 134 | 135 | // Reusing connections 136 | if (connectionName in connections) { 137 | return connections[connectionName]; 138 | } 139 | 140 | if (configKey in config.db) { 141 | let dbConfig = config.db[configKey]; 142 | if (options) { 143 | // Spread is used to make mutable copy without side-effects 144 | dbConfig = { ...dbConfig, ...options }; 145 | delete dbConfig.identifier; 146 | } 147 | 148 | dbConfig._name = configKey; 149 | 150 | const connector = new DriverClass(dbConfig); 151 | connector._self = DriverClass; 152 | connector._name = connectionName; 153 | return connections[connectionName] = maintainConnector(connector, dbConfig); 154 | } else { 155 | log.error(`Database configuration "${configKey}" not found`); 156 | } 157 | }; 158 | 159 | drivers[driverName] = DriverClass; 160 | return DriverClass; 161 | } 162 | 163 | exports.connectDatabase = (configKey, options) => { 164 | const [driverName, connectionName] = configKey.split('.', 2); 165 | const DriverClass = drivers[driverName]; 166 | 167 | if (DriverClass) return DriverClass.connect(connectionName, options); 168 | else log.error(`Database driver "${driverName}" not registered`); 169 | } 170 | 171 | const { EventEmitter } = require('events'); 172 | const { existsSync, readdirSync } = require('fs'); 173 | const { getFlag } = require('./utils'); 174 | exports.DatabaseDriver = class DatabaseDriver extends EventEmitter {} 175 | 176 | class ModelField { 177 | info = {}; 178 | constructor (type) { 179 | this.info.type = type; 180 | this.info.required = false; 181 | } 182 | 183 | get required () { 184 | this.info.required = true; 185 | return this; 186 | } 187 | 188 | default (value) { 189 | this.info.default = value; 190 | return this; 191 | } 192 | } 193 | 194 | class ModelInt extends ModelField { 195 | constructor () { super('int'); } 196 | } 197 | exports.int = () => new ModelInt(); 198 | 199 | class ModelLong extends ModelField { 200 | constructor () { super('long'); } 201 | } 202 | exports.long = () => new ModelLong(); 203 | 204 | class ModelString extends ModelField { 205 | constructor (length) { 206 | super('string'); 207 | this.info.length = length; 208 | } 209 | } 210 | exports.string = length => new ModelString(length); 211 | 212 | class ModelText extends ModelField { 213 | constructor () { super('text'); } 214 | } 215 | exports.text = () => new ModelText(); 216 | 217 | class ModelJson extends ModelField { 218 | constructor () { super('json'); } 219 | } 220 | exports.json = () => new ModelJson(); 221 | 222 | class ModelEnum extends ModelField { 223 | constructor (values) { 224 | super('enum'); 225 | this.info.values = values; 226 | } 227 | } 228 | exports.enumerable = (...values) => new ModelEnum(values); 229 | -------------------------------------------------------------------------------- /errors.d.ts: -------------------------------------------------------------------------------- 1 | export interface IErrorInfo { 2 | isSystem: boolean; 3 | type: string; 4 | error?: any; 5 | } 6 | 7 | export interface IDispatchErrorInfo extends IErrorInfo { 8 | isSystem: true; 9 | type: 'DispatchError'; 10 | 11 | /** Message returned with response */ 12 | message: any; 13 | /** Response code */ 14 | code: number; 15 | /** Error name */ 16 | name?: string; 17 | } 18 | 19 | export interface IDatabaseConnectionErrorInfo extends IErrorInfo { 20 | isSystem: true; 21 | type: 'DatabaseConnectionError'; 22 | /** Connection name */ 23 | name: string; 24 | } 25 | 26 | export interface ISessionErrorInfo extends IErrorInfo { 27 | isSystem: true; 28 | type: 'SessionError'; 29 | code: 500; 30 | message: string; 31 | } 32 | 33 | export interface ISocketUnhandledErrorInfo extends IErrorInfo { 34 | isSystem: true; 35 | type: 'SocketUnhandledError'; 36 | /** Disconnection code */ 37 | code: number; 38 | /** Driver message */ 39 | message: string; 40 | } 41 | 42 | export interface IRequestErrorInfo extends IErrorInfo { 43 | isSystem: true; 44 | type: 'RequestError'; 45 | code: 400; 46 | url: string; 47 | /** Error message */ 48 | message: any; 49 | /** Raw request body */ 50 | body?: Buffer; 51 | } 52 | 53 | type KnownErrors = IDispatchErrorInfo | IDatabaseConnectionErrorInfo | ISessionErrorInfo | ISocketUnhandledErrorInfo | IRequestErrorInfo | IErrorInfo; 54 | 55 | export function onError (handler: (info: KnownErrors) => void): void; 56 | export function emitError (info: KnownErrors): void; 57 | export function clearErrorStack (stack: string): string; 58 | export function splitError (error: any): string[]; 59 | 60 | export class HttpError { 61 | public message: any; 62 | public code: number; 63 | constructor (message: any, code?: number); 64 | } 65 | -------------------------------------------------------------------------------- /errors.js: -------------------------------------------------------------------------------- 1 | const errorHandlers = []; 2 | exports.onError = handler => errorHandlers.push(handler); 3 | exports.emitError = info => { 4 | for (const handler of errorHandlers) { 5 | handler(info); 6 | } 7 | }; 8 | 9 | exports.HttpError = class HttpError { 10 | constructor (message, code = 500) { 11 | this.message = message; 12 | this.code = code; 13 | } 14 | } 15 | 16 | const INTERNAL_REGEXP = /(.+\(internal\/modules\/|\s*at internal\/|.+\(node:).+(\n|$)/g; 17 | exports.clearErrorStack = stack => { 18 | return stack.replace(INTERNAL_REGEXP, ''); 19 | }; 20 | 21 | exports.splitError = error => { 22 | // Error stack also includes message 23 | return (error.stack ? exports.clearErrorStack(error.stack) : error.toString()).trim().split('\n'); 24 | }; 25 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { SocketController } from './contexts/websocket' 2 | import { HttpController } from './contexts/http' 3 | import { Validated } from './typescript/validator' 4 | 5 | export { SocketController, HttpController } 6 | 7 | export type AwaitObject = { 8 | [Key in keyof T]: Awaited 9 | }; 10 | 11 | export type Data = ValidationType['ctx']; 12 | export type Query = ValidationType['ctx']; 13 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const log = require('./log'); 3 | const app = require('./app'); 4 | 5 | const { loadPlugins, getController, executeStartup, iterControllers } = require('./utils/loader'); 6 | const { initSessions } = require('./session'); 7 | const { camelToKebab } = require('./utils'); 8 | const router = require('./router'); 9 | const { prepareHttpConnection, fetchBody, abortRequest } = require('./utils/http'); 10 | 11 | const { SocketController, registerSocketController } = require('./contexts/websocket'); 12 | exports.SocketController = SocketController; 13 | const { HttpController, registerHttpController, dispatchHttp } = require('./contexts/http'); 14 | exports.HttpController = HttpController; 15 | 16 | 17 | loadPlugins(); 18 | 19 | if (config.typescript) { 20 | log.info('Warming up TypeScript...'); 21 | require('./typescript'); 22 | } 23 | 24 | initSessions(); 25 | 26 | executeStartup().then(() => { 27 | // Preloading controllers 28 | for (let { name: controllerName } of iterControllers()) { 29 | try { 30 | const controller = getController(controllerName); 31 | 32 | if (controller instanceof SocketController) { 33 | registerSocketController('/' + camelToKebab(controllerName), controller); 34 | } else { 35 | registerHttpController('/' + camelToKebab(controllerName), controller); 36 | } 37 | } catch (error) { 38 | log.error(`Loading "${controllerName}" controller failed`, error); 39 | process.exit(-1); 40 | } 41 | } 42 | 43 | // TODO: do smth with custom routes 44 | app.any('/*', async (res, req) => { 45 | prepareHttpConnection(req, res); 46 | const route = router.match(req.path); 47 | if (!route) { 48 | return abortRequest(res, 404, 'API endpoint not found'); 49 | } 50 | 51 | if (req.getMethod() == 'post') await fetchBody(req, res); 52 | if (res.aborted) return; 53 | 54 | await dispatchHttp(req, res, ctx => { 55 | ctx.params = route.params; 56 | return route.target(ctx); 57 | }); 58 | }); 59 | 60 | // Listening port 61 | app.listen(config.port, socket => { 62 | const status = `on port ${config.port} ${config.ssl ? 'with' : 'without'} SSL`; 63 | if (socket) { 64 | log.success('Server started ' + status); 65 | } else { 66 | log.error('Cannot start server ' + status); 67 | } 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /install.js: -------------------------------------------------------------------------------- 1 | // const { exec, spawn } = require('child_process'); 2 | const { exec: e, spawn: s } = require('child_process'); 3 | function exec (cmd, cb) { 4 | e(cmd.replace('dc-api-cli', 'node /Volumes/DATA/Projects/Node/dc-api-cli/index.js'), cb); 5 | } 6 | 7 | function spawn (cmd, args, opts) { 8 | if (cmd == 'dc-api-cli') { 9 | return s('node', ['/Volumes/DATA/Projects/Node/dc-api-cli/index.js', ...args], opts); 10 | } else { 11 | return s(cmd, args, opts); 12 | } 13 | } 14 | 15 | function prompt (question, list) { 16 | return new Promise(resolve => { 17 | process.stdout.write(question + ' '); 18 | process.stdin.resume(); 19 | process.stdin.once('data', chunk => { 20 | process.stdin.pause(); 21 | const answer = chunk.toString().replace(/\r?\n/, '').toLowerCase(); 22 | if (list && !~list.indexOf(answer)) prompt(question, list).then(resolve); 23 | else resolve(answer); 24 | }); 25 | }); 26 | } 27 | 28 | function init () { 29 | prompt('Init dc-api-core project for you? [Y/n]:', ['', 'y', 'n']).then(isInit => { 30 | if (!isInit || isInit == 'y') { 31 | console.log('\n$ dc-api-cli init'); 32 | spawn('dc-api-cli', ['init'], { stdio: 'inherit' }); 33 | } 34 | }); 35 | } 36 | 37 | exec('dc-api-cli --version', (err, stdout, stderr) => { 38 | if (err || stderr) { 39 | prompt('Install dc-api-cli globally (not required for production)? [Y/n]:', ['', 'y', 'n']).then(answer => { 40 | if (!answer || answer == 'y') { 41 | prompt('Select package manager\n1) npm\n2) Yarn\nAnswer:', ['1', '2']).then(pm => { 42 | switch (pm) { 43 | case '1': 44 | pm = 'npm install --global dc-api-cli'; 45 | break; 46 | case '2': 47 | pm = 'yarn global add dc-api-cli'; 48 | break; 49 | } 50 | 51 | console.log('\n$ ' + pm); 52 | pm = pm.split(' '); 53 | const pmProcess = spawn(pm[0], pm.slice(1), { stdio: 'inherit' }); 54 | pmProcess.once('exit', code => { 55 | if (code) { 56 | prompt('Package manager exited with error, continue? [y/N]:', ['', 'y', 'n']).then(isContinue => { 57 | if (isContinue == 'y') init(); 58 | }); 59 | } else { 60 | init(); 61 | } 62 | }); 63 | }); 64 | } 65 | }); 66 | } else { 67 | stdout = stdout.toString().replace(/\r?\n/, ''); 68 | console.log('Found dc-api-cli v' + stdout + ', skipping installation'); 69 | init(); 70 | } 71 | }); 72 | 73 | -------------------------------------------------------------------------------- /log.d.ts: -------------------------------------------------------------------------------- 1 | export function text (text: string): void; 2 | export function info (text: string): void; 3 | export function success (text: string): void; 4 | export function warn (text: string): void; 5 | export function error (text: string, error?: any): void; 6 | -------------------------------------------------------------------------------- /log.js: -------------------------------------------------------------------------------- 1 | const { splitError } = require('./errors'); 2 | const { getArg } = require('./utils'); 3 | 4 | const LOG_COLORS = { 5 | INFO: { 6 | ansi: 6, 7 | named: 39, 8 | rgb: [0, 192, 255] 9 | }, 10 | OK: { 11 | ansi: 2, 12 | named: 35, 13 | rgb: [0, 192, 64] 14 | }, 15 | WARN: { 16 | ansi: 3, 17 | named: 202, 18 | rgb: [255, 112, 0] 19 | }, 20 | ERR: { 21 | ansi: 1, 22 | named: 160, 23 | rgb: [224, 0, 0] 24 | } 25 | }; 26 | 27 | const FG = 30; 28 | const BG = 40; 29 | const RESET = '\x1B[0m'; 30 | const BOLD = '\x1B[1m'; 31 | 32 | const currentTheme = {}; 33 | function buildTheme (pallette) { 34 | let parser; 35 | switch (pallette) { 36 | case 'rgb': 37 | parser = (color, offset) => `\x1B[${offset + 8};2;${color[0]};${color[1]};${color[2]}m`; 38 | break; 39 | case 'named': 40 | parser = (color, offset) => `\x1B[${offset + 8};5;${color}m`; 41 | break; 42 | case 'ansi': 43 | parser = (color, offset = FG) => `\x1B[${color + offset}m`; 44 | break; 45 | default: 46 | process.stdout.write(`Unknown color pallette "${pallette}"\n`); 47 | process.exit(-1); 48 | } 49 | 50 | for (const type in LOG_COLORS) { 51 | currentTheme[type] = parser(LOG_COLORS[type][pallette], BG); 52 | } 53 | 54 | currentTheme.ERR_LINE = parser(LOG_COLORS.ERR[pallette], FG); 55 | currentTheme.TEXT = parser(({ ansi: 7, named: 255, rgb: [255, 255, 255] })[pallette], FG); 56 | } 57 | 58 | // todo! doc changes 59 | let pallette = getArg('--colors'); 60 | if (!pallette) { 61 | if (process.env.COLORTERM) { 62 | if (process.env.COLORTERM == 'truecolor' || process.env.COLORTERM == 'x24') { 63 | pallette = 'rgb'; 64 | } else if (~process.env.COLORTERM.indexOf('256color')) { 65 | pallette = 'named'; 66 | } 67 | } else { 68 | pallette = 'ansi'; 69 | } 70 | } 71 | 72 | buildTheme(pallette); 73 | 74 | const print = (type, text) => { 75 | process.stdout.write(`${currentTheme[type]}${currentTheme.TEXT}${BOLD} ${type} ${RESET} ${text}${RESET}\n`); 76 | } 77 | 78 | exports.text = text => process.stdout.write(text + '\n'), 79 | exports.info = text => print('INFO', text), 80 | exports.success = text => print('OK', text), 81 | exports.warn = text => print('WARN', text), 82 | exports.error = (text, error) => { 83 | print('ERR', text); 84 | if (error) { 85 | if (!(error instanceof Array)) error = splitError(error); 86 | 87 | for (const line of error) { 88 | process.stdout.write(` ${currentTheme.ERR_LINE}│${RESET} ${line}\n`); 89 | } 90 | 91 | process.stdout.write(` ${currentTheme.ERR_LINE}└─${RESET}\n`); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dc-api-core", 3 | "version": "0.4.0-1", 4 | "description": "Simple API core for your projects", 5 | "author": "DimaCrafter", 6 | "homepage": "https://github.com/DimaCrafter/dc-api-core", 7 | "bugs": "https://github.com/DimaCrafter/dc-api-core/issues", 8 | "repository": "github.com:DimaCrafter/dc-api-core", 9 | "license": "MIT", 10 | "bin": "cli.js", 11 | "types": "index.d.ts", 12 | "dependencies": { 13 | "jwa": "^2.0.0", 14 | "ms": "^2.1.2", 15 | "uWebSockets.js": "github:uNetworking/uWebSockets.js#v19.3.0", 16 | "watch": "^1.0.2" 17 | }, 18 | "peerDependencies": { 19 | "esbuild": "^0.21.5", 20 | "ts-node": "^10.9.2", 21 | "typescript": "^5.5.2" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^20.14.8" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /router.d.ts: -------------------------------------------------------------------------------- 1 | export let routes: any[]; 2 | export function register(pattern: any, target: any): { 3 | target: any; 4 | }; 5 | export function match(path: any): { 6 | target: any; 7 | params?: undefined; 8 | } | { 9 | target: any; 10 | params: {}; 11 | }; 12 | -------------------------------------------------------------------------------- /router.js: -------------------------------------------------------------------------------- 1 | const { getController, getActionCaller } = require("./utils/loader"); 2 | 3 | module.exports = { 4 | routes: [], 5 | register (pattern, target) { 6 | const params = []; 7 | pattern = pattern.replace(/\${([a-zA-z0-9_]+)}/g, (_, param) => { 8 | params.push(param); 9 | return '(.*?)'; 10 | }); 11 | 12 | if (typeof target == 'string') { 13 | target = target.split('.'); 14 | const controller = getController(target[0]); 15 | target = getActionCaller(controller, controller[target[1]]); 16 | } 17 | 18 | const route = { target }; 19 | if (params.length) { 20 | route.pattern = new RegExp('^' + pattern + '$'); 21 | route.params = params; 22 | } else { 23 | route.pattern = pattern; 24 | route.isText = true; 25 | } 26 | 27 | this.routes.push(route); 28 | return route; 29 | }, 30 | 31 | match (path) { 32 | for (const route of this.routes) { 33 | if (route.isText) { 34 | if (route.pattern == path) return { target: route.target }; 35 | else continue; 36 | } else { 37 | let matched = route.pattern.exec(path); 38 | if (matched) { 39 | let params = {}; 40 | for (let i = 0; i < route.params.length; i++) { 41 | params[route.params[i]] = matched[i + 1]; 42 | } 43 | 44 | return { target: route.target, params }; 45 | } else { 46 | continue; 47 | } 48 | } 49 | } 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /session.d.ts: -------------------------------------------------------------------------------- 1 | export const enabled: boolean; 2 | export function cryptSession(id: any): { 3 | id: any; 4 | expires: any; 5 | }; 6 | export function decodeSession(input: any): any; 7 | export declare function init(): any; 8 | export declare function parse(header: any): Promise; 9 | export declare function registerStore(StoreClass: any, id: any): void; 10 | export declare function initSessions(): void; 11 | -------------------------------------------------------------------------------- /session.js: -------------------------------------------------------------------------------- 1 | const jwa = require('jwa')('HS256'); 2 | const config = require('./config'); 3 | const log = require('./log'); 4 | const { connectDatabase, makeModel, json, long } = require('./db'); 5 | 6 | const enabled = !!config.session; 7 | const stores = {}; 8 | 9 | let store; 10 | 11 | function cryptSession (id) { 12 | let result = { id, expires: Date.now() + config.session.ttl }; 13 | result.sign = jwa.sign(result, config.session.secret); 14 | return result; 15 | } 16 | 17 | function decodeSession (input) { 18 | const { sign } = input; 19 | delete input.sign; 20 | 21 | if (!jwa.verify(input, sign, config.session.secret)) return; 22 | else if (input.expires <= Date.now()) return; 23 | else return input; 24 | } 25 | 26 | function wrap (document) { 27 | document.save = async () => { 28 | await document._init; 29 | if (!document.id && !document._id) { 30 | return log.error('Cannot save session, `id` or `_id` field not present'); 31 | } 32 | 33 | const data = {}; 34 | for (const key in document) { 35 | if (key == 'id' || key == '_id' || key == 'expires' || key == '_init') continue; 36 | 37 | const value = document[key]; 38 | if (typeof value == 'function') continue; 39 | 40 | data[key] = document[key]; 41 | } 42 | 43 | return await store.update(document.id || document._id, data); 44 | } 45 | 46 | document.destroy = async () => { 47 | await document._init; 48 | if (!document.id && !document._id) { 49 | return log.error('Cannot destroy session, `id` or `_id` field not present'); 50 | } 51 | 52 | return await store.delete(document.id || document._id); 53 | }; 54 | 55 | return document; 56 | } 57 | 58 | module.exports = { 59 | enabled, 60 | cryptSession, 61 | decodeSession, 62 | init () { 63 | const object = wrap({}); 64 | object._init = store 65 | .create(Date.now() + config.session.ttl) 66 | .then(document => { 67 | object.id = document.id; 68 | object._id = document._id; 69 | return cryptSession(document.id || document._id); 70 | }); 71 | 72 | return object; 73 | }, 74 | async parse (header) { 75 | if (!header) return; 76 | 77 | const parsed = decodeSession(header); 78 | if (!parsed) return; 79 | 80 | const session = await store.get(parsed.id); 81 | if (!session) return; 82 | 83 | const { data } = session; 84 | Object.assign(session, data); 85 | delete session.data; 86 | 87 | return wrap(session); 88 | }, 89 | registerStore (StoreClass, id) { 90 | stores[id] = StoreClass; 91 | }, 92 | // todo: rename init() 93 | initSessions () { 94 | if (enabled) { 95 | const [storeName, configKey] = config.session.store.split('.', 2); 96 | const StoreClass = stores[storeName]; 97 | if (StoreClass) { 98 | store = new StoreClass(configKey); 99 | 100 | async function cleanup () { 101 | await store.destroyExpired(Date.now() - config.session.ttl); 102 | } 103 | 104 | if (config.session.ttl !== false) { 105 | cleanup(); 106 | setInterval(cleanup, config.session.ttl / 3); 107 | } 108 | } else { 109 | // todo: delete deprecated 110 | log.warn('Using database instead of session store is deprecated'); 111 | 112 | const db = connectDatabase(config.session.store); 113 | // todo: implement in dc-api-mysql 114 | const model = makeModel(db, 'Session', { 115 | data: json().required, 116 | expires: long().required 117 | }); 118 | 119 | let _initPromise; 120 | if (model.init) _initPromise = model.init(); 121 | 122 | store = { 123 | create: expires => model.create({ data: {}, expires }), 124 | get: _id => model.findById(_id), 125 | save: (_id, data) => model.updateById(_id, { data }), 126 | destroy: _id => model.deleteById(_id) 127 | }; 128 | 129 | async function cleanup () { 130 | await _initPromise; 131 | await model.delete({ expires: { $lte: Date.now() - config.session.ttl } }); 132 | } 133 | 134 | if (config.session.ttl !== false) { 135 | cleanup(); 136 | setInterval(cleanup, config.session.ttl / 3); 137 | } 138 | } 139 | } 140 | } 141 | }; 142 | -------------------------------------------------------------------------------- /test/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 6080 3 | } -------------------------------------------------------------------------------- /test/controllers/Socket.js: -------------------------------------------------------------------------------- 1 | module.exports = class Socket { 2 | open () { 3 | this.emit('open-reply'); 4 | } 5 | 6 | sum (...args) { 7 | this.emit('sum', args.reduce((prev, curr) => prev + curr)); 8 | } 9 | 10 | sub_test () { 11 | this.subscribe('test-channel'); 12 | setTimeout(() => this.broadcast('test-channel', 'sub_test', 'Channeling works!'), 1000); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/controllers/TestEndpoint.js: -------------------------------------------------------------------------------- 1 | module.exports = class TestEndpoint { 2 | ping () { 3 | return 'pong'; 4 | } 5 | 6 | get () { 7 | return this.query; 8 | } 9 | 10 | post () { 11 | return this.data; 12 | } 13 | 14 | hash () { 15 | return this.params.hash; 16 | } 17 | 18 | _private () { return 'secured content'; } 19 | exposedPrivate () { return this.controller._private(); } 20 | } 21 | -------------------------------------------------------------------------------- /test/jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/en/configuration.html 4 | */ 5 | 6 | module.exports = { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/private/var/folders/fk/mwvx59514vs3h71bgsmvpbnr0000gn/T/jest_dx", 15 | 16 | // Automatically clear mock calls and instances between every test 17 | clearMocks: true, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | // collectCoverage: false, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: "coverage", 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: "v8", 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // Force coverage collection from ignored files using an array of glob patterns 54 | // forceCoverageMatch: [], 55 | 56 | // A path to a module which exports an async function that is triggered once before all test suites 57 | globalSetup: './tasks/setup.js', 58 | 59 | // A path to a module which exports an async function that is triggered once after all test suites 60 | globalTeardown: './tasks/teardown.js', 61 | 62 | // A set of global variables that need to be available in all test environments 63 | // globals: {}, 64 | 65 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 66 | // maxWorkers: "50%", 67 | 68 | // An array of directory names to be searched recursively up from the requiring module's location 69 | // moduleDirectories: [ 70 | // "node_modules" 71 | // ], 72 | 73 | // An array of file extensions your modules use 74 | // moduleFileExtensions: [ 75 | // "js", 76 | // "json", 77 | // "jsx", 78 | // "ts", 79 | // "tsx", 80 | // "node" 81 | // ], 82 | 83 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 84 | // moduleNameMapper: {}, 85 | 86 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 87 | // modulePathIgnorePatterns: [], 88 | 89 | // Activates notifications for test results 90 | // notify: false, 91 | 92 | // An enum that specifies notification mode. Requires { notify: true } 93 | // notifyMode: "failure-change", 94 | 95 | // A preset that is used as a base for Jest's configuration 96 | // preset: undefined, 97 | 98 | // Run tests from one or more projects 99 | // projects: undefined, 100 | 101 | // Use this configuration option to add custom reporters to Jest 102 | // reporters: undefined, 103 | 104 | // Automatically reset mock state between every test 105 | // resetMocks: false, 106 | 107 | // Reset the module registry before running each individual test 108 | // resetModules: false, 109 | 110 | // A path to a custom resolver 111 | // resolver: undefined, 112 | 113 | // Automatically restore mock state between every test 114 | // restoreMocks: false, 115 | 116 | // The root directory that Jest should scan for tests and modules within 117 | // rootDir: undefined, 118 | 119 | // A list of paths to directories that Jest should use to search for files in 120 | // roots: [ 121 | // "" 122 | // ], 123 | 124 | // Allows you to use a custom runner instead of Jest's default test runner 125 | // runner: "jest-runner", 126 | 127 | // The paths to modules that run some code to configure or set up the testing environment before each test 128 | // setupFiles: [], 129 | 130 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 131 | // setupFilesAfterEnv: [], 132 | 133 | // The number of seconds after which a test is considered as slow and reported as such in the results. 134 | // slowTestThreshold: 5, 135 | 136 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 137 | // snapshotSerializers: [], 138 | 139 | // The test environment that will be used for testing 140 | testEnvironment: "node", 141 | 142 | // Options that will be passed to the testEnvironment 143 | // testEnvironmentOptions: {}, 144 | 145 | // Adds a location field to test results 146 | // testLocationInResults: false, 147 | 148 | // The glob patterns Jest uses to detect test files 149 | // testMatch: [ 150 | // "**/__tests__/**/*.[jt]s?(x)", 151 | // "**/?(*.)+(spec|test).[tj]s?(x)" 152 | // ], 153 | 154 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 155 | // testPathIgnorePatterns: [ 156 | // "/node_modules/" 157 | // ], 158 | 159 | // The regexp pattern or array of patterns that Jest uses to detect test files 160 | // testRegex: [], 161 | 162 | // This option allows the use of a custom results processor 163 | // testResultsProcessor: undefined, 164 | 165 | // This option allows use of a custom test runner 166 | // testRunner: "jasmine2", 167 | 168 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 169 | // testURL: "http://localhost", 170 | 171 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 172 | // timers: "real", 173 | 174 | // A map from regular expressions to paths to transformers 175 | // transform: undefined, 176 | 177 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 178 | // transformIgnorePatterns: [ 179 | // "/node_modules/", 180 | // "\\.pnp\\.[^\\/]+$" 181 | // ], 182 | 183 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 184 | // unmockedModulePathPatterns: undefined, 185 | 186 | // Indicates whether each individual test should be reported during the run 187 | // verbose: undefined, 188 | 189 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 190 | // watchPathIgnorePatterns: [], 191 | 192 | // Whether to use watchman for file crawling 193 | // watchman: true, 194 | }; 195 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "version": "1.0.0", 4 | "devDependencies": { 5 | "@types/jest": "^26.0.23", 6 | "jest": "^26.6.3", 7 | "ws": "^7.4.5" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/startup.js: -------------------------------------------------------------------------------- 1 | const Router = require('../router'); 2 | Router.register('/test-custom/h${hash}.json', 'TestEndpoint.hash') 3 | -------------------------------------------------------------------------------- /test/tasks/controller-context.test.js: -------------------------------------------------------------------------------- 1 | const { testJSON } = require('./utils'); 2 | 3 | test('this.controller', async () => { 4 | await testJSON('/test-endpoint/exposed-private', 200, 'secured content'); 5 | }); 6 | -------------------------------------------------------------------------------- /test/tasks/routing.test.js: -------------------------------------------------------------------------------- 1 | const { testJSON } = require('./utils'); 2 | 3 | test('404 error', async () => { 4 | await testJSON('/random-not-found', 404, 'API endpoint not found'); 5 | }); 6 | 7 | test('URI cases', async () => { 8 | await testJSON('/test-endpoint/ping', 200, 'pong'); 9 | }); 10 | 11 | test('Custom route', async () => { 12 | const hash = (~~(Math.random() * 16**4)).toString(16).padStart(4, '0'); 13 | await testJSON('/test-custom/h' + hash + '.json', 200, hash); 14 | }); 15 | 16 | test('Private handlers', async () => { 17 | await testJSON('/test-endpoint/_private', 404, 'API endpoint not found'); 18 | }); 19 | -------------------------------------------------------------------------------- /test/tasks/setup.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process'); 2 | const config = require('../config.json'); 3 | 4 | module.exports = () => { 5 | server = spawn('node', ['..'], { stdio: ['ignore', 'pipe', 'ignore'] }); 6 | return new Promise(done => { 7 | server.stdout.on('data', chunk => { 8 | if (~chunk.toString().indexOf('Server started on port ' + config.port + ' without SSL')) { 9 | global.SERVER = server; 10 | done(); 11 | } 12 | }); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /test/tasks/teardown.js: -------------------------------------------------------------------------------- 1 | module.exports = () => global.SERVER.kill(); 2 | -------------------------------------------------------------------------------- /test/tasks/utils.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | /** 3 | * Send request 4 | * @param {String} path 5 | * @param {'json' | 'urlencoded' | 'multipart'} type Payload type 6 | * @param {any} payload 7 | */ 8 | function request (path, type, payload) { 9 | const options = { 10 | method: payload ? 'POST' : 'GET' 11 | }; 12 | 13 | // if (payload) { 14 | // options.headers = { 15 | // 'Content-Type': 'appli' 16 | // }; 17 | // } 18 | let resolve; 19 | const req = http.request('http://localhost:6080' + path, options, res => { 20 | const body = []; 21 | res.on('data', chunk => body.push(chunk)); 22 | res.on('end', () => { 23 | resolve({ 24 | code: res.statusCode, 25 | message: Buffer.concat(body) 26 | }); 27 | }); 28 | }); 29 | 30 | if (payload) req.write(payload); 31 | req.end(); 32 | 33 | return new Promise(resolver => resolve = resolver); 34 | } 35 | 36 | function requestJSON (path, payload) { 37 | return request(path, 'json', payload).then(result => { 38 | result.message = JSON.parse(result.message); 39 | return result; 40 | }); 41 | } 42 | 43 | module.exports = { 44 | request, 45 | requestJSON, 46 | testJSON: async (path, code, message) => expect(await requestJSON(path)).toStrictEqual({ code, message }), 47 | testSocketEvent: (events, name, payload) => { 48 | return new Promise(resolve => { 49 | events.once(name, data => { 50 | expect(data).toStrictEqual(payload); 51 | resolve(); 52 | }) 53 | }); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /test/tasks/websockets.test.js: -------------------------------------------------------------------------------- 1 | const config = require('../config.json'); 2 | const WebSocket = require('ws'); 3 | const EventEmitter = require('events'); 4 | const { testSocketEvent } = require('./utils'); 5 | 6 | let events = new EventEmitter(); 7 | const connection = new WebSocket('ws://localhost:' + config.port + '/socket'); 8 | connection.on('message', data => { 9 | /** @type {[string]} */ 10 | const eventData = JSON.parse(data.toString()); 11 | events.emit(...eventData); 12 | }); 13 | 14 | const testEvent = (name, payload) => testSocketEvent(events, name, payload); 15 | 16 | test('Connection handler', () => { 17 | return testEvent('open-reply'); 18 | }); 19 | 20 | test('Simple event', () => { 21 | connection.send('["sum",2,3,7]'); 22 | return testEvent('sum', 12); 23 | }); 24 | 25 | test('Channel subscription', () => { 26 | connection.send('["sub_test"]'); 27 | return testEvent('sub_test', 'Channeling works!'); 28 | }); 29 | 30 | afterAll(() => { 31 | connection.close(); 32 | }); 33 | -------------------------------------------------------------------------------- /typescript/ValidationError.js: -------------------------------------------------------------------------------- 1 | const { HttpError } = require('../errors'); 2 | 3 | 4 | module.exports = class ValidationError extends HttpError { 5 | name = 'ValidationError'; 6 | 7 | /** 8 | * @param {string} message 9 | * @param {string=} field 10 | */ 11 | constructor (message, field) { 12 | super({ type: 'ValidationError', field, message }, 400); 13 | } 14 | 15 | toString () { 16 | return this.message.field 17 | ? `[ValidationError] ${this.message.field}: ${this.message.message}` 18 | : `[ValidationError] ${this.message.message}`; 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /typescript/build.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const tn = require('ts-node'); 3 | const fs = require('fs'); 4 | const Path = require('path'); 5 | 6 | const config = require('../config'); 7 | const Preprocessor = require('./preprocessor'); 8 | const tsconfig = require('./tsconfig_build.json'); 9 | const { getFlag, camelToKebab } = require('../utils'); 10 | const { iterControllers, loadPlugins, locateStartupScript } = require('../utils/loader'); 11 | 12 | 13 | const ROOT = process.cwd(); 14 | const BUILD_DIR = Path.join(ROOT, 'build'); 15 | const KEEP_UWS = getFlag('--keep-uws'); 16 | const PRODUCE_SOURCE_MAP = getFlag('--sourcemap'); 17 | const NO_MINIFY = getFlag('--no-minify'); 18 | 19 | loadPlugins(); 20 | const startupPath = locateStartupScript(); 21 | 22 | const chores = [ 23 | { 24 | ends: Path.join('dc-api-core', 'index.js'), 25 | replace: [ 26 | { 27 | match: /iterControllers\(\)/g, 28 | content: '[]' 29 | } 30 | ] 31 | }, 32 | { 33 | ends: Path.join('dc-api-core', 'utils', 'loader.js'), 34 | replace: [ 35 | { 36 | region: ':loadPlugins', 37 | content: (config.plugins || []) 38 | .map(plugin => ` 39 | try { 40 | require('${plugin}'); 41 | log.success(\`Plugin "${plugin}" loaded\`); 42 | } catch (error) { 43 | log.error(\`Cannot load plugin "${plugin}"\`, error); 44 | process.exit(-1); 45 | } 46 | `) 47 | .join('') 48 | }, 49 | { 50 | match: /iterPlugins\(\)/g, 51 | content: '[]' 52 | }, 53 | { 54 | region: ':startupLocate', 55 | content: `return ${!!startupPath};` 56 | }, 57 | { 58 | region: ':startupLoad', 59 | content: startupPath ? `let startup = require(${JSON.stringify(startupPath)});` : 'return;' 60 | } 61 | ] 62 | }, 63 | { 64 | ends: Path.join('dc-api-core', 'db.js'), 65 | replace: [ 66 | { 67 | match: /\/\/ #paste :modelsMap/, 68 | content: ` 69 | const modelsMap = {${ 70 | fs.readdirSync(Path.join(ROOT, 'models')) 71 | .map(dbConfigName => ` 72 | ['${dbConfigName}']: {${ 73 | fs.readdirSync(Path.join(ROOT, 'models', dbConfigName)) 74 | .map(modelName => `${modelName.slice(0, -3)}: require(${JSON.stringify(Path.join(ROOT, 'models', dbConfigName, modelName))})`) 75 | .join(',') 76 | }} 77 | `) 78 | .join(',') 79 | }}; 80 | ` 81 | }, 82 | { 83 | region: ':iterModels', 84 | content: ` 85 | for (const name in modelsMap[dbConfigName]) { 86 | const schema = modelsMap[dbConfigName][name]; 87 | yield { name, schema }; 88 | } 89 | ` 90 | } 91 | ] 92 | } 93 | ]; 94 | 95 | Preprocessor.attachHooks(); 96 | fs.rmSync(BUILD_DIR, { recursive: true, force: true }); 97 | 98 | const compiler = tn.create({ 99 | compilerOptions: tsconfig.compilerOptions, 100 | ignore: ["(?:^|/)node_modules/", "\\.js(on)?$"], 101 | skipProject: true, 102 | transformers: { 103 | before: [ 104 | ctx => sourceFile => new Preprocessor(ctx, sourceFile).run() 105 | ] 106 | } 107 | }); 108 | 109 | function processFile (path) { 110 | const relativePath = Path.relative(ROOT, path); 111 | console.log('Transpiling', relativePath); 112 | 113 | let transpiled = compiler.compile(fs.readFileSync(path, 'utf8'), path); 114 | transpiled = transpiled.replace(/^global\.fakeUsage.+$/m, ''); 115 | 116 | return transpiled; 117 | } 118 | 119 | /** 120 | * @param {esbuild.OnLoadArgs} args 121 | * @returns {esbuild.OnLoadResult | null} 122 | */ 123 | function loadFile ({ path }) { 124 | if (path.endsWith('.ts')) { 125 | let relativePath = Path.relative(ROOT, path); 126 | // yarn link fix 127 | if (relativePath.startsWith('..')) { 128 | relativePath = relativePath.replace(/^(\.\.([\\\/]))+/, 'node_modules$2'); 129 | } 130 | 131 | return { contents: processFile(path), loader: 'js' }; 132 | } else if (!KEEP_UWS && path.endsWith('uws.js')) { 133 | let contents = fs.readFileSync(path, 'utf8'); 134 | 135 | const currentBin = `./uws_${process.platform}_${process.arch}_${process.versions.modules}.node`; 136 | contents = contents.replace(/require\('\.\/uws_.+?\.node'\)/, `require('${currentBin}')`); 137 | 138 | return { contents }; 139 | } else if (path.includes(`dc-api-core${Path.sep}typescript`)) { 140 | return { contents: '', loader: 'empty' }; 141 | } else { 142 | for (const chore of chores) { 143 | if (path.endsWith(chore.ends)) { 144 | let contents = fs.readFileSync(path, 'utf8'); 145 | 146 | for (const replacement of chore.replace) { 147 | if (replacement.match) { 148 | contents = contents.replace(replacement.match, replacement.content); 149 | } else if (replacement.region) { 150 | const startIndex = contents.indexOf(`// #region ${replacement.region}`); 151 | const endIndex = contents.indexOf('// #endregion', startIndex); 152 | const cutIndex = contents.indexOf('\n', endIndex); 153 | 154 | contents = contents.slice(0, startIndex) + replacement.content + contents.slice(cutIndex); 155 | } 156 | } 157 | 158 | return { contents }; 159 | } 160 | } 161 | } 162 | 163 | return null; 164 | } 165 | 166 | const entrypoint = [ 167 | "import { camelToKebab } from 'dc-api-core/utils'", 168 | "import { registerController } from 'dc-api-core/utils/loader'", 169 | "import { registerSocketController } from 'dc-api-core/contexts/websocket'", 170 | "import { registerHttpController } from 'dc-api-core/contexts/http'", 171 | "" 172 | ]; 173 | 174 | for (const { name, path } of iterControllers()) { 175 | const controller = fs.readFileSync(path, 'utf-8'); 176 | const match = new RegExp(`class\\s+${name}\\s.*?extends\\s+(Http|Socket)Controller`).exec(controller); 177 | const register = `register${match[1]}Controller`; 178 | 179 | entrypoint.push(`import { ${name} } from './${Path.relative(ROOT, path).replace(/\\/g, '/')}'`); 180 | entrypoint.push(`${register}('/${camelToKebab(name)}', registerController('${name}', ${name}));`); 181 | } 182 | 183 | esbuild.build({ 184 | bundle: true, 185 | platform: 'node', 186 | target: 'node16', 187 | loader: { '.node': 'copy' }, 188 | outfile: Path.join(BUILD_DIR, 'app.js'), 189 | legalComments: 'none', 190 | external: ['ts-node', 'typescript'], 191 | sourcemap: PRODUCE_SOURCE_MAP ? 'linked' : false, 192 | keepNames: true, 193 | minify: !NO_MINIFY, 194 | stdin: { 195 | contents: entrypoint.join('\n'), 196 | resolveDir: ROOT, 197 | sourcefile: '@@entrypoint.js' 198 | }, 199 | plugins: [{ 200 | name: 'dc-api-core', 201 | setup (build) { 202 | build.onLoad({ filter: /./ }, loadFile); 203 | } 204 | }] 205 | }).then(() => { 206 | console.log('Generating assets...'); 207 | 208 | const cfg = JSON.parse(fs.readFileSync(Path.join(ROOT, 'config.json'), 'utf-8')); 209 | delete cfg.dev; 210 | delete cfg.typescript; 211 | 212 | fs.writeFileSync(Path.join(BUILD_DIR, 'config.json'), JSON.stringify(cfg, null, '\t'), 'utf-8'); 213 | 214 | const pkg = JSON.parse(fs.readFileSync(Path.join(ROOT, 'package.json'), 'utf-8')); 215 | delete pkg.dependencies; 216 | delete pkg.devDependencies; 217 | delete pkg.peerDependencies; 218 | 219 | pkg.scripts = { 220 | start: 'node app.js' 221 | }; 222 | 223 | fs.writeFileSync(Path.join(BUILD_DIR, 'package.json'), JSON.stringify(pkg, null, '\t'), 'utf-8'); 224 | 225 | console.log(); 226 | console.log('Bundle is ready to use in `build` directory.'); 227 | console.log('You can start it with `yarn start`, `npm start` or just `node app.js`.'); 228 | console.log(); 229 | }); 230 | -------------------------------------------------------------------------------- /typescript/db-typings.js: -------------------------------------------------------------------------------- 1 | const Path = require('path'); 2 | const { writeFileSync } = require('fs'); 3 | 4 | const { findPluginDirectory, pluginLoadEnd } = require('../utils/loader'); 5 | const { getArg } = require('../utils'); 6 | 7 | 8 | module.exports = class DbTypesGenerator { 9 | constructor (connector) { 10 | if (!connector.getModelInterface || !connector.getModelImports) { 11 | this.shouldUpdate = false; 12 | } else { 13 | const restartReason = getArg('--restart-reason'); 14 | // Update file if running in development mode and restart was triggered by system reason or a model change 15 | this.shouldUpdate = restartReason && (restartReason[0] == '@' || restartReason.search(/(\/|\\)models\1/) != -1); 16 | } 17 | 18 | this.connector = connector; 19 | this.result = []; 20 | this.types = []; 21 | } 22 | 23 | add (schemaName, model) { 24 | if (!this.shouldUpdate) return; 25 | 26 | const { code, type } = this.connector.getModelInterface(schemaName, model); 27 | this.result.push(code); 28 | this.result.push(''); 29 | 30 | this.types.push(`${schemaName}: ${type}`); 31 | } 32 | 33 | async write (configName) { 34 | if (!this.shouldUpdate) return; 35 | 36 | this.result.unshift(''); 37 | this.result.unshift(this.connector.getModelImports()); 38 | this.result.unshift(`// Generated at ${new Date().toLocaleString('en-GB')}`); 39 | 40 | let connectionName = ''; 41 | if (configName.includes('.')) { 42 | connectionName = `'${configName.split('.')[1]}'`; 43 | } 44 | 45 | this.result.push(`import { connect } from '.'`); 46 | this.result.push(`const _db = connect(${connectionName});`); 47 | this.result.push(`const db: typeof _db & { ${this.types.join('; ')} } = _db;`); 48 | this.result.push(`export default db`); 49 | this.result.push(''); 50 | 51 | await pluginLoadEnd; 52 | const basePath = findPluginDirectory(this.connector._self); 53 | if (!basePath) return; 54 | 55 | const typingPath = Path.join(basePath, configName + '.ts'); 56 | writeFileSync(typingPath, this.result.join('\n')); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /typescript/index.js: -------------------------------------------------------------------------------- 1 | const tn = require('ts-node'); 2 | 3 | const Preprocessor = require('./preprocessor'); 4 | const config = require('../config'); 5 | const tsconfig = require('./tsconfig_run.json'); 6 | 7 | 8 | Preprocessor.attachHooks(); 9 | 10 | tn.register({ 11 | compilerOptions: tsconfig.compilerOptions, 12 | ignore: ["\\.js(on)?$"], 13 | skipProject: true, 14 | // Skip type checking in production 15 | transpileOnly: !config.isDev, 16 | 17 | transformers: { 18 | before: [ 19 | ctx => sourceFile => new Preprocessor(ctx, sourceFile).run() 20 | ] 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /typescript/preprocessor.js: -------------------------------------------------------------------------------- 1 | const ts = require('typescript'); 2 | const fs = require('fs'); 3 | 4 | 5 | const VALIDATOR_USAGE_REGEX = /(Data|Query)<(.+?)>/g; 6 | 7 | class Preprocessor { 8 | static attachHooks () { 9 | const __readFileSync = fs.readFileSync; 10 | fs.readFileSync = function (path, encoding) { 11 | let result = __readFileSync.call(this, path, encoding); 12 | if (path.includes('controllers')) { 13 | const usages = []; 14 | 15 | let match; 16 | while (match = VALIDATOR_USAGE_REGEX.exec(result)) { 17 | usages.push(match[2]); 18 | } 19 | 20 | result += `\n\n( global).fakeUsage?.(${usages.join(', ')});`; 21 | } 22 | 23 | return result; 24 | } 25 | } 26 | 27 | /** 28 | * @param {ts.TransformationContext} ctx 29 | * @param {ts.SourceFile} sourceFile 30 | */ 31 | constructor (ctx, sourceFile) { 32 | this.ctx = ctx; 33 | this.sourceFile = sourceFile; 34 | 35 | this.httpClassVisit = this.httpClassVisit.bind(this); 36 | this.httpMemberVisit = this.httpMemberVisit.bind(this); 37 | } 38 | 39 | /** @returns {ts.SourceFile} */ 40 | run () { 41 | if (!this.sourceFile.fileName.includes('controllers')) { 42 | return this.sourceFile; 43 | } 44 | 45 | // @ts-ignore 46 | return ts.visitNode(this.sourceFile, rootNode => { 47 | return ts.visitEachChild(rootNode, this.httpClassVisit, this.ctx); 48 | }); 49 | } 50 | 51 | static printNode (node) { 52 | const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); 53 | printer.printFile(node); 54 | return node; 55 | } 56 | 57 | httpClassVisit (node) { 58 | if (!ts.isClassDeclaration(node) || !node.heritageClauses) { 59 | return node; 60 | } 61 | 62 | for (const clause of node.heritageClauses) { 63 | for (const type of clause.types) { 64 | const extendedType = type.getText(this.sourceFile); 65 | if (extendedType == 'HttpController') { 66 | return ts.visitEachChild(node, this.httpMemberVisit, this.ctx); 67 | } 68 | } 69 | } 70 | 71 | return node; 72 | } 73 | 74 | httpMemberVisit (node) { 75 | if (ts.isMethodDeclaration(node)) { 76 | const validatedStatements = []; 77 | for (const param of node.parameters) { 78 | const statement = this.createValidatedVariable(param); 79 | if (statement) { 80 | validatedStatements.push(statement); 81 | } 82 | } 83 | 84 | if (validatedStatements.length != 0) { 85 | // async is required for validation 86 | let { modifiers } = node; 87 | if (!modifiers) { 88 | modifiers = this.ctx.factory.createNodeArray([ 89 | this.ctx.factory.createModifier(ts.SyntaxKind.AsyncKeyword) 90 | ]); 91 | } else if (!modifiers.some(m => m.kind == ts.SyntaxKind.AsyncKeyword)) { 92 | modifiers = this.ctx.factory.createNodeArray([ 93 | this.ctx.factory.createModifier(ts.SyntaxKind.AsyncKeyword), 94 | ...modifiers 95 | ]); 96 | } 97 | 98 | let { body } = node; 99 | if (!body) return node; 100 | 101 | body = this.ctx.factory.updateBlock(body, [ 102 | ...validatedStatements, 103 | ...body.statements 104 | ]); 105 | 106 | return this.ctx.factory.updateMethodDeclaration( 107 | node, 108 | modifiers, 109 | node.asteriskToken, 110 | node.name, 111 | node.questionToken, 112 | node.typeParameters, 113 | [], 114 | node.type, 115 | body 116 | ); 117 | } 118 | } 119 | 120 | return node; 121 | } 122 | 123 | /** 124 | * @param {ts.ParameterDeclaration} param 125 | * @returns 126 | */ 127 | createValidatedVariable (param) { 128 | if (!param.type || !ts.isTypeReferenceNode(param.type) || !param.type.typeArguments) { 129 | return; 130 | } 131 | 132 | // todo? ensure type source 133 | const paramName = param.name.getText(this.sourceFile); 134 | const kind = param.type.typeName.getText(this.sourceFile); 135 | 136 | // typeArgument: TypeReference { Identifier } 137 | const typeId = param.type.typeArguments[0].getChildAt(0); 138 | if (!ts.isIdentifier(typeId)) { 139 | return; 140 | } 141 | 142 | // var = await this.__validate(); 143 | return this.ctx.factory.createVariableStatement( 144 | undefined, 145 | [this.ctx.factory.createVariableDeclaration( 146 | paramName, 147 | undefined, 148 | undefined, 149 | this.ctx.factory.createAwaitExpression( 150 | this.ctx.factory.createCallExpression( 151 | this.ctx.factory.createPropertyAccessExpression( 152 | this.ctx.factory.createThis(), 153 | this.ctx.factory.createIdentifier('__validate' + kind) 154 | ), 155 | undefined, 156 | [typeId] 157 | ) 158 | ) 159 | )] 160 | ); 161 | } 162 | } 163 | 164 | module.exports = Preprocessor; 165 | -------------------------------------------------------------------------------- /typescript/tsconfig_build.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "sourceMap": true, 6 | "removeComments": true, 7 | "skipLibCheck": true, 8 | "checkJs": false, 9 | "allowJs": true, 10 | "noImplicitAny": false, 11 | "lib": [ 12 | "esnext", 13 | "dom" 14 | ], 15 | "types": [ 16 | "node" 17 | ] 18 | } 19 | } -------------------------------------------------------------------------------- /typescript/tsconfig_run.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "skipLibCheck": true, 7 | "checkJs": false, 8 | "allowJs": true, 9 | "noImplicitAny": false, 10 | "lib": [ 11 | "esnext", 12 | "dom" 13 | ], 14 | "types": [ 15 | "node" 16 | ] 17 | } 18 | } -------------------------------------------------------------------------------- /typescript/validator.ts: -------------------------------------------------------------------------------- 1 | import ValidationError from './ValidationError' 2 | import { AwaitObject } from '..' 3 | 4 | 5 | export type ValidatedCtor = new (raw: Record) => T; 6 | 7 | export class Validated { 8 | /** Awaited values only of getters and field **described above**. */ 9 | ctx: AwaitObject> = {}; 10 | 11 | constructor (public raw: Record) { 12 | } 13 | 14 | async validate (): Promise { 15 | for (const field of Object.getOwnPropertyNames(this)) { 16 | if (field == 'raw' || field == 'ctx') { 17 | continue; 18 | } 19 | 20 | ( this.ctx)[field] = await ( this)[field]; 21 | } 22 | 23 | for (const member of Object.getOwnPropertyNames(this.constructor.prototype)) { 24 | if (member == 'constructor') { 25 | continue; 26 | } 27 | 28 | ( this.ctx)[member] = await ( this)[member]; 29 | } 30 | 31 | return this.ctx; 32 | } 33 | } 34 | 35 | export function isNone (value: null | undefined) { 36 | return value === null || value === undefined; 37 | } 38 | 39 | 40 | type IGet = (obj: T) => R; 41 | type CommonDescriptor = ClassGetterDecoratorContext | ClassFieldDecoratorContext; 42 | 43 | function getGetter (target: any, descriptor: CommonDescriptor): IGet { 44 | if (descriptor.kind == 'field') { 45 | return obj => obj.raw[ descriptor.name]; 46 | } else { 47 | return obj => target.call(obj); 48 | } 49 | } 50 | 51 | /** 52 | * Throws an error with the specified message if the field is not provided 53 | * @param field If specified, the field with the specified name will be checked 54 | */ 55 | export function whenEmpty (message: string, field?: string) { 56 | return (target: any, descriptor: CommonDescriptor) => { 57 | const rawKey = field || descriptor.name; 58 | const getValue = getGetter(target, descriptor); 59 | 60 | return function (this: Validated) { 61 | if (rawKey in this.raw) { 62 | return getValue(this); 63 | } else { 64 | throw new ValidationError(message, rawKey); 65 | } 66 | } 67 | }; 68 | } 69 | 70 | type GetError = string | ((error: any) => string); 71 | 72 | /** 73 | * If an error is caught, it throws a ValidationError with the specified message. 74 | * @param {GetError} message The error message or a function that returns the error message. 75 | * @param {string} field The name of the field to check for existence. 76 | */ 77 | export function whenError (message?: GetError, field?: string) { 78 | return (target: any, descriptor: ClassGetterDecoratorContext | ClassFieldDecoratorContext) => { 79 | const rawKey = field || descriptor.name; 80 | const getValue = getGetter(target, descriptor); 81 | 82 | return function (this: Validated) { 83 | try { 84 | return getValue(this); 85 | } catch (error: any) { 86 | let errorMessage: string; 87 | switch (typeof message) { 88 | case 'string': 89 | errorMessage = message; 90 | break; 91 | case 'function': 92 | errorMessage = message(error); 93 | break; 94 | case 'undefined': 95 | errorMessage = error.message || error.toString(); 96 | break; 97 | } 98 | 99 | throw new ValidationError(errorMessage, rawKey); 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /utils/cors.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | 3 | const info = { 4 | methods: 'GET,POST', 5 | headers: { 6 | allow: ['session', 'content-type'], 7 | expose: ['session', 'location'] 8 | }, 9 | ttl: '86400' 10 | }; 11 | 12 | if (config.cors) { 13 | info.origin = config.cors.origin; 14 | 15 | if (config.cors.headers?.allow) { 16 | for (const header of config.cors.headers.allow) { 17 | info.headers.allow.push(header.toLowerCase()); 18 | } 19 | } 20 | 21 | if (config.cors.headers?.expose) { 22 | for (const header of config.cors.headers.expose) { 23 | info.headers.expose.push(header.toLowerCase()); 24 | } 25 | } 26 | 27 | if (config.cors.ttl) { 28 | info.ttl = config.cors.ttl.toString(); 29 | } 30 | } 31 | 32 | const allowedHeaders = info.headers.allow.join(','); 33 | const exposedHeaders = info.headers.expose.join(','); 34 | 35 | module.exports = { 36 | preflight (req, res) { 37 | res.writeHeader('Access-Control-Allow-Methods', info.methods); 38 | res.writeHeader('Access-Control-Allow-Headers', allowedHeaders); 39 | res.writeHeader('Access-Control-Max-Age', info.ttl); 40 | res.writeHeader('Access-Control-Allow-Origin', info.origin || req.getHeader('origin')); 41 | }, 42 | normal (req, res) { 43 | res.writeHeader('Access-Control-Allow-Origin', info.origin || req ? req.headers.origin : '*'); 44 | res.writeHeader('Access-Control-Expose-Headers', exposedHeaders); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /utils/http.js: -------------------------------------------------------------------------------- 1 | const { emitError } = require('../errors'); 2 | const { parseQueryString, HTTP_TYPES } = require('./parsers'); 3 | const CORS = require('./cors'); 4 | 5 | /** 6 | * Parses `req` data and makes it stored in `out` 7 | * @argument {import('uWebSockets.js').HttpRequest} req 8 | * @argument {Object} out 9 | */ 10 | function parseRequest (req, out) { 11 | out.query = parseQueryString(req.getQuery()); 12 | out.headers = {}; 13 | req.forEach((name, value) => out.headers[name] = value); 14 | } 15 | 16 | /** 17 | * Parses request and setting common fields in response 18 | * @argument {import('uWebSockets.js').HttpRequest} req 19 | * @argument {import('uWebSockets.js').HttpResponse} res 20 | */ 21 | function prepareHttpConnection (req, res) { 22 | res.aborted = false; 23 | res.onAborted(() => res.aborted = true); 24 | req.path = req.getUrl(); 25 | parseRequest(req, req); 26 | 27 | res.headers = {}; 28 | } 29 | 30 | async function fetchBody (req, res) { 31 | let contentType = req.headers['content-type']; 32 | if (!contentType) { 33 | return abortRequest(res, 400, 'Content-Type header is required'); 34 | } 35 | 36 | const delimIndex = contentType.indexOf(';'); 37 | if (delimIndex != -1) contentType = contentType.slice(0, delimIndex); 38 | contentType = contentType.toLowerCase(); 39 | 40 | const bodySize = parseInt(req.headers['content-length']); 41 | if (Number.isNaN(bodySize)) { 42 | return abortRequest(res, 400, 'Content-Length header is invalid'); 43 | } 44 | 45 | const parseBody = HTTP_TYPES[contentType]; 46 | if (parseBody) { 47 | const rawBody = await getBody(res, bodySize); 48 | const result = parseBody(req, rawBody); 49 | if (result.error) { 50 | emitError({ 51 | isSystem: true, 52 | type: 'RequestError', 53 | code: 400, 54 | url: req.path, 55 | message: result.message, 56 | error: result.error, 57 | body: rawBody 58 | }); 59 | 60 | return abortRequest(res, 400, result.message); 61 | } else { 62 | req.body = result.body; 63 | } 64 | } else { 65 | emitError({ 66 | isSystem: true, 67 | type: 'RequestError', 68 | code: 400, 69 | url: req.path, 70 | message: `Content-Type "${contentType}" not supported` 71 | }); 72 | 73 | return abortRequest(res, 400, 'Content-Type not supported'); 74 | } 75 | } 76 | 77 | function abortRequest (res, code, message) { 78 | if (res.aborted) return; 79 | res.cork(() => { 80 | res.writeStatus(getResponseStatus(code)); 81 | res.writeHeader('Content-Type', 'text/plain'); 82 | CORS.normal(null, res); 83 | res.end(message); 84 | }); 85 | 86 | res.aborted = true; 87 | } 88 | 89 | /** 90 | * Reads body buffer from request (response in uWS) 91 | * @param {import('uWebSockets.js').HttpResponse} res 92 | * @returns {Promise} 93 | */ 94 | function getBody (res, size) { 95 | return new Promise(resolve => { 96 | const result = Buffer.allocUnsafe(size); 97 | let offset = 0; 98 | res.onData((chunk, isLast) => { 99 | chunk = new Uint8Array(chunk); 100 | for (let i = 0; i < chunk.byteLength; i++) { 101 | result[offset + i] = chunk[i]; 102 | } 103 | 104 | offset += chunk.byteLength; 105 | 106 | if (isLast) resolve(result); 107 | }); 108 | }); 109 | } 110 | 111 | /** 112 | * Returns HTTP response status line for passed numeric code 113 | * @param {Number} code HTTP response code 114 | * @returns {String} 115 | */ 116 | function getResponseStatus (code) { 117 | switch (code) { 118 | case 200: return '200 OK'; 119 | case 201: return '201 Created'; 120 | case 202: return '202 Accepted'; 121 | case 203: return '203 Non-Authoritative Information'; 122 | case 204: return '204 No Content'; 123 | case 205: return '205 Reset Content'; 124 | case 206: return '206 Partial Content'; 125 | 126 | case 301: return '301 Moved Permanently'; 127 | case 302: return '302 Found'; 128 | case 303: return '303 See Other'; 129 | case 304: return '304 Not Modified'; 130 | case 307: return '307 Temporary Redirect'; 131 | 132 | case 400: return '400 Bad Request'; 133 | case 401: return '401 Unauthorized'; 134 | case 403: return '403 Forbidden'; 135 | case 404: return '404 Not Found'; 136 | case 405: return '405 Method Not Allowed'; 137 | case 406: return '406 Not Acceptable'; 138 | case 408: return '408 Request Timeout'; 139 | case 409: return '409 Conflict'; 140 | case 410: return '410 Gone'; 141 | case 415: return '415 Unsupported Media Type'; 142 | 143 | case 500: return '500 Internal Server Error'; 144 | case 501: return '501 Not Implemented'; 145 | default: return code.toString(); 146 | } 147 | } 148 | 149 | module.exports = { 150 | parseRequest, 151 | fetchBody, 152 | prepareHttpConnection, 153 | abortRequest, 154 | getResponseStatus 155 | }; 156 | -------------------------------------------------------------------------------- /utils/index.d.ts: -------------------------------------------------------------------------------- 1 | export function camelToKebab (value: string): string; 2 | export function mergeObj (target: any, source: any): void; 3 | export function getArg (name: string): string | null; 4 | export function getFlag (name: string): boolean; 5 | export function sleep (duration: number, value?: T): Promise; 6 | -------------------------------------------------------------------------------- /utils/index.js: -------------------------------------------------------------------------------- 1 | const UPPER_CHARS = 'QWERTYUIOPLKJHGFDSAZXCVBNM'; 2 | const LOWER_CHARS = 'qwertyuioplkjhgfdsazxcvbnm'; 3 | 4 | function camelToKebab (value) { 5 | let isLastUpper = false; 6 | let result = ''; 7 | 8 | for (const char of value) { 9 | const upperIndex = UPPER_CHARS.indexOf(char); 10 | if (upperIndex != -1) { 11 | if (!isLastUpper) result += '-'; 12 | result += LOWER_CHARS[upperIndex]; 13 | isLastUpper = true; 14 | } else { 15 | result += char; 16 | if (LOWER_CHARS.includes(char)) { 17 | isLastUpper = false; 18 | } 19 | } 20 | } 21 | 22 | return result[0] == '-' ? result.slice(1) : result; 23 | } 24 | 25 | function mergeObj (target, source) { 26 | Object.keys(source).forEach(key => { 27 | if (typeof source[key] === 'object') { 28 | if (!target[key]) target[key] = source[key] instanceof Array ? [] : {}; 29 | mergeObj(target[key], source[key]); 30 | } else { 31 | target[key] = source[key]; 32 | } 33 | }); 34 | } 35 | 36 | function getArg (name) { 37 | const i = process.argv.indexOf(name); 38 | return i == -1 ? null: process.argv[i + 1]; 39 | } 40 | 41 | function getFlag (name) { 42 | return process.argv.includes(name); 43 | } 44 | 45 | function sleep (duration, value) { 46 | return new Promise(resolve => setTimeout(resolve, duration, value)); 47 | } 48 | 49 | module.exports = { camelToKebab, mergeObj, getArg, getFlag, sleep }; 50 | -------------------------------------------------------------------------------- /utils/loader.js: -------------------------------------------------------------------------------- 1 | const { existsSync, readdirSync } = require('fs'); 2 | const Path = require('path'); 3 | const log = require('../log'); 4 | const config = require('../config'); 5 | 6 | const ROOT = process.cwd(); 7 | 8 | const controllers = {}; 9 | /** 10 | * @param {string} name Controller name without extension 11 | * @returns {object} Cached controller instance 12 | */ 13 | exports.getController = name => { 14 | if (controllers && name in controllers) { 15 | return controllers[name]; 16 | } 17 | 18 | let path = Path.join(ROOT, 'controllers', name + '.js'); 19 | let exists = existsSync(path); 20 | 21 | if (!exists && config.typescript) { 22 | path = Path.join(ROOT, 'controllers', name + '.ts'); 23 | exists = existsSync(path); 24 | } 25 | 26 | if (!exists) { 27 | throw `Controller "${name}" at "${Path.dirname(path)}" not found`; 28 | } 29 | 30 | return module.exports.registerController(name, require(path)); 31 | } 32 | 33 | exports.iterControllers = function* () { 34 | const basePath = Path.join(ROOT, 'controllers'); 35 | if (!existsSync(basePath)) { 36 | log.warn('No "controllers" directory'); 37 | return; 38 | } 39 | 40 | for (let filename of readdirSync(basePath)) { 41 | if (filename.endsWith('.js') || config.typescript && filename.endsWith('.ts')) { 42 | yield { 43 | name: filename.slice(0, -3), 44 | path: Path.join(basePath, filename), 45 | }; 46 | } 47 | } 48 | } 49 | 50 | exports.registerController = (name, ControllerClass) => { 51 | if (typeof ControllerClass === 'object') { 52 | if (ControllerClass.default) { 53 | // export default class ... 54 | ControllerClass = ControllerClass.default; 55 | } else if (name in ControllerClass) { 56 | // export class ... 57 | ControllerClass = ControllerClass[name]; 58 | } 59 | } 60 | 61 | // module.exports = class ... 62 | if (typeof ControllerClass != 'function') { 63 | throw new Error(`Exported value from ${name} controller isn't a class`); 64 | } 65 | 66 | const controller = new ControllerClass(); 67 | controller._name = name; 68 | 69 | controllers[name] = controller; 70 | return controller; 71 | } 72 | 73 | exports.getActionCaller = (controller, actionFn) => { 74 | return async ctx => { 75 | if (controller.onLoad) { 76 | await controller.onLoad.call(ctx); 77 | if (ctx._res.aborted) return; 78 | } 79 | 80 | ctx.controller = controller; 81 | return await actionFn.call(ctx); 82 | }; 83 | } 84 | 85 | function* iterPlugins () { 86 | if (!config.plugins) return; 87 | 88 | for (const plugin of config.plugins) { 89 | if (plugin.startsWith('local:')) { 90 | yield { plugin, path: require.resolve(Path.join(ROOT, plugin.slice(6))) }; 91 | } else { 92 | yield { plugin, path: require.resolve(plugin) }; 93 | } 94 | } 95 | } 96 | 97 | let onPluginsLoaded; 98 | exports.pluginLoadEnd = new Promise(resolve => onPluginsLoaded = resolve); 99 | 100 | exports.loadPlugins = () => { 101 | // #region :loadPlugins 102 | for (const { plugin, path } of iterPlugins()) { 103 | try { 104 | require(path); 105 | log.success(`Plugin "${plugin}" loaded`); 106 | } catch (error) { 107 | log.error(`Cannot load plugin "${plugin}"`, error); 108 | process.exit(-1); 109 | } 110 | } 111 | // #endregion 112 | 113 | onPluginsLoaded(); 114 | } 115 | 116 | exports.findPluginDirectory = mainEntry => { 117 | for (const { path } of iterPlugins()) { 118 | if (require(path) == mainEntry) { 119 | return Path.dirname(path); 120 | } 121 | } 122 | } 123 | 124 | exports.locateStartupScript = () => { 125 | // #region :startupLocate 126 | let path = Path.join(ROOT, 'startup.js'); 127 | if (existsSync(path)) { 128 | return path; 129 | } 130 | 131 | if (config.typescript) { 132 | path = Path.join(ROOT, 'startup.ts'); 133 | if (existsSync(path)) { 134 | return path; 135 | } 136 | } 137 | 138 | return null; 139 | // #endregion 140 | }; 141 | 142 | exports.executeStartup = async () => { 143 | const path = exports.locateStartupScript(); 144 | if (!path) { 145 | return; 146 | } 147 | 148 | log.info('Running startup script'); 149 | try { 150 | // #region :startupLoad 151 | let startup = require(path); 152 | // #endregion 153 | 154 | if (typeof startup == 'function') { 155 | startup = startup(); 156 | } 157 | 158 | if (startup instanceof Promise) { 159 | await startup; 160 | } 161 | } catch (error) { 162 | log.error('Startup script error', error); 163 | process.exit(-1); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /utils/parsers.js: -------------------------------------------------------------------------------- 1 | const { getParts } = require('uWebSockets.js'); 2 | 3 | function parseQueryString (query) { 4 | const result = {}; 5 | for (const [key, value] of new URLSearchParams(query)) { 6 | if (value === '' || value == 'true' || value == 'yes') { 7 | result[key] = true; 8 | } else if (value == 'false' || value == 'no') { 9 | result[key] = false; 10 | } else { 11 | result[key] = value; 12 | } 13 | } 14 | 15 | return result; 16 | } 17 | 18 | function parseMultipart (body, type) { 19 | const result = {}; 20 | for (const part of getParts(body, type)) { 21 | result[part.name] = { 22 | name: part.filename || part.name, 23 | type: part.type, 24 | content: Buffer.from(part.data) 25 | }; 26 | } 27 | 28 | if (result.json) { 29 | const parsed = result.json.content.toString(); 30 | Object.assign(result, JSON.parse(parsed)); 31 | delete result.json; 32 | } 33 | 34 | return result; 35 | } 36 | 37 | /** 38 | * @param {number} part 39 | */ 40 | function parseIPv6Part (part) { 41 | let result = part.toString(16); 42 | if (result[1]) return result; 43 | else return '0' + result; 44 | } 45 | 46 | /** @typedef {(req: import('../contexts/http').Request, body: Buffer) => { body: any, error?: undefined } | { error: Error, message: string }} HttpBodyParser */ 47 | /** @type {{ [type: string]: HttpBodyParser }} */ 48 | const HTTP_TYPES = { 49 | 'application/json' (_req, body) { 50 | try { 51 | return { body: JSON.parse(body.toString()) }; 52 | } catch (error) { 53 | return { error, message: 'Incorrect JSON body: ' + error.message }; 54 | } 55 | }, 56 | 'application/x-www-form-urlencoded' (_req, body) { 57 | return { body: parseQueryString(body.toString()) }; 58 | }, 59 | 'multipart/form-data' (req, body) { 60 | try { 61 | // Only JSON error can be thrown from this parser 62 | return { body: parseMultipart(body, req.headers['content-type']) }; 63 | } catch (error) { 64 | return { error, message: 'Incorrect `json` field: ' + error.message }; 65 | } 66 | } 67 | }; 68 | 69 | /** 70 | * @param {string} type Content-Type header value 71 | * @param {HttpBodyParser} parser 72 | */ 73 | function registerContentType (type, parser) { 74 | HTTP_TYPES[type] = parser; 75 | } 76 | 77 | module.exports = { 78 | HTTP_TYPES, registerContentType, 79 | parseQueryString, parseMultipart, parseIPv6Part 80 | }; 81 | --------------------------------------------------------------------------------