Browse Source

chore: initial commit

e22m4u 1 year ago
commit
9c123b7342
100 changed files with 6602 additions and 0 deletions
  1. 9 0
      .c8rc
  2. 5 0
      .commitlintrc
  3. 13 0
      .editorconfig
  4. 18 0
      .gitignore
  5. 1 0
      .husky/commit-msg
  6. 5 0
      .husky/pre-commit
  7. 4 0
      .mocharc.cjs
  8. 7 0
      .prettierrc
  9. 21 0
      LICENSE
  10. 127 0
      README.md
  11. 34 0
      eslint.config.js
  12. 28 0
      examples/cookie-parsing-example.js
  13. 28 0
      examples/params-parsing-example.js
  14. 28 0
      examples/query-parsing-example.js
  15. 40 0
      examples/uptime-example.js
  16. 57 0
      package.json
  17. 7 0
      src/chai.js
  18. 25 0
      src/hooks/hook-invoker.d.ts
  19. 104 0
      src/hooks/hook-invoker.js
  20. 462 0
      src/hooks/hook-invoker.spec.js
  21. 43 0
      src/hooks/hook-registry.d.ts
  22. 88 0
      src/hooks/hook-registry.js
  23. 165 0
      src/hooks/hook-registry.spec.js
  24. 2 0
      src/hooks/index.d.ts
  25. 2 0
      src/hooks/index.js
  26. 1 0
      src/index.d.ts
  27. 9 0
      src/index.js
  28. 52 0
      src/parsers/body-parser.d.ts
  29. 161 0
      src/parsers/body-parser.js
  30. 297 0
      src/parsers/body-parser.spec.js
  31. 21 0
      src/parsers/cookie-parser.d.ts
  32. 32 0
      src/parsers/cookie-parser.js
  33. 26 0
      src/parsers/cookie-parser.spec.js
  34. 4 0
      src/parsers/index.d.ts
  35. 4 0
      src/parsers/index.js
  36. 21 0
      src/parsers/query-parser.d.ts
  37. 32 0
      src/parsers/query-parser.js
  38. 25 0
      src/parsers/query-parser.spec.js
  39. 34 0
      src/parsers/request-parser.d.ts
  40. 65 0
      src/parsers/request-parser.js
  41. 137 0
      src/parsers/request-parser.spec.js
  42. 54 0
      src/request-context.d.ts
  43. 108 0
      src/request-context.js
  44. 89 0
      src/request-context.spec.js
  45. 39 0
      src/route-registry.d.ts
  46. 95 0
      src/route-registry.js
  47. 77 0
      src/route-registry.spec.js
  48. 82 0
      src/route.d.ts
  49. 184 0
      src/route.js
  50. 299 0
      src/route.spec.js
  51. 18 0
      src/router-options.d.ts
  52. 41 0
      src/router-options.js
  53. 52 0
      src/router-options.spec.js
  54. 15 0
      src/senders/data-sender.d.ts
  55. 72 0
      src/senders/data-sender.js
  56. 193 0
      src/senders/data-sender.spec.js
  57. 25 0
      src/senders/error-sender.d.ts
  58. 85 0
      src/senders/error-sender.js
  59. 90 0
      src/senders/error-sender.spec.js
  60. 2 0
      src/senders/index.d.ts
  61. 2 0
      src/senders/index.js
  62. 14 0
      src/service.d.ts
  63. 28 0
      src/service.js
  64. 11 0
      src/service.spec.js
  65. 66 0
      src/trie-router.d.ts
  66. 189 0
      src/trie-router.js
  67. 471 0
      src/trie-router.spec.js
  68. 19 0
      src/types.d.ts
  69. 6 0
      src/utils/create-cookie-string.d.ts
  70. 24 0
      src/utils/create-cookie-string.js
  71. 36 0
      src/utils/create-cookie-string.spec.js
  72. 11 0
      src/utils/create-debugger.d.ts
  73. 22 0
      src/utils/create-debugger.js
  74. 30 0
      src/utils/create-debugger.spec.js
  75. 14 0
      src/utils/create-error.d.ts
  76. 28 0
      src/utils/create-error.js
  77. 50 0
      src/utils/create-error.spec.js
  78. 28 0
      src/utils/create-request-mock.d.ts
  79. 345 0
      src/utils/create-request-mock.js
  80. 482 0
      src/utils/create-request-mock.spec.js
  81. 16 0
      src/utils/create-response-mock.d.ts
  82. 119 0
      src/utils/create-response-mock.js
  83. 130 0
      src/utils/create-response-mock.spec.js
  84. 17 0
      src/utils/fetch-request-body.d.ts
  85. 133 0
      src/utils/fetch-request-body.js
  86. 211 0
      src/utils/fetch-request-body.spec.js
  87. 8 0
      src/utils/get-request-path.d.ts
  88. 23 0
      src/utils/get-request-path.js
  89. 31 0
      src/utils/get-request-path.spec.js
  90. 11 0
      src/utils/index.d.ts
  91. 11 0
      src/utils/index.js
  92. 10 0
      src/utils/is-promise.d.ts
  93. 13 0
      src/utils/is-promise.js
  94. 20 0
      src/utils/is-promise.spec.js
  95. 9 0
      src/utils/is-readable-stream.d.ts
  96. 11 0
      src/utils/is-readable-stream.js
  97. 23 0
      src/utils/is-readable-stream.spec.js
  98. 8 0
      src/utils/is-response-sent.d.ts
  99. 23 0
      src/utils/is-response-sent.js
  100. 35 0
      src/utils/is-response-sent.spec.js

+ 9 - 0
.c8rc

@@ -0,0 +1,9 @@
+{
+  "all": true,
+  "include": [
+    "src/**/*.js"
+  ],
+  "exclude": [
+    "src/**/*.spec.js"
+  ]
+}

+ 5 - 0
.commitlintrc

@@ -0,0 +1,5 @@
+{
+  "extends": [
+    "@commitlint/config-conventional"
+  ]
+}

+ 13 - 0
.editorconfig

@@ -0,0 +1,13 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+indent_style = space
+indent_size = 2
+max_line_length = 80

+ 18 - 0
.gitignore

@@ -0,0 +1,18 @@
+# OS
+.DS_Store
+
+# Editor
+.idea
+.vscode
+
+# Npm
+node_modules
+npm-debug.log
+package-lock.json
+
+# Yarn
+.yarn/
+.yarn*
+
+# c8
+coverage

+ 1 - 0
.husky/commit-msg

@@ -0,0 +1 @@
+npx --no -- commitlint --edit $1

+ 5 - 0
.husky/pre-commit

@@ -0,0 +1,5 @@
+npm run lint:fix
+npm run format
+npm run test
+
+git add -A

+ 4 - 0
.mocharc.cjs

@@ -0,0 +1,4 @@
+module.exports = {
+  extension: ['js'],
+  spec: 'src/**/*.spec.js',
+}

+ 7 - 0
.prettierrc

@@ -0,0 +1,7 @@
+{
+  "bracketSpacing": false,
+  "singleQuote": true,
+  "printWidth": 80,
+  "trailingComma": "all",
+  "arrowParens": "avoid"
+}

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 e22m4u@yandex.ru
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 127 - 0
README.md

@@ -0,0 +1,127 @@
+## @e22m4u/js-trie-router
+
+A pure ES-module of the Node.js HTTP router that uses the
+[Trie](https://en.wikipedia.org/wiki/Trie) for routing.
+
+- Uses [path-to-regexp](https://github.com/pillarjs/path-to-regexp) syntax.
+- Supports path parameters.
+- Parses JSON-body automatically.
+- Parses a query string and the Cookie header.
+- Supports `preHandler` and `postHandler` hooks.
+- Asynchronous request handler.
+
+## Installation
+
+```bash
+npm install @e22m4u/js-trie-router
+```
+
+## Overview
+
+A basic "Hello world." example.
+
+```js
+import http from 'http';
+import {TrieRouter} from '../src/index.js';
+import {HTTP_METHOD} from '../src/route.js';
+
+const server = new http.Server(); // A Node.js HTTP server.
+const router = new TrieRouter();  // A TrieRouter instance.
+
+router.defineRoute({
+  method: HTTP_METHOD.GET,        // Request method.
+  path: '/',                      // Path template like "/user/:id".
+  handler(ctx) {                  // Request handler.
+    return 'Hello world!';
+  },
+});
+
+server.on('request', router.requestHandler);
+server.listen(3000, 'localhost');
+
+// Open in browser http://localhost:3000
+```
+
+### RequestContext
+
+The first parameter of the `Router` handler is the `RequestContext` instance.
+
+- `container: ServiceContainer`
+- `req: IncomingMessage`
+- `res: ServerResponse`
+- `query: ParsedQuery`
+- `headers: ParsedHeaders`
+- `cookie: ParsedCookie`
+
+The `RequestContext` can be destructured.
+
+```js
+router.defineRoute({
+  // ...
+  handler({req, res, query, headers, cookie}) {
+    console.log(req);     // IncomingMessage
+    console.log(res);     // ServerResponse
+    console.log(query);   // {id: '10', ...}
+    console.log(headers); // {'cookie': 'foo=bar', ...}
+    console.log(cookie);  // {foo: 'bar', ...}
+    // ...
+  },
+});
+```
+
+### Sending response
+
+Return values of the `Route` handler will be sent as described below.
+
+| type    | content-type             |
+|---------|--------------------------|
+| `string`  | text/plain               |
+| `number`  | application/json         |
+| `boolean` | application/json         |
+| `object`  | application/json         |
+| `Buffer`  | application/octet-stream |
+| `Stream`  | application/octet-stream |
+
+Here is an example of a JSON response.
+
+```js
+router.defineRoute({
+  // ...
+  handler(ctx) {
+    // sends "application/json"
+    return {foo: 'bar'};
+  },
+});
+```
+
+If the `ServerResponse` has been sent manually, then the return
+value will be ignored.
+
+```js
+router.defineRoute({
+  // ...
+  handler(ctx) {
+    res.statusCode = 404;
+    res.setHeader('content-type', 'text/plain; charset=utf-8');
+    res.end('404 Not Found', 'utf-8');
+  },
+});
+```
+
+## Debug
+
+Set environment variable `DEBUG=jsTrieRouter*` before start.
+
+```bash
+DEBUG=jsPathTrie* npm run test
+```
+
+## Testing
+
+```bash
+npm run test
+```
+
+## License
+
+MIT

+ 34 - 0
eslint.config.js

@@ -0,0 +1,34 @@
+import globals from 'globals';
+import eslintJs from '@eslint/js';
+import eslintJsdocPlugin from 'eslint-plugin-jsdoc';
+import eslintMochaPlugin from 'eslint-plugin-mocha';
+import eslintPrettierConfig from 'eslint-config-prettier';
+import eslintChaiExpectPlugin from 'eslint-plugin-chai-expect';
+
+export default [{
+  languageOptions: {
+    globals: {
+      ...globals.node,
+      ...globals.es2021,
+      ...globals.mocha,
+    },
+  },
+  plugins: {
+    'jsdoc': eslintJsdocPlugin,
+    'mocha': eslintMochaPlugin,
+    'chai-expect': eslintChaiExpectPlugin,
+  },
+  rules: {
+    ...eslintJs.configs.recommended.rules,
+    ...eslintPrettierConfig.rules,
+    ...eslintJsdocPlugin.configs['flat/recommended-error'].rules,
+    ...eslintMochaPlugin.configs.flat.recommended.rules,
+    ...eslintChaiExpectPlugin.configs['recommended-flat'].rules,
+    'no-unused-vars': ['error', {'caughtErrors': 'none'}],
+    'jsdoc/require-param-description': 0,
+    'jsdoc/require-returns-description': 0,
+    'jsdoc/require-property-description': 0,
+    'jsdoc/tag-lines': ['error', 'any', {startLines: 1}],
+  },
+  files: ['src/**/*.js'],
+}];

+ 28 - 0
examples/cookie-parsing-example.js

@@ -0,0 +1,28 @@
+import http from 'http';
+import {TrieRouter} from '../src/index.js';
+import {HTTP_METHOD} from '../src/route.js';
+
+const router = new TrieRouter();
+
+// регистрация роута для вывода
+// переданных Cookie
+router.defineRoute({
+  method: HTTP_METHOD.GET,
+  path: '/showCookie',
+  handler: ({cookie}) => cookie,
+});
+
+// создаем экземпляр HTTP сервера
+// и подключаем обработчик запросов
+const server = new http.Server();
+server.on('request', router.requestHandler);
+
+// слушаем входящие запросы
+// на указанный адрес и порт
+const port = 3000;
+const host = '0.0.0.0';
+server.listen(port, host, function () {
+  const cyan = '\x1b[36m%s\x1b[0m';
+  console.log(cyan, 'Server listening on port:', port);
+  console.log(cyan, 'Open in browser:', `http://${host}:${port}/showCookie`);
+});

+ 28 - 0
examples/params-parsing-example.js

@@ -0,0 +1,28 @@
+import http from 'http';
+import {TrieRouter} from '../src/index.js';
+import {HTTP_METHOD} from '../src/route.js';
+
+const router = new TrieRouter();
+
+// регистрация роута для вывода
+// переданных параметров пути
+router.defineRoute({
+  method: HTTP_METHOD.GET,
+  path: '/showParams/:p1/:p2',
+  handler: ({params}) => params,
+});
+
+// создаем экземпляр HTTP сервера
+// и подключаем обработчик запросов
+const server = new http.Server();
+server.on('request', router.requestHandler);
+
+// слушаем входящие запросы
+// на указанный адрес и порт
+const port = 3000;
+const host = '0.0.0.0';
+server.listen(port, host, function () {
+  const cyan = '\x1b[36m%s\x1b[0m';
+  console.log(cyan, 'Server listening on port:', port);
+  console.log(cyan, 'Open in browser:', `http://${host}:${port}/showParams/foo/bar`);
+});

+ 28 - 0
examples/query-parsing-example.js

@@ -0,0 +1,28 @@
+import http from 'http';
+import {TrieRouter} from '../src/index.js';
+import {HTTP_METHOD} from '../src/route.js';
+
+const router = new TrieRouter();
+
+// регистрация роута для вывода
+// переданных "query" параметров
+router.defineRoute({
+  method: HTTP_METHOD.GET,
+  path: '/showQuery',
+  handler: ({query}) => query,
+});
+
+// создаем экземпляр HTTP сервера
+// и подключаем обработчик запросов
+const server = new http.Server();
+server.on('request', router.requestHandler);
+
+// слушаем входящие запросы
+// на указанный адрес и порт
+const port = 3000;
+const host = '0.0.0.0';
+server.listen(port, host, function () {
+  const cyan = '\x1b[36m%s\x1b[0m';
+  console.log(cyan, 'Server listening on port:', port);
+  console.log(cyan, 'Open in browser:', `http://${host}:${port}/showQuery?foo=bar&baz=qux`);
+});

+ 40 - 0
examples/uptime-example.js

@@ -0,0 +1,40 @@
+import http from 'http';
+import {TrieRouter} from '../src/index.js';
+import {HTTP_METHOD} from '../src/route.js';
+
+const router = new TrieRouter();
+
+// регистрация роута для вывода
+// времени работы сервера
+router.defineRoute({
+  method: HTTP_METHOD.GET,
+  path: '/',
+  handler() {
+    const uptimeSec = process.uptime();
+    const days = Math.floor(uptimeSec / (60 * 60 * 24));
+    const hours = Math.floor((uptimeSec / (60 * 60)) % 24);
+    const mins = Math.floor((uptimeSec / 60) % 60);
+    const secs = Math.floor(uptimeSec % 60);
+    let res = 'Uptime';
+    if (days) res += ` ${days}d`;
+    if (days || hours) res += ` ${hours}h`;
+    if (days || hours || mins) res += ` ${mins}m`;
+    res += ` ${secs}s`;
+    return res;
+  },
+})
+
+// создаем экземпляр HTTP сервера
+// и подключаем обработчик запросов
+const server = new http.Server();
+server.on('request', router.requestHandler);
+
+// слушаем входящие запросы
+// на указанный адрес и порт
+const port = 3000;
+const host = '0.0.0.0';
+server.listen(port, host, function () {
+  const cyan = '\x1b[36m%s\x1b[0m';
+  console.log(cyan, 'Server listening on port:', port);
+  console.log(cyan, 'Open in browser:', `http://${host}:${port}`);
+});

+ 57 - 0
package.json

@@ -0,0 +1,57 @@
+{
+  "name": "@e22m4u/js-trie-router",
+  "version": "0.0.1",
+  "description": "HTTP роутер для Node.js",
+  "type": "module",
+  "main": "src/index.js",
+  "scripts": {
+    "lint": "tsc && eslint .",
+    "lint:fix": "tsc && eslint . --fix",
+    "format": "prettier --write \"./src/**/*.{js,ts}\"",
+    "test": "npm run lint && c8 --reporter=text-summary mocha --bail",
+    "test:coverage": "npm run lint && c8 --reporter=text mocha",
+    "prepare": "husky"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/e22m4u/js-trie-router.git"
+  },
+  "keywords": [
+    "router",
+    "trie",
+    "http",
+    "server",
+    "nodejs"
+  ],
+  "author": "e22m4u <e22m4u@yandex.ru>",
+  "license": "MIT",
+  "homepage": "https://github.com/e22m4u/js-trie-router",
+  "devDependencies": {
+    "@commitlint/cli": "~19.4.0",
+    "@commitlint/config-conventional": "~19.2.2",
+    "@eslint/js": "~9.9.0",
+    "@types/chai-as-promised": "^8.0.0",
+    "c8": "~10.1.2",
+    "chai": "~5.1.1",
+    "chai-as-promised": "^8.0.0",
+    "eslint": "~9.9.0",
+    "eslint-config-prettier": "~9.1.0",
+    "eslint-plugin-chai-expect": "~3.1.0",
+    "eslint-plugin-jsdoc": "^50.2.2",
+    "eslint-plugin-mocha": "~10.5.0",
+    "globals": "~15.9.0",
+    "husky": "~9.1.4",
+    "mocha": "~10.7.3",
+    "prettier": "~3.3.3",
+    "typescript": "~5.5.4"
+  },
+  "dependencies": {
+    "@e22m4u/js-format": "^0.1.0",
+    "@e22m4u/js-path-trie": "^0.0.1",
+    "@e22m4u/js-service": "^0.0.12",
+    "debug": "^4.3.6",
+    "http-errors": "^2.0.0",
+    "path-to-regexp": "^7.1.0",
+    "statuses": "^2.0.1"
+  }
+}

+ 7 - 0
src/chai.js

@@ -0,0 +1,7 @@
+import * as chaiModule from 'chai';
+import chaiAsPromised from 'chai-as-promised';
+const chai = {...chaiModule};
+
+chaiAsPromised(chai, chai.util);
+
+export const expect = chai.expect;

+ 25 - 0
src/hooks/hook-invoker.d.ts

@@ -0,0 +1,25 @@
+import {Route} from '../route.js';
+import {ServerResponse} from 'http';
+import {Service} from '../service.js';
+import {ValueOrPromise} from '../types.js';
+import {HOOK_NAME} from './hook-registry.js';
+
+/**
+ * Hook invoker.
+ */
+export declare class HookInvoker extends Service {
+  /**
+   * Invoke and continue until value received.
+   *
+   * @param route
+   * @param hookName
+   * @param response
+   * @param args
+   */
+  invokeAndContinueUntilValueReceived(
+    route: Route,
+    hookName: HOOK_NAME,
+    response: ServerResponse,
+    ...args: unknown[]
+  ): ValueOrPromise<unknown>;
+}

+ 104 - 0
src/hooks/hook-invoker.js

@@ -0,0 +1,104 @@
+import {Route} from '../route.js';
+import {Service} from '../service.js';
+import {Errorf} from '@e22m4u/js-format';
+import {isPromise} from '../utils/index.js';
+import {HOOK_NAME} from './hook-registry.js';
+import {HookRegistry} from './hook-registry.js';
+import {isResponseSent} from '../utils/index.js';
+
+/**
+ * Hook invoker.
+ */
+export class HookInvoker extends Service {
+  /**
+   * Invoke and continue until value received.
+   *
+   * @param {Route} route
+   * @param {string} hookName
+   * @param {import('http').ServerResponse} response
+   * @param {*[]} args
+   * @returns {Promise<*>|*}
+   */
+  invokeAndContinueUntilValueReceived(route, hookName, response, ...args) {
+    if (!route || !(route instanceof Route))
+      throw new Errorf(
+        'The parameter "route" of ' +
+          'the HookInvoker.invokeAndContinueUntilValueReceived ' +
+          'should be a Route instance, but %v given.',
+        route,
+      );
+    if (!hookName || typeof hookName !== 'string')
+      throw new Errorf(
+        'The parameter "hookName" of ' +
+          'the HookInvoker.invokeAndContinueUntilValueReceived ' +
+          'should be a non-empty String, but %v given.',
+        hookName,
+      );
+    if (!Object.values(HOOK_NAME).includes(hookName))
+      throw new Errorf('The hook name %v is not supported.', hookName);
+    if (
+      !response ||
+      typeof response !== 'object' ||
+      Array.isArray(response) ||
+      typeof response.headersSent !== 'boolean'
+    ) {
+      throw new Errorf(
+        'The parameter "response" of ' +
+          'the HookInvoker.invokeAndContinueUntilValueReceived ' +
+          'should be a ServerResponse instance, but %v given.',
+        response,
+      );
+    }
+    // так как хуки роута выполняются
+    // после глобальных, то объединяем
+    // их в данной последовательности
+    const hooks = [
+      ...this.getService(HookRegistry).getHooks(hookName),
+      ...route.hookRegistry.getHooks(hookName),
+    ];
+    // последовательный вызов хуков будет прерван,
+    // если один из них вернет значение (или Promise)
+    // отличное от "undefined" и "null"
+    let result = undefined;
+    for (const hook of hooks) {
+      // если ответ уже был отправлен,
+      // то завершаем обход
+      if (isResponseSent(response)) {
+        result = response;
+        break;
+      }
+      // если выполняется первый хук, или предыдущий
+      // хук вернул пустое значение, то выполняем
+      // следующий, записывая возвращаемое
+      // значение в результат
+      if (result == null) {
+        result = hook(...args);
+      }
+      // если какой-то из предыдущих хуков вернул
+      // Promise, то последующие значения будут
+      // оборачиваться именно им
+      else if (isPromise(result)) {
+        result = result.then(prevVal => {
+          // если ответ уже был отправлен,
+          // то останавливаем выполнение
+          if (isResponseSent(response)) {
+            result = response;
+            return;
+          }
+          // если предыдущий Promise вернул значение
+          // отличное от "undefined" и "null",
+          // то завершаем обход
+          if (prevVal != null) return prevVal;
+          return hook(...args);
+        });
+      }
+      // если предыдущий хук вернул значение
+      // отличное от "undefined" и "null",
+      // то завершаем обход
+      else {
+        break;
+      }
+    }
+    return result;
+  }
+}

+ 462 - 0
src/hooks/hook-invoker.spec.js

@@ -0,0 +1,462 @@
+import {expect} from '../chai.js';
+import {Route} from '../route.js';
+import {HTTP_METHOD} from '../route.js';
+import {format} from '@e22m4u/js-format';
+import {HOOK_NAME} from './hook-registry.js';
+import {HookInvoker} from './hook-invoker.js';
+import {HookRegistry} from './hook-registry.js';
+import {createResponseMock} from '../utils/create-response-mock.js';
+
+describe('HookInvoker', function () {
+  describe('invokeAndContinueUntilValueReceived', function () {
+    it('requires the parameter "route" to be a Route instance', function () {
+      const s = new HookInvoker();
+      const res = createResponseMock();
+      const throwable = v => () =>
+        s.invokeAndContinueUntilValueReceived(v, HOOK_NAME.PRE_HANDLER, res);
+      const error = v =>
+        format(
+          'The parameter "route" of ' +
+            'the HookInvoker.invokeAndContinueUntilValueReceived ' +
+            'should be a Route instance, but %s given.',
+          v,
+        );
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable(
+        new Route({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          handler: () => undefined,
+        }),
+      )();
+    });
+
+    it('requires the parameter "hookName" to be a non-empty String', function () {
+      const s = new HookInvoker();
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: () => undefined,
+      });
+      const res = createResponseMock();
+      const throwable = v => () =>
+        s.invokeAndContinueUntilValueReceived(route, v, res);
+      const error = v =>
+        format(
+          'The parameter "hookName" of ' +
+            'the HookInvoker.invokeAndContinueUntilValueReceived ' +
+            'should be a non-empty String, but %s given.',
+          v,
+        );
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable(HOOK_NAME.PRE_HANDLER)();
+    });
+
+    it('requires the parameter "hookName" to be a supported hook', function () {
+      const s = new HookInvoker();
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: () => undefined,
+      });
+      const res = createResponseMock();
+      Object.values(HOOK_NAME).forEach(name =>
+        s.invokeAndContinueUntilValueReceived(route, name, res),
+      );
+      const throwable = () =>
+        s.invokeAndContinueUntilValueReceived(route, 'unknown', res);
+      expect(throwable).to.throw('The hook name "unknown" is not supported.');
+    });
+
+    it('requires the parameter "response" to be an instance of ServerResponse', function () {
+      const s = new HookInvoker();
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: () => undefined,
+      });
+      const throwable = v => () =>
+        s.invokeAndContinueUntilValueReceived(route, HOOK_NAME.PRE_HANDLER, v);
+      const error = v =>
+        format(
+          'The parameter "response" of ' +
+            'the HookInvoker.invokeAndContinueUntilValueReceived ' +
+            'should be a ServerResponse instance, but %s given.',
+          v,
+        );
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable(createResponseMock())();
+    });
+
+    it('invokes global hooks in priority', function () {
+      const s = new HookInvoker();
+      const order = [];
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook1');
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook2');
+      });
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        preHandler: [
+          () => {
+            order.push('routeHook1');
+          },
+          () => {
+            order.push('routeHook2');
+          },
+        ],
+        handler: () => undefined,
+      });
+      s.invokeAndContinueUntilValueReceived(
+        route,
+        HOOK_NAME.PRE_HANDLER,
+        createResponseMock(),
+      );
+      expect(order).to.be.eql([
+        'globalHook1',
+        'globalHook2',
+        'routeHook1',
+        'routeHook2',
+      ]);
+    });
+
+    it('stops global hooks invocation if any of them returns a value', function () {
+      const s = new HookInvoker();
+      const order = [];
+      const ret = 'OK';
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook1');
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook2');
+        return ret;
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook3');
+      });
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        preHandler: [
+          () => {
+            order.push('routeHook1');
+          },
+          () => {
+            order.push('routeHook2');
+          },
+        ],
+        handler: () => undefined,
+      });
+      const result = s.invokeAndContinueUntilValueReceived(
+        route,
+        HOOK_NAME.PRE_HANDLER,
+        createResponseMock(),
+      );
+      expect(result).to.be.eq(ret);
+      expect(order).to.be.eql(['globalHook1', 'globalHook2']);
+    });
+
+    it('stops route hooks invocation if any of them returns a value', function () {
+      const s = new HookInvoker();
+      const order = [];
+      const ret = 'OK';
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook1');
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook2');
+      });
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        preHandler: [
+          () => {
+            order.push('routeHook1');
+          },
+          () => {
+            order.push('routeHook2');
+            return ret;
+          },
+          () => {
+            order.push('routeHook3');
+          },
+        ],
+        handler: () => undefined,
+      });
+      const result = s.invokeAndContinueUntilValueReceived(
+        route,
+        HOOK_NAME.PRE_HANDLER,
+        createResponseMock(),
+      );
+      expect(result).to.be.eq(ret);
+      expect(order).to.be.eql([
+        'globalHook1',
+        'globalHook2',
+        'routeHook1',
+        'routeHook2',
+      ]);
+    });
+
+    it('stops global hooks invocation and returns the given response if it was sent', function () {
+      const s = new HookInvoker();
+      const order = [];
+      const res = createResponseMock();
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook1');
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook2');
+        res._headersSent = true;
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook3');
+      });
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        preHandler: [
+          () => {
+            order.push('routeHook1');
+          },
+          () => {
+            order.push('routeHook2');
+          },
+        ],
+        handler: () => undefined,
+      });
+      const result = s.invokeAndContinueUntilValueReceived(
+        route,
+        HOOK_NAME.PRE_HANDLER,
+        res,
+      );
+      expect(result).to.be.eq(res);
+      expect(order).to.be.eql(['globalHook1', 'globalHook2']);
+    });
+
+    it('stops route hooks invocation and returns the given response if it was sent', function () {
+      const s = new HookInvoker();
+      const order = [];
+      const res = createResponseMock();
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook1');
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook2');
+      });
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        preHandler: [
+          () => {
+            order.push('routeHook1');
+          },
+          () => {
+            order.push('routeHook2');
+            res._headersSent = true;
+          },
+          () => {
+            order.push('routeHook3');
+          },
+        ],
+        handler: () => undefined,
+      });
+      const result = s.invokeAndContinueUntilValueReceived(
+        route,
+        HOOK_NAME.PRE_HANDLER,
+        res,
+      );
+      expect(result).to.be.eq(res);
+      expect(order).to.be.eql([
+        'globalHook1',
+        'globalHook2',
+        'routeHook1',
+        'routeHook2',
+      ]);
+    });
+
+    it('returns a Promise if any global hook is asynchronous', async function () {
+      const s = new HookInvoker();
+      const order = [];
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook1');
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, async () => {
+        order.push('globalHook2');
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook3');
+      });
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        preHandler: [
+          () => {
+            order.push('routeHook1');
+          },
+          () => {
+            order.push('routeHook2');
+          },
+        ],
+        handler: () => undefined,
+      });
+      const promise = s.invokeAndContinueUntilValueReceived(
+        route,
+        HOOK_NAME.PRE_HANDLER,
+        createResponseMock(),
+      );
+      expect(promise).to.be.instanceof(Promise);
+      await expect(promise).to.eventually.be.undefined;
+      expect(order).to.be.eql([
+        'globalHook1',
+        'globalHook2',
+        'globalHook3',
+        'routeHook1',
+        'routeHook2',
+      ]);
+    });
+
+    it('returns a Promise if entire global hooks are asynchronous', async function () {
+      const s = new HookInvoker();
+      const order = [];
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, async () => {
+        order.push('globalHook1');
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, async () => {
+        order.push('globalHook2');
+      });
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        preHandler: [
+          () => {
+            order.push('routeHook1');
+          },
+          () => {
+            order.push('routeHook2');
+          },
+        ],
+        handler: () => undefined,
+      });
+      const promise = s.invokeAndContinueUntilValueReceived(
+        route,
+        HOOK_NAME.PRE_HANDLER,
+        createResponseMock(),
+      );
+      expect(promise).to.be.instanceof(Promise);
+      await expect(promise).to.eventually.be.undefined;
+      expect(order).to.be.eql([
+        'globalHook1',
+        'globalHook2',
+        'routeHook1',
+        'routeHook2',
+      ]);
+    });
+
+    it('returns a Promise if any route hook is asynchronous', async function () {
+      const s = new HookInvoker();
+      const order = [];
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook1');
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook2');
+      });
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        preHandler: [
+          () => {
+            order.push('routeHook1');
+          },
+          async () => {
+            order.push('routeHook2');
+          },
+          () => {
+            order.push('routeHook3');
+          },
+        ],
+        handler: () => undefined,
+      });
+      const promise = s.invokeAndContinueUntilValueReceived(
+        route,
+        HOOK_NAME.PRE_HANDLER,
+        createResponseMock(),
+      );
+      expect(promise).to.be.instanceof(Promise);
+      await expect(promise).to.eventually.be.undefined;
+      expect(order).to.be.eql([
+        'globalHook1',
+        'globalHook2',
+        'routeHook1',
+        'routeHook2',
+        'routeHook3',
+      ]);
+    });
+
+    it('returns a Promise if entire route hooks are asynchronous', async function () {
+      const s = new HookInvoker();
+      const order = [];
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook1');
+      });
+      s.getService(HookRegistry).addHook(HOOK_NAME.PRE_HANDLER, () => {
+        order.push('globalHook2');
+      });
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        preHandler: [
+          async () => {
+            order.push('routeHook1');
+          },
+          async () => {
+            order.push('routeHook2');
+          },
+        ],
+        handler: () => undefined,
+      });
+      const promise = s.invokeAndContinueUntilValueReceived(
+        route,
+        HOOK_NAME.PRE_HANDLER,
+        createResponseMock(),
+      );
+      expect(promise).to.be.instanceof(Promise);
+      await expect(promise).to.eventually.be.undefined;
+      expect(order).to.be.eql([
+        'globalHook1',
+        'globalHook2',
+        'routeHook1',
+        'routeHook2',
+      ]);
+    });
+  });
+});

