├── .applications ├── .eslintrc.json ├── .gitattributes ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── lib ├── common.d.ts ├── common.js ├── db.d.ts ├── db.js └── logger.js ├── log └── .gitkeep ├── main.js ├── package-lock.json ├── package.json ├── src ├── loader.js ├── server.js └── transport.js └── tsconfig.json /.applications: -------------------------------------------------------------------------------- 1 | ../NodeJS-Application 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": "latest" 10 | }, 11 | "globals": { 12 | "BigInt": true 13 | }, 14 | "rules": { 15 | "indent": [ 16 | "error", 17 | 2 18 | ], 19 | "linebreak-style": [ 20 | "error", 21 | "unix" 22 | ], 23 | "quotes": [ 24 | "error", 25 | "single" 26 | ], 27 | "semi": [ 28 | "error", 29 | "always" 30 | ], 31 | "no-loop-func": [ 32 | "error" 33 | ], 34 | "block-spacing": [ 35 | "error", 36 | "always" 37 | ], 38 | "camelcase": [ 39 | "error" 40 | ], 41 | "eqeqeq": [ 42 | "error", 43 | "always" 44 | ], 45 | "strict": [ 46 | "error", 47 | "global" 48 | ], 49 | "brace-style": [ 50 | "error", 51 | "1tbs", 52 | { 53 | "allowSingleLine": true 54 | } 55 | ], 56 | "comma-style": [ 57 | "error", 58 | "last" 59 | ], 60 | "comma-spacing": [ 61 | "error", 62 | { 63 | "before": false, 64 | "after": true 65 | } 66 | ], 67 | "eol-last": [ 68 | "error" 69 | ], 70 | "func-call-spacing": [ 71 | "error", 72 | "never" 73 | ], 74 | "key-spacing": [ 75 | "error", 76 | { 77 | "beforeColon": false, 78 | "afterColon": true, 79 | "mode": "minimum" 80 | } 81 | ], 82 | "keyword-spacing": [ 83 | "error", 84 | { 85 | "before": true, 86 | "after": true, 87 | "overrides": { 88 | "function": { 89 | "after": false 90 | } 91 | } 92 | } 93 | ], 94 | "max-len": [ 95 | "error", 96 | { 97 | "code": 80, 98 | "ignoreUrls": true 99 | } 100 | ], 101 | "max-nested-callbacks": [ 102 | "error", 103 | { 104 | "max": 7 105 | } 106 | ], 107 | "new-cap": [ 108 | "error", 109 | { 110 | "newIsCap": true, 111 | "capIsNew": false, 112 | "properties": true 113 | } 114 | ], 115 | "new-parens": [ 116 | "error" 117 | ], 118 | "no-lonely-if": [ 119 | "error" 120 | ], 121 | "no-trailing-spaces": [ 122 | "error" 123 | ], 124 | "no-unneeded-ternary": [ 125 | "error" 126 | ], 127 | "no-whitespace-before-property": [ 128 | "error" 129 | ], 130 | "object-curly-spacing": [ 131 | "error", 132 | "always" 133 | ], 134 | "operator-assignment": [ 135 | "error", 136 | "always" 137 | ], 138 | "operator-linebreak": [ 139 | "error", 140 | "after" 141 | ], 142 | "semi-spacing": [ 143 | "error", 144 | { 145 | "before": false, 146 | "after": true 147 | } 148 | ], 149 | "space-before-blocks": [ 150 | "error", 151 | "always" 152 | ], 153 | "space-before-function-paren": [ 154 | "error", 155 | { 156 | "anonymous": "never", 157 | "named": "never", 158 | "asyncArrow": "always" 159 | } 160 | ], 161 | "space-in-parens": [ 162 | "error", 163 | "never" 164 | ], 165 | "space-infix-ops": [ 166 | "error" 167 | ], 168 | "space-unary-ops": [ 169 | "error", 170 | { 171 | "words": true, 172 | "nonwords": false, 173 | "overrides": { 174 | "typeof": false 175 | } 176 | } 177 | ], 178 | "no-unreachable": [ 179 | "error" 180 | ], 181 | "no-global-assign": [ 182 | "error" 183 | ], 184 | "no-self-compare": [ 185 | "error" 186 | ], 187 | "no-unmodified-loop-condition": [ 188 | "error" 189 | ], 190 | "no-constant-condition": [ 191 | "error", 192 | { 193 | "checkLoops": false 194 | } 195 | ], 196 | "no-console": [ 197 | "off" 198 | ], 199 | "no-useless-concat": [ 200 | "error" 201 | ], 202 | "no-useless-escape": [ 203 | "error" 204 | ], 205 | "no-shadow-restricted-names": [ 206 | "error" 207 | ], 208 | "no-use-before-define": [ 209 | "error", 210 | { 211 | "functions": false 212 | } 213 | ], 214 | "arrow-parens": [ 215 | "error", 216 | "always" 217 | ], 218 | "arrow-body-style": [ 219 | "error", 220 | "as-needed" 221 | ], 222 | "arrow-spacing": [ 223 | "error" 224 | ], 225 | "no-confusing-arrow": [ 226 | "error", 227 | { 228 | "allowParens": true 229 | } 230 | ], 231 | "no-useless-computed-key": [ 232 | "error" 233 | ], 234 | "no-useless-rename": [ 235 | "error" 236 | ], 237 | "no-var": [ 238 | "error" 239 | ], 240 | "object-shorthand": [ 241 | "error", 242 | "always" 243 | ], 244 | "prefer-arrow-callback": [ 245 | "error" 246 | ], 247 | "prefer-const": [ 248 | "error" 249 | ], 250 | "prefer-numeric-literals": [ 251 | "error" 252 | ], 253 | "prefer-rest-params": [ 254 | "error" 255 | ], 256 | "prefer-spread": [ 257 | "error" 258 | ], 259 | "rest-spread-spacing": [ 260 | "error", 261 | "never" 262 | ], 263 | "template-curly-spacing": [ 264 | "error", 265 | "never" 266 | ] 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * -text 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Testing CI 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | node: 12 | - 16 13 | - 18 14 | - 19 15 | - 20 16 | os: 17 | - ubuntu-latest 18 | - windows-latest 19 | - macos-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node }} 27 | - uses: actions/cache@v2 28 | with: 29 | path: ~/.npm 30 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 31 | restore-keys: | 32 | ${{ runner.os }}-node- 33 | - run: npm ci 34 | - run: npm test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /application/tasks 3 | *.log 4 | *.done 5 | *.pem 6 | .DS_Store 7 | data/ 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "overrides": [ 5 | { 6 | "files": ["**/.*rc"], 7 | "options": { "parser": "json" } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2023 HowProgrammingWorks 4 | Copyright (c) 2017-2023 Metarhia contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pure NodeJS application runtime 2 | 3 | | Framework | System layer | Domain layer | Branch | 4 | | ------------ | ------------ | ------------ | ------- | 5 | | Pure Node.js | CommonJS | Metamodules | [main](https://github.com/metatech-university/NodeJS-Pure/tree/main) | 6 | | Pure Node.js | CommonJS | CommonJS | cjs-cjs | 7 | | Pure Node.js | CommonJS | Ecma modules | cjs-esm | 8 | | Pure Node.js | Ecma modules | Metarhia | esm-mtm | 9 | | Pure Node.js | Ecma modules | Ecma modules | esm-mtm | 10 | -------------------------------------------------------------------------------- /lib/common.d.ts: -------------------------------------------------------------------------------- 1 | import { ClientRequest } from 'node:http'; 2 | 3 | declare namespace common { 4 | function hashPassword(password: string): Promise; 5 | function validatePassword( 6 | password: string, 7 | serHash: string, 8 | ): Promise; 9 | function jsonParse(buffer: Buffer): object | null; 10 | function receiveBody(req: ClientRequest): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /lib/common.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('node:crypto'); 4 | 5 | const SCRYPT_PARAMS = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; 6 | const SCRYPT_PREFIX = '$scrypt$N=32768,r=8,p=1,maxmem=67108864$'; 7 | 8 | const serializeHash = (hash, salt) => { 9 | const saltString = salt.toString('base64').split('=')[0]; 10 | const hashString = hash.toString('base64').split('=')[0]; 11 | return `${SCRYPT_PREFIX}${saltString}$${hashString}`; 12 | }; 13 | 14 | const parseOptions = (options) => { 15 | const values = []; 16 | const items = options.split(','); 17 | for (const item of items) { 18 | const [key, val] = item.split('='); 19 | values.push([key, Number(val)]); 20 | } 21 | return Object.fromEntries(values); 22 | }; 23 | 24 | const deserializeHash = (phcString) => { 25 | const [, name, options, salt64, hash64] = phcString.split('$'); 26 | if (name !== 'scrypt') { 27 | throw new Error('Node.js crypto module only supports scrypt'); 28 | } 29 | const params = parseOptions(options); 30 | const salt = Buffer.from(salt64, 'base64'); 31 | const hash = Buffer.from(hash64, 'base64'); 32 | return { params, salt, hash }; 33 | }; 34 | 35 | const SALT_LEN = 32; 36 | const KEY_LEN = 64; 37 | 38 | const hashPassword = (password) => 39 | new Promise((resolve, reject) => { 40 | crypto.randomBytes(SALT_LEN, (err, salt) => { 41 | if (err) { 42 | reject(err); 43 | return; 44 | } 45 | crypto.scrypt(password, salt, KEY_LEN, SCRYPT_PARAMS, (err, hash) => { 46 | if (err) { 47 | reject(err); 48 | return; 49 | } 50 | resolve(serializeHash(hash, salt)); 51 | }); 52 | }); 53 | }); 54 | 55 | const validatePassword = (password, serHash) => { 56 | const { params, salt, hash } = deserializeHash(serHash); 57 | return new Promise((resolve, reject) => { 58 | const callback = (err, hashedPassword) => { 59 | if (err) { 60 | reject(err); 61 | return; 62 | } 63 | resolve(crypto.timingSafeEqual(hashedPassword, hash)); 64 | }; 65 | crypto.scrypt(password, salt, hash.length, params, callback); 66 | }); 67 | }; 68 | 69 | const jsonParse = (buffer) => { 70 | if (buffer.length === 0) return null; 71 | try { 72 | return JSON.parse(buffer); 73 | } catch { 74 | return null; 75 | } 76 | }; 77 | 78 | const receiveBody = async (req) => { 79 | const buffers = []; 80 | for await (const chunk of req) buffers.push(chunk); 81 | return Buffer.concat(buffers).toString(); 82 | }; 83 | 84 | module.exports = Object.freeze({ 85 | hashPassword, 86 | validatePassword, 87 | jsonParse, 88 | receiveBody, 89 | }); 90 | -------------------------------------------------------------------------------- /lib/db.d.ts: -------------------------------------------------------------------------------- 1 | type QueryResult = Promise; 2 | 3 | function deleteRecord(id: number): QueryResult; 4 | 5 | declare namespace db { 6 | export function query(sql: string, args: Array): QueryResult; 7 | export function read(id: number, fields: Array): QueryResult; 8 | export function create(record: object): QueryResult; 9 | export function update(id: number, record: object): QueryResult; 10 | export { deleteRecord as delete }; 11 | } 12 | -------------------------------------------------------------------------------- /lib/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const pg = require('pg'); 4 | 5 | const crud = (pool) => (table) => ({ 6 | async query(sql, args) { 7 | const result = await pool.query(sql, args); 8 | return result.rows; 9 | }, 10 | 11 | async read(id, fields = ['*']) { 12 | const names = fields.join(', '); 13 | const sql = `SELECT ${names} FROM ${table}`; 14 | if (!id) return pool.query(sql); 15 | return pool.query(`${sql} WHERE id = $1`, [id]); 16 | }, 17 | 18 | async create({ ...record }) { 19 | const keys = Object.keys(record); 20 | const nums = new Array(keys.length); 21 | const data = new Array(keys.length); 22 | let i = 0; 23 | for (const key of keys) { 24 | data[i] = record[key]; 25 | nums[i] = `$${++i}`; 26 | } 27 | const fields = '"' + keys.join('", "') + '"'; 28 | const params = nums.join(', '); 29 | const sql = `INSERT INTO "${table}" (${fields}) VALUES (${params})`; 30 | return pool.query(sql, data); 31 | }, 32 | 33 | async update(id, { ...record }) { 34 | const keys = Object.keys(record); 35 | const updates = new Array(keys.length); 36 | const data = new Array(keys.length); 37 | let i = 0; 38 | for (const key of keys) { 39 | data[i] = record[key]; 40 | updates[i] = `${key} = $${++i}`; 41 | } 42 | const delta = updates.join(', '); 43 | const sql = `UPDATE ${table} SET ${delta} WHERE id = $${++i}`; 44 | data.push(id); 45 | return pool.query(sql, data); 46 | }, 47 | 48 | async delete(id) { 49 | const sql = 'DELETE FROM ${table} WHERE id = $1'; 50 | return pool.query(sql, [id]); 51 | }, 52 | }); 53 | 54 | module.exports = { crud, pg }; 55 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('node:fs'); 4 | const util = require('node:util'); 5 | const path = require('node:path'); 6 | 7 | const COLORS = { 8 | info: '\x1b[1;37m', 9 | debug: '\x1b[1;33m', 10 | error: '\x1b[0;31m', 11 | system: '\x1b[1;34m', 12 | access: '\x1b[1;38m', 13 | }; 14 | 15 | const DATETIME_LENGTH = 19; 16 | 17 | class Logger { 18 | constructor(logPath) { 19 | this.path = logPath; 20 | const date = new Date().toISOString().substring(0, 10); 21 | const filePath = path.join(logPath, `${date}.log`); 22 | this.stream = fs.createWriteStream(filePath, { flags: 'a' }); 23 | this.regexp = new RegExp(path.dirname(this.path), 'g'); 24 | } 25 | 26 | close() { 27 | return new Promise((resolve) => this.stream.end(resolve)); 28 | } 29 | 30 | write(type = 'info', s) { 31 | const now = new Date().toISOString(); 32 | const date = now.substring(0, DATETIME_LENGTH); 33 | const color = COLORS[type]; 34 | const line = date + '\t' + s; 35 | console.log(color + line + '\x1b[0m'); 36 | const out = line.replace(/[\n\r]\s*/g, '; ') + '\n'; 37 | this.stream.write(out); 38 | } 39 | 40 | log(...args) { 41 | const msg = util.format(...args); 42 | this.write('info', msg); 43 | } 44 | 45 | dir(...args) { 46 | const msg = util.inspect(...args); 47 | this.write('info', msg); 48 | } 49 | 50 | debug(...args) { 51 | const msg = util.format(...args); 52 | this.write('debug', msg); 53 | } 54 | 55 | error(...args) { 56 | const msg = util.format(...args).replace(/[\n\r]{2,}/g, '\n'); 57 | this.write('error', msg.replaceAll(path.dirname(this.path), '')); 58 | } 59 | 60 | system(...args) { 61 | const msg = util.format(...args); 62 | this.write('system', msg); 63 | } 64 | 65 | access(...args) { 66 | const msg = util.format(...args); 67 | this.write('access', msg); 68 | } 69 | } 70 | 71 | module.exports = Object.freeze(new Logger('./log')); 72 | -------------------------------------------------------------------------------- /log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metatech-university/NodeJS-Pure/32a11673b69409d1c54e091dfbdb44dc95db3c71/log/.gitkeep -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const vm = require('node:vm'); 4 | const fsp = require('node:fs').promises; 5 | const path = require('node:path'); 6 | 7 | const console = require('./lib/logger.js'); 8 | const common = require('./lib/common.js'); 9 | 10 | const { loadDir, createRouting } = require('./src/loader.js'); 11 | const { Server } = require('./src/server.js'); 12 | 13 | const sandbox = vm.createContext({ console, common }); 14 | 15 | (async () => { 16 | const applications = await fsp.readFile('.applications', 'utf8'); 17 | const appPath = path.join(process.cwd(), applications.trim()); 18 | 19 | const configPath = path.join(appPath, './config'); 20 | const config = await loadDir(configPath, sandbox); 21 | 22 | const libPath = path.join(appPath, './lib'); 23 | const lib = await loadDir(libPath, sandbox); 24 | 25 | const domainPath = path.join(appPath, './domain'); 26 | const domain = await loadDir(domainPath, sandbox); 27 | 28 | sandbox.db = require('./lib/db.js'); 29 | 30 | const apiPath = path.join(appPath, './api'); 31 | const api = await loadDir(apiPath, sandbox, true); 32 | const routing = createRouting(api); 33 | 34 | const application = { path: appPath, sandbox, console, routing, config }; 35 | Object.assign(sandbox, { api, lib, domain, config, application }); 36 | application.server = new Server(application); 37 | })(); 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-pure", 3 | "version": "13.0.0", 4 | "description": "Pure NodeJS application runtime", 5 | "license": "MIT", 6 | "private": true, 7 | "main": "main.js", 8 | "scripts": { 9 | "test": "npm run lint && tsc", 10 | "lint": "eslint . && prettier -c \"**/*.js\" \"**/*.ts\"", 11 | "fmt": "prettier --write \"**/*.js\" \"**/*.ts\"" 12 | }, 13 | "engines": { 14 | "node": "16 || 18 || 19 || 20" 15 | }, 16 | "dependencies": { 17 | "pg": "^8.11.1", 18 | "ws": "^8.12.0" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^20.4.4", 22 | "@types/pg": "^8.10.2", 23 | "@types/ws": "^8.5.5", 24 | "eslint": "^8.45.0", 25 | "eslint-config-prettier": "^8.6.0", 26 | "eslint-plugin-import": "^2.23.4", 27 | "eslint-plugin-prettier": "^5.0.0", 28 | "prettier": "^3.0.0", 29 | "typescript": "^5.1.6" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/loader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fsp = require('node:fs').promises; 4 | const vm = require('node:vm'); 5 | const path = require('node:path'); 6 | 7 | const OPTIONS = { 8 | timeout: 5000, 9 | displayErrors: false, 10 | }; 11 | 12 | const load = async (filePath, sandbox, contextualize = false) => { 13 | const src = await fsp.readFile(filePath, 'utf8'); 14 | const opening = contextualize ? '(context) => ' : ''; 15 | const code = `'use strict';\n${opening}${src}`; 16 | const script = new vm.Script(code, { ...OPTIONS, lineOffset: -1 }); 17 | return script.runInContext(sandbox, OPTIONS); 18 | }; 19 | 20 | const loadDir = async (dir, sandbox, contextualize = false) => { 21 | const files = await fsp.readdir(dir, { withFileTypes: true }); 22 | const container = {}; 23 | for (const file of files) { 24 | const { name } = file; 25 | if (file.isFile() && !name.endsWith('.js')) continue; 26 | const location = path.join(dir, name); 27 | const key = path.basename(name, '.js'); 28 | const loader = file.isFile() ? load : loadDir; 29 | container[key] = await loader(location, sandbox, contextualize); 30 | } 31 | return container; 32 | }; 33 | 34 | const createRouting = (container, path = '', routing = new Map()) => { 35 | for (const [key, value] of Object.entries(container)) { 36 | const location = path ? `${path}.${key}` : key; 37 | if (typeof value === 'function') routing.set(location, value); 38 | else createRouting(value, location, routing); 39 | } 40 | return routing; 41 | }; 42 | 43 | module.exports = { load, loadDir, createRouting }; 44 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('node:http'); 4 | const fs = require('node:fs'); 5 | const path = require('node:path'); 6 | const crypto = require('node:crypto'); 7 | const { EventEmitter } = require('node:events'); 8 | const ws = require('ws'); 9 | const { receiveBody, jsonParse } = require('../lib/common.js'); 10 | const transport = require('./transport.js'); 11 | const { HttpTransport, WsTransport, MIME_TYPES, HEADERS } = transport; 12 | 13 | class Session { 14 | constructor(token, data) { 15 | this.token = token; 16 | this.state = { ...data }; 17 | } 18 | } 19 | 20 | const sessions = new Map(); // token: Session 21 | 22 | class Context { 23 | constructor(client) { 24 | this.client = client; 25 | this.uuid = crypto.randomUUID(); 26 | this.state = {}; 27 | this.session = client?.session || null; 28 | } 29 | } 30 | 31 | class Client extends EventEmitter { 32 | #transport; 33 | 34 | constructor(transport) { 35 | super(); 36 | this.#transport = transport; 37 | this.ip = transport.ip; 38 | this.session = null; 39 | } 40 | 41 | error(code, options) { 42 | this.#transport.error(code, options); 43 | } 44 | 45 | send(obj, code) { 46 | this.#transport.send(obj, code); 47 | } 48 | 49 | createContext() { 50 | return new Context(this); 51 | } 52 | 53 | emit(name, data) { 54 | if (name === 'close') { 55 | super.emit(name, data); 56 | return; 57 | } 58 | this.send({ type: 'event', name, data }); 59 | } 60 | 61 | initializeSession(token, data = {}) { 62 | this.finalizeSession(); 63 | this.session = new Session(token, data); 64 | sessions.set(token, this.session); 65 | return true; 66 | } 67 | 68 | finalizeSession() { 69 | if (!this.session) return false; 70 | sessions.delete(this.session.token); 71 | this.session = null; 72 | return true; 73 | } 74 | 75 | restoreSession(token) { 76 | const session = sessions.get(token); 77 | if (!session) return false; 78 | this.session = session; 79 | return true; 80 | } 81 | 82 | destroy() { 83 | this.emit('close'); 84 | if (!this.session) return; 85 | this.finalizeSession(); 86 | } 87 | } 88 | 89 | const serveStatic = (staticPath) => async (req, res) => { 90 | const url = req.url === '/' ? '/index.html' : req.url; 91 | const filePath = path.join(staticPath, url); 92 | try { 93 | const data = await fs.promises.readFile(filePath); 94 | const fileExt = path.extname(filePath).substring(1); 95 | const mimeType = MIME_TYPES[fileExt] || MIME_TYPES.html; 96 | res.writeHead(200, { ...HEADERS, 'Content-Type': mimeType }); 97 | res.end(data); 98 | } catch (err) { 99 | res.statusCode = 404; 100 | res.end('"File is not found"'); 101 | } 102 | }; 103 | 104 | class Server { 105 | constructor(application) { 106 | this.application = application; 107 | const { console, routing, config } = application; 108 | const staticPath = path.join(application.path, './static'); 109 | this.staticHandler = serveStatic(staticPath); 110 | this.routing = routing; 111 | this.console = console; 112 | this.httpServer = http.createServer(); 113 | const [port] = config.server.ports; 114 | this.listen(port); 115 | console.log(`API on port ${port}`); 116 | } 117 | 118 | listen(port) { 119 | this.httpServer.on('request', async (req, res) => { 120 | if (!req.url.startsWith('/api')) { 121 | this.staticHandler(req, res); 122 | return; 123 | } 124 | const transport = new HttpTransport(this, req, res); 125 | const client = new Client(transport); 126 | const data = await receiveBody(req); 127 | this.rpc(client, data); 128 | 129 | req.on('close', () => { 130 | client.destroy(); 131 | }); 132 | }); 133 | 134 | const wsServer = new ws.Server({ server: this.httpServer }); 135 | wsServer.on('connection', (connection, req) => { 136 | const transport = new WsTransport(this, req, connection); 137 | const client = new Client(transport); 138 | 139 | connection.on('message', (data) => { 140 | this.rpc(client, data); 141 | }); 142 | 143 | connection.on('close', () => { 144 | client.destroy(); 145 | }); 146 | }); 147 | 148 | this.httpServer.listen(port); 149 | } 150 | 151 | rpc(client, data) { 152 | const packet = jsonParse(data); 153 | if (!packet) { 154 | const error = new Error('JSON parsing error'); 155 | client.error(500, { error, pass: true }); 156 | return; 157 | } 158 | const { id, type, args } = packet; 159 | if (type !== 'call' || !id || !args) { 160 | const error = new Error('Packet structure error'); 161 | client.error(400, { id, error, pass: true }); 162 | return; 163 | } 164 | /* TODO: resumeCookieSession(); */ 165 | const [unit, method] = packet.method.split('/'); 166 | const proc = this.routing.get(unit + '.' + method); 167 | if (!proc) { 168 | client.error(404, { id }); 169 | return; 170 | } 171 | const context = client.createContext(); 172 | /* TODO: check rights 173 | if (!client.session && proc.access !== 'public') { 174 | client.error(403, { id }); 175 | return; 176 | }*/ 177 | this.console.log(`${client.ip}\t${packet.method}`); 178 | proc(context) 179 | .method(packet.args) 180 | .then((result) => { 181 | if (result?.constructor?.name === 'Error') { 182 | const { code, httpCode = 200 } = result; 183 | client.error(code, { id, error: result, httpCode }); 184 | return; 185 | } 186 | client.send({ type: 'callback', id, result }); 187 | }) 188 | .catch((error) => { 189 | client.error(error.code, { id, error }); 190 | }); 191 | } 192 | } 193 | 194 | module.exports = { Server }; 195 | -------------------------------------------------------------------------------- /src/transport.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('node:http'); 4 | 5 | const MIME_TYPES = { 6 | html: 'text/html; charset=UTF-8', 7 | json: 'application/json; charset=UTF-8', 8 | js: 'application/javascript; charset=UTF-8', 9 | css: 'text/css', 10 | png: 'image/png', 11 | ico: 'image/x-icon', 12 | svg: 'image/svg+xml', 13 | }; 14 | 15 | const HEADERS = { 16 | 'X-XSS-Protection': '1; mode=block', 17 | 'X-Content-Type-Options': 'nosniff', 18 | 'Strict-Transport-Security': 'max-age=31536000; includeSubdomains; preload', 19 | 'Access-Control-Allow-Origin': '*', 20 | 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 21 | 'Access-Control-Allow-Headers': 'Content-Type', 22 | }; 23 | 24 | class Transport { 25 | constructor(server, req) { 26 | this.server = server; 27 | this.req = req; 28 | this.ip = req.socket.remoteAddress; 29 | } 30 | 31 | error(code = 500, { id, error = null, httpCode = null } = {}) { 32 | const { console } = this.server; 33 | const { url, method } = this.req; 34 | if (!httpCode) httpCode = error?.httpCode || code; 35 | const status = http.STATUS_CODES[httpCode]; 36 | const pass = httpCode < 500 || httpCode > 599; 37 | const message = pass ? error?.message : status || 'Unknown error'; 38 | const reason = `${code}\t${error ? error.stack : status}`; 39 | console.error(`${this.ip}\t${method}\t${url}\t${reason}`); 40 | const packet = { type: 'callback', id, error: { message, code } }; 41 | this.send(packet, httpCode); 42 | } 43 | 44 | send(obj, code = 200) { 45 | const data = JSON.stringify(obj); 46 | this.write(data, code, 'json'); 47 | } 48 | } 49 | 50 | class HttpTransport extends Transport { 51 | constructor(server, req, res) { 52 | super(server, req); 53 | this.res = res; 54 | } 55 | 56 | write(data, httpCode = 200, ext = 'json') { 57 | if (this.res.writableEnded) return; 58 | const mimeType = MIME_TYPES[ext] || MIME_TYPES.html; 59 | this.res.writeHead(httpCode, { ...HEADERS, 'Content-Type': mimeType }); 60 | this.res.end(data); 61 | } 62 | } 63 | 64 | class WsTransport extends Transport { 65 | constructor(server, req, connection) { 66 | super(server, req); 67 | this.connection = connection; 68 | } 69 | 70 | write(data) { 71 | this.connection.send(data); 72 | } 73 | } 74 | 75 | module.exports = { Transport, HttpTransport, WsTransport, MIME_TYPES, HEADERS }; 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "moduleResolution": "node", 5 | "strict": true, 6 | "baseUrl": ".", 7 | "preserveWatchOutput": true, 8 | "allowJs": true, 9 | "noEmit": true, 10 | "skipLibCheck": true 11 | }, 12 | "include": ["*", "**/*"] 13 | } 14 | --------------------------------------------------------------------------------