+ 43 - 0
src/hooks/hook-registry.d.ts

@@ -0,0 +1,43 @@
+import {Callable} from '../types.js';
+import {Service} from '../service.js';
+
+/**
+ * Hook type.
+ */
+export enum HOOK_NAME {
+  PRE_HANDLER = 'preHandler',
+  POST_HANDLER = 'postHandler',
+}
+
+/**
+ * Router hook.
+ */
+export type RouterHook<T = unknown> = Callable<T>;
+
+/**
+ * Hook registry.
+ */
+export declare class HookRegistry extends Service {
+  /**
+   * Add hook.
+   *
+   * @param name
+   * @param hook
+   */
+  addHook(name: HOOK_NAME, hook: RouterHook): this;
+
+  /**
+   * Has hook.
+   *
+   * @param name
+   * @param hook
+   */
+  hasHook(name: HOOK_NAME, hook: RouterHook): this;
+
+  /**
+   * Get hooks.
+   *
+   * @param name
+   */
+  getHooks(name: HOOK_NAME): RouterHook[];
+}

+ 88 - 0
src/hooks/hook-registry.js

@@ -0,0 +1,88 @@
+import {Service} from '../service.js';
+import {Errorf} from '@e22m4u/js-format';
+
+/**
+ * Router hook.
+ *
+ * @type {{
+ *   PRE_HANDLER: 'preHandler',
+ *   POST_HANDLER: 'postHandler',
+ * }}
+ */
+export const HOOK_NAME = {
+  PRE_HANDLER: 'preHandler',
+  POST_HANDLER: 'postHandler',
+};
+
+/**
+ * Hook registry.
+ */
+export class HookRegistry extends Service {
+  /**
+   * Hooks.
+   *
+   * @type {Map<string, Function[]>}
+   * @private
+   */
+  _hooks = new Map();
+
+  /**
+   * Add hook.
+   *
+   * @param {string} name
+   * @param {Function} hook
+   * @returns {this}
+   */
+  addHook(name, hook) {
+    if (!name || typeof name !== 'string')
+      throw new Errorf('The hook name is required, but %v given.', name);
+    if (!Object.values(HOOK_NAME).includes(name))
+      throw new Errorf('The hook name %v is not supported.', name);
+    if (!hook || typeof hook !== 'function')
+      throw new Errorf(
+        'The hook %v should be a Function, but %v given.',
+        name,
+        hook,
+      );
+    const hooks = this._hooks.get(name) || [];
+    hooks.push(hook);
+    this._hooks.set(name, hooks);
+    return this;
+  }
+
+  /**
+   * Has hook.
+   *
+   * @param {string} name
+   * @param {Function} hook
+   * @returns {boolean}
+   */
+  hasHook(name, hook) {
+    if (!name || typeof name !== 'string')
+      throw new Errorf('The hook name is required, but %v given.', name);
+    if (!Object.values(HOOK_NAME).includes(name))
+      throw new Errorf('The hook name %v is not supported.', name);
+    if (!hook || typeof hook !== 'function')
+      throw new Errorf(
+        'The hook %v should be a Function, but %v given.',
+        name,
+        hook,
+      );
+    const hooks = this._hooks.get(name) || [];
+    return hooks.indexOf(hook) > -1;
+  }
+
+  /**
+   * Get hooks.
+   *
+   * @param {string} name
+   * @returns {Function[]}
+   */
+  getHooks(name) {
+    if (!name || typeof name !== 'string')
+      throw new Errorf('The hook name is required, but %v given.', name);
+    if (!Object.values(HOOK_NAME).includes(name))
+      throw new Errorf('The hook name %v is not supported.', name);
+    return this._hooks.get(name) || [];
+  }
+}

+ 165 - 0
src/hooks/hook-registry.spec.js

@@ -0,0 +1,165 @@
+import {expect} from '../chai.js';
+import {format} from '@e22m4u/js-format';
+import {HOOK_NAME} from './hook-registry.js';
+import {HookRegistry} from './hook-registry.js';
+
+describe('HookRegistry', function () {
+  describe('addHook', function () {
+    it('requires the parameter "name" to be a non-empty String', function () {
+      const s = new HookRegistry();
+      const throwable = v => () => s.addHook(v, () => undefined);
+      const error = v => format('The hook name is required, but %s given.', v);
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(() => undefined)).to.throw(error('Function'));
+      throwable(HOOK_NAME.PRE_HANDLER)();
+    });
+
+    it('requires the parameter "hook" to be a Function', function () {
+      const s = new HookRegistry();
+      const throwable = v => () => s.addHook(HOOK_NAME.PRE_HANDLER, v);
+      const error = v =>
+        format('The hook "preHandler" should be a Function, but %s given.', v);
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable(() => undefined)();
+    });
+
+    it('requires the parameter "name" to be a supported hook', function () {
+      const s = new HookRegistry();
+      const hook = () => undefined;
+      Object.values(HOOK_NAME).forEach(name => s.addHook(name, hook));
+      const throwable = () => s.addHook('unknown', hook);
+      expect(throwable).to.throw('The hook name "unknown" is not supported.');
+    });
+
+    it('sets the given function to the map array by the hook name', function () {
+      const s = new HookRegistry();
+      const name = HOOK_NAME.PRE_HANDLER;
+      const hook = () => undefined;
+      s.addHook(name, hook);
+      expect(s._hooks.get(name)).to.include(hook);
+    });
+
+    it('returns this', function () {
+      const s = new HookRegistry();
+      const hook = () => undefined;
+      const name = HOOK_NAME.PRE_HANDLER;
+      const res = s.addHook(name, hook);
+      expect(res).to.be.eq(s);
+    });
+  });
+
+  describe('hasHook', function () {
+    it('requires the parameter "name" to be a non-empty String', function () {
+      const s = new HookRegistry();
+      const throwable = v => () => s.hasHook(v, () => undefined);
+      const error = v => format('The hook name is required, but %s given.', v);
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(() => undefined)).to.throw(error('Function'));
+      throwable(HOOK_NAME.PRE_HANDLER)();
+    });
+
+    it('requires the parameter "hook" to be a Function', function () {
+      const s = new HookRegistry();
+      const throwable = v => () => s.hasHook(HOOK_NAME.PRE_HANDLER, v);
+      const error = v =>
+        format('The hook "preHandler" should be a Function, but %s given.', v);
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable(() => undefined)();
+    });
+
+    it('requires the parameter "name" to be a supported hook', function () {
+      const s = new HookRegistry();
+      const hook = () => undefined;
+      Object.values(HOOK_NAME).forEach(name => s.hasHook(name, hook));
+      const throwable = () => s.hasHook('unknown', hook);
+      expect(throwable).to.throw('The hook name "unknown" is not supported.');
+    });
+
+    it('returns true if the given hook is set or false', function () {
+      const s = new HookRegistry();
+      const name = HOOK_NAME.PRE_HANDLER;
+      const hook = () => undefined;
+      expect(s.hasHook(name, hook)).to.be.false;
+      s.addHook(name, hook);
+      expect(s.hasHook(name, hook)).to.be.true;
+    });
+  });
+
+  describe('getHooks', function () {
+    it('requires the parameter "name" to be a non-empty String', function () {
+      const s = new HookRegistry();
+      const throwable = v => () => s.getHooks(v);
+      const error = v => format('The hook name is required, but %s given.', v);
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(() => undefined)).to.throw(error('Function'));
+      throwable(HOOK_NAME.PRE_HANDLER)();
+    });
+
+    it('requires the parameter "name" to be a supported hook', function () {
+      const s = new HookRegistry();
+      Object.values(HOOK_NAME).forEach(name => s.getHooks(name));
+      const throwable = () => s.getHooks('unknown');
+      expect(throwable).to.throw('The hook name "unknown" is not supported.');
+    });
+
+    it('returns existing hooks', function () {
+      const s = new HookRegistry();
+      const hook = () => undefined;
+      const name = HOOK_NAME.PRE_HANDLER;
+      const res1 = s.getHooks(name);
+      expect(res1).to.be.eql([]);
+      s.addHook(name, hook);
+      const res2 = s.getHooks(name);
+      expect(res2).to.have.length(1);
+      expect(res2[0]).to.be.eq(hook);
+    });
+
+    it('returns an empty array if no hook exists', function () {
+      const s = new HookRegistry();
+      const res = s.getHooks(HOOK_NAME.PRE_HANDLER);
+      expect(res).to.be.eql([]);
+    });
+  });
+});

+ 2 - 0
src/hooks/index.d.ts

@@ -0,0 +1,2 @@
+export * from './hook-invoker.js';
+export * from './hook-registry.js';

+ 2 - 0
src/hooks/index.js

@@ -0,0 +1,2 @@
+export * from './hook-invoker.js';
+export * from './hook-registry.js';

+ 1 - 0
src/index.d.ts

@@ -0,0 +1 @@
+export * from './utils/index.js';

+ 9 - 0
src/index.js

@@ -0,0 +1,9 @@
+export * from './route.js';
+export * from './trie-router.js';
+export * from './hooks/index.js';
+export * from './utils/index.js';
+export * from './senders/index.js';
+export * from './parsers/index.js';
+export * from './route-registry.js';
+export * from './router-options.js';
+export * from './request-context.js';

+ 52 - 0
src/parsers/body-parser.d.ts

@@ -0,0 +1,52 @@
+import {IncomingMessage} from 'http';
+import {Service} from '../service.js';
+import {ValueOrPromise} from '../types.js';
+
+/**
+ * Method names to be parsed.
+ */
+export type METHODS_WITH_BODY = string[];
+
+/**
+ * Unparsable media types.
+ */
+export type UNPARSABLE_MEDIA_TYPES = string[];
+
+/**
+ * Body parser function.
+ */
+export type BodyParserFunction = <T = unknown>(input: string) => T;
+
+/**
+ * Body parser.
+ */
+export declare class BodyParser extends Service {
+  /**
+   * Define parser.
+   *
+   * @param mediaType
+   * @param parser
+   */
+  defineParser(mediaType: string, parser: BodyParserFunction): this;
+
+  /**
+   * Has parser.
+   *
+   * @param mediaType
+   */
+  hasParser(mediaType: string): boolean;
+
+  /**
+   * Delete parser.
+   *
+   * @param mediaType
+   */
+  deleteParser(mediaType: string): this;
+
+  /**
+   * Parse.
+   *
+   * @param req
+   */
+  parse<T = unknown>(req: IncomingMessage): ValueOrPromise<T>;
+}

+ 161 - 0
src/parsers/body-parser.js

@@ -0,0 +1,161 @@
+import HttpErrors from 'http-errors';
+import {Service} from '../service.js';
+import {Errorf} from '@e22m4u/js-format';
+import {createError} from '../utils/index.js';
+import {RouterOptions} from '../router-options.js';
+import {fetchRequestBody} from '../utils/index.js';
+import {parseContentType} from '../utils/parse-content-type.js';
+
+/**
+ * Method names to be parsed.
+ *
+ * @type {string[]}
+ */
+export const METHODS_WITH_BODY = ['post', 'put', 'patch', 'delete'];
+
+/**
+ * Unparsable media types.
+ *
+ * @type {string[]}
+ */
+export const UNPARSABLE_MEDIA_TYPES = ['multipart/form-data'];
+
+/**
+ * Body parser.
+ */
+export class BodyParser extends Service {
+  /**
+   * Parsers.
+   *
+   * @type {{[mime: string]: Function}}
+   */
+  _parsers = {
+    'text/plain': v => String(v),
+    'application/json': parseJsonBody,
+  };
+
+  /**
+   * Set parser.
+   *
+   * @param {string} mediaType
+   * @param {Function} parser
+   * @returns {this}
+   */
+  defineParser(mediaType, parser) {
+    if (!mediaType || typeof mediaType !== 'string')
+      throw new Errorf(
+        'The parameter "mediaType" of BodyParser.defineParser ' +
+          'should be a non-empty String, but %v given.',
+        mediaType,
+      );
+    if (!parser || typeof parser !== 'function')
+      throw new Errorf(
+        'The parameter "parser" of BodyParser.defineParser ' +
+          'should be a Function, but %v given.',
+        parser,
+      );
+    this._parsers[mediaType] = parser;
+    return this;
+  }
+
+  /**
+   * Has parser.
+   *
+   * @param {string} mediaType
+   * @returns {boolean}
+   */
+  hasParser(mediaType) {
+    if (!mediaType || typeof mediaType !== 'string')
+      throw new Errorf(
+        'The parameter "mediaType" of BodyParser.hasParser ' +
+          'should be a non-empty String, but %v given.',
+        mediaType,
+      );
+    return Boolean(this._parsers[mediaType]);
+  }
+
+  /**
+   * Delete parser.
+   *
+   * @param {string} mediaType
+   * @returns {this}
+   */
+  deleteParser(mediaType) {
+    if (!mediaType || typeof mediaType !== 'string')
+      throw new Errorf(
+        'The parameter "mediaType" of BodyParser.deleteParser ' +
+          'should be a non-empty String, but %v given.',
+        mediaType,
+      );
+    const parser = this._parsers[mediaType];
+    if (!parser) throw new Errorf('The parser of %v is not found.', mediaType);
+    delete this._parsers[mediaType];
+    return this;
+  }
+
+  /**
+   * Parse.
+   *
+   * @param {import('http').IncomingMessage} req
+   * @returns {Promise<*>|undefined}
+   */
+  parse(req) {
+    if (!METHODS_WITH_BODY.includes(req.method.toLowerCase())) {
+      this.debug(
+        'Body parsing was skipped for the %s request.',
+        req.method.toUpperCase(),
+      );
+      return;
+    }
+    const contentType = (req.headers['content-type'] || '').replace(
+      /^([^;]+);.*$/,
+      '$1',
+    );
+    if (!contentType) {
+      this.debug(
+        'Body parsing was skipped because the request has no content type.',
+      );
+      return;
+    }
+    const {mediaType} = parseContentType(contentType);
+    if (!mediaType)
+      throw createError(
+        HttpErrors.BadRequest,
+        'Unable to parse the "content-type" header.',
+      );
+    const parser = this._parsers[mediaType];
+    if (!parser) {
+      if (UNPARSABLE_MEDIA_TYPES.includes(mediaType)) {
+        this.debug('Body parsing was skipped for %v.', mediaType);
+        return;
+      }
+      throw createError(
+        HttpErrors.UnsupportedMediaType,
+        'Media type %v is not supported.',
+        mediaType,
+      );
+    }
+    const bodyBytesLimit = this.getService(RouterOptions).requestBodyBytesLimit;
+    return fetchRequestBody(req, bodyBytesLimit).then(rawBody => {
+      if (rawBody != null) return parser(rawBody);
+      return rawBody;
+    });
+  }
+}
+
+/**
+ * Parse json body.
+ *
+ * @param {string} input
+ * @returns {*|undefined}
+ */
+export function parseJsonBody(input) {
+  if (typeof input !== 'string') return undefined;
+  try {
+    return JSON.parse(input);
+  } catch (error) {
+    if (process.env['DEBUG'] || process.env['NODE_ENV'] === 'development')
+      console.warn(error);
+    throw createError(HttpErrors.BadRequest, 'Unable to parse request body.');
+  }
+}

+ 297 - 0
src/parsers/body-parser.spec.js

@@ -0,0 +1,297 @@
+import {expect} from '../chai.js';
+import {HTTP_METHOD} from '../route.js';
+import {format} from '@e22m4u/js-format';
+import {BodyParser} from './body-parser.js';
+import {METHODS_WITH_BODY} from './body-parser.js';
+import {UNPARSABLE_MEDIA_TYPES} from './body-parser.js';
+import {createRequestMock} from '../utils/create-request-mock.js';
+import {RouterOptions} from '../router-options.js';
+import HttpErrors from 'http-errors';
+
+describe('BodyParser', function () {
+  describe('defineParser', function () {
+    it('requires the parameter "mediaType" to be a non-empty String', function () {
+      const parser = new BodyParser();
+      const throwable = v => () => parser.defineParser(v, () => undefined);
+      const error = v =>
+        format(
+          'The parameter "mediaType" of BodyParser.defineParser ' +
+            'should be a non-empty String, but %s given.',
+          v,
+        );
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(() => undefined)).to.throw(error('Function'));
+      throwable('text/plain')();
+    });
+
+    it('requires the parameter "parser" to be a Function', function () {
+      const parser = new BodyParser();
+      const throwable = v => () => parser.defineParser('str', v);
+      const error = v =>
+        format(
+          'The parameter "parser" of BodyParser.defineParser ' +
+            'should be a Function, but %s given.',
+          v,
+        );
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable(() => undefined)();
+    });
+
+    it('overrides existing parser', function () {
+      const parser = new BodyParser();
+      const fn = v => v;
+      parser.defineParser('text/plain', fn);
+      expect(parser['_parsers']['text/plain']).to.be.eq(fn);
+    });
+
+    it('sets a new parser', function () {
+      const parser = new BodyParser();
+      const fn = v => v;
+      parser.defineParser('my/type', fn);
+      expect(parser['_parsers']['my/type']).to.be.eq(fn);
+    });
+  });
+
+  describe('hasParser', function () {
+    it('requires the parameter "mediaType" to be a non-empty String', function () {
+      const parser = new BodyParser();
+      const throwable = v => () => parser.hasParser(v);
+      const error = v =>
+        format(
+          'The parameter "mediaType" of BodyParser.hasParser ' +
+            'should be a non-empty String, but %s given.',
+          v,
+        );
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(() => undefined)).to.throw(error('Function'));
+      throwable('text/plain')();
+    });
+
+    it('returns true if the parser is exist', function () {
+      const parser = new BodyParser();
+      parser.defineParser('type/media', v => v);
+      expect(parser.hasParser('type/media')).to.be.true;
+    });
+
+    it('returns false if the parser is not exist', function () {
+      const parser = new BodyParser();
+      expect(parser.hasParser('text/unknown')).to.be.false;
+    });
+  });
+
+  describe('removesParser', function () {
+    it('requires the parameter "mediaType" to be a non-empty String', function () {
+      const parser = new BodyParser();
+      const throwable = v => () => parser.deleteParser(v);
+      const error = v =>
+        format(
+          'The parameter "mediaType" of BodyParser.deleteParser ' +
+            'should be a non-empty String, but %s given.',
+          v,
+        );
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(() => undefined)).to.throw(error('Function'));
+      throwable('text/plain')();
+    });
+
+    it('remove existing parser', function () {
+      const parser = new BodyParser();
+      const fn = v => v;
+      parser.defineParser('my/type', fn);
+      expect(parser['_parsers']['my/type']).to.be.eq(fn);
+      parser.deleteParser('my/type');
+      expect(parser['_parsers']['my/type']).to.be.undefined;
+    });
+
+    it('throws an error if the media type does not exist', function () {
+      const parser = new BodyParser();
+      const throwable = () => parser.deleteParser('unknown');
+      expect(throwable).to.throw('The parser of "unknown" is not found.');
+    });
+  });
+
+  describe('parse', function () {
+    it('returns undefined if the request method is not supported', async function () {
+      const parser = new BodyParser();
+      const req = createRequestMock({
+        method: 'unsupported',
+        body: 'Lorem Ipsum is simply dummy text.',
+      });
+      const result = await parser.parse(req);
+      expect(result).to.be.undefined;
+    });
+
+    it('returns undefined if the request method is not supported even the header "content-type" is specified', async function () {
+      const parser = new BodyParser();
+      const req = createRequestMock({
+        method: 'unsupported',
+        headers: {'content-type': 'text/plain'},
+        body: 'Lorem Ipsum is simply dummy text.',
+      });
+      const result = await parser.parse(req);
+      expect(result).to.be.undefined;
+    });
+
+    it('returns undefined if no "content-type" header', async function () {
+      const parser = new BodyParser();
+      const req = createRequestMock({method: HTTP_METHOD.POST});
+      const result = await parser.parse(req);
+      expect(result).to.be.undefined;
+    });
+
+    it('returns undefined if the media type is excluded', async function () {
+      const parser = new BodyParser();
+      for await (const mediaType of UNPARSABLE_MEDIA_TYPES) {
+        const req = createRequestMock({
+          method: HTTP_METHOD.POST,
+          headers: {'content-type': mediaType},
+          body: 'Lorem Ipsum is simply dummy text.',
+        });
+        const result = await parser.parse(req);
+        expect(result).to.be.undefined;
+      }
+    });
+
+    it('parses the request body for available methods', async function () {
+      const parser = new BodyParser();
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const headers = {'content-type': 'text/plain'};
+      for await (const method of Object.values(METHODS_WITH_BODY)) {
+        const req = createRequestMock({method, body, headers});
+        const result = await parser.parse(req);
+        expect(result).to.be.eq(body);
+      }
+    });
+
+    it('throws an error for unsupported media type', function () {
+      const parser = new BodyParser();
+      const req = createRequestMock({
+        method: HTTP_METHOD.POST,
+        headers: {'content-type': 'media/unknown'},
+      });
+      const throwable = () => parser.parse(req);
+      expect(throwable).to.throw(
+        'Media type "media/unknown" is not supported.',
+      );
+    });
+
+    it('uses the option "bodyBytesLimit" from the RouterOptions', async function () {
+      const parser = new BodyParser();
+      parser.getService(RouterOptions).setRequestBodyBytesLimit(1);
+      const req = createRequestMock({
+        method: HTTP_METHOD.POST,
+        headers: {
+          'content-type': 'text/plain',
+          'content-length': '2',
+        },
+      });
+      const promise = parser.parse(req);
+      await expect(promise).to.be.rejectedWith(HttpErrors.PayloadTooLarge);
+    });
+
+    describe('text/plain', function () {
+      it('returns undefined if no request body', async function () {
+        const parser = new BodyParser();
+        const req = createRequestMock({
+          method: HTTP_METHOD.POST,
+          headers: {'content-type': 'text/plain'},
+        });
+        const result = await parser.parse(req);
+        expect(result).to.be.undefined;
+      });
+
+      it('returns a string from the string body', async function () {
+        const body = 'Lorem Ipsum is simply dummy text.';
+        const parser = new BodyParser();
+        const req = createRequestMock({
+          method: HTTP_METHOD.POST,
+          headers: {'content-type': 'text/plain'},
+          body,
+        });
+        const result = await parser.parse(req);
+        expect(result).to.be.eq(body);
+      });
+
+      it('returns a string from the Buffer body', async function () {
+        const body = 'Lorem Ipsum is simply dummy text.';
+        const parser = new BodyParser();
+        const req = createRequestMock({
+          method: HTTP_METHOD.POST,
+          headers: {'content-type': 'text/plain'},
+          body: Buffer.from(body, 'utf-8'),
+        });
+        const result = await parser.parse(req);
+        expect(result).to.be.eq(body);
+      });
+    });
+
+    describe('application/json', function () {
+      it('returns undefined if no request body', async function () {
+        const parser = new BodyParser();
+        const req = createRequestMock({
+          method: HTTP_METHOD.POST,
+          headers: {'content-type': 'application/json'},
+        });
+        const result = await parser.parse(req);
+        expect(result).to.be.undefined;
+      });
+
+      it('returns parsed JSON from the string body', async function () {
+        const body = {foo: 'bar'};
+        const parser = new BodyParser();
+        const req = createRequestMock({
+          method: HTTP_METHOD.POST,
+          headers: {'content-type': 'application/json'},
+          body: JSON.stringify(body),
+        });
+        const result = await parser.parse(req);
+        expect(result).to.be.eql(body);
+      });
+
+      it('returns parsed JSON from the Buffer body', async function () {
+        const body = {foo: 'bar'};
+        const parser = new BodyParser();
+        const req = createRequestMock({
+          method: HTTP_METHOD.POST,
+          headers: {'content-type': 'application/json'},
+          body: Buffer.from(JSON.stringify(body)),
+        });
+        const result = await parser.parse(req);
+        expect(result).to.be.eql(body);
+      });
+    });
+  });
+});

+ 21 - 0
src/parsers/cookie-parser.d.ts

@@ -0,0 +1,21 @@
+import {IncomingMessage} from 'http';
+import {Service} from '../service.js';
+
+/**
+ * Parsed cookie.
+ */
+export type ParsedCookie = {
+  [key: string]: string | undefined;
+};
+
+/**
+ * Cookie parser.
+ */
+export declare class CookieParser extends Service {
+  /**
+   * Parse.
+   *
+   * @param req
+   */
+  parse(req: IncomingMessage): ParsedCookie;
+}

+ 32 - 0
src/parsers/cookie-parser.js

@@ -0,0 +1,32 @@
+import {Service} from '../service.js';
+import {parseCookie} from '../utils/index.js';
+import {getRequestPath} from '../utils/index.js';
+
+/**
+ * Cookie parser.
+ */
+export class CookieParser extends Service {
+  /**
+   * Parse
+   *
+   * @param {import('http').IncomingMessage} req
+   * @returns {object}
+   */
+  parse(req) {
+    const cookieString = req.headers['cookie'] || '';
+    const cookie = parseCookie(cookieString);
+    const cookieKeys = Object.keys(cookie);
+    if (cookieKeys.length) {
+      cookieKeys.forEach(key => {
+        this.debug('The cookie %v has the value %v.', key, cookie[key]);
+      });
+    } else {
+      this.debug(
+        'The request %s %v has no cookie.',
+        req.method,
+        getRequestPath(req),
+      );
+    }
+    return cookie;
+  }
+}

+ 26 - 0
src/parsers/cookie-parser.spec.js

@@ -0,0 +1,26 @@
+import {expect} from '../chai.js';
+import {CookieParser} from './cookie-parser.js';
+
+describe('CookieParser', function () {
+  describe('parse', function () {
+    it('returns cookie parameters', function () {
+      const parser = new CookieParser();
+      const value = 'pkg=math; equation=E%3Dmc%5E2';
+      const result = parser.parse({url: '', headers: {cookie: value}});
+      expect(result).to.have.property('pkg', 'math');
+      expect(result).to.have.property('equation', 'E=mc^2');
+    });
+
+    it('returns an empty object if no cookies', function () {
+      const parser = new CookieParser();
+      const result = parser.parse({url: '', headers: {}});
+      expect(result).to.be.eql({});
+    });
+
+    it('returns an empty object for an empty string', function () {
+      const parser = new CookieParser();
+      const result = parser.parse({url: '', headers: {cookie: ''}});
+      expect(result).to.be.eql({});
+    });
+  });
+});

+ 4 - 0
src/parsers/index.d.ts

@@ -0,0 +1,4 @@
+export * from './body-parser.js';
+export * from './query-parser.js';
+export * from './cookie-parser.js';
+export * from './request-parser.js';

+ 4 - 0
src/parsers/index.js

@@ -0,0 +1,4 @@
+export * from './body-parser.js';
+export * from './query-parser.js';
+export * from './cookie-parser.js';
+export * from './request-parser.js';

+ 21 - 0
src/parsers/query-parser.d.ts

@@ -0,0 +1,21 @@
+import {IncomingMessage} from 'http';
+import {Service} from '../service.js';
+
+/**
+ * Parsed query.
+ */
+export type ParsedQuery = {
+  [key: string]: string | undefined;
+};
+
+/**
+ * Query parser.
+ */
+export declare class QueryParser extends Service {
+  /**
+   * Parse.
+   *
+   * @param req
+   */
+  parse(req: IncomingMessage): ParsedQuery;
+}

+ 32 - 0
src/parsers/query-parser.js

@@ -0,0 +1,32 @@
+import querystring from 'querystring';
+import {Service} from '../service.js';
+import {getRequestPath} from '../utils/index.js';
+
+/**
+ * Query parser.
+ */
+export class QueryParser extends Service {
+  /**
+   * Parse
+   *
+   * @param {import('http').IncomingMessage} req
+   * @returns {object}
+   */
+  parse(req) {
+    const queryStr = req.url.replace(/^[^?]*\??/, '');
+    const query = queryStr ? querystring.parse(queryStr) : {};
+    const queryKeys = Object.keys(query);
+    if (queryKeys.length) {
+      queryKeys.forEach(key => {
+        this.debug('The query %v has the value %v.', key, query[key]);
+      });
+    } else {
+      this.debug(
+        'The request %s %v has no query.',
+        req.method,
+        getRequestPath(req),
+      );
+    }
+    return query;
+  }
+}

+ 25 - 0
src/parsers/query-parser.spec.js

@@ -0,0 +1,25 @@
+import {expect} from '../chai.js';
+import {QueryParser} from './query-parser.js';
+
+describe('QueryParser', function () {
+  describe('parse', function () {
+    it('returns query parameters', function () {
+      const parser = new QueryParser();
+      const value = 'foo=bar&baz=qux';
+      const result = parser.parse({url: `/test?${value}`});
+      expect(result).to.be.eql({foo: 'bar', baz: 'qux'});
+    });
+
+    it('returns an empty object if no query', function () {
+      const parser = new QueryParser();
+      const result = parser.parse({url: `/test`});
+      expect(result).to.be.eql({});
+    });
+
+    it('returns an empty object for an empty query', function () {
+      const parser = new QueryParser();
+      const result = parser.parse({url: `/test?`});
+      expect(result).to.be.eql({});
+    });
+  });
+});

+ 34 - 0
src/parsers/request-parser.d.ts

@@ -0,0 +1,34 @@
+import {IncomingMessage} from 'http';
+import {Service} from '../service.js';
+import {ValueOrPromise} from '../types.js';
+import {ParsedQuery} from './query-parser.js';
+import {ParsedCookie} from './cookie-parser.js';
+
+/**
+ * Parsed headers.
+ */
+export type ParsedHeaders = {
+  [key: string]: string | undefined;
+};
+
+/**
+ * Parsed request.
+ */
+type ParsedRequestData = {
+  query: ParsedQuery;
+  cookie: ParsedCookie;
+  body: unknown;
+  headers: ParsedHeaders;
+};
+
+/**
+ * Request parser.
+ */
+export declare class RequestParser extends Service {
+  /**
+   * Parse.
+   *
+   * @param req
+   */
+  parse(req: IncomingMessage): ValueOrPromise<ParsedRequestData>;
+}

+ 65 - 0
src/parsers/request-parser.js

@@ -0,0 +1,65 @@
+import {IncomingMessage} from 'http';
+import {Service} from '../service.js';
+import {Errorf} from '@e22m4u/js-format';
+import {isPromise} from '../utils/index.js';
+import {BodyParser} from './body-parser.js';
+import {QueryParser} from './query-parser.js';
+import {CookieParser} from './cookie-parser.js';
+
+/**
+ * Request parser.
+ */
+export class RequestParser extends Service {
+  /**
+   * Parse.
+   *
+   * @param {IncomingMessage} req
+   * @returns {Promise<object>|object}
+   */
+  parse(req) {
+    if (!(req instanceof IncomingMessage))
+      throw new Errorf(
+        'The first argument of RequestParser.parse should be ' +
+          'an instance of IncomingMessage, but %v given.',
+        req,
+      );
+    const data = {};
+    const promises = [];
+    // парсинг "query" выполняется с проверкой
+    // значения, так как парсер может вернуть
+    // Promise, и тогда придется разрывать
+    // "eventLoop" с помощью "await"
+    const parsedQuery = this.getService(QueryParser).parse(req);
+    if (isPromise(parsedQuery)) {
+      promises.push(parsedQuery.then(v => (data.query = v)));
+    } else {
+      data.query = parsedQuery;
+    }
+    // аналогично предыдущей операции, разбираем
+    // данные заголовка "cookie" с проверкой
+    // значения на Promise, и разрываем
+    // "eventLoop" при необходимости
+    const parsedCookie = this.getService(CookieParser).parse(req);
+    if (isPromise(parsedCookie)) {
+      promises.push(parsedCookie.then(v => (data.cookie = v)));
+    } else {
+      data.cookie = parsedCookie;
+    }
+    // аналогично предыдущей операции, разбираем
+    // тело запроса с проверкой результата
+    // на наличие Promise
+    const parsedBody = this.getService(BodyParser).parse(req);
+    if (isPromise(parsedBody)) {
+      promises.push(parsedBody.then(v => (data.body = v)));
+    } else {
+      data.body = parsedBody;
+    }
+    // что бы предотвратить модификацию
+    // заголовков, возвращаем их копию
+    data.headers = JSON.parse(JSON.stringify(req.headers));
+    // если имеются асинхронные операции, то результат
+    // будет обернут в Promise, в противном случае
+    // данные возвращаются сразу
+    return promises.length ? Promise.all(promises).then(() => data) : data;
+  }
+}

+ 137 - 0
src/parsers/request-parser.spec.js

@@ -0,0 +1,137 @@
+import {expect} from '../chai.js';
+import {HTTP_METHOD} from '../route.js';
+import {format} from '@e22m4u/js-format';
+import {RequestParser} from './request-parser.js';
+import {createRequestMock} from '../utils/create-request-mock.js';
+
+describe('RequestParser', function () {
+  describe('parse', function () {
+    it('requires the first argument to be an instance of IncomingMessage', function () {
+      const s = new RequestParser();
+      const throwable = v => () => s.parse(v);
+      const error = v =>
+        format(
+          'The first argument of RequestParser.parse should be ' +
+            'an instance of IncomingMessage, but %s given.',
+          v,
+        );
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(() => undefined)).to.throw(error('Function'));
+      throwable(createRequestMock())();
+    });
+
+    it('returns the result object if no request body to parse', function () {
+      const s = new RequestParser();
+      const req = createRequestMock();
+      const res = s.parse(req);
+      expect(res).to.be.eql({
+        query: {},
+        cookie: {},
+        body: undefined,
+        headers: {host: 'localhost'},
+      });
+    });
+
+    it('returns a Promise of the result object in case of the body parsing', async function () {
+      const s = new RequestParser();
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const req = createRequestMock({
+        method: HTTP_METHOD.POST,
+        headers: {'content-type': 'text/plain'},
+        body,
+      });
+      const promise = s.parse(req);
+      expect(promise).to.be.instanceof(Promise);
+      const res = await promise;
+      expect(res).to.be.eql({
+        query: {},
+        cookie: {},
+        body,
+        headers: {
+          host: 'localhost',
+          'content-type': 'text/plain',
+          'content-length': String(Buffer.from(body).byteLength),
+        },
+      });
+    });
+
+    it('returns the parsed query in the result object', function () {
+      const s = new RequestParser();
+      const req = createRequestMock({path: '/path?p1=foo&p2=bar'});
+      const res = s.parse(req);
+      expect(res).to.be.eql({
+        query: {p1: 'foo', p2: 'bar'},
+        cookie: {},
+        body: undefined,
+        headers: {host: 'localhost'},
+      });
+    });
+
+    it('returns the parsed cookie in the result object', function () {
+      const s = new RequestParser();
+      const req = createRequestMock({headers: {cookie: 'p1=foo; p2=bar;'}});
+      const res = s.parse(req);
+      expect(res).to.be.eql({
+        query: {},
+        cookie: {p1: 'foo', p2: 'bar'},
+        body: undefined,
+        headers: {
+          host: 'localhost',
+          cookie: 'p1=foo; p2=bar;',
+        },
+      });
+    });
+
+    it('returns the parsed body of the media type "text/plain" in the result object', async function () {
+      const s = new RequestParser();
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const req = createRequestMock({
+        method: HTTP_METHOD.POST,
+        headers: {'content-type': 'text/plain'},
+        body,
+      });
+      const res = await s.parse(req);
+      expect(res).to.be.eql({
+        query: {},
+        cookie: {},
+        body,
+        headers: {
+          host: 'localhost',
+          'content-type': 'text/plain',
+          'content-length': String(Buffer.from(body).byteLength),
+        },
+      });
+    });
+
+    it('returns the parsed body of the media type "application/json" in the result object', async function () {
+      const s = new RequestParser();
+      const body = {foo: 'bar', baz: 'qux'};
+      const json = JSON.stringify(body);
+      const req = createRequestMock({
+        method: HTTP_METHOD.POST,
+        headers: {'content-type': 'application/json'},
+        body,
+      });
+      const res = await s.parse(req);
+      expect(res).to.be.eql({
+        query: {},
+        cookie: {},
+        body,
+        headers: {
+          host: 'localhost',
+          'content-type': 'application/json',
+          'content-length': String(Buffer.from(json).byteLength),
+        },
+      });
+    });
+  });
+});

+ 54 - 0
src/request-context.d.ts

@@ -0,0 +1,54 @@
+import {ServerResponse} from 'http';
+import {IncomingMessage} from 'http';
+import {ParsedQuery} from './parsers/index.js';
+import {ParsedCookie} from './parsers/index.js';
+import {ParsedHeaders} from './parsers/index.js';
+import {ServiceContainer} from '@e22m4u/js-service';
+
+/**
+ * Request context.
+ */
+export declare class RequestContext {
+  /**
+   * Container.
+   */
+  container: ServiceContainer;
+
+  /**
+   * Request.
+   */
+  req: IncomingMessage;
+
+  /**
+   * Response.
+   */
+  res: ServerResponse;
+
+  /**
+   * Query.
+   */
+  query: ParsedQuery;
+
+  /**
+   * Headers.
+   */
+  headers: ParsedHeaders;
+
+  /**
+   * Cookie.
+   */
+  cookie: ParsedCookie;
+
+  /**
+   * Constructor.
+   *
+   * @param container
+   * @param request
+   * @param response
+   */
+  constructor(
+    container: ServiceContainer,
+    request: IncomingMessage,
+    response: ServerResponse,
+  );
+}

+ 108 - 0
src/request-context.js

@@ -0,0 +1,108 @@
+import {Errorf} from '@e22m4u/js-format';
+import {isReadableStream} from './utils/index.js';
+import {isWritableStream} from './utils/index.js';
+import {ServiceContainer} from '@e22m4u/js-service';
+
+/**
+ * Request context.
+ */
+export class RequestContext {
+  /**
+   * Service container.
+   *
+   * @type {import('@e22m4u/js-service').ServiceContainer}
+   */
+  container;
+
+  /**
+   * Request.
+   *
+   * @type {import('http').IncomingMessage}
+   */
+  req;
+
+  /**
+   * Response.
+   *
+   * @type {import('http').ServerResponse}
+   */
+  res;
+
+  /**
+   * Query.
+   *
+   * @type {object}
+   */
+  query = {};
+
+  /**
+   * Path parameters.
+   *
+   * @type {object}
+   */
+  params = {};
+
+  /**
+   * Parsed body.
+   *
+   * @type {*}
+   */
+  body;
+
+  /**
+   * Headers.
+   *
+   * @type {object}
+   */
+  headers = {};
+
+  /**
+   * Parsed cookie.
+   *
+   * @type {object}
+   */
+  cookie = {};
+
+  /**
+   * Constructor.
+   *
+   * @param {ServiceContainer} container
+   * @param {import('http').IncomingMessage} request
+   * @param {import('http').ServerResponse} response
+   */
+  constructor(container, request, response) {
+    if (!(container instanceof ServiceContainer))
+      throw new Errorf(
+        'The parameter "container" of RequestContext.constructor ' +
+          'should be an instance of ServiceContainer, but %v given.',
+        container,
+      );
+    this.container = container;
+    if (
+      !request ||
+      typeof request !== 'object' ||
+      Array.isArray(request) ||
+      !isReadableStream(request)
+    ) {
+      throw new Errorf(
+        'The parameter "request" of RequestContext.constructor ' +
+          'should be an instance of IncomingMessage, but %v given.',
+        request,
+      );
+    }
+    this.req = request;
+    if (
+      !response ||
+      typeof response !== 'object' ||
+      Array.isArray(response) ||
+      !isWritableStream(response)
+    ) {
+      throw new Errorf(
+        'The parameter "response" of RequestContext.constructor ' +
+          'should be an instance of ServerResponse, but %v given.',
+        response,
+      );
+    }
+    this.res = response;
+  }
+}

+ 89 - 0
src/request-context.spec.js

@@ -0,0 +1,89 @@
+import {expect} from './chai.js';
+import {format} from '@e22m4u/js-format';
+import {RequestContext} from './request-context.js';
+import {ServiceContainer} from '@e22m4u/js-service';
+import {createRequestMock} from './utils/create-request-mock.js';
+import {createResponseMock} from './utils/create-response-mock.js';
+
+describe('RequestContext', function () {
+  describe('constructor', function () {
+    it('requires the parameter "container" to be the ServiceContainer', function () {
+      const req = createRequestMock();
+      const res = createResponseMock();
+      const throwable = v => () => new RequestContext(v, req, res);
+      const error = v =>
+        format(
+          'The parameter "container" of RequestContext.constructor ' +
+            'should be an instance of ServiceContainer, but %s given.',
+          v,
+        );
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable(new ServiceContainer())();
+    });
+
+    it('requires the parameter "request" to be the ServiceContainer', function () {
+      const res = createResponseMock();
+      const cnt = new ServiceContainer();
+      const throwable = v => () => new RequestContext(cnt, v, res);
+      const error = v =>
+        format(
+          'The parameter "request" of RequestContext.constructor ' +
+            'should be an instance of IncomingMessage, but %s given.',
+          v,
+        );
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable(createRequestMock())();
+    });
+
+    it('requires the parameter "response" to be the ServiceContainer', function () {
+      const req = createRequestMock();
+      const cnt = new ServiceContainer();
+      const throwable = v => () => new RequestContext(cnt, req, v);
+      const error = v =>
+        format(
+          'The parameter "response" of RequestContext.constructor ' +
+            'should be an instance of ServerResponse, but %s given.',
+          v,
+        );
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable(createResponseMock())();
+    });
+
+    it('sets properties from given arguments', function () {
+      const req = createRequestMock();
+      const res = createResponseMock();
+      const cnt = new ServiceContainer();
+      const ctx = new RequestContext(cnt, req, res);
+      expect(ctx.container).to.be.eq(cnt);
+      expect(ctx.req).to.be.eq(req);
+      expect(ctx.res).to.be.eq(res);
+    });
+  });
+});

+ 39 - 0
src/route-registry.d.ts

@@ -0,0 +1,39 @@
+import {Route} from './route.js';
+import {Service} from './service.js';
+import {IncomingMessage} from 'http';
+import {RouteDefinition} from './route.js';
+import {ServiceContainer} from '@e22m4u/js-service';
+
+/**
+ * Resolved route.
+ */
+export type ResolvedRoute = {
+  route: Route;
+  params: {[key: string]: string | undefined};
+};
+
+/**
+ * Route registry.
+ */
+export declare class RouteRegistry extends Service {
+  /**
+   * Constructor.
+   *
+   * @param container
+   */
+  constructor(container: ServiceContainer);
+
+  /**
+   * Define route.
+   *
+   * @param routeDef
+   */
+  defineRoute(routeDef: RouteDefinition): Route;
+
+  /**
+   * Match route by request.
+   *
+   * @param req
+   */
+  matchRouteByRequest(req: IncomingMessage): ResolvedRoute | undefined;
+}

+ 95 - 0
src/route-registry.js

@@ -0,0 +1,95 @@
+import {Route} from './route.js';
+import {Service} from './service.js';
+import {Errorf} from '@e22m4u/js-format';
+import {PathTrie} from '@e22m4u/js-path-trie';
+import {ServiceContainer} from '@e22m4u/js-service';
+
+/**
+ * @typedef {{
+ *   route: Route,
+ *   params: object,
+ * }} ResolvedRoute
+ */
+
+/**
+ * Route registry.
+ */
+export class RouteRegistry extends Service {
+  /**
+   * Constructor.
+   *
+   * @param {ServiceContainer} container
+   */
+  constructor(container) {
+    super(container);
+    this._trie = new PathTrie();
+  }
+
+  /**
+   * Define route.
+   *
+   * @param {import('./route.js').RouteDefinition} routeDef
+   * @returns {Route}
+   */
+  defineRoute(routeDef) {
+    if (!routeDef || typeof routeDef !== 'object' || Array.isArray(routeDef))
+      throw new Errorf(
+        'The route definition should be an Object, but %v given.',
+        routeDef,
+      );
+    const route = new Route(routeDef);
+    const triePath = `${route.method}/${route.path}`;
+    this._trie.add(triePath, route);
+    this.debug(
+      'The route %s %v is registered.',
+      route.method.toUpperCase(),
+      route.path,
+    );
+    return route;
+  }
+
+  /**
+   * Match route by request.
+   *
+   * @param {import('http').IncomingRequest} req
+   * @returns {ResolvedRoute|undefined}
+   */
+  matchRouteByRequest(req) {
+    const requestPath = (req.url || '/').replace(/\?.*$/, '');
+    this.debug(
+      'Matching %s %v with registered routes.',
+      req.method.toUpperCase(),
+      requestPath,
+    );
+    const triePath = `${req.method.toLowerCase()}/${requestPath}`;
+    const resolved = this._trie.match(triePath);
+    if (resolved) {
+      const route = resolved.value;
+      this.debug(
+        'The request %s %v was matched to the route %s %v.',
+        req.method.toUpperCase(),
+        requestPath,
+        route.method.toUpperCase(),
+        route.path,
+      );
+      const paramNames = Object.keys(resolved.params);
+      if (paramNames) {
+        paramNames.forEach(name => {
+          this.debug(
+            'The path parameter %v has the value %v.',
+            name,
+            resolved.params[name],
+          );
+        });
+      } else {
+        this.debug('No path parameters found.');
+      }
+      return {route, params: resolved.params};
+    }
+    this.debug(
+      'No matched route for the request %s %v.',
+      req.method.toUpperCase(),
+      requestPath,
+    );
+  }
+}

+ 77 - 0
src/route-registry.spec.js

@@ -0,0 +1,77 @@
+import {Route} from './route.js';
+import {expect} from './chai.js';
+import {HTTP_METHOD} from './route.js';
+import {format} from '@e22m4u/js-format';
+import {RouteRegistry} from './route-registry.js';
+import {ServiceContainer} from '@e22m4u/js-service';
+
+describe('RouteRegistry', function () {
+  describe('defineRoute', function () {
+    it('requires the first parameter to be an Object', function () {
+      const s = new RouteRegistry();
+      const throwable = v => () => s.defineRoute(v);
+      const error = v =>
+        format('The route definition should be an Object, but %s given.', v);
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(() => undefined)).to.throw(error('Function'));
+      throwable({
+        method: HTTP_METHOD.GET,
+        path: '/path',
+        handler: () => undefined,
+      })();
+    });
+
+    it('returns a new route with the given "method", "path" and "handler"', function () {
+      const s = new RouteRegistry();
+      const method = HTTP_METHOD.PATCH;
+      const path = '/myPath';
+      const handler = () => undefined;
+      const route = s.defineRoute({method, path, handler});
+      expect(route.method).to.be.eq(method);
+      expect(route.path).to.be.eq(path);
+      expect(route.handler).to.be.eq(handler);
+    });
+
+    it('adds a new route to the Trie', function () {
+      const s = new RouteRegistry();
+      const method = HTTP_METHOD.PATCH;
+      const path = '/myPath';
+      const handler = () => undefined;
+      const route = s.defineRoute({method, path, handler});
+      const triePath = `${method}/${path}`;
+      const res = s._trie.match(triePath);
+      expect(typeof res).to.be.eq('object');
+      expect(res.value).to.be.eq(route);
+    });
+  });
+
+  describe('matchRouteByRequest', function () {
+    it('returns the route and parsed parameters', function () {
+      const s = new RouteRegistry(new ServiceContainer());
+      const handler = () => undefined;
+      s.defineRoute({
+        method: HTTP_METHOD.GET,
+        path: '/foo/:p1/bar/:p2',
+        handler,
+      });
+      const res = s.matchRouteByRequest({
+        url: '/foo/baz/bar/qux',
+        method: HTTP_METHOD.GET,
+      });
+      expect(typeof res).to.be.eq('object');
+      expect(res.route).to.be.instanceof(Route);
+      expect(res.route.method).to.be.eq(HTTP_METHOD.GET);
+      expect(res.route.path).to.be.eq('/foo/:p1/bar/:p2');
+      expect(res.route.handler).to.be.eq(handler);
+      expect(res.params).to.be.eql({p1: 'baz', p2: 'qux'});
+    });
+  });
+});

+ 82 - 0
src/route.d.ts

@@ -0,0 +1,82 @@
+import {ValueOrPromise} from './types.js';
+import {HookRegistry} from './hooks/index.js';
+import {RequestContext} from './request-context.js';
+
+/**
+ * Http method.
+ */
+export enum HTTP_METHOD {
+  GET = 'get',
+  POST = 'post',
+  PUT = 'put',
+  PATCH = 'patch',
+  DELETE = 'delete',
+}
+
+/**
+ * Route handler.
+ */
+export type RouteHandler = (ctx: RequestContext) => ValueOrPromise<unknown>;
+
+/**
+ * Route pre-handler.
+ */
+export type RoutePreHandler = RouteHandler;
+
+/**
+ * Route post-handler.
+ */
+export type RoutePostHandler<T = unknown, U = unknown> = (
+  ctx: RequestContext,
+  data: T,
+) => ValueOrPromise<U>;
+
+/**
+ * Route definition.
+ */
+export type RouteDefinition = {
+  method: string;
+  path: string;
+  preHandler: RoutePreHandler | RoutePreHandler[];
+  handler: RouteHandler;
+  postHandler: RoutePostHandler | RoutePostHandler[];
+};
+
+/**
+ * Route.
+ */
+export declare class Route {
+  /**
+   * Method.
+   */
+  get method(): string;
+
+  /**
+   * Path.
+   */
+  get path(): string;
+
+  /**
+   * Handler.
+   */
+  get handler(): RouteHandler;
+
+  /**
+   * Hook registry.
+   */
+  get hookRegistry(): HookRegistry;
+
+  /**
+   * Constructor.
+   *
+   * @param routeDef
+   */
+  constructor(routeDef: RouteDefinition);
+
+  /**
+   * Handle.
+   *
+   * @param context
+   */
+  handle(context: RequestContext): ValueOrPromise<unknown>;
+}

+ 184 - 0
src/route.js

@@ -0,0 +1,184 @@
+import {Errorf} from '@e22m4u/js-format';
+import {HOOK_NAME} from './hooks/index.js';
+import {HookRegistry} from './hooks/index.js';
+import {createDebugger} from './utils/index.js';
+import {getRequestPath} from './utils/index.js';
+
+/**
+ * @typedef {import('./request-context.js').RequestContext} RequestContext
+ * @typedef {(ctx: RequestContext) => *} RoutePreHandler
+ * @typedef {(ctx: RequestContext) => *} RouteHandler
+ * @typedef {(ctx: RequestContext, data: *) => *} RoutePostHandler
+ * @typedef {{
+ *   method: string,
+ *   path: string,
+ *   preHandler: RoutePreHandler|(RoutePreHandler[])
+ *   handler: RouteHandler,
+ *   postHandler: RoutePostHandler|(RoutePostHandler[])
+ * }} RouteDefinition
+ */
+
+/**
+ * Http method.
+ *
+ * @type {{
+ *   DELETE: 'delete',
+ *   POST: 'post',
+ *   GET: 'get',
+ *   PUT: 'put',
+ *   PATCH: 'patch',
+ * }}
+ */
+export const HTTP_METHOD = {
+  GET: 'get',
+  POST: 'post',
+  PUT: 'put',
+  PATCH: 'patch',
+  DELETE: 'delete',
+};
+
+/**
+ * Debugger.
+ *
+ * @type {Function}
+ */
+const debug = createDebugger('route');
+
+/**
+ * Route.
+ */
+export class Route {
+  /**
+   * Method.
+   *
+   * @type {string}
+   * @private
+   */
+  _method;
+
+  /**
+   * Getter of the method.
+   *
+   * @returns {string}
+   */
+  get method() {
+    return this._method;
+  }
+
+  /**
+   * Path template.
+   *
+   * @type {string}
+   * @private
+   */
+  _path;
+
+  /**
+   * Getter of the path.
+   *
+   * @returns {string}
+   */
+  get path() {
+    return this._path;
+  }
+
+  /**
+   * Handler.
+   *
+   * @type {RouteHandler}
+   * @private
+   */
+  _handler;
+
+  /**
+   * Getter of the handler.
+   *
+   * @returns {*}
+   */
+  get handler() {
+    return this._handler;
+  }
+
+  /**
+   * Hook registry.
+   *
+   * @type {HookRegistry}
+   * @private
+   */
+  _hookRegistry = new HookRegistry();
+
+  /**
+   * Getter of the hook registry.
+   *
+   * @returns {HookRegistry}
+   */
+  get hookRegistry() {
+    return this._hookRegistry;
+  }
+
+  /**
+   * Constructor.
+   *
+   * @param {RouteDefinition} routeDef
+   */
+  constructor(routeDef) {
+    if (!routeDef || typeof routeDef !== 'object' || Array.isArray(routeDef))
+      throw new Errorf(
+        'The first parameter of Route.controller ' +
+          'should be an Object, but %v given.',
+        routeDef,
+      );
+    if (!routeDef.method || typeof routeDef.method !== 'string')
+      throw new Errorf(
+        'The option "method" of the Route should be ' +
+          'a non-empty String, but %v given.',
+        routeDef.method,
+      );
+    this._method = routeDef.method.toLowerCase();
+    if (typeof routeDef.path !== 'string')
+      throw new Errorf(
+        'The option "path" of the Route should be ' + 'a String, but %v given.',
+        routeDef.path,
+      );
+    this._path = routeDef.path;
+    if (typeof routeDef.handler !== 'function')
+      throw new Errorf(
+        'The option "handler" of the Route should be ' +
+          'a Function, but %v given.',
+        routeDef.handler,
+      );
+    this._handler = routeDef.handler;
+    if (routeDef.preHandler != null) {
+      const preHandlerHooks = Array.isArray(routeDef.preHandler)
+        ? routeDef.preHandler
+        : [routeDef.preHandler];
+      preHandlerHooks.forEach(hook => {
+        this._hookRegistry.addHook(HOOK_NAME.PRE_HANDLER, hook);
+      });
+    }
+    if (routeDef.postHandler != null) {
+      const postHandlerHooks = Array.isArray(routeDef.postHandler)
+        ? routeDef.postHandler
+        : [routeDef.postHandler];
+      postHandlerHooks.forEach(hook => {
+        this._hookRegistry.addHook(HOOK_NAME.POST_HANDLER, hook);
+      });
+    }
+  }
+
+  /**
+   * Handle request.
+   *
+   * @param {RequestContext} context
+   * @returns {*}
+   */
+  handle(context) {
+    const requestPath = getRequestPath(context.req);
+    debug(
+      'Invoking the Route handler for the request %s %v.',
+      this.method.toUpperCase(),
+      requestPath,
+    );
+    return this._handler(context);
+  }
+}

+ 299 - 0
src/route.spec.js

@@ -0,0 +1,299 @@
+import {Route} from './route.js';
+import {expect} from './chai.js';
+import {HTTP_METHOD} from './route.js';
+import {format} from '@e22m4u/js-format';
+import {HOOK_NAME} from './hooks/index.js';
+import {RequestContext} from './request-context.js';
+import {ServiceContainer} from '@e22m4u/js-service';
+import {createRequestMock} from './utils/create-request-mock.js';
+import {createResponseMock} from './utils/create-response-mock.js';
+
+describe('Route', function () {
+  describe('constructor', function () {
+    it('requires the first parameter to be an Object', function () {
+      const throwable = v => () => new Route(v);
+      const error = v =>
+        format(
+          'The first parameter of Route.controller ' +
+            'should be an Object, but %s given.',
+          v,
+        );
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(() => undefined)).to.throw(error('Function'));
+      throwable({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: () => undefined,
+      })();
+    });
+
+    it('requires the option "method" to be a non-empty String', function () {
+      const throwable = v => () =>
+        new Route({
+          method: v,
+          path: '/',
+          handler: () => undefined,
+        });
+      const error = v =>
+        format(
+          'The option "method" of the Route should be ' +
+            'a non-empty String, but %s given.',
+          v,
+        );
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(() => undefined)).to.throw(error('Function'));
+      throwable(HTTP_METHOD.GET)();
+    });
+
+    it('requires the option "path" to be a non-empty String', function () {
+      const throwable = v => () =>
+        new Route({
+          method: HTTP_METHOD.GET,
+          path: v,
+          handler: () => undefined,
+        });
+      const error = v =>
+        format(
+          'The option "path" of the Route should be ' +
+            'a String, but %s given.',
+          v,
+        );
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(() => undefined)).to.throw(error('Function'));
+      throwable('str')();
+      throwable('')();
+    });
+
+    it('requires the option "handler" to be a non-empty String', function () {
+      const throwable = v => () =>
+        new Route({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          handler: v,
+        });
+      const error = v =>
+        format(
+          'The option "handler" of the Route should be ' +
+            'a Function, but %s given.',
+          v,
+        );
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable(() => undefined)();
+    });
+
+    it('requires the option "preHandler" to be a Function or an Array of Function', function () {
+      const throwable1 = v => () =>
+        new Route({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          preHandler: v,
+          handler: () => undefined,
+        });
+      const error = v =>
+        format('The hook "preHandler" should be a Function, but %s given.', v);
+      expect(throwable1('str')).to.throw(error('"str"'));
+      expect(throwable1('')).to.throw(error('""'));
+      expect(throwable1(10)).to.throw(error('10'));
+      expect(throwable1(0)).to.throw(error('0'));
+      expect(throwable1(true)).to.throw(error('true'));
+      expect(throwable1(false)).to.throw(error('false'));
+      expect(throwable1({})).to.throw(error('Object'));
+      throwable1([])();
+      throwable1(() => undefined)();
+      throwable1(null)();
+      throwable1(undefined)();
+      const throwable2 = v => () =>
+        new Route({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          preHandler: [v],
+          handler: () => undefined,
+        });
+      expect(throwable2('str')).to.throw(error('"str"'));
+      expect(throwable2('')).to.throw(error('""'));
+      expect(throwable2(10)).to.throw(error('10'));
+      expect(throwable2(0)).to.throw(error('0'));
+      expect(throwable2(true)).to.throw(error('true'));
+      expect(throwable2(false)).to.throw(error('false'));
+      expect(throwable2({})).to.throw(error('Object'));
+      expect(throwable2(null)).to.throw(error('null'));
+      expect(throwable2([])).to.throw(error('Array'));
+      expect(throwable2(undefined)).to.throw(error('undefined'));
+      throwable2(() => undefined)();
+    });
+
+    it('requires the option "postHandler" to be a Function or an Array of Function', function () {
+      const throwable1 = v => () =>
+        new Route({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          handler: () => undefined,
+          postHandler: v,
+        });
+      const error = v =>
+        format('The hook "postHandler" should be a Function, but %s given.', v);
+      expect(throwable1('str')).to.throw(error('"str"'));
+      expect(throwable1('')).to.throw(error('""'));
+      expect(throwable1(10)).to.throw(error('10'));
+      expect(throwable1(0)).to.throw(error('0'));
+      expect(throwable1(true)).to.throw(error('true'));
+      expect(throwable1(false)).to.throw(error('false'));
+      expect(throwable1({})).to.throw(error('Object'));
+      throwable1([])();
+      throwable1(() => undefined)();
+      throwable1(null)();
+      throwable1(undefined)();
+      const throwable2 = v => () =>
+        new Route({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          handler: () => undefined,
+          postHandler: [v],
+        });
+      expect(throwable2('str')).to.throw(error('"str"'));
+      expect(throwable2('')).to.throw(error('""'));
+      expect(throwable2(10)).to.throw(error('10'));
+      expect(throwable2(0)).to.throw(error('0'));
+      expect(throwable2(true)).to.throw(error('true'));
+      expect(throwable2(false)).to.throw(error('false'));
+      expect(throwable2({})).to.throw(error('Object'));
+      expect(throwable2(null)).to.throw(error('null'));
+      expect(throwable2([])).to.throw(error('Array'));
+      expect(throwable2(undefined)).to.throw(error('undefined'));
+      throwable2(() => undefined)();
+    });
+
+    it('sets the option "method" in lowercase to the "method" property', function () {
+      const route = new Route({
+        method: 'POST',
+        path: '/',
+        handler: () => undefined,
+      });
+      expect(route.method).to.be.eq('post');
+    });
+
+    it('sets the option "path" to the "path" property', function () {
+      const value = '/myPath';
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: value,
+        handler: () => undefined,
+      });
+      expect(route.path).to.be.eq(value);
+    });
+
+    it('sets the option "handler" to the "handler" property', function () {
+      const value = () => undefined;
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: value,
+      });
+      expect(route.handler).to.be.eq(value);
+    });
+
+    it('adds a Function to "preHandler" hooks', function () {
+      const value = () => undefined;
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        preHandler: value,
+        handler: () => undefined,
+      });
+      expect(route.hookRegistry.hasHook(HOOK_NAME.PRE_HANDLER, value)).to.be
+        .true;
+    });
+
+    it('adds Function items of an Array to "preHandler" hooks', function () {
+      const value = [() => undefined, () => undefined];
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        preHandler: value,
+        handler: () => undefined,
+      });
+      expect(route.hookRegistry.hasHook(HOOK_NAME.PRE_HANDLER, value[0])).to.be
+        .true;
+      expect(route.hookRegistry.hasHook(HOOK_NAME.PRE_HANDLER, value[1])).to.be
+        .true;
+    });
+
+    it('adds a Function to "postHandler" hooks', function () {
+      const value = () => undefined;
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: () => undefined,
+        postHandler: value,
+      });
+      expect(route.hookRegistry.hasHook(HOOK_NAME.POST_HANDLER, value)).to.be
+        .true;
+    });
+
+    it('adds Function items of an Array to "postHandler" hooks', function () {
+      const value = [() => undefined, () => undefined];
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: () => undefined,
+        postHandler: value,
+      });
+      expect(route.hookRegistry.hasHook(HOOK_NAME.POST_HANDLER, value[0])).to.be
+        .true;
+      expect(route.hookRegistry.hasHook(HOOK_NAME.POST_HANDLER, value[1])).to.be
+        .true;
+    });
+  });
+
+  describe('handle', function () {
+    it('invokes the handler with the given RequestContext and return its result', function () {
+      const handler = ctx => {
+        expect(ctx).to.be.instanceof(RequestContext);
+        return 'OK';
+      };
+      const route = new Route({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler,
+      });
+      const req = createRequestMock();
+      const res = createResponseMock();
+      const cnt = new ServiceContainer();
+      const ctx = new RequestContext(cnt, req, res);
+      const result = route.handle(ctx);
+      expect(result).to.be.eq('OK');
+    });
+  });
+});

+ 18 - 0
src/router-options.d.ts

@@ -0,0 +1,18 @@
+import {Service} from './service.js';
+
+/**
+ * Router options.
+ */
+export declare class RouterOptions extends Service {
+  /**
+   * Request body bytes limit.
+   */
+  get requestBodyBytesLimit(): number;
+
+  /**
+   * Set request body bytes limit.
+   *
+   * @param input
+   */
+  setRequestBodyBytesLimit(input: number): this;
+}

+ 41 - 0
src/router-options.js

@@ -0,0 +1,41 @@
+import {Service} from './service.js';
+import {Errorf} from '@e22m4u/js-format';
+
+/**
+ * Router options.
+ */
+export class RouterOptions extends Service {
+  /**
+   * Request body bytes limit.
+   *
+   * @type {number}
+   * @private
+   */
+  _requestBodyBytesLimit = 512000; // 512kb
+
+  /**
+   * Getter of request body bytes limit.
+   *
+   * @returns {number}
+   */
+  get requestBodyBytesLimit() {
+    return this._requestBodyBytesLimit;
+  }
+
+  /**
+   * Set request body bytes limit.
+   *
+   * @param {number} input
+   * @returns {RouterOptions}
+   */
+  setRequestBodyBytesLimit(input) {
+    if (typeof input !== 'number' || input < 0)
+      throw new Errorf(
+        'The option "requestBodyBytesLimit" must be ' +
+          'a positive Number or 0, but %v given.',
+        input,
+      );
+    this._requestBodyBytesLimit = input;
+    return this;
+  }
+}

+ 52 - 0
src/router-options.spec.js

@@ -0,0 +1,52 @@
+import {describe} from 'mocha';
+import {expect} from './chai.js';
+import {format} from '@e22m4u/js-format';
+import {RouterOptions} from './router-options.js';
+
+describe('RouterOptions', function () {
+  describe('requestBodyBytesLimit', function () {
+    it('returns the default value', function () {
+      const s = new RouterOptions();
+      expect(s.requestBodyBytesLimit).to.be.eq(512000);
+    });
+
+    it('returns a value of the property "_requestBodyBytesLimit"', function () {
+      const s = new RouterOptions();
+      s._requestBodyBytesLimit = 1;
+      expect(s.requestBodyBytesLimit).to.be.eq(1);
+      s._requestBodyBytesLimit = 2;
+      expect(s.requestBodyBytesLimit).to.be.eq(2);
+    });
+  });
+
+  describe('setRequestBodyBytesLimit', function () {
+    it('requires the first parameter to be a positive Number or 0', function () {
+      const s = new RouterOptions();
+      const throwable = v => () => s.setRequestBodyBytesLimit(v);
+      const error = v =>
+        format(
+          'The option "requestBodyBytesLimit" must be ' +
+            'a positive Number or 0, but %s given.',
+          v,
+        );
+      expect(throwable('str')).to.throw(error('"str"'));
+      expect(throwable('')).to.throw(error('""'));
+      expect(throwable(-1)).to.throw(error('-1'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable(10)();
+      throwable(0)();
+    });
+
+    it('sets the given value to the property "_requestBodyBytesLimit"', function () {
+      const s = new RouterOptions();
+      expect(s._requestBodyBytesLimit).to.be.eq(512000);
+      s.setRequestBodyBytesLimit(0);
+      expect(s._requestBodyBytesLimit).to.be.eq(0);
+    });
+  });
+});

+ 15 - 0
src/senders/data-sender.d.ts

@@ -0,0 +1,15 @@
+import {Service} from '../service.js';
+import {ServerResponse} from 'http';
+
+/**
+ * Data sender.
+ */
+export declare class DataSender extends Service {
+  /**
+   * Send.
+   *
+   * @param res
+   * @param data
+   */
+  send(res: ServerResponse, data: unknown): void;
+}

+ 72 - 0
src/senders/data-sender.js

@@ -0,0 +1,72 @@
+import {Service} from '../service.js';
+import {format} from '@e22m4u/js-format';
+import {isReadableStream} from '../utils/index.js';
+
+/**
+ * Data sender.
+ */
+export class DataSender extends Service {
+  /**
+   * Send.
+   *
+   * @param {import('http').ServerResponse} res
+   * @param {*} data
+   * @returns {undefined}
+   */
+  send(res, data) {
+    // если ответ контроллера является объектом
+    // ServerResponse, или имеются отправленные
+    // заголовки, то считаем, что контроллер
+    // уже отправил ответ самостоятельно
+    if (data === res || res.headersSent) {
+      this.debug(
+        'Response sending was skipped because ' +
+          'its headers where sent already .',
+      );
+      return;
+    }
+    // если ответ контроллера пуст, то отправляем
+    // статус 204 "No Content"
+    if (data == null) {
+      res.statusCode = 204;
+      res.end();
+      this.debug('The empty response was sent.');
+      return;
+    }
+    // если ответ контроллера является стримом,
+    // то отправляем его как бинарные данные
+    if (isReadableStream(data)) {
+      res.setHeader('Content-Type', 'application/octet-stream');
+      data.pipe(res);
+      this.debug('The stream response was sent.');
+      return;
+    }
+    // подготовка данных перед отправкой, и установка
+    // нужного заголовка в зависимости от их типа
+    let debugMsg;
+    switch (typeof data) {
+      case 'object':
+      case 'boolean':
+      case 'number':
+        if (Buffer.isBuffer(data)) {
+          // тип Buffer отправляется
+          // как бинарные данные
+          res.setHeader('content-type', 'application/octet-stream');
+          debugMsg = 'The Buffer was sent as binary data.';
+        } else {
+          res.setHeader('content-type', 'application/json');
+          debugMsg = format('The %v was sent as JSON.', typeof data);
+          data = JSON.stringify(data);
+        }
+        break;
+      default:
+        res.setHeader('content-type', 'text/plain');
+        debugMsg = 'The response data was sent as plain text.';
+        data = String(data);
+        break;
+    }
+    // отправка подготовленных данных
+    res.end(data);
+    this.debug(debugMsg);
+  }
+}

+ 193 - 0
src/senders/data-sender.spec.js

@@ -0,0 +1,193 @@
+import {expect} from '../chai.js';
+import {Readable, Writable} from 'stream';
+import {DataSender} from './data-sender.js';
+import {createResponseMock} from '../utils/create-response-mock.js';
+
+describe('DataSender', function () {
+  describe('send', function () {
+    it('does nothing if the data is the given response', function (done) {
+      const res = createResponseMock();
+      const writable = new Writable();
+      writable._write = function () {
+        throw new Error('Should not be called');
+      };
+      writable._final = function () {
+        throw new Error('Should not be called');
+      };
+      res.pipe(writable);
+      const s = new DataSender();
+      const result = s.send(res, res);
+      expect(result).to.be.undefined;
+      setTimeout(() => done(), 5);
+    });
+
+    it('does nothing if response headers already sent', function (done) {
+      const res = createResponseMock();
+      res._headersSent = true;
+      const writable = new Writable();
+      writable._write = function () {
+        throw new Error('Should not be called');
+      };
+      writable._final = function () {
+        throw new Error('Should not be called');
+      };
+      res.pipe(writable);
+      const s = new DataSender();
+      const result = s.send(res, 'data');
+      expect(result).to.be.undefined;
+      setTimeout(() => done(), 5);
+    });
+
+    it('sends 204 if no data', function (done) {
+      const res = createResponseMock();
+      res.on('data', () => done(new Error('Should not be called')));
+      res.on('error', e => done(e));
+      res.on('end', () => {
+        expect(res.statusCode).to.be.eq(204);
+        done();
+      });
+      const s = new DataSender();
+      const result = s.send(res, undefined);
+      expect(result).to.be.undefined;
+    });
+
+    it('sends the given readable stream as binary data', function (done) {
+      const data = 'text';
+      const stream = new Readable();
+      stream._read = () => {};
+      stream.push(data);
+      stream.push(null);
+      const res = createResponseMock();
+      const writable = new Writable();
+      const chunks = [];
+      writable._write = function (chunk, encoding, done) {
+        chunks.push(chunk);
+        done();
+      };
+      writable._final = function (callback) {
+        const sentData = Buffer.concat(chunks).toString('utf-8');
+        expect(sentData).to.be.eq(data);
+        const ct = res.getHeader('content-type');
+        expect(ct).to.be.eq('application/octet-stream');
+        callback();
+        done();
+      };
+      res.pipe(writable);
+      const s = new DataSender();
+      s.send(res, stream);
+    });
+
+    it('sends the given Buffer as binary data', function (done) {
+      const data = Buffer.from('text');
+      const res = createResponseMock();
+      const writable = new Writable();
+      const chunks = [];
+      writable._write = function (chunk, encoding, done) {
+        chunks.push(chunk);
+        done();
+      };
+      writable._final = function (callback) {
+        const sentData = Buffer.concat(chunks);
+        expect(sentData).to.be.eql(sentData);
+        const ct = res.getHeader('content-type');
+        expect(ct).to.be.eq('application/octet-stream');
+        callback();
+        done();
+      };
+      res.pipe(writable);
+      const s = new DataSender();
+      s.send(res, data);
+    });
+
+    it('sends the given string as plain text', function (done) {
+      const data = 'text';
+      const res = createResponseMock();
+      const writable = new Writable();
+      const chunks = [];
+      writable._write = function (chunk, encoding, done) {
+        chunks.push(chunk);
+        done();
+      };
+      writable._final = function (callback) {
+        const sentData = Buffer.concat(chunks).toString('utf-8');
+        expect(sentData).to.be.eq(data);
+        const ct = res.getHeader('content-type');
+        expect(ct).to.be.eq('text/plain');
+        callback();
+        done();
+      };
+      res.pipe(writable);
+      const s = new DataSender();
+      s.send(res, data);
+    });
+
+    it('sends the given object as JSON', function (done) {
+      const data = {foo: 'bar'};
+      const res = createResponseMock();
+      const writable = new Writable();
+      const chunks = [];
+      writable._write = function (chunk, encoding, done) {
+        chunks.push(chunk);
+        done();
+      };
+      writable._final = function (callback) {
+        const sentJson = Buffer.concat(chunks).toString('utf-8');
+        const sentData = JSON.parse(sentJson);
+        expect(sentData).to.be.eql(data);
+        const ct = res.getHeader('content-type');
+        expect(ct).to.be.eq('application/json');
+        callback();
+        done();
+      };
+      res.pipe(writable);
+      const s = new DataSender();
+      s.send(res, data);
+    });
+
+    it('sends the given boolean as JSON', function (done) {
+      const data = true;
+      const res = createResponseMock();
+      const writable = new Writable();
+      const chunks = [];
+      writable._write = function (chunk, encoding, done) {
+        chunks.push(chunk);
+        done();
+      };
+      writable._final = function (callback) {
+        const sentJson = Buffer.concat(chunks).toString('utf-8');
+        const sentData = JSON.parse(sentJson);
+        expect(sentData).to.be.eql(data);
+        const ct = res.getHeader('content-type');
+        expect(ct).to.be.eq('application/json');
+        callback();
+        done();
+      };
+      res.pipe(writable);
+      const s = new DataSender();
+      s.send(res, data);
+    });
+
+    it('sends the given number as JSON', function (done) {
+      const data = 10;
+      const res = createResponseMock();
+      const writable = new Writable();
+      const chunks = [];
+      writable._write = function (chunk, encoding, done) {
+        chunks.push(chunk);
+        done();
+      };
+      writable._final = function (callback) {
+        const sentJson = Buffer.concat(chunks).toString('utf-8');
+        const sentData = JSON.parse(sentJson);
+        expect(sentData).to.be.eql(data);
+        const ct = res.getHeader('content-type');
+        expect(ct).to.be.eq('application/json');
+        callback();
+        done();
+      };
+      res.pipe(writable);
+      const s = new DataSender();
+      s.send(res, data);
+    });
+  });
+});

+ 25 - 0
src/senders/error-sender.d.ts

@@ -0,0 +1,25 @@
+import {ServerResponse} from 'http';
+import {IncomingMessage} from 'http';
+import {Service} from '../service.js';
+
+/**
+ * Error sender.
+ */
+export declare class ErrorSender extends Service {
+  /**
+   * Send.
+   *
+   * @param req
+   * @param res
+   * @param error
+   */
+  send(req: IncomingMessage, res: ServerResponse, error: Error): void;
+
+  /**
+   * Send 404.
+   *
+   * @param req
+   * @param res
+   */
+  send404(req: IncomingMessage, res: ServerResponse): void;
+}

+ 85 - 0
src/senders/error-sender.js

@@ -0,0 +1,85 @@
+import {inspect} from 'util';
+import {Service} from '../service.js';
+import getStatusMessage from 'statuses';
+import {getRequestPath} from '../utils/index.js';
+
+/**
+ * Exposed error properties.
+ *
+ * @type {string[]}
+ */
+export const EXPOSED_ERROR_PROPERTIES = ['code', 'details'];
+
+/**
+ * Error sender.
+ */
+export class ErrorSender extends Service {
+  /**
+   * Handle.
+   *
+   * @param {import('http').IncomingMessage} req
+   * @param {import('http').ServerResponse} res
+   * @param {Error} error
+   * @returns {undefined}
+   */
+  send(req, res, error) {
+    let safeError = {};
+    if (error) {
+      if (typeof error === 'object') {
+        safeError = error;
+      } else {
+        safeError = {message: String(error)};
+      }
+    }
+    const statusCode = error.statusCode || error.status || 500;
+    const body = {error: {}};
+    if (safeError.message && typeof safeError.message === 'string') {
+      body.error.message = safeError.message;
+    } else {
+      body.error.message = getStatusMessage(statusCode);
+    }
+    EXPOSED_ERROR_PROPERTIES.forEach(name => {
+      if (name in safeError) body.error[name] = safeError[name];
+    });
+    const requestData = {
+      url: req.url,
+      method: req.method,
+      headers: req.headers,
+    };
+    const inspectOptions = {
+      showHidden: false,
+      depth: null,
+      colors: true,
+      compact: false,
+    };
+    console.warn(inspect(requestData, inspectOptions));
+    console.warn(inspect(body, inspectOptions));
+    res.statusCode = statusCode;
+    res.setHeader('content-type', 'application/json; charset=utf-8');
+    res.end(JSON.stringify(body, null, 2), 'utf-8');
+    this.debug(
+      'The %s error is sent for the request %s %v.',
+      statusCode,
+      req.method,
+      getRequestPath(req),
+    );
+  }
+
+  /**
+   * Send 404.
+   *
+   * @param {import('http').IncomingMessage} req
+   * @param {import('http').ServerResponse} res
+   * @returns {undefined}
+   */
+  send404(req, res) {
+    res.statusCode = 404;
+    res.setHeader('content-type', 'text/plain; charset=utf-8');
+    res.end('404 Not Found', 'utf-8');
+    this.debug(
+      'The 404 error is sent for the request %s %v.',
+      req.method,
+      getRequestPath(req),
+    );
+  }
+}

+ 90 - 0
src/senders/error-sender.spec.js

@@ -0,0 +1,90 @@
+import {Writable} from 'stream';
+import {expect} from '../chai.js';
+import HttpErrors from 'http-errors';
+import {ErrorSender} from './error-sender.js';
+import {EXPOSED_ERROR_PROPERTIES} from './error-sender.js';
+import {createRequestMock} from '../utils/create-request-mock.js';
+import {createResponseMock} from '../utils/create-response-mock.js';
+
+describe('ErrorSender', function () {
+  describe('send', function () {
+    it('sends error as utf-8 JSON', function (done) {
+      const error = HttpErrors.Unauthorized();
+      const req = createRequestMock();
+      const res = createResponseMock();
+      const writable = new Writable();
+      const chunks = [];
+      writable._write = function (chunk, encoding, done) {
+        chunks.push(chunk);
+        done();
+      };
+      writable._final = function (callback) {
+        const json = Buffer.concat(chunks).toString('utf-8');
+        const data = JSON.parse(json);
+        expect(data).to.be.eql({error: {message: 'Unauthorized'}});
+        expect(res.statusCode).to.be.eq(401);
+        const ct = res.getHeader('content-type');
+        expect(ct).to.be.eq('application/json; charset=utf-8');
+        callback();
+        done();
+      };
+      res.pipe(writable);
+      const s = new ErrorSender();
+      s.send(req, res, error);
+    });
+
+    it('exposes only specified properties of the given error', function (done) {
+      const error = HttpErrors.Unauthorized();
+      EXPOSED_ERROR_PROPERTIES.forEach(name => (error[name] = name));
+      error.shouldNotBeExposedProp = 'shouldNotBeExposedProp';
+      const req = createRequestMock();
+      const res = createResponseMock();
+      const writable = new Writable();
+      const chunks = [];
+      writable._write = function (chunk, encoding, done) {
+        chunks.push(chunk);
+        done();
+      };
+      writable._final = function (callback) {
+        const json = Buffer.concat(chunks).toString('utf-8');
+        const data = JSON.parse(json);
+        const expectedData = {error: {message: 'Unauthorized'}};
+        EXPOSED_ERROR_PROPERTIES.forEach(name => (expectedData[name] = name));
+        expect(data).not.to.have.property('shouldNotBeExposedProp');
+        expect(res.statusCode).to.be.eq(401);
+        const ct = res.getHeader('content-type');
+        expect(ct).to.be.eq('application/json; charset=utf-8');
+        callback();
+        done();
+      };
+      res.pipe(writable);
+      const s = new ErrorSender();
+      s.send(req, res, error);
+    });
+  });
+
+  describe('send404', function () {
+    it('sends plain text', function (done) {
+      const req = createRequestMock();
+      const res = createResponseMock();
+      const writable = new Writable();
+      const chunks = [];
+      writable._write = function (chunk, encoding, done) {
+        chunks.push(chunk);
+        done();
+      };
+      writable._final = function (callback) {
+        const body = Buffer.concat(chunks).toString('utf-8');
+        expect(body).to.be.eql('404 Not Found');
+        expect(res.statusCode).to.be.eq(404);
+        const ct = res.getHeader('content-type');
+        expect(ct).to.be.eq('text/plain; charset=utf-8');
+        callback();
+        done();
+      };
+      res.pipe(writable);
+      const s = new ErrorSender();
+      s.send404(req, res);
+    });
+  });
+});

+ 2 - 0
src/senders/index.d.ts

@@ -0,0 +1,2 @@
+export * from './data-sender.js';
+export * from './error-sender.js';

+ 2 - 0
src/senders/index.js

@@ -0,0 +1,2 @@
+export * from './data-sender.js';
+export * from './error-sender.js';

+ 14 - 0
src/service.d.ts

@@ -0,0 +1,14 @@
+import {Debugger} from './utils/index.js';
+import {Service as BaseService} from '@e22m4u/js-service';
+
+/**
+ * Service.
+ */
+declare class Service extends BaseService {
+  /**
+   * Debug.
+   *
+   * @protected
+   */
+  protected debug: Debugger;
+}

+ 28 - 0
src/service.js

@@ -0,0 +1,28 @@
+import {toCamelCase} from './utils/index.js';
+import {createDebugger} from './utils/index.js';
+import {ServiceContainer} from '@e22m4u/js-service';
+import {Service as BaseService} from '@e22m4u/js-service';
+
+/**
+ * Service.
+ */
+export class Service extends BaseService {
+  /**
+   * Debug.
+   *
+   * @type {Function}
+   */
+  debug;
+
+  /**
+   * Constructor.
+   *
+   * @param {ServiceContainer} container
+   */
+  constructor(container) {
+    super(container);
+    const serviceName = toCamelCase(this.constructor.name);
+    this.debug = createDebugger(serviceName);
+    this.debug('The %v is created.', this.constructor);
+  }
+}

+ 11 - 0
src/service.spec.js

@@ -0,0 +1,11 @@
+import {expect} from './chai.js';
+import {Service} from './service.js';
+
+describe('Service', function () {
+  describe('constructor', function () {
+    it('sets the debugger to the "debug" property', function () {
+      const service = new Service();
+      expect(service.debug).to.be.instanceof(Function);
+    });
+  });
+});

+ 66 - 0
src/trie-router.d.ts

@@ -0,0 +1,66 @@
+import {Route} from './route.js';
+import {Service} from './service.js';
+import {RequestListener} from 'http';
+import {RouteDefinition} from './route.js';
+import {HOOK_NAME} from './hooks/index.js';
+import {RouterHook} from './hooks/index.js';
+
+/**
+ * Trie router.
+ */
+export declare class TrieRouter extends Service {
+  /**
+   * Define route.
+   *
+   * Example 1:
+   * ```
+   * const router = new TrieRouter();
+   * router.defineRoute({
+   *   method: HTTP_METHOD.GET,        // Request method.
+   *   path: '/',                      // Path template.
+   *   handler: ctx => 'Hello world!', // Request handler.
+   * });
+   * ```
+   *
+   * Example 2:
+   * ```
+   * const router = new TrieRouter();
+   * router.defineRoute({
+   *   method: HTTP_METHOD.POST,       // Request method.
+   *   path: '/users/:id',             // The path template may have parameters.
+   *   preHandler(ctx) { ... },        // The "preHandler" is executed before a route handler.
+   *   handler(ctx) { ... },           // Request handler function.
+   *   postHandler(ctx, data) { ... }, // The "postHandler" is executed after a route handler
+   * });
+   * ```
+   *
+   * @param routeDef
+   */
+  defineRoute(routeDef: RouteDefinition): Route;
+
+  /**
+   * Request handler.
+   *
+   * Example:
+   * ```
+   * import http from 'http';
+   * import {TrieRouter} from '@e22m4u/js-trie-router';
+   *
+   * const router = new TrieRouter();
+   * const server = new http.Server();
+   * server.on('request', router.requestHandler); // Sets the request handler.
+   * server.listen(3000);                         // Starts listening for connections.
+   * ```
+   *
+   * @returns {Function}
+   */
+  get requestHandler(): RequestListener;
+
+  /**
+   * Add hook.
+   *
+   * @param name
+   * @param hook
+   */
+  addHook(name: HOOK_NAME, hook: RouterHook): this;
+}

+ 189 - 0
src/trie-router.js

@@ -0,0 +1,189 @@
+import {Service} from './service.js';
+import {isPromise} from './utils/index.js';
+import {HOOK_NAME} from './hooks/index.js';
+import {HookInvoker} from './hooks/index.js';
+import {DataSender} from './senders/index.js';
+import {HookRegistry} from './hooks/index.js';
+import {ErrorSender} from './senders/index.js';
+import {RequestParser} from './parsers/index.js';
+import {RouteRegistry} from './route-registry.js';
+import {RequestContext} from './request-context.js';
+import {ServiceContainer} from '@e22m4u/js-service';
+
+/**
+ * Trie router.
+ */
+export class TrieRouter extends Service {
+  /**
+   * Define route.
+   *
+   * Example 1:
+   * ```
+   * const router = new TrieRouter();
+   * router.defineRoute({
+   *   method: HTTP_METHOD.GET,        // Request method.
+   *   path: '/',                      // Path template.
+   *   handler: ctx => 'Hello world!', // Request handler.
+   * });
+   * ```
+   *
+   * Example 2:
+   * ```
+   * const router = new TrieRouter();
+   * router.defineRoute({
+   *   method: HTTP_METHOD.POST,       // Request method.
+   *   path: '/users/:id',             // The path template may have parameters.
+   *   preHandler(ctx) { ... },        // The "preHandler" is executed before a route handler.
+   *   handler(ctx) { ... },           // Request handler function.
+   *   postHandler(ctx, data) { ... }, // The "postHandler" is executed after a route handler
+   * });
+   * ```
+   *
+   * @param {import('./route-registry.js').RouteDefinition} routeDef
+   * @returns {import('./route.js').Route}
+   */
+  defineRoute(routeDef) {
+    return this.getService(RouteRegistry).defineRoute(routeDef);
+  }
+
+  /**
+   * Request handler.
+   *
+   * Example:
+   * ```
+   * import http from 'http';
+   * import {TrieRouter} from '@e22m4u/js-trie-router';
+   *
+   * const router = new TrieRouter();
+   * const server = new http.Server();
+   * server.on('request', router.requestHandler); // Sets the request handler.
+   * server.listen(3000);                         // Starts listening for connections.
+   * ```
+   *
+   * @returns {Function}
+   */
+  get requestHandler() {
+    return this._handleRequest.bind(this);
+  }
+
+  /**
+   * Handle incoming request.
+   *
+   * @param {import('http').IncomingMessage} req
+   * @param {import('http').ServerResponse} res
+   * @returns {Promise<undefined>}
+   * @private
+   */
+  async _handleRequest(req, res) {
+    const requestPath = (req.url || '/').replace(/\?.*$/, '');
+    this.debug('Preparing to handle %s %v.', req.method, requestPath);
+    const resolved = this.getService(RouteRegistry).matchRouteByRequest(req);
+    if (!resolved) {
+      this.debug('No route for the request %s %v.', req.method, requestPath);
+      this.getService(ErrorSender).send404(req, res);
+    } else {
+      const {route, params} = resolved;
+      // создание дочернего сервис-контейнера для передачи
+      // в контекст запроса, что бы родительский контекст
+      // нельзя было модифицировать
+      const container = new ServiceContainer(this.container);
+      const context = new RequestContext(container, req, res);
+      // запись параметров пути в контекст запроса,
+      // так как они были определены в момент
+      // поиска подходящего роута
+      context.params = params;
+      // разбор тела, заголовков и других данных
+      // запроса выполняется отдельным сервисом,
+      // после чего результат записывается
+      // в контекст передаваемый обработчику
+      const reqDataOrPromise = this.getService(RequestParser).parse(req);
+      // результат разбора может являться асинхронным,
+      // и вместо того, что бы разрывать поток выполнения,
+      // стоит проверить, действительно ли необходимо
+      // использование оператора "await"
+      if (isPromise(reqDataOrPromise)) {
+        const reqData = await reqDataOrPromise;
+        Object.assign(context, reqData);
+      } else {
+        Object.assign(context, reqDataOrPromise);
+      }
+      // получение данных от обработчика, который
+      // находится в найденном роуте, и отправка
+      // результата в качестве ответа сервера
+      let data, error;
+      const hookInvoker = this.getService(HookInvoker);
+      try {
+        // если результатом вызова хуков "preHandler" является
+        // значение (или Promise) отличное от "undefined" и "null",
+        // то такое значение используется в качестве ответа
+        data = hookInvoker.invokeAndContinueUntilValueReceived(
+          route,
+          HOOK_NAME.PRE_HANDLER,
+          res,
+          context,
+        );
+        if (isPromise(data)) data = await data;
+        // если ответ не определен хуками "preHandler",
+        // то вызывается обработчик роута, результат
+        // которого передается в хуки "postHandler"
+        if (data == null) {
+          data = route.handle(context);
+          if (isPromise(data)) data = await data;
+          // вызываются хуки "postHandler", результат
+          // которых также может быть использован
+          // в качестве ответа
+          let postHandlerData = hookInvoker.invokeAndContinueUntilValueReceived(
+            route,
+            HOOK_NAME.POST_HANDLER,
+            res,
+            context,
+            data,
+          );
+          if (isPromise(postHandlerData))
+            postHandlerData = await postHandlerData;
+          if (postHandlerData != null) data = postHandlerData;
+        }
+      } catch (err) {
+        error = err;
+      }
+      if (error) {
+        this.getService(ErrorSender).send(req, res, error);
+      } else {
+        this.getService(DataSender).send(res, data);
+      }
+    }
+  }
+
+  /**
+   * Add hook.
+   *
+   * Example:
+   * ```
+   * import {TrieRouter} from '@e22m4u/js-trie-router';
+   * import {HOOK_NAME} from '@e22m4u/js-trie-router';
+   *
+   * // Router instance.
+   * const router = new TrieRouter();
+   *
+   * // Adds the "preHandler" hook for each route.
+   * router.addHook(
+   *   HOOK_NAME.PRE_HANDLER,
+   *   ctx => { ... },
+   * );
+   *
+   * // Adds the "postHandler" hook for each route.
+   * router.addHook(
+   *   HOOK_NAME.POST_HANDLER,
+   *   ctx => { ... },
+   * );
+   * ```
+   *
+   * @param {string} name
+   * @param {Function} hook
+   * @returns {this}
+   */
+  addHook(name, hook) {
+    this.getService(HookRegistry).addHook(name, hook);
+    return this;
+  }
+}

+ 471 - 0
src/trie-router.spec.js

@@ -0,0 +1,471 @@
+import {describe} from 'mocha';
+import {Route} from './route.js';
+import {expect} from './chai.js';
+import {HTTP_METHOD} from './route.js';
+import {TrieRouter} from './trie-router.js';
+import {HOOK_NAME} from './hooks/index.js';
+import {HookRegistry} from './hooks/index.js';
+import {DataSender} from './senders/index.js';
+import {ErrorSender} from './senders/index.js';
+import {RequestContext} from './request-context.js';
+import {createRequestMock} from './utils/create-request-mock.js';
+import {createResponseMock} from './utils/create-response-mock.js';
+
+describe('TrieRouter', function () {
+  describe('defineRoute', function () {
+    it('returns the Route instance', function () {
+      const router = new TrieRouter();
+      const path = '/path';
+      const handler = () => 'ok';
+      const res = router.defineRoute({method: HTTP_METHOD.GET, path, handler});
+      expect(res).to.be.instanceof(Route);
+      expect(res.method).to.be.eq(HTTP_METHOD.GET);
+      expect(res.path).to.be.eq(path);
+      expect(res.handler).to.be.eq(handler);
+    });
+  });
+
+  describe('requestHandler', function () {
+    it('to be a function', function () {
+      const router = new TrieRouter();
+      expect(typeof router.requestHandler).to.be.eq('function');
+    });
+
+    it('passes request context to the route handler', function (done) {
+      const router = new TrieRouter();
+      router.defineRoute({
+        method: HTTP_METHOD.GET,
+        path: '/test',
+        handler: ctx => {
+          expect(ctx).to.be.instanceof(RequestContext);
+          done();
+        },
+      });
+      const req = createRequestMock({path: '/test'});
+      const res = createResponseMock();
+      router.requestHandler(req, res);
+    });
+
+    it('passes path parameters to the request context', function (done) {
+      const router = new TrieRouter();
+      router.defineRoute({
+        method: HTTP_METHOD.GET,
+        path: '/:p1-:p2',
+        handler: ({params}) => {
+          expect(params).to.be.eql({p1: 'foo', p2: 'bar'});
+          done();
+        },
+      });
+      const req = createRequestMock({path: '/foo-bar'});
+      const res = createResponseMock();
+      router.requestHandler(req, res);
+    });
+
+    it('passes query parameters to the request context', function (done) {
+      const router = new TrieRouter();
+      router.defineRoute({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: ({query}) => {
+          expect(query).to.be.eql({p1: 'foo', p2: 'bar'});
+          done();
+        },
+      });
+      const req = createRequestMock({path: '?p1=foo&p2=bar'});
+      const res = createResponseMock();
+      router.requestHandler(req, res);
+    });
+
+    it('passes parsed cookie to the request context', function (done) {
+      const router = new TrieRouter();
+      router.defineRoute({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: ({cookie}) => {
+          expect(cookie).to.be.eql({p1: 'foo', p2: 'bar'});
+          done();
+        },
+      });
+      const req = createRequestMock({headers: {cookie: 'p1=foo; p2=bar;'}});
+      const res = createResponseMock();
+      router.requestHandler(req, res);
+    });
+
+    it('passes plain text body to the request context', function (done) {
+      const router = new TrieRouter();
+      const body = 'Lorem Ipsum is simply dummy text.';
+      router.defineRoute({
+        method: HTTP_METHOD.POST,
+        path: '/',
+        handler: ctx => {
+          expect(ctx.body).to.be.eq(body);
+          done();
+        },
+      });
+      const req = createRequestMock({method: HTTP_METHOD.POST, body});
+      const res = createResponseMock();
+      router.requestHandler(req, res);
+    });
+
+    it('passes parsed JSON body to the request context', function (done) {
+      const router = new TrieRouter();
+      const data = {p1: 'foo', p2: 'bar'};
+      router.defineRoute({
+        method: HTTP_METHOD.POST,
+        path: '/',
+        handler: ({body}) => {
+          expect(body).to.be.eql(data);
+          done();
+        },
+      });
+      const req = createRequestMock({method: HTTP_METHOD.POST, body: data});
+      const res = createResponseMock();
+      router.requestHandler(req, res);
+    });
+
+    it('passes headers to the request context', function (done) {
+      const router = new TrieRouter();
+      router.defineRoute({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: ({headers}) => {
+          expect(headers).to.be.eql({
+            host: 'localhost',
+            foo: 'bar',
+          });
+          done();
+        },
+      });
+      const req = createRequestMock({headers: {foo: 'bar'}});
+      const res = createResponseMock();
+      router.requestHandler(req, res);
+    });
+
+    it('uses DataSender to send the response', function (done) {
+      const router = new TrieRouter();
+      const resBody = 'Lorem Ipsum is simply dummy text.';
+      router.defineRoute({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: () => resBody,
+      });
+      const req = createRequestMock();
+      const res = createResponseMock();
+      router.setService(DataSender, {
+        send(response, data) {
+          expect(response).to.be.eq(res);
+          expect(data).to.be.eq(resBody);
+          done();
+        },
+      });
+      router.requestHandler(req, res);
+    });
+
+    it('uses ErrorSender to send the response', function (done) {
+      const router = new TrieRouter();
+      const error = new Error();
+      router.defineRoute({
+        method: HTTP_METHOD.GET,
+        path: '/',
+        handler: () => {
+          throw error;
+        },
+      });
+      const req = createRequestMock();
+      const res = createResponseMock();
+      router.setService(ErrorSender, {
+        send(request, response, err) {
+          expect(request).to.be.eq(req);
+          expect(response).to.be.eq(res);
+          expect(err).to.be.eq(error);
+          done();
+        },
+      });
+      router.requestHandler(req, res);
+    });
+
+    describe('hooks', function () {
+      it('invokes entire "preHandler" hooks before the route handler', async function () {
+        const router = new TrieRouter();
+        const order = [];
+        const body = 'OK';
+        router.defineRoute({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          preHandler: [
+            () => {
+              order.push('preHandler1');
+            },
+            () => {
+              order.push('preHandler2');
+            },
+          ],
+          handler: () => {
+            order.push('handler');
+            return body;
+          },
+        });
+        const req = createRequestMock();
+        const res = createResponseMock();
+        router.requestHandler(req, res);
+        const result = await res.getBody();
+        expect(result).to.be.eq(body);
+        expect(order).to.be.eql(['preHandler1', 'preHandler2', 'handler']);
+      });
+
+      it('invokes entire "preHandler" hooks after the route handler', async function () {
+        const router = new TrieRouter();
+        const order = [];
+        const body = 'OK';
+        router.defineRoute({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          handler: () => {
+            order.push('handler');
+            return body;
+          },
+          postHandler: [
+            () => {
+              order.push('postHandler1');
+            },
+            () => {
+              order.push('postHandler2');
+            },
+          ],
+        });
+        const req = createRequestMock();
+        const res = createResponseMock();
+        router.requestHandler(req, res);
+        const result = await res.getBody();
+        expect(result).to.be.eq(body);
+        expect(order).to.be.eql(['handler', 'postHandler1', 'postHandler2']);
+      });
+
+      it('passes the request context to the "preHandler" hooks', async function () {
+        const router = new TrieRouter();
+        const order = [];
+        const body = 'OK';
+        router.defineRoute({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          preHandler: [
+            ctx => {
+              order.push('preHandler1');
+              expect(ctx).to.be.instanceof(RequestContext);
+            },
+            ctx => {
+              order.push('preHandler2');
+              expect(ctx).to.be.instanceof(RequestContext);
+            },
+          ],
+          handler: ctx => {
+            order.push('handler');
+            expect(ctx).to.be.instanceof(RequestContext);
+            return body;
+          },
+        });
+        const req = createRequestMock();
+        const res = createResponseMock();
+        router.requestHandler(req, res);
+        const result = await res.getBody();
+        expect(result).to.be.eq(body);
+        expect(order).to.be.eql(['preHandler1', 'preHandler2', 'handler']);
+      });
+
+      it('passes the request context and return value from the route handler to the "postHandler" hooks', async function () {
+        const router = new TrieRouter();
+        const order = [];
+        const body = 'OK';
+        let requestContext;
+        router.defineRoute({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          handler: ctx => {
+            order.push('handler');
+            expect(ctx).to.be.instanceof(RequestContext);
+            requestContext = ctx;
+            return body;
+          },
+          postHandler: [
+            (ctx, data) => {
+              order.push('postHandler1');
+              expect(ctx).to.be.eq(requestContext);
+              expect(data).to.be.eq(body);
+            },
+            (ctx, data) => {
+              order.push('postHandler2');
+              expect(ctx).to.be.eq(requestContext);
+              expect(data).to.be.eq(body);
+            },
+          ],
+        });
+        const req = createRequestMock();
+        const res = createResponseMock();
+        router.requestHandler(req, res);
+        const result = await res.getBody();
+        expect(result).to.be.eq(body);
+        expect(order).to.be.eql(['handler', 'postHandler1', 'postHandler2']);
+      });
+
+      it('invokes the route handler if entire "preHandler" hooks returns undefined or null', async function () {
+        const router = new TrieRouter();
+        const order = [];
+        const body = 'OK';
+        router.defineRoute({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          preHandler: [
+            () => {
+              order.push('preHandler1');
+              return undefined;
+            },
+            () => {
+              order.push('preHandler2');
+              return null;
+            },
+          ],
+          handler: () => {
+            order.push('handler');
+            return body;
+          },
+        });
+        const req = createRequestMock();
+        const res = createResponseMock();
+        router.requestHandler(req, res);
+        const result = await res.getBody();
+        expect(result).to.be.eq(body);
+        expect(order).to.be.eql(['preHandler1', 'preHandler2', 'handler']);
+      });
+
+      it('sends a returns value from the route handler if entire "postHandler" hooks returns undefined or null', async function () {
+        const router = new TrieRouter();
+        const order = [];
+        const body = 'OK';
+        router.defineRoute({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          handler: () => {
+            order.push('handler');
+            return body;
+          },
+          postHandler: [
+            () => {
+              order.push('postHandler1');
+              return undefined;
+            },
+            () => {
+              order.push('postHandler2');
+              return null;
+            },
+          ],
+        });
+        const req = createRequestMock();
+        const res = createResponseMock();
+        router.requestHandler(req, res);
+        const result = await res.getBody();
+        expect(result).to.be.eq(body);
+        expect(order).to.be.eql(['handler', 'postHandler1', 'postHandler2']);
+      });
+
+      it('sends a return value from the "preHandler" hook in the first priority', async function () {
+        const router = new TrieRouter();
+        const order = [];
+        const preHandlerBody = 'foo';
+        const handlerBody = 'bar';
+        const postHandlerBody = 'baz';
+        router.defineRoute({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          preHandler() {
+            order.push('preHandler');
+            return preHandlerBody;
+          },
+          handler: () => {
+            order.push('handler');
+            return handlerBody;
+          },
+          postHandler() {
+            order.push('postHandler');
+            return postHandlerBody;
+          },
+        });
+        const req = createRequestMock();
+        const res = createResponseMock();
+        router.requestHandler(req, res);
+        const result = await res.getBody();
+        expect(result).to.be.eq(preHandlerBody);
+        expect(result).not.to.be.eq(handlerBody);
+        expect(result).not.to.be.eq(postHandlerBody);
+        expect(order).to.be.eql(['preHandler']);
+      });
+
+      it('sends a return value from the "postHandler" hook in the second priority', async function () {
+        const router = new TrieRouter();
+        const order = [];
+        const handlerBody = 'foo';
+        const postHandlerBody = 'bar';
+        router.defineRoute({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          preHandler() {
+            order.push('preHandler');
+          },
+          handler: () => {
+            order.push('handler');
+            return handlerBody;
+          },
+          postHandler() {
+            order.push('postHandler');
+            return postHandlerBody;
+          },
+        });
+        const req = createRequestMock();
+        const res = createResponseMock();
+        router.requestHandler(req, res);
+        const result = await res.getBody();
+        expect(result).not.to.be.eq(handlerBody);
+        expect(result).to.be.eq(postHandlerBody);
+        expect(order).to.be.eql(['preHandler', 'handler', 'postHandler']);
+      });
+
+      it('sends a return value from the root handler in the third priority', async function () {
+        const router = new TrieRouter();
+        const order = [];
+        const body = 'OK';
+        router.defineRoute({
+          method: HTTP_METHOD.GET,
+          path: '/',
+          preHandler() {
+            order.push('preHandler');
+          },
+          handler: () => {
+            order.push('handler');
+            return body;
+          },
+          postHandler() {
+            order.push('postHandler');
+          },
+        });
+        const req = createRequestMock();
+        const res = createResponseMock();
+        router.requestHandler(req, res);
+        const result = await res.getBody();
+        expect(result).to.be.eq(body);
+        expect(order).to.be.eql(['preHandler', 'handler', 'postHandler']);
+      });
+    });
+  });
+
+  describe('addHook', function () {
+    it('adds the given hook to the HookRegistry and returns itself', function () {
+      const router = new TrieRouter();
+      const reg = router.getService(HookRegistry);
+      const name = HOOK_NAME.PRE_HANDLER;
+      const hook = () => undefined;
+      expect(reg.hasHook(name, hook)).to.be.false;
+      const res = router.addHook(name, hook);
+      expect(res).to.be.eq(router);
+      expect(reg.hasHook(name, hook)).to.be.true;
+    });
+  });
+});

+ 19 - 0
src/types.d.ts

@@ -0,0 +1,19 @@
+/**
+ * A callable type with the "new" operator
+ * allows class and constructor.
+ */
+export interface Constructor<T = unknown> {
+  new (...args: any[]): T;
+}
+
+/**
+ * A function type without class and constructor.
+ */
+export type Callable<T = unknown> = (...args: any[]) => T;
+
+/**
+ * Representing a value or promise. This type is used
+ * to represent results of synchronous/asynchronous
+ * resolution of values.
+ */
+export type ValueOrPromise<T> = T | PromiseLike<T>;

+ 6 - 0
src/utils/create-cookie-string.d.ts

@@ -0,0 +1,6 @@
+/**
+ * Create cookie string.
+ *
+ * @param data
+ */
+export declare function createCookieString(data: object): string;

+ 24 - 0
src/utils/create-cookie-string.js

@@ -0,0 +1,24 @@
+import {Errorf} from '@e22m4u/js-format';
+
+/**
+ * Create cookie string.
+ *
+ * @param {object} data
+ * @returns {string}
+ */
+export function createCookieString(data) {
+  if (!data || typeof data !== 'object' || Array.isArray(data))
+    throw new Errorf(
+      'The first parameter of "createCookieString" should be ' +
+        'an Object, but %v given.',
+      data,
+    );
+  let cookies = '';
+  for (const key in data) {
+    if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
+    const val = data[key];
+    if (val == null) continue;
+    cookies += `${key}=${val}; `;
+  }
+  return cookies.trim();
+}

+ 36 - 0
src/utils/create-cookie-string.spec.js

@@ -0,0 +1,36 @@
+import {expect} from '../chai.js';
+import {format} from '@e22m4u/js-format';
+import {createCookieString} from './create-cookie-string.js';
+
+describe('createCookieString', function () {
+  it('requires the first argument to be an object', function () {
+    const throwable = v => () => createCookieString(v);
+    const error = v =>
+      format(
+        'The first parameter of "createCookieString" should be ' +
+          'an Object, but %s given.',
+        v,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    throwable({key: 'value'})();
+    throwable({})();
+  });
+
+  it('returns an empty string if no keys', function () {
+    expect(createCookieString({})).to.be.eq('');
+  });
+
+  it('returns a cookies string from a given object', function () {
+    const data = {foo: 'bar', baz: 'quz'};
+    const result = createCookieString(data);
+    expect(result).to.be.eq('foo=bar; baz=quz;');
+  });
+});

+ 11 - 0
src/utils/create-debugger.d.ts

@@ -0,0 +1,11 @@
+/**
+ * Debugger.
+ */
+export type Debugger = (message: string, ...args: unknown[]) => void;
+
+/**
+ * Create debugger.
+ *
+ * @param name
+ */
+export declare function createDebugger(name: string): Debugger;

+ 22 - 0
src/utils/create-debugger.js

@@ -0,0 +1,22 @@
+import DebugFactory from 'debug';
+import {Errorf, format} from '@e22m4u/js-format';
+
+/**
+ * Create debugger.
+ *
+ * @param {string} name
+ * @returns {Function}
+ */
+export function createDebugger(name) {
+  if (typeof name !== 'string')
+    throw new Errorf(
+      'The first argument of "createDebugger" should be ' +
+        'a String, but %v given.',
+      name,
+    );
+  const debug = DebugFactory(`jsTrieRouter:${name}`);
+  return function (message, ...args) {
+    const interpolatedMessage = format(message, ...args);
+    return debug(interpolatedMessage);
+  };
+}

+ 30 - 0
src/utils/create-debugger.spec.js

@@ -0,0 +1,30 @@
+import {expect} from '../chai.js';
+import {format} from '@e22m4u/js-format';
+import {createDebugger} from './create-debugger.js';
+
+describe('createDebugger', function () {
+  it('requires the first parameter to be a String', function () {
+    const throwable = v => () => createDebugger(v);
+    const error = v =>
+      format(
+        'The first argument of "createDebugger" should be ' +
+          'a String, but %s given.',
+        v,
+      );
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    throwable('str')();
+    throwable('')();
+  });
+
+  it('returns a function', function () {
+    const res = createDebugger('name');
+    expect(typeof res).to.be.eq('function');
+  });
+});

+ 14 - 0
src/utils/create-error.d.ts

@@ -0,0 +1,14 @@
+import {Constructor} from '../types.js';
+
+/**
+ * Create error.
+ *
+ * @param errorCtor
+ * @param message
+ * @param args
+ */
+export declare function createError<T>(
+  errorCtor: Constructor<T>,
+  message: string,
+  ...args: unknown[]
+): T;

+ 28 - 0
src/utils/create-error.js

@@ -0,0 +1,28 @@
+import {format} from '@e22m4u/js-format';
+import {Errorf} from '@e22m4u/js-format';
+
+/**
+ * Create error.
+ *
+ * @param {Function} errorCtor
+ * @param {string} message
+ * @param {*[]|undefined} args
+ * @returns {object}
+ */
+export function createError(errorCtor, message, ...args) {
+  if (typeof errorCtor !== 'function')
+    throw new Errorf(
+      'The first argument of "createError" should be ' +
+        'a constructor, but %v given.',
+      errorCtor,
+    );
+  if (message != null && typeof message !== 'string')
+    throw new Errorf(
+      'The second argument of "createError" should be ' +
+        'a String, but %v given.',
+      message,
+    );
+  if (message == null) return new errorCtor();
+  const interpolatedMessage = format(message, ...args);
+  return new errorCtor(interpolatedMessage);
+}

+ 50 - 0
src/utils/create-error.spec.js

@@ -0,0 +1,50 @@
+import {expect} from '../chai.js';
+import {format} from '@e22m4u/js-format';
+import {createError} from './create-error.js';
+
+describe('createError', function () {
+  it('requires the first parameter to be a constructor', function () {
+    const throwable = v => () => createError(v);
+    const error = v =>
+      format(
+        'The first argument of "createError" should be ' +
+          'a constructor, but %s given.',
+        v,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    throwable(Error)();
+  });
+
+  it('requires the second parameter to be a String', function () {
+    const throwable = v => () => createError(Error, v);
+    const error = v =>
+      format(
+        'The second argument of "createError" should be ' +
+          'a String, but %s given.',
+        v,
+      );
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable([])).to.throw(error('Array'));
+    throwable('str')();
+    throwable('')();
+    throwable(null)();
+    throwable(undefined)();
+  });
+
+  it('interpolates the given message with arguments', function () {
+    const res = createError(Error, 'My %s', 'message');
+    expect(res.message).to.be.eq('My message');
+  });
+});

+ 28 - 0
src/utils/create-request-mock.d.ts

@@ -0,0 +1,28 @@
+import {Readable} from 'stream';
+import {IncomingMessage} from 'http';
+
+/**
+ * Request patch.
+ */
+type RequestPatch = {
+  host?: string;
+  method?: string;
+  secure?: boolean;
+  path?: string;
+  query?: object;
+  hash?: string;
+  cookie?: object;
+  headers?: object;
+  body?: string;
+  stream?: Readable;
+  encoding?: BufferEncoding;
+};
+
+/**
+ * Create request mock.
+ *
+ * @param patch
+ */
+export declare function createRequestMock(
+  patch?: RequestPatch,
+): IncomingMessage;

+ 345 - 0
src/utils/create-request-mock.js

@@ -0,0 +1,345 @@
+import {Socket} from 'net';
+import {TLSSocket} from 'tls';
+import {IncomingMessage} from 'http';
+import queryString from 'querystring';
+import {Errorf} from '@e22m4u/js-format';
+import {isReadableStream} from './is-readable-stream.js';
+import {createCookieString} from './create-cookie-string.js';
+import {BUFFER_ENCODING_LIST} from './fetch-request-body.js';
+
+/**
+ * @typedef {{
+ *   host?: string;
+ *   method?: string;
+ *   secure?: boolean;
+ *   path?: string;
+ *   query?: object;
+ *   hash?: string;
+ *   cookie?: object;
+ *   headers?: object;
+ *   body?: string;
+ *   stream?: import('stream').Readable;
+ *   encoding?: import('buffer').BufferEncoding;
+ * }} RequestPatch
+ */
+
+/**
+ * Create request mock.
+ *
+ * @param {RequestPatch} patch
+ * @returns {import('http').IncomingMessage}
+ */
+export function createRequestMock(patch) {
+  if ((patch != null && typeof patch !== 'object') || Array.isArray(patch)) {
+    throw new Errorf(
+      'The first parameter of "createRequestMock" ' +
+        'should be an Object, but %v given.',
+      patch,
+    );
+  }
+  patch = patch || {};
+  if (patch.host != null && typeof patch.host !== 'string')
+    throw new Errorf(
+      'The parameter "host" of "createRequestMock" ' +
+        'should be a String, but %v given.',
+      patch.host,
+    );
+  if (patch.method != null && typeof patch.method !== 'string')
+    throw new Errorf(
+      'The parameter "method" of "createRequestMock" ' +
+        'should be a String, but %v given.',
+      patch.method,
+    );
+  if (patch.secure != null && typeof patch.secure !== 'boolean')
+    throw new Errorf(
+      'The parameter "secure" of "createRequestMock" ' +
+        'should be a Boolean, but %v given.',
+      patch.secure,
+    );
+  if (patch.path != null && typeof patch.path !== 'string')
+    throw new Errorf(
+      'The parameter "path" of "createRequestMock" ' +
+        'should be a String, but %v given.',
+      patch.path,
+    );
+  if (
+    (patch.query != null &&
+      typeof patch.query !== 'object' &&
+      typeof patch.query !== 'string') ||
+    Array.isArray(patch.query)
+  ) {
+    throw new Errorf(
+      'The parameter "query" of "createRequestMock" ' +
+        'should be a String or Object, but %v given.',
+      patch.query,
+    );
+  }
+  if (patch.hash != null && typeof patch.hash !== 'string')
+    throw new Errorf(
+      'The parameter "hash" of "createRequestMock" ' +
+        'should be a String, but %v given.',
+      patch.hash,
+    );
+  if (
+    (patch.cookie != null &&
+      typeof patch.cookie !== 'string' &&
+      typeof patch.cookie !== 'object') ||
+    Array.isArray(patch.cookie)
+  ) {
+    throw new Errorf(
+      'The parameter "cookie" of "createRequestMock" ' +
+        'should be a String or Object, but %v given.',
+      patch.cookie,
+    );
+  }
+  if (
+    (patch.headers != null && typeof patch.headers !== 'object') ||
+    Array.isArray(patch.headers)
+  ) {
+    throw new Errorf(
+      'The parameter "headers" of "createRequestMock" ' +
+        'should be an Object, but %v given.',
+      patch.headers,
+    );
+  }
+  if (patch.stream != null && !isReadableStream(patch.stream))
+    throw new Errorf(
+      'The parameter "stream" of "createRequestMock" ' +
+        'should be a Stream, but %v given.',
+      patch.stream,
+    );
+  if (patch.encoding != null) {
+    if (typeof patch.encoding !== 'string')
+      throw new Errorf(
+        'The parameter "encoding" of "createRequestMock" ' +
+          'should be a String, but %v given.',
+        patch.encoding,
+      );
+    if (!BUFFER_ENCODING_LIST.includes(patch.encoding))
+      throw new Errorf('Buffer encoding %v is not supported.', patch.encoding);
+  }
+  // если передан поток, выполняется
+  // проверка на несовместимые опции
+  if (patch.stream) {
+    if (patch.secure != null)
+      throw new Errorf(
+        'The "createRequestMock" does not allow specifying the ' +
+          '"stream" and "secure" options simultaneously.',
+      );
+    if (patch.body != null)
+      throw new Errorf(
+        'The "createRequestMock" does not allow specifying the ' +
+          '"stream" and "body" options simultaneously.',
+      );
+    if (patch.encoding != null)
+      throw new Errorf(
+        'The "createRequestMock" does not allow specifying the ' +
+          '"stream" and "encoding" options simultaneously.',
+      );
+  }
+  // если передан поток, он будет использован
+  // в качестве объекта запроса, в противном
+  // случае создается новый
+  const req =
+    patch.stream ||
+    createRequestStream(patch.secure, patch.body, patch.encoding);
+  req.url = createRequestUrl(patch.path || '/', patch.query, patch.hash);
+  req.headers = createRequestHeaders(
+    patch.host,
+    patch.secure,
+    patch.body,
+    patch.cookie,
+    patch.encoding,
+    patch.headers,
+  );
+  req.method = (patch.method || 'get').toUpperCase();
+  return req;
+}
+
+/**
+ * Create request stream.
+ *
+ * @param {boolean|null|undefined} secure
+ * @param {*} body
+ * @param {import('buffer').BufferEncoding|null|undefined} encoding
+ * @returns {import('http').IncomingMessage}
+ */
+function createRequestStream(secure, body, encoding) {
+  if (encoding != null && typeof encoding !== 'string')
+    throw new Errorf(
+      'The parameter "encoding" of "createRequestStream" ' +
+        'should be a String, but %v given.',
+      encoding,
+    );
+  encoding = encoding || 'utf-8';
+  // для безопасного подключения
+  // использует обертка TLSSocket
+  let socket = new Socket();
+  if (secure) socket = new TLSSocket(socket);
+  const req = new IncomingMessage(socket);
+  // тело запроса должно являться
+  // строкой или бинарными данными
+  if (body != null) {
+    if (typeof body === 'string') {
+      req.push(body, encoding);
+    } else if (Buffer.isBuffer(body)) {
+      req.push(body);
+    } else {
+      req.push(JSON.stringify(body));
+    }
+  }
+  // передача "null" определяет
+  // конец данных
+  req.push(null);
+  return req;
+}
+
+/**
+ * Create request url.
+ *
+ * @param {string} path
+ * @param {string|object|null|undefined} query
+ * @param {string|null|undefined} hash
+ * @returns {string}
+ */
+function createRequestUrl(path, query, hash) {
+  if (typeof path !== 'string')
+    throw new Errorf(
+      'The parameter "path" of "createRequestUrl" ' +
+        'should be a String, but %v given.',
+      path,
+    );
+  if (
+    (query != null && typeof query !== 'string' && typeof query !== 'object') ||
+    Array.isArray(query)
+  ) {
+    throw new Errorf(
+      'The parameter "query" of "createRequestUrl" ' +
+        'should be a String or Object, but %v given.',
+      query,
+    );
+  }
+  if (hash != null && typeof hash !== 'string')
+    throw new Errorf(
+      'The parameter "hash" of "createRequestUrl" ' +
+        'should be a String, but %v given.',
+      path,
+    );
+  let url = ('/' + path).replace('//', '/');
+  if (typeof query === 'object') {
+    const qs = queryString.stringify(query);
+    if (qs) url += `?${qs}`;
+  } else if (typeof query === 'string') {
+    url += `?${query.replace(/^\?/, '')}`;
+  }
+  hash = (hash || '').replace('#', '');
+  if (hash) url += `#${hash}`;
+  return url;
+}
+
+/**
+ * Create request headers.
+ *
+ * @param {string|null|undefined} host
+ * @param {boolean|null|undefined} secure
+ * @param {*} body
+ * @param {string|object|null|undefined} cookie
+ * @param {import('buffer').BufferEncoding|null|undefined} encoding
+ * @param {object|null|undefined} headers
+ * @returns {object}
+ */
+function createRequestHeaders(host, secure, body, cookie, encoding, headers) {
+  if (host != null && typeof host !== 'string')
+    throw new Errorf(
+      'The parameter "host" of "createRequestHeaders" ' +
+        'a non-empty String, but %v given.',
+      host,
+    );
+  host = host || 'localhost';
+  if (secure != null && typeof secure !== 'boolean')
+    throw new Errorf(
+      'The parameter "secure" of "createRequestHeaders" ' +
+        'should be a String, but %v given.',
+      secure,
+    );
+  secure = Boolean(secure);
+  if (
+    (cookie != null &&
+      typeof cookie !== 'object' &&
+      typeof cookie !== 'string') ||
+    Array.isArray(cookie)
+  ) {
+    throw new Errorf(
+      'The parameter "cookie" of "createRequestHeaders" ' +
+        'should be a String or Object, but %v given.',
+      cookie,
+    );
+  }
+  if (
+    (headers != null && typeof headers !== 'object') ||
+    Array.isArray(headers)
+  ) {
+    throw new Errorf(
+      'The parameter "headers" of "createRequestHeaders" ' +
+        'should be an Object, but %v given.',
+      headers,
+    );
+  }
+  headers = headers || {};
+  if (encoding != null && typeof encoding !== 'string')
+    throw new Errorf(
+      'The parameter "encoding" of "createRequestHeaders" ' +
+        'should be a String, but %v given.',
+      encoding,
+    );
+  encoding = encoding || 'utf-8';
+  const obj = {...headers};
+  obj['host'] = host;
+  if (secure) obj['x-forwarded-proto'] = 'https';
+  // формирование заголовка Cookie
+  // из строки или объекта
+  if (cookie != null) {
+    if (typeof cookie === 'string') {
+      obj['cookie'] = obj['cookie'] ? obj['cookie'] : '';
+      obj['cookie'] += cookie;
+    } else if (typeof cookie === 'object') {
+      obj['cookie'] = obj['cookie'] ? obj['cookie'] : '';
+      obj['cookie'] += createCookieString(cookie);
+    }
+  }
+  // установка заголовка "content-type"
+  // в зависимости от тела запроса
+  if (obj['content-type'] == null) {
+    if (typeof body === 'string') {
+      obj['content-type'] = 'text/plain';
+    } else if (Buffer.isBuffer(body)) {
+      obj['content-type'] = 'application/octet-stream';
+    } else if (
+      typeof body === 'object' ||
+      typeof body === 'boolean' ||
+      typeof body === 'number'
+    ) {
+      obj['content-type'] = 'application/json';
+    }
+  }
+  // подсчет количества байт тела
+  // для заголовка "content-length"
+  if (body != null && obj['content-length'] == null) {
+    if (typeof body === 'string') {
+      const length = Buffer.byteLength(body, encoding);
+      obj['content-length'] = String(length);
+    } else if (Buffer.isBuffer(body)) {
+      const length = Buffer.byteLength(body);
+      obj['content-length'] = String(length);
+    } else if (
+      typeof body === 'object' ||
+      typeof body === 'boolean' ||
+      typeof body === 'number'
+    ) {
+      const json = JSON.stringify(body);
+      const length = Buffer.byteLength(json, encoding);
+      obj['content-length'] = String(length);
+    }
+  }
+  return obj;
+}

+ 482 - 0
src/utils/create-request-mock.spec.js

@@ -0,0 +1,482 @@
+import {Socket} from 'net';
+import {Stream} from 'stream';
+import {TLSSocket} from 'tls';
+import {expect} from '../chai.js';
+import {format} from '@e22m4u/js-format';
+import {createRequestMock} from './create-request-mock.js';
+import {BUFFER_ENCODING_LIST} from './fetch-request-body.js';
+
+describe('createRequestMock', function () {
+  it('requires the first argument to be an Object', function () {
+    const throwable = v => () => createRequestMock(v);
+    const error = v =>
+      format(
+        'The first parameter of "createRequestMock" ' +
+          'should be an Object, but %s given.',
+        v,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    throwable({})();
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('requires the parameter "host" to be a String', function () {
+    const throwable = v => () => createRequestMock({host: v});
+    const error = v =>
+      format(
+        'The parameter "host" of "createRequestMock" ' +
+          'should be a String, but %s given.',
+        v,
+      );
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable({})).to.throw(error('Object'));
+    throwable('str')();
+    throwable('')();
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('requires the parameter "method" to be a String', function () {
+    const throwable = v => () => createRequestMock({method: v});
+    const error = v =>
+      format(
+        'The parameter "method" of "createRequestMock" ' +
+          'should be a String, but %s given.',
+        v,
+      );
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable({})).to.throw(error('Object'));
+    throwable('str')();
+    throwable('')();
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('requires the parameter "secure" to be a Boolean', function () {
+    const throwable = v => () => createRequestMock({secure: v});
+    const error = v =>
+      format(
+        'The parameter "secure" of "createRequestMock" ' +
+          'should be a Boolean, but %s given.',
+        v,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable({})).to.throw(error('Object'));
+    throwable(true)();
+    throwable(false)();
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('requires the parameter "path" to be a String', function () {
+    const throwable = v => () => createRequestMock({path: v});
+    const error = v =>
+      format(
+        'The parameter "path" of "createRequestMock" ' +
+          'should be a String, but %s given.',
+        v,
+      );
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable({})).to.throw(error('Object'));
+    throwable('str')();
+    throwable('')();
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('requires the parameter "query" to be a String or Object', function () {
+    const throwable = v => () => createRequestMock({query: v});
+    const error = v =>
+      format(
+        'The parameter "query" of "createRequestMock" ' +
+          'should be a String or Object, but %s given.',
+        v,
+      );
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    throwable('str')();
+    throwable('')();
+    throwable({foo: 'bar'})();
+    throwable({})();
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('requires the parameter "hash" to be a String', function () {
+    const throwable = v => () => createRequestMock({hash: v});
+    const error = v =>
+      format(
+        'The parameter "hash" of "createRequestMock" ' +
+          'should be a String, but %s given.',
+        v,
+      );
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable({})).to.throw(error('Object'));
+    throwable('str')();
+    throwable('')();
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('requires the parameter "cookie" to be a String or Object', function () {
+    const throwable = v => () => createRequestMock({cookie: v});
+    const error = v =>
+      format(
+        'The parameter "cookie" of "createRequestMock" ' +
+          'should be a String or Object, but %s given.',
+        v,
+      );
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    throwable('str')();
+    throwable('')();
+    throwable({foo: 'bar'})();
+    throwable({})();
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('requires the parameter "headers" to be an Object', function () {
+    const throwable = v => () => createRequestMock({headers: v});
+    const error = v =>
+      format(
+        'The parameter "headers" of "createRequestMock" ' +
+          'should be an Object, but %s given.',
+        v,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    throwable({foo: 'bar'})();
+    throwable({})();
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('requires the parameter "stream" to be a Stream', function () {
+    const throwable = v => () => createRequestMock({stream: v});
+    const error = v =>
+      format(
+        'The parameter "stream" of "createRequestMock" ' +
+          'should be a Stream, but %s given.',
+        v,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable({})).to.throw(error('Object'));
+    throwable(new Stream())();
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('requires the parameter "encoding" to be a String', function () {
+    const throwable = v => () => createRequestMock({encoding: v});
+    const error = v =>
+      format(
+        'The parameter "encoding" of "createRequestMock" ' +
+          'should be a String, but %s given.',
+        v,
+      );
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable({})).to.throw(error('Object'));
+    throwable('utf-8')();
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('requires the parameter "encoding" to be the BufferEncoding', function () {
+    const throwable = v => () => createRequestMock({encoding: v});
+    const error = v => format('Buffer encoding %s is not supported.', v);
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    BUFFER_ENCODING_LIST.forEach(v => throwable(v)());
+  });
+
+  it('does not allow the option "secure" with the "stream"', function () {
+    const throwable = v => () =>
+      createRequestMock({stream: new Stream(), secure: v});
+    const error =
+      'The "createRequestMock" does not allow specifying the ' +
+      '"stream" and "secure" options simultaneously.';
+    expect(throwable(true)).to.throw(error);
+    expect(throwable(false)).to.throw(error);
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('does not allow the option "body" with the "stream"', function () {
+    const throwable = v => () =>
+      createRequestMock({stream: new Stream(), body: v});
+    const error =
+      'The "createRequestMock" does not allow specifying the ' +
+      '"stream" and "body" options simultaneously.';
+    expect(throwable('str')).to.throw(error);
+    expect(throwable({foo: 'bar'})).to.throw(error);
+    expect(throwable(Buffer.from('str'))).to.throw(error);
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('does not allow the option "encoding" with the "stream"', function () {
+    const throwable = v => () =>
+      createRequestMock({stream: new Stream(), encoding: v});
+    const error =
+      'The "createRequestMock" does not allow specifying the ' +
+      '"stream" and "encoding" options simultaneously.';
+    expect(throwable('utf-8')).to.throw(error);
+    throwable(undefined)();
+    throwable(null)();
+  });
+
+  it('uses the default host "localhost"', function () {
+    const req = createRequestMock();
+    expect(req.headers['host']).to.be.eq('localhost');
+  });
+
+  it('uses the default method "GET"', function () {
+    const req = createRequestMock();
+    expect(req.method).to.be.eq('GET');
+  });
+
+  it('uses the Socket class by default', function () {
+    const req = createRequestMock();
+    expect(req.socket).to.be.instanceof(Socket);
+  });
+
+  it('uses the default path "/" without query and hash', function () {
+    const req = createRequestMock();
+    expect(req.url).to.be.eq('/');
+  });
+
+  it('uses by default only the "host" header', function () {
+    const req = createRequestMock();
+    expect(req.headers).to.be.eql({host: 'localhost'});
+  });
+
+  it('uses "utf-8" encoding by default', async function () {
+    const body = 'test';
+    const req = createRequestMock({body: Buffer.from(body)});
+    const chunks = [];
+    const data = await new Promise((resolve, reject) => {
+      req.on('data', chunk => chunks.push(Buffer.from(chunk)));
+      req.on('error', err => reject(err));
+      req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
+    });
+    expect(data).to.be.eql(body);
+  });
+
+  it('uses Socket if the parameter "secure" is false', function () {
+    const req = createRequestMock({secure: false});
+    expect(req.socket).to.be.instanceof(Socket);
+  });
+
+  it('uses TLSSocket if the parameter "secure" is true', function () {
+    const req = createRequestMock({secure: true});
+    expect(req.socket).to.be.instanceof(TLSSocket);
+  });
+
+  it('sets the string body to the stream and uses "utf-8" encoding by default', async function () {
+    const body = 'requestBody';
+    const req = createRequestMock({body});
+    const chunks = [];
+    const data = await new Promise((resolve, reject) => {
+      req.on('data', chunk => chunks.push(Buffer.from(chunk)));
+      req.on('error', err => reject(err));
+      req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
+    });
+    expect(data).to.be.eq(body);
+  });
+
+  it('sets the string body to the stream with "ascii" encoding', async function () {
+    const body = 'requestBody';
+    const req = createRequestMock({body, encoding: 'ascii'});
+    const chunks = [];
+    const data = await new Promise((resolve, reject) => {
+      req.on('data', chunk => chunks.push(Buffer.from(chunk)));
+      req.on('error', err => reject(err));
+      req.on('end', () => resolve(Buffer.concat(chunks).toString('ascii')));
+    });
+    expect(data).to.be.eq(body);
+  });
+
+  it('sets the binary data to the stream', async function () {
+    const body = Buffer.from('test');
+    const req = createRequestMock({body});
+    const chunks = [];
+    const data = await new Promise((resolve, reject) => {
+      req.on('data', chunk => chunks.push(Buffer.from(chunk)));
+      req.on('error', err => reject(err));
+      req.on('end', () => resolve(Buffer.concat(chunks)));
+    });
+    expect(data).to.be.eql(body);
+  });
+
+  it('set the path value to the request url', function () {
+    const req = createRequestMock({path: 'test'});
+    expect(req.url).to.be.eq('/test');
+  });
+
+  it('set the path value to the request url with the prefix "/"', function () {
+    const req = createRequestMock({path: '/test'});
+    expect(req.url).to.be.eq('/test');
+  });
+
+  it('sets the query string to the request url', async function () {
+    const req = createRequestMock({query: 'p1=foo&p2=bar'});
+    expect(req.url).to.be.eq('/?p1=foo&p2=bar');
+  });
+
+  it('sets the query string to the request url with the prefix "?"', async function () {
+    const req = createRequestMock({query: '?p1=foo&p2=bar'});
+    expect(req.url).to.be.eq('/?p1=foo&p2=bar');
+  });
+
+  it('sets the hash value to the request url', async function () {
+    const req = createRequestMock({hash: 'myHash'});
+    expect(req.url).to.be.eq('/#myHash');
+  });
+
+  it('sets the hash value to the request url with the prefix "#"', async function () {
+    const req = createRequestMock({hash: '#myHash'});
+    expect(req.url).to.be.eq('/#myHash');
+  });
+
+  it('set parameters "path", "query" and "hash" to the request url', function () {
+    const req1 = createRequestMock({
+      path: 'test',
+      query: 'p1=foo&p2=bar',
+      hash: 'myHash1',
+    });
+    const req2 = createRequestMock({
+      path: '/test',
+      query: {p1: 'baz', p2: 'qux'},
+      hash: '#myHash2',
+    });
+    expect(req1.url).to.be.eq('/test?p1=foo&p2=bar#myHash1');
+    expect(req2.url).to.be.eq('/test?p1=baz&p2=qux#myHash2');
+  });
+
+  it('sets the parameter "method" in uppercase', async function () {
+    const req1 = createRequestMock({method: 'get'});
+    const req2 = createRequestMock({method: 'post'});
+    expect(req1.method).to.be.eq('GET');
+    expect(req2.method).to.be.eq('POST');
+  });
+
+  it('the parameter "host" does not affect the url', async function () {
+    const req = createRequestMock({host: 'myHost'});
+    expect(req.url).to.be.eq('/');
+    expect(req.headers['host']).to.be.eq('myHost');
+  });
+
+  it('the parameter "secure" sets the header "x-forwarded-proto"', async function () {
+    const req = createRequestMock({secure: true});
+    expect(req.headers['x-forwarded-proto']).to.be.eq('https');
+  });
+
+  it('sets the header "cookie" from a String', function () {
+    const req = createRequestMock({cookie: 'test'});
+    expect(req.headers['cookie']).to.be.eq('test');
+  });
+
+  it('sets the header "cookie" from an Object', function () {
+    const req = createRequestMock({cookie: {p1: 'foo', p2: 'bar'}});
+    expect(req.headers['cookie']).to.be.eq('p1=foo; p2=bar;');
+  });
+
+  it('set the header "content-type" for a String body', function () {
+    const req = createRequestMock({body: 'test'});
+    expect(req.headers['content-type']).to.be.eq('text/plain');
+  });
+
+  it('set the header "content-type" for a Buffer body', function () {
+    const req = createRequestMock({body: Buffer.from('test')});
+    expect(req.headers['content-type']).to.be.eq('application/octet-stream');
+  });
+
+  it('set the header "content-type" for an Object body', function () {
+    const req = createRequestMock({body: {foo: 'bar'}});
+    expect(req.headers['content-type']).to.be.eq('application/json');
+  });
+
+  it('set the header "content-type" for a Boolean body', function () {
+    const req1 = createRequestMock({body: true});
+    const req2 = createRequestMock({body: true});
+    expect(req1.headers['content-type']).to.be.eq('application/json');
+    expect(req2.headers['content-type']).to.be.eq('application/json');
+  });
+
+  it('set the header "content-type" for a Number body', function () {
+    const req = createRequestMock({body: 10});
+    expect(req.headers['content-type']).to.be.eq('application/json');
+  });
+
+  it('does not override the header "content-type"', function () {
+    const req = createRequestMock({
+      body: Buffer.from('test'),
+      headers: {'content-type': 'media/type'},
+    });
+    expect(req.headers['content-type']).to.be.eq('media/type');
+  });
+
+  it('calculate the header "content-length"', function () {
+    const body = 'test';
+    const length = Buffer.byteLength(body);
+    const req = createRequestMock({body});
+    expect(req.headers['content-length']).to.be.eq(String(length));
+  });
+
+  it('does not override the header "content-length"', function () {
+    const req = createRequestMock({
+      body: 'test',
+      headers: {'content-length': '100'},
+    });
+    expect(req.headers['content-length']).to.be.eq('100');
+  });
+});

+ 16 - 0
src/utils/create-response-mock.d.ts

@@ -0,0 +1,16 @@
+import {ServerResponse} from 'http';
+
+/**
+ * Server response mock.
+ */
+export type ServerResponseMock = ServerResponse & {
+  _headersSent: boolean;
+  _headers: {[name: string]: string | undefined};
+  getEncoding(encoding: BufferEncoding): ServerResponseMock;
+  getBody(): Promise<string>;
+};
+
+/**
+ * Create response mock.
+ */
+export declare function createResponseMock(): ServerResponseMock;

+ 119 - 0
src/utils/create-response-mock.js

@@ -0,0 +1,119 @@
+import {PassThrough} from 'stream';
+
+/**
+ * Create response mock.
+ *
+ * @returns {import('http').ServerResponse}
+ */
+export function createResponseMock() {
+  const res = new PassThrough();
+  patchEncoding(res);
+  patchHeaders(res);
+  patchBody(res);
+  return res;
+}
+
+/**
+ * Patch encoding.
+ *
+ * @param {object} res
+ */
+function patchEncoding(res) {
+  Object.defineProperty(res, '_encoding', {
+    configurable: true,
+    writable: true,
+    value: undefined,
+  });
+  Object.defineProperty(res, 'setEncoding', {
+    configurable: true,
+    value: function (enc) {
+      this._encoding = enc;
+      return this;
+    },
+  });
+  Object.defineProperty(res, 'getEncoding', {
+    configurable: true,
+    value: function () {
+      return this._encoding;
+    },
+  });
+}
+
+/**
+ * Patch headers.
+ *
+ * @param {object} res
+ */
+function patchHeaders(res) {
+  Object.defineProperty(res, '_headersSent', {
+    configurable: true,
+    writable: true,
+    value: false,
+  });
+  Object.defineProperty(res, 'headersSent', {
+    configurable: true,
+    get() {
+      return this._headersSent;
+    },
+  });
+  Object.defineProperty(res, '_headers', {
+    configurable: true,
+    writable: true,
+    value: {},
+  });
+  Object.defineProperty(res, 'setHeader', {
+    configurable: true,
+    value: function (name, value) {
+      if (this.headersSent)
+        throw new Error(
+          'Error [ERR_HTTP_HEADERS_SENT]: ' +
+            'Cannot set headers after they are sent to the client',
+        );
+      const key = name.toLowerCase();
+      this._headers[key] = String(value);
+      return this;
+    },
+  });
+  Object.defineProperty(res, 'getHeader', {
+    configurable: true,
+    value: function (name) {
+      return this._headers[name.toLowerCase()];
+    },
+  });
+  Object.defineProperty(res, 'getHeaders', {
+    configurable: true,
+    value: function () {
+      return JSON.parse(JSON.stringify(this._headers));
+    },
+  });
+}
+
+/**
+ * Patch body.
+ *
+ * @param {object} res
+ */
+function patchBody(res) {
+  let resolve, reject;
+  const promise = new Promise((res, rej) => {
+    resolve = res;
+    reject = rej;
+  });
+  const data = [];
+  res.on('data', c => data.push(c));
+  res.on('error', e => reject(e));
+  res.on('end', () => {
+    res._headersSent = true;
+    resolve(Buffer.concat(data));
+  });
+  Object.defineProperty(res, 'getBody', {
+    configurable: true,
+    value: function () {
+      return promise.then(buffer => {
+        const enc = this.getEncoding();
+        const str = buffer.toString(enc);
+        return data.length ? str : undefined;
+      });
+    },
+  });
+}

+ 130 - 0
src/utils/create-response-mock.spec.js

@@ -0,0 +1,130 @@
+import {describe} from 'mocha';
+import {expect} from '../chai.js';
+import {PassThrough} from 'stream';
+import {createResponseMock} from './create-response-mock.js';
+
+describe('createResponseMock', function () {
+  it('returns an instance of PassThrough', function () {
+    const res = createResponseMock();
+    expect(res).to.be.instanceof(PassThrough);
+  });
+
+  describe('setEncoding', function () {
+    it('sets the given encoding and returns the response', function () {
+      const res = createResponseMock();
+      expect(res._encoding).to.be.undefined;
+      const ret = res.setEncoding('utf-8');
+      expect(ret).to.be.eq(res);
+      expect(res._encoding).to.be.eq('utf-8');
+    });
+  });
+
+  describe('getEncoding', function () {
+    it('returns encoding', function () {
+      const res = createResponseMock();
+      expect(res._encoding).to.be.undefined;
+      const ret1 = res.getEncoding();
+      expect(ret1).to.be.undefined;
+      res._encoding = 'utf-8';
+      const ret2 = res.getEncoding();
+      expect(ret2).to.be.eq('utf-8');
+    });
+  });
+
+  describe('headersSent', function () {
+    it('returns false if the response is not sent', function () {
+      const res = createResponseMock();
+      expect(res._headersSent).to.be.false;
+      expect(res.headersSent).to.be.false;
+    });
+
+    it('returns a value of the "_headersSent" property', function () {
+      const res = createResponseMock();
+      expect(res._headersSent).to.be.false;
+      expect(res.headersSent).to.be.false;
+      res._headersSent = true;
+      expect(res.headersSent).to.be.true;
+    });
+  });
+
+  describe('setHeader', function () {
+    it('sets the given header and returns the response', function () {
+      const res = createResponseMock();
+      expect(res._headers['foo']).to.be.eq(undefined);
+      const ret = res.setHeader('foo', 'bar');
+      expect(ret).to.be.eq(res);
+      expect(res._headers['foo']).to.be.eq('bar');
+    });
+
+    it('throws an error if headers is sent', function () {
+      const res = createResponseMock();
+      res._headersSent = true;
+      const throwable = () => res.setHeader('foo');
+      expect(throwable).to.throw(
+        'Error [ERR_HTTP_HEADERS_SENT]: ' +
+          'Cannot set headers after they are sent to the client',
+      );
+    });
+
+    it('sets the header value as a string', function () {
+      const res = createResponseMock();
+      expect(res._headers['num']).to.be.eq(undefined);
+      const ret = res.setHeader('num', 10);
+      expect(ret).to.be.eq(res);
+      expect(res._headers['num']).to.be.eq('10');
+    });
+  });
+
+  describe('getHeader', function () {
+    it('returns the header value if exists', function () {
+      const res = createResponseMock();
+      res._headers['foo'] = 'bar';
+      const ret = res.getHeader('foo');
+      expect(ret).to.be.eq('bar');
+    });
+
+    it('uses case-insensitivity lookup', function () {
+      const res = createResponseMock();
+      res._headers['foo'] = 'bar';
+      const ret = res.getHeader('FOO');
+      expect(ret).to.be.eq('bar');
+    });
+  });
+
+  describe('getHeaders', function () {
+    it('returns a copy of the headers object', function () {
+      const res = createResponseMock();
+      const ret1 = res.getHeaders();
+      res._headers['foo'] = 'bar';
+      res._headers['baz'] = 'qux';
+      const ret2 = res.getHeaders();
+      expect(ret1).to.be.eql({});
+      expect(ret2).to.be.eql({foo: 'bar', baz: 'qux'});
+      expect(ret1).not.to.be.eq(res._headers);
+      expect(ret2).not.to.be.eq(res._headers);
+    });
+  });
+
+  describe('getBody', function () {
+    it('returns a promise of the stream content', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const res = createResponseMock();
+      res.end(body);
+      const promise = res.getBody();
+      expect(promise).to.be.instanceof(Promise);
+      await expect(promise).to.eventually.be.eq(body);
+    });
+  });
+
+  describe('Stream', function () {
+    it('sets "headerSent" to true when the stream ends', function (done) {
+      const res = createResponseMock();
+      expect(res.headersSent).to.be.false;
+      res.on('end', () => {
+        expect(res.headersSent).to.be.true;
+        done();
+      });
+      res.end('test');
+    });
+  });
+});

+ 17 - 0
src/utils/fetch-request-body.d.ts

@@ -0,0 +1,17 @@
+import {IncomingMessage} from 'http';
+
+/**
+ * Buffer encoding list.
+ */
+export type BUFFER_ENCODING_LIST = BufferEncoding[];
+
+/**
+ * Fetch request body.
+ *
+ * @param req
+ * @param bodyBytesLimit
+ */
+export declare function fetchRequestBody(
+  req: IncomingMessage,
+  bodyBytesLimit?: number,
+): Promise<string>;

+ 133 - 0
src/utils/fetch-request-body.js

@@ -0,0 +1,133 @@
+import HttpErrors from 'http-errors';
+import {createError} from './create-error.js';
+import {parseContentType} from './parse-content-type.js';
+import {Errorf} from '@e22m4u/js-format';
+import {IncomingMessage} from 'http';
+
+/**
+ * Buffer encoding.
+ *
+ * @type {import('buffer').BufferEncoding[]}
+ */
+export const BUFFER_ENCODING_LIST = [
+  'ascii',
+  'utf8',
+  'utf-8',
+  'utf16le',
+  'utf-16le',
+  'ucs2',
+  'ucs-2',
+  'base64',
+  'base64url',
+  'latin1',
+  'binary',
+  'hex',
+];
+
+/**
+ * Fetch request body.
+ *
+ * @param {IncomingMessage} req
+ * @param {number} bodyBytesLimit
+ * @returns {Promise<string|undefined>}
+ */
+export function fetchRequestBody(req, bodyBytesLimit = 0) {
+  if (!(req instanceof IncomingMessage))
+    throw new Errorf(
+      'The first parameter of "fetchRequestBody" should be ' +
+        'an IncomingMessage instance, but %v given.',
+      req,
+    );
+  if (typeof bodyBytesLimit !== 'number')
+    throw new Errorf(
+      'The parameter "bodyBytesLimit" of "fetchRequestBody" ' +
+        'should be a number, but %v given.',
+      bodyBytesLimit,
+    );
+  return new Promise((resolve, reject) => {
+    // сравнение внутреннего ограничения
+    // размера тела запроса с заголовком
+    // "content-length"
+    const contentLength = parseInt(req.headers['content-length'] || '0', 10);
+    if (bodyBytesLimit && contentLength && contentLength > bodyBytesLimit)
+      throw createError(
+        HttpErrors.PayloadTooLarge,
+        'Request body limit is %s bytes, but %s bytes given.',
+        bodyBytesLimit,
+        contentLength,
+      );
+    // определение кодировки
+    // по заголовку "content-type"
+    let encoding = 'utf-8';
+    const contentType = req.headers['content-type'] || '';
+    if (contentType) {
+      const parsedContentType = parseContentType(contentType);
+      if (parsedContentType && parsedContentType.charset) {
+        encoding = parsedContentType.charset.toLowerCase();
+        if (!BUFFER_ENCODING_LIST.includes(encoding))
+          throw createError(
+            HttpErrors.UnsupportedMediaType,
+            'Request encoding %v is not supported.',
+            encoding,
+          );
+      }
+    }
+    // подготовка массива загружаемых байтов
+    // и счетчика для отслеживания их объема
+    const data = [];
+    let receivedLength = 0;
+    // обработчик проверяет объем загружаемых
+    // данных и складывает их в массив
+    const onData = chunk => {
+      receivedLength += chunk.length;
+      if (bodyBytesLimit && receivedLength > bodyBytesLimit) {
+        req.removeAllListeners();
+        const error = createError(
+          HttpErrors.PayloadTooLarge,
+          'Request body limit is %v bytes, but %v bytes given.',
+          bodyBytesLimit,
+          receivedLength,
+        );
+        reject(error);
+        return;
+      }
+      data.push(chunk);
+    };
+    // кода данные полностью загружены, нужно удалить
+    // обработчики событий, и сравнить полученный объем
+    // данных с заявленным в заголовке "content-length"
+    const onEnd = () => {
+      req.removeAllListeners();
+      if (contentLength && contentLength !== receivedLength) {
+        const error = createError(
+          HttpErrors.BadRequest,
+          'Received bytes do not match the "content-length" header.',
+        );
+        reject(error);
+        return;
+      }
+      // объединение массива байтов в буфер,
+      // кодирование результата в строку,
+      // и передача полученных данных
+      // в ожидающий Promise
+      const buffer = Buffer.concat(data);
+      const body = Buffer.from(buffer, encoding).toString();
+      resolve(body || undefined);
+    };
+    // при ошибке загрузки тела запроса,
+    // удаляются обработчики событий,
+    // и отклоняется ожидающий Promise
+    // ошибкой с кодом 400
+    const onError = error => {
+      req.removeAllListeners();
+      reject(HttpErrors(400, error));
+    };
+    // добавление обработчиков прослушивающих
+    // события входящего запроса и возобновление
+    // потока данных
+    req.on('data', onData);
+    req.on('end', onEnd);
+    req.on('error', onError);
+    req.resume();
+  });
+}

+ 211 - 0
src/utils/fetch-request-body.spec.js

@@ -0,0 +1,211 @@
+import {expect} from '../chai.js';
+import {format} from '@e22m4u/js-format';
+import {fetchRequestBody} from './fetch-request-body.js';
+import {createRequestMock} from './create-request-mock.js';
+
+describe('fetchRequestBody', function () {
+  it('requires the first parameter to be an IncomingMessage instance', function () {
+    const throwable = v => () => fetchRequestBody(v);
+    const error = v =>
+      format(
+        'The first parameter of "fetchRequestBody" should be ' +
+          'an IncomingMessage instance, but %s given.',
+        v,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    throwable(createRequestMock())();
+  });
+
+  it('requires the parameter "bodyBytesLimit" to be an IncomingMessage instance', function () {
+    const req = createRequestMock();
+    const throwable = v => () => fetchRequestBody(req, v);
+    const error = v =>
+      format(
+        'The parameter "bodyBytesLimit" of "fetchRequestBody" ' +
+          'should be a number, but %s given.',
+        v,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable([])).to.throw(error('Array'));
+    throwable(10)();
+    throwable(0)();
+    throwable(undefined)();
+  });
+
+  it('returns a string from the string body', async function () {
+    const body = 'Lorem Ipsum is simply dummy text.';
+    const req = createRequestMock({body});
+    const result = await fetchRequestBody(req);
+    expect(result).to.be.eq(body);
+  });
+
+  it('returns a string from the buffer body', async function () {
+    const body = 'Lorem Ipsum is simply dummy text.';
+    const req = createRequestMock({body: Buffer.from(body)});
+    const result = await fetchRequestBody(req);
+    expect(result).to.be.eq(body);
+  });
+
+  describe('encoding of the header "content-type"', function () {
+    it('throws an error for an unsupported encoding', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const req = createRequestMock({
+        body,
+        headers: {'content-type': 'text/plain; charset=unknown'},
+      });
+      const promise = fetchRequestBody(req);
+      await expect(promise).to.be.rejectedWith(
+        'Request encoding "unknown" is not supported.',
+      );
+    });
+
+    it('does not throw an error if the header "content-type" does not have a specified charset', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const req = createRequestMock({
+        body,
+        headers: {'content-type': 'text/plain'},
+      });
+      const result = await fetchRequestBody(req);
+      expect(result).to.be.eq(body);
+    });
+
+    it('decodes non-standard encoding', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const encoding = 'base64';
+      const encodedBody = Buffer.from(body).toString(encoding);
+      const req = createRequestMock({
+        body: Buffer.from(encodedBody, encoding),
+        headers: {'content-type': `text/plain; charset=${encoding}`},
+      });
+      const result = await fetchRequestBody(req);
+      expect(result).to.be.eq(body);
+    });
+  });
+
+  describe('the header "content-length"', function () {
+    it('throws an error if the body length is greater than the header', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const bodyLength = Buffer.from(body).byteLength;
+      const contentLength = String(bodyLength + 10);
+      const req = createRequestMock({
+        body,
+        headers: {'content-length': contentLength},
+      });
+      const promise = fetchRequestBody(req);
+      await expect(promise).to.be.rejectedWith(
+        'Received bytes do not match the "content-length" header.',
+      );
+    });
+
+    it('throws an error if the body length is lower than the header', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const bodyLength = Buffer.from(body).byteLength;
+      const contentLength = String(bodyLength - 10);
+      const req = createRequestMock({
+        body,
+        headers: {'content-length': contentLength},
+      });
+      const promise = fetchRequestBody(req);
+      await expect(promise).to.be.rejectedWith(
+        'Received bytes do not match the "content-length" header.',
+      );
+    });
+
+    it('does not throw an error if the body length does match with the header', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const contentLength = String(Buffer.from(body).byteLength);
+      const req = createRequestMock({
+        body,
+        headers: {'content-length': contentLength},
+      });
+      const result = await fetchRequestBody(req);
+      expect(result).to.be.eq(body);
+    });
+  });
+
+  describe('the parameter "bodyBytesLimit"', function () {
+    it('throws an error if the "content-length" header is greater than the limit', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const bodyLength = Buffer.from(body).byteLength;
+      const bodyLimit = bodyLength - 10;
+      const req = createRequestMock({
+        body,
+        headers: {'content-length': String(bodyLength)},
+      });
+      const error = format(
+        'Request body limit is %s bytes, but %s bytes given.',
+        bodyLimit,
+        bodyLength,
+      );
+      const promise = fetchRequestBody(req, bodyLimit);
+      await expect(promise).to.be.rejectedWith(error);
+    });
+
+    it('does not throw an error if the "content-length" header does match with the limit', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const bodyLength = Buffer.from(body).byteLength;
+      const req = createRequestMock({
+        body,
+        headers: {'content-length': String(bodyLength)},
+      });
+      const result = await fetchRequestBody(req, bodyLength);
+      expect(result).to.be.eq(body);
+    });
+
+    it('does not throw an error if the "content-length" header is lower than the limit', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const bodyLength = Buffer.from(body).byteLength;
+      const req = createRequestMock({
+        body,
+        headers: {'content-length': String(bodyLength)},
+      });
+      const result = await fetchRequestBody(req, bodyLength + 10);
+      expect(result).to.be.eq(body);
+    });
+
+    it('throws an error if the body length is greater than the limit', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const bodyLength = Buffer.from(body).byteLength;
+      const bodyLimit = bodyLength - 10;
+      const req = createRequestMock({body});
+      const error = format(
+        'Request body limit is %s bytes, but %s bytes given.',
+        bodyLimit,
+        bodyLength,
+      );
+      const promise = fetchRequestBody(req, bodyLimit);
+      await expect(promise).to.be.rejectedWith(error);
+    });
+
+    it('does not throw an error if the body length does match with the limit', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const bodyLength = Buffer.from(body).byteLength;
+      const req = createRequestMock({body});
+      const result = await fetchRequestBody(req, bodyLength);
+      expect(result).to.be.eq(body);
+    });
+
+    it('does not throw an error if the body length is lower than the limit', async function () {
+      const body = 'Lorem Ipsum is simply dummy text.';
+      const bodyLength = Buffer.from(body).byteLength;
+      const bodyLimit = bodyLength + 10;
+      const req = createRequestMock({body});
+      const result = await fetchRequestBody(req, bodyLimit);
+      expect(result).to.be.eq(body);
+    });
+  });
+});

+ 8 - 0
src/utils/get-request-path.d.ts

@@ -0,0 +1,8 @@
+import {IncomingMessage} from 'http';
+
+/**
+ * Get request path.
+ *
+ * @param req
+ */
+export declare function getRequestPath(req: IncomingMessage): string;

+ 23 - 0
src/utils/get-request-path.js

@@ -0,0 +1,23 @@
+import {Errorf} from '@e22m4u/js-format';
+
+/**
+ * Get request path.
+ *
+ * @param {import('http').IncomingMessage} req
+ * @returns {string}
+ */
+export function getRequestPath(req) {
+  if (
+    !req ||
+    typeof req !== 'object' ||
+    Array.isArray(req) ||
+    typeof req.url !== 'string'
+  ) {
+    throw new Errorf(
+      'The first argument of "getRequestPath" should be ' +
+        'an instance of IncomingMessage, but %v given.',
+      req,
+    );
+  }
+  return (req.url || '/').replace(/\?.*$/, '');
+}

+ 31 - 0
src/utils/get-request-path.spec.js

@@ -0,0 +1,31 @@
+import {expect} from '../chai.js';
+import {format} from '@e22m4u/js-format';
+import {getRequestPath} from './get-request-path.js';
+
+describe('getRequestPath', function () {
+  it('requires the argument to be an Object with "url" property', function () {
+    const throwable = v => () => getRequestPath(v);
+    const error = v =>
+      format(
+        'The first argument of "getRequestPath" should be ' +
+          'an instance of IncomingMessage, but %s given.',
+        v,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    throwable({url: ''})();
+  });
+
+  it('returns the request path without query parameters', function () {
+    const res = getRequestPath({url: '/test?foo=bar'});
+    expect(res).to.be.eq('/test');
+  });
+});

+ 11 - 0
src/utils/index.d.ts

@@ -0,0 +1,11 @@
+export * from './is-promise.js';
+export * from './parse-cookie.js';
+export * from './create-error.js';
+export * from './to-camel-case.js';
+export * from './create-debugger.js';
+export * from './is-response-sent.js';
+export * from './get-request-path.js';
+export * from './is-readable-stream.js';
+export * from './is-writable-stream.js';
+export * from './fetch-request-body.js';
+export * from './create-cookie-string.js';

+ 11 - 0
src/utils/index.js

@@ -0,0 +1,11 @@
+export * from './is-promise.js';
+export * from './parse-cookie.js';
+export * from './create-error.js';
+export * from './to-camel-case.js';
+export * from './create-debugger.js';
+export * from './is-response-sent.js';
+export * from './get-request-path.js';
+export * from './is-readable-stream.js';
+export * from './is-writable-stream.js';
+export * from './fetch-request-body.js';
+export * from './create-cookie-string.js';

+ 10 - 0
src/utils/is-promise.d.ts

@@ -0,0 +1,10 @@
+/**
+ * Check whether a value is a Promise-like
+ * instance. Recognizes both native promises
+ * and third-party promise libraries.
+ *
+ * @param value
+ */
+export declare function isPromise<T = unknown>(
+  value: unknown,
+): value is Promise<T>;

+ 13 - 0
src/utils/is-promise.js

@@ -0,0 +1,13 @@
+/**
+ * Check whether a value is a Promise-like
+ * instance. Recognizes both native promises
+ * and third-party promise libraries.
+ *
+ * @param {*} value
+ * @returns {boolean}
+ */
+export function isPromise(value) {
+  if (!value) return false;
+  if (typeof value !== 'object') return false;
+  return typeof value.then === 'function';
+}

+ 20 - 0
src/utils/is-promise.spec.js

@@ -0,0 +1,20 @@
+import {expect} from '../chai.js';
+import {isPromise} from './is-promise.js';
+
+describe('isPromise', function () {
+  it('returns true if the value is a promise', function () {
+    const value = Promise.resolve();
+    expect(isPromise(value)).to.be.true;
+  });
+
+  it('returns false if the value is not a promise', function () {
+    expect(isPromise('string')).to.be.false;
+    expect(isPromise(5)).to.be.false;
+    expect(isPromise([])).to.be.false;
+    expect(isPromise({})).to.be.false;
+    expect(isPromise(undefined)).to.be.false;
+    expect(isPromise(null)).to.be.false;
+    expect(isPromise(NaN)).to.be.false;
+    expect(isPromise(() => 10)).to.be.false;
+  });
+});

+ 9 - 0
src/utils/is-readable-stream.d.ts

@@ -0,0 +1,9 @@
+import {Readable} from 'stream';
+
+/**
+ * Check whether a value has a pipe
+ * method.
+ *
+ * @param value
+ */
+export declare function isReadableStream(value: unknown): value is Readable;

+ 11 - 0
src/utils/is-readable-stream.js

@@ -0,0 +1,11 @@
+/**
+ * Check whether a value has a pipe
+ * method.
+ *
+ * @param {*} value
+ * @returns {boolean}
+ */
+export function isReadableStream(value) {
+  if (!value || typeof value !== 'object') return false;
+  return typeof value.pipe === 'function';
+}

+ 23 - 0
src/utils/is-readable-stream.spec.js

@@ -0,0 +1,23 @@
+import {expect} from '../chai.js';
+import {Readable} from 'stream';
+import {isReadableStream} from './is-readable-stream.js';
+
+describe('isReadableStream', function () {
+  it('returns true if the value is a readable stream', function () {
+    const value1 = new Readable();
+    expect(isReadableStream(value1)).to.be.true;
+    const value2 = {pipe: () => undefined};
+    expect(isReadableStream(value2)).to.be.true;
+  });
+
+  it('returns false if the value is not a stream', function () {
+    expect(isReadableStream('string')).to.be.false;
+    expect(isReadableStream(5)).to.be.false;
+    expect(isReadableStream([])).to.be.false;
+    expect(isReadableStream({})).to.be.false;
+    expect(isReadableStream(undefined)).to.be.false;
+    expect(isReadableStream(null)).to.be.false;
+    expect(isReadableStream(NaN)).to.be.false;
+    expect(isReadableStream(() => 10)).to.be.false;
+  });
+});

+ 8 - 0
src/utils/is-response-sent.d.ts

@@ -0,0 +1,8 @@
+import {ServerResponse} from 'http';
+
+/**
+ * Is response sent.
+ *
+ * @param res
+ */
+export declare function isResponseSent(res: ServerResponse): boolean;

+ 23 - 0
src/utils/is-response-sent.js

@@ -0,0 +1,23 @@
+import {Errorf} from '@e22m4u/js-format';
+
+/**
+ * Is response sent.
+ *
+ * @param {import('http').ServerResponse} res
+ * @returns {boolean}
+ */
+export function isResponseSent(res) {
+  if (
+    !res ||
+    typeof res !== 'object' ||
+    Array.isArray(res) ||
+    typeof res.headersSent !== 'boolean'
+  ) {
+    throw new Errorf(
+      'The first argument of "isResponseSent" should be ' +
+        'an instance of ServerResponse, but %v given.',
+      res,
+    );
+  }
+  return res.headersSent;
+}

+ 35 - 0
src/utils/is-response-sent.spec.js

@@ -0,0 +1,35 @@
+import {expect} from '../chai.js';
+import {format} from '@e22m4u/js-format';
+import {isResponseSent} from './is-response-sent.js';
+
+describe('isResponseSent', function () {
+  it('requires the argument to be an Object with "headersSent" property', function () {
+    const throwable = v => () => isResponseSent(v);
+    const error = v =>
+      format(
+        'The first argument of "isResponseSent" should be ' +
+          'an instance of ServerResponse, but %s given.',
+        v,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable({})).to.throw(error('Object'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    throwable({headersSent: true})();
+    throwable({headersSent: false})();
+  });
+
+  it('returns true if the property "headersSent" is true', function () {
+    const res = isResponseSent({headersSent: true});
+    expect(res).to.be.true;
+  });
+
+  it('returns false if the property "headersSent" is false', function () {
+    const res = isResponseSent({headersSent: false});
+    expect(res).to.be.false;
+  });
+});

Some files were not shown because too many files changed in this diff