Browse Source

feat: adds the meta option to RequestContext

e22m4u 3 weeks ago
parent
commit
49a592a060

+ 43 - 7
README.md

@@ -74,6 +74,7 @@ server.listen(3000, 'localhost');             // прослушивание за
 - `path: string` путь включающий строку запроса, например `/myPath?foo=bar`
 - `pathname: string` путь запроса, например `/myPath`
 - `body: unknown` тело запроса
+- `meta: object` мета-данные из определения маршрута
 
 Пример доступа к контексту из обработчика маршрута.
 
@@ -81,6 +82,7 @@ server.listen(3000, 'localhost');             // прослушивание за
 router.defineRoute({
   method: 'GET',
   path: '/users/:id',
+  meta: {prop: 'value'},
   handler(ctx) {
     // GET /users/10?include=city
     // Cookie: foo=bar; baz=qux;
@@ -93,6 +95,7 @@ router.defineRoute({
     console.log(ctx.method);   // "GET"
     console.log(ctx.path);     // "/users/10?include=city"
     console.log(ctx.pathname); // "/users/10"
+    console.log(ctx.meta);     // {prop: 'value'}
     // ...
   },
 });
@@ -213,18 +216,16 @@ router.defineRoute({
 имеют более высокий приоритет перед хуками маршрута, и вызываются
 в первую очередь.
 
-- `preHandler` выполняется перед вызовом обработчика каждого маршрута
-- `postHandler` выполняется после вызова обработчика каждого маршрута
+- `preHandler` выполняется перед вызовом обработчика каждого маршрута;
+- `postHandler` выполняется после вызова обработчика каждого маршрута;
 
-Добавить глобальные хуки можно методом `addHook` экземпляра роутера,
-где первым параметром передается тип хука, а вторым его функция.
+Добавить глобальные хуки можно методами экземпляра `TrieRouter`.
 
 ```js
-router.addHook('preHandler', (ctx) => {
+router.addPreHandler((ctx) => {
   // перед обработчиком маршрута
 });
-
-router.addHook('postHandler', (ctx, data) => {
+router.addPostHandler((ctx, data) => {
   // после обработчика маршрута
 });
 ```
@@ -233,6 +234,41 @@ router.addHook('postHandler', (ctx, data) => {
 отличное от `undefined` и `null`, то такое значение будет использовано
 как ответ сервера.
 
+### Метаданные маршрута
+
+Иногда требуется связать с маршрутом дополнительные, статические данные, которые
+могут быть использованы хуками для расширения функционала. Например, это могут
+быть схемы для валидации данных, правила доступа или настройки кэширования.
+Для этой цели определение маршрута поддерживает необязательное свойство `meta`.
+
+Маршрутизатор лишь обеспечивает передачу мета-данных в контекст запроса,
+откуда его могут прочитать обработчики или хуки.
+
+```js
+import http from 'http';
+import {TrieRouter} from '@e22m4u/js-trie-router';
+
+const server = new http.Server();
+const router = new TrieRouter();
+
+router.addPreHandler((ctx) => {
+  // доступ к мета-данным текущего маршрута
+  console.log(ctx.meta); // {foo: 'bar'}
+});
+
+router.defineRoute({
+  method: 'GET',
+  path: '/',
+  meta: {foo: 'bar'}, // мета-данные
+  handler(ctx) {
+    return 'Hello World!';
+  },
+});
+
+server.on('request', router.requestListener);
+server.listen(3000, 'localhost');
+```
+
 ## Отладка
 
 Установка переменной `DEBUG` включает вывод логов.

+ 238 - 161
dist/cjs/index.cjs

@@ -50,6 +50,7 @@ __export(index_exports, {
   RouterOptions: () => RouterOptions,
   TrieRouter: () => TrieRouter,
   UNPARSABLE_MEDIA_TYPES: () => UNPARSABLE_MEDIA_TYPES,
+  cloneDeep: () => cloneDeep,
   createCookiesString: () => createCookiesString,
   createDebugger: () => createDebugger,
   createError: () => createError,
@@ -75,6 +76,31 @@ var import_js_debug = require("@e22m4u/js-debug");
 // src/hooks/hook-invoker.js
 var import_js_format13 = require("@e22m4u/js-format");
 
+// src/utils/clone-deep.js
+function cloneDeep(value) {
+  if (value == null || typeof value !== "object") {
+    return value;
+  }
+  if (value instanceof Date) {
+    return new Date(value.getTime());
+  }
+  if (Array.isArray(value)) {
+    return value.map((item) => cloneDeep(item));
+  }
+  const proto = Object.getPrototypeOf(value);
+  if (proto === Object.prototype || proto === null) {
+    const newObj = {};
+    for (const key in value) {
+      if (Object.prototype.hasOwnProperty.call(value, key)) {
+        newObj[key] = cloneDeep(value[key]);
+      }
+    }
+    return newObj;
+  }
+  return value;
+}
+__name(cloneDeep, "cloneDeep");
+
 // src/utils/is-promise.js
 function isPromise(value) {
   if (!value) return false;
@@ -831,6 +857,20 @@ var _Route = class _Route extends import_js_debug.Debuggable {
   get path() {
     return this._path;
   }
+  /**
+   * Meta.
+   *
+   * @type {object}
+   */
+  _meta = {};
+  /**
+   * Getter of the meta.
+   *
+   * @returns {object}
+   */
+  get meta() {
+    return this._meta;
+  }
   /**
    * Handler.
    *
@@ -894,6 +934,14 @@ var _Route = class _Route extends import_js_debug.Debuggable {
         'The option "handler" of the Route should be a Function, but %v was given.',
         routeDef.handler
       );
+    if (routeDef.meta != null) {
+      if (typeof routeDef.meta !== "object" || Array.isArray(routeDef.meta))
+        throw new import_js_format14.Errorf(
+          'The option "meta" of the Route should be a plain Object, but %v was given.',
+          routeDef.meta
+        );
+      this._meta = cloneDeep(routeDef.meta);
+    }
     this._handler = routeDef.handler;
     if (routeDef.preHandler != null) {
       const preHandlerHooks = Array.isArray(routeDef.preHandler) ? routeDef.preHandler : [routeDef.preHandler];
@@ -929,156 +977,12 @@ var _Route = class _Route extends import_js_debug.Debuggable {
 __name(_Route, "Route");
 var Route = _Route;
 
-// src/trie-router.js
-var import_http4 = require("http");
-var import_http5 = require("http");
-
-// src/senders/data-sender.js
-var import_js_format15 = require("@e22m4u/js-format");
-var _DataSender = class _DataSender extends DebuggableService {
-  /**
-   * Send.
-   *
-   * @param {import('http').ServerResponse} res
-   * @param {*} data
-   * @returns {undefined}
-   */
-  send(res, data) {
-    const debug = this.getDebuggerFor(this.send);
-    if (data === res || res.headersSent) {
-      debug(
-        "Response sending was skipped because its headers where sent already."
-      );
-      return;
-    }
-    if (data == null) {
-      res.statusCode = 204;
-      res.end();
-      debug("The empty response was sent.");
-      return;
-    }
-    if (isReadableStream(data)) {
-      res.setHeader("Content-Type", "application/octet-stream");
-      data.pipe(res);
-      debug("The stream response was sent.");
-      return;
-    }
-    let debugMsg;
-    switch (typeof data) {
-      case "object":
-      case "boolean":
-      case "number":
-        if (Buffer.isBuffer(data)) {
-          res.setHeader("content-type", "application/octet-stream");
-          debugMsg = "The Buffer was sent as binary data.";
-        } else {
-          res.setHeader("content-type", "application/json");
-          debugMsg = (0, import_js_format15.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);
-    debug(debugMsg);
-  }
-};
-__name(_DataSender, "DataSender");
-var DataSender = _DataSender;
-
-// src/senders/error-sender.js
-var import_util = require("util");
-var import_statuses = __toESM(require("statuses"), 1);
-var EXPOSED_ERROR_PROPERTIES = ["code", "details"];
-var _ErrorSender = class _ErrorSender extends DebuggableService {
-  /**
-   * Handle.
-   *
-   * @param {import('http').IncomingMessage} req
-   * @param {import('http').ServerResponse} res
-   * @param {Error} error
-   * @returns {undefined}
-   */
-  send(req, res, error) {
-    const debug = this.getDebuggerFor(this.send);
-    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 = (0, import_statuses.default)(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((0, import_util.inspect)(requestData, inspectOptions));
-    console.warn((0, import_util.inspect)(body, inspectOptions));
-    if (error.stack) {
-      console.log(error.stack);
-    } else {
-      console.error(error);
-    }
-    res.statusCode = statusCode;
-    res.setHeader("content-type", "application/json; charset=utf-8");
-    res.end(JSON.stringify(body, null, 2), "utf-8");
-    debug(
-      "The %s error was sent for the request %s %v.",
-      statusCode,
-      req.method,
-      getRequestPathname(req)
-    );
-  }
-  /**
-   * Send 404.
-   *
-   * @param {import('http').IncomingMessage} req
-   * @param {import('http').ServerResponse} res
-   * @returns {undefined}
-   */
-  send404(req, res) {
-    const debug = this.getDebuggerFor(this.send404);
-    res.statusCode = 404;
-    res.setHeader("content-type", "text/plain; charset=utf-8");
-    res.end("404 Not Found", "utf-8");
-    debug(
-      "The 404 error was sent for the request %s %v.",
-      req.method,
-      getRequestPathname(req)
-    );
-  }
-};
-__name(_ErrorSender, "ErrorSender");
-var ErrorSender = _ErrorSender;
-
 // src/parsers/body-parser.js
 var import_http_errors2 = __toESM(require("http-errors"), 1);
-var import_js_format17 = require("@e22m4u/js-format");
+var import_js_format16 = require("@e22m4u/js-format");
 
 // src/router-options.js
-var import_js_format16 = require("@e22m4u/js-format");
+var import_js_format15 = require("@e22m4u/js-format");
 var _RouterOptions = class _RouterOptions extends DebuggableService {
   /**
    * Request body bytes limit.
@@ -1104,7 +1008,7 @@ var _RouterOptions = class _RouterOptions extends DebuggableService {
    */
   setRequestBodyBytesLimit(input) {
     if (typeof input !== "number" || input < 0)
-      throw new import_js_format16.Errorf(
+      throw new import_js_format15.Errorf(
         'The option "requestBodyBytesLimit" must be a positive Number or 0, but %v was given.',
         input
       );
@@ -1137,12 +1041,12 @@ var _BodyParser = class _BodyParser extends DebuggableService {
    */
   defineParser(mediaType, parser) {
     if (!mediaType || typeof mediaType !== "string")
-      throw new import_js_format17.Errorf(
+      throw new import_js_format16.Errorf(
         'The parameter "mediaType" of BodyParser.defineParser should be a non-empty String, but %v was given.',
         mediaType
       );
     if (!parser || typeof parser !== "function")
-      throw new import_js_format17.Errorf(
+      throw new import_js_format16.Errorf(
         'The parameter "parser" of BodyParser.defineParser should be a Function, but %v was given.',
         parser
       );
@@ -1157,7 +1061,7 @@ var _BodyParser = class _BodyParser extends DebuggableService {
    */
   hasParser(mediaType) {
     if (!mediaType || typeof mediaType !== "string")
-      throw new import_js_format17.Errorf(
+      throw new import_js_format16.Errorf(
         'The parameter "mediaType" of BodyParser.hasParser should be a non-empty String, but %v was given.',
         mediaType
       );
@@ -1171,12 +1075,12 @@ var _BodyParser = class _BodyParser extends DebuggableService {
    */
   deleteParser(mediaType) {
     if (!mediaType || typeof mediaType !== "string")
-      throw new import_js_format17.Errorf(
+      throw new import_js_format16.Errorf(
         'The parameter "mediaType" of BodyParser.deleteParser should be a non-empty String, but %v was given.',
         mediaType
       );
     const parser = this._parsers[mediaType];
-    if (!parser) throw new import_js_format17.Errorf("The parser of %v is not found.", mediaType);
+    if (!parser) throw new import_js_format16.Errorf("The parser of %v is not found.", mediaType);
     delete this._parsers[mediaType];
     return this;
   }
@@ -1305,7 +1209,7 @@ var CookiesParser = _CookiesParser;
 
 // src/parsers/request-parser.js
 var import_http3 = require("http");
-var import_js_format18 = require("@e22m4u/js-format");
+var import_js_format17 = require("@e22m4u/js-format");
 var _RequestParser = class _RequestParser extends DebuggableService {
   /**
    * Parse.
@@ -1315,7 +1219,7 @@ var _RequestParser = class _RequestParser extends DebuggableService {
    */
   parse(req) {
     if (!(req instanceof import_http3.IncomingMessage))
-      throw new import_js_format18.Errorf(
+      throw new import_js_format17.Errorf(
         "The first argument of RequestParser.parse should be an instance of IncomingMessage, but %v was given.",
         req
       );
@@ -1347,7 +1251,7 @@ __name(_RequestParser, "RequestParser");
 var RequestParser = _RequestParser;
 
 // src/route-registry.js
-var import_js_format19 = require("@e22m4u/js-format");
+var import_js_format18 = require("@e22m4u/js-format");
 var import_js_path_trie = require("@e22m4u/js-path-trie");
 var import_js_service2 = require("@e22m4u/js-service");
 var _RouteRegistry = class _RouteRegistry extends DebuggableService {
@@ -1369,7 +1273,7 @@ var _RouteRegistry = class _RouteRegistry extends DebuggableService {
   defineRoute(routeDef) {
     const debug = this.getDebuggerFor(this.defineRoute);
     if (!routeDef || typeof routeDef !== "object" || Array.isArray(routeDef))
-      throw new import_js_format19.Errorf(
+      throw new import_js_format18.Errorf(
         "The route definition should be an Object, but %v was given.",
         routeDef
       );
@@ -1432,7 +1336,7 @@ __name(_RouteRegistry, "RouteRegistry");
 var RouteRegistry = _RouteRegistry;
 
 // src/request-context.js
-var import_js_format20 = require("@e22m4u/js-format");
+var import_js_format19 = require("@e22m4u/js-format");
 var import_js_service3 = require("@e22m4u/js-service");
 var import_js_service4 = require("@e22m4u/js-service");
 var _RequestContext = class _RequestContext {
@@ -1484,6 +1388,12 @@ var _RequestContext = class _RequestContext {
    * @type {*}
    */
   body;
+  /**
+   * Route meta.
+   *
+   * @type {object}
+   */
+  meta = {};
   /**
    * Method.
    *
@@ -1526,20 +1436,20 @@ var _RequestContext = class _RequestContext {
    */
   constructor(container, request, response) {
     if (!(0, import_js_service4.isServiceContainer)(container))
-      throw new import_js_format20.Errorf(
+      throw new import_js_format19.Errorf(
         'The parameter "container" of RequestContext.constructor should be an instance of ServiceContainer, but %v was given.',
         container
       );
     this.container = container;
     if (!request || typeof request !== "object" || Array.isArray(request) || !isReadableStream(request)) {
-      throw new import_js_format20.Errorf(
+      throw new import_js_format19.Errorf(
         'The parameter "request" of RequestContext.constructor should be an instance of IncomingMessage, but %v was given.',
         request
       );
     }
     this.req = request;
     if (!response || typeof response !== "object" || Array.isArray(response) || !isWritableStream(response)) {
-      throw new import_js_format20.Errorf(
+      throw new import_js_format19.Errorf(
         'The parameter "response" of RequestContext.constructor should be an instance of ServerResponse, but %v was given.',
         response
       );
@@ -1552,6 +1462,149 @@ var RequestContext = _RequestContext;
 
 // src/trie-router.js
 var import_js_service5 = require("@e22m4u/js-service");
+var import_http4 = require("http");
+
+// src/senders/data-sender.js
+var import_js_format20 = require("@e22m4u/js-format");
+var _DataSender = class _DataSender extends DebuggableService {
+  /**
+   * Send.
+   *
+   * @param {import('http').ServerResponse} res
+   * @param {*} data
+   * @returns {undefined}
+   */
+  send(res, data) {
+    const debug = this.getDebuggerFor(this.send);
+    if (data === res || res.headersSent) {
+      debug(
+        "Response sending was skipped because its headers where sent already."
+      );
+      return;
+    }
+    if (data == null) {
+      res.statusCode = 204;
+      res.end();
+      debug("The empty response was sent.");
+      return;
+    }
+    if (isReadableStream(data)) {
+      res.setHeader("Content-Type", "application/octet-stream");
+      data.pipe(res);
+      debug("The stream response was sent.");
+      return;
+    }
+    let debugMsg;
+    switch (typeof data) {
+      case "object":
+      case "boolean":
+      case "number":
+        if (Buffer.isBuffer(data)) {
+          res.setHeader("content-type", "application/octet-stream");
+          debugMsg = "The Buffer was sent as binary data.";
+        } else {
+          res.setHeader("content-type", "application/json");
+          debugMsg = (0, import_js_format20.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);
+    debug(debugMsg);
+  }
+};
+__name(_DataSender, "DataSender");
+var DataSender = _DataSender;
+
+// src/senders/error-sender.js
+var import_util = require("util");
+var import_statuses = __toESM(require("statuses"), 1);
+var EXPOSED_ERROR_PROPERTIES = ["code", "details"];
+var _ErrorSender = class _ErrorSender extends DebuggableService {
+  /**
+   * Handle.
+   *
+   * @param {import('http').IncomingMessage} req
+   * @param {import('http').ServerResponse} res
+   * @param {Error} error
+   * @returns {undefined}
+   */
+  send(req, res, error) {
+    const debug = this.getDebuggerFor(this.send);
+    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 = (0, import_statuses.default)(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((0, import_util.inspect)(requestData, inspectOptions));
+    console.warn((0, import_util.inspect)(body, inspectOptions));
+    if (error.stack) {
+      console.log(error.stack);
+    } else {
+      console.error(error);
+    }
+    res.statusCode = statusCode;
+    res.setHeader("content-type", "application/json; charset=utf-8");
+    res.end(JSON.stringify(body, null, 2), "utf-8");
+    debug(
+      "The %s error was sent for the request %s %v.",
+      statusCode,
+      req.method,
+      getRequestPathname(req)
+    );
+  }
+  /**
+   * Send 404.
+   *
+   * @param {import('http').IncomingMessage} req
+   * @param {import('http').ServerResponse} res
+   * @returns {undefined}
+   */
+  send404(req, res) {
+    const debug = this.getDebuggerFor(this.send404);
+    res.statusCode = 404;
+    res.setHeader("content-type", "text/plain; charset=utf-8");
+    res.end("404 Not Found", "utf-8");
+    debug(
+      "The 404 error was sent for the request %s %v.",
+      req.method,
+      getRequestPathname(req)
+    );
+  }
+};
+__name(_ErrorSender, "ErrorSender");
+var ErrorSender = _ErrorSender;
+
+// src/trie-router.js
 var _TrieRouter = class _TrieRouter extends DebuggableService {
   /**
    * Define route.
@@ -1627,8 +1680,11 @@ var _TrieRouter = class _TrieRouter extends DebuggableService {
       const { route, params } = resolved;
       const container = new import_js_service5.ServiceContainer(this.container);
       const context = new RequestContext(container, req, res);
+      if (route.meta != null) {
+        context.meta = cloneDeep(route.meta);
+      }
       container.set(RequestContext, context);
-      container.set(import_http5.IncomingMessage, req);
+      container.set(import_http4.IncomingMessage, req);
       container.set(import_http4.ServerResponse, res);
       context.params = params;
       let data;
@@ -1703,6 +1759,26 @@ var _TrieRouter = class _TrieRouter extends DebuggableService {
     this.getService(HookRegistry).addHook(type, hook);
     return this;
   }
+  /**
+   * Add pre-handler hook.
+   *
+   * @param {Function} hook
+   * @returns {this}
+   */
+  addPreHandler(hook) {
+    this.getService(HookRegistry).addHook(RouterHookType.PRE_HANDLER, hook);
+    return this;
+  }
+  /**
+   * Add post-handler hook.
+   *
+   * @param {Function} hook
+   * @returns {this}
+   */
+  addPostHandler(hook) {
+    this.getService(HookRegistry).addHook(RouterHookType.POST_HANDLER, hook);
+    return this;
+  }
 };
 __name(_TrieRouter, "TrieRouter");
 var TrieRouter = _TrieRouter;
@@ -1727,6 +1803,7 @@ var TrieRouter = _TrieRouter;
   RouterOptions,
   TrieRouter,
   UNPARSABLE_MEDIA_TYPES,
+  cloneDeep,
   createCookiesString,
   createDebugger,
   createError,

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

@@ -1,5 +1,6 @@
 import {ServerResponse} from 'http';
 import {IncomingMessage} from 'http';
+import {RouteMeta} from './route.js';
 import {ParsedQuery} from './parsers/index.js';
 import {ParsedCookies} from './utils/index.js';
 import {ParsedHeaders} from './parsers/index.js';
@@ -56,6 +57,11 @@ export declare class RequestContext {
    */
   body: unknown;
 
+  /**
+   * Route meta.
+   */
+  meta: RouteMeta;
+
   /**
    * Method.
    */

+ 7 - 0
src/request-context.js

@@ -65,6 +65,13 @@ export class RequestContext {
    */
   body;
 
+  /**
+   * Route meta.
+   *
+   * @type {object}
+   */
+  meta = {};
+
   /**
    * Method.
    *

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

@@ -85,6 +85,14 @@ describe('RequestContext', function () {
       expect(ctx.req).to.be.eq(req);
       expect(ctx.res).to.be.eq(res);
     });
+
+    it('sets an empty object to the "meta" property', function () {
+      const req = createRequestMock();
+      const res = createResponseMock();
+      const cnt = new ServiceContainer();
+      const ctx = new RequestContext(cnt, req, res);
+      expect(ctx.meta).to.be.eql({});
+    });
   });
 
   describe('method', function () {

+ 11 - 0
src/route.d.ts

@@ -36,6 +36,11 @@ export type RoutePostHandler<T = unknown, U = unknown> = (
   data: T,
 ) => ValueOrPromise<U>;
 
+/**
+ * Route meta.
+ */
+export type RouteMeta = Record<PropertyKey, any>;
+
 /**
  * Route definition.
  */
@@ -45,6 +50,7 @@ export type RouteDefinition = {
   handler: RouteHandler;
   preHandler?: RoutePreHandler | RoutePreHandler[];
   postHandler?: RoutePostHandler | RoutePostHandler[];
+  meta?: RouteMeta;
 };
 
 /**
@@ -61,6 +67,11 @@ export declare class Route {
    */
   get path(): string;
 
+  /**
+   * Meta.
+   */
+  get meta(): RouteMeta;
+
   /**
    * Handler.
    */

+ 30 - 5
src/route.js

@@ -1,9 +1,8 @@
 import {Errorf} from '@e22m4u/js-format';
 import {Debuggable} from '@e22m4u/js-debug';
-import {HookRegistry} from './hooks/index.js';
-import {RouterHookType} from './hooks/index.js';
-import {getRequestPathname} from './utils/index.js';
+import {HookRegistry, RouterHookType} from './hooks/index.js';
 import {MODULE_DEBUG_NAMESPACE} from './debuggable-service.js';
+import {cloneDeep, getRequestPathname} from './utils/index.js';
 
 /**
  * @typedef {import('./request-context.js').RequestContext} RequestContext
@@ -14,8 +13,9 @@ import {MODULE_DEBUG_NAMESPACE} from './debuggable-service.js';
  *   method: string,
  *   path: string,
  *   handler: RouteHandler,
- *   preHandler: RoutePreHandler|(RoutePreHandler[])|undefined,
- *   postHandler: RoutePostHandler|(RoutePostHandler[])|undefined
+ *   preHandler?: RoutePreHandler|(RoutePreHandler[]),
+ *   postHandler?: RoutePostHandler|(RoutePostHandler[]),
+ *   meta?: object,
  * }} RouteDefinition
  */
 
@@ -76,6 +76,22 @@ export class Route extends Debuggable {
     return this._path;
   }
 
+  /**
+   * Meta.
+   *
+   * @type {object}
+   */
+  _meta = {};
+
+  /**
+   * Getter of the meta.
+   *
+   * @returns {object}
+   */
+  get meta() {
+    return this._meta;
+  }
+
   /**
    * Handler.
    *
@@ -147,6 +163,15 @@ export class Route extends Debuggable {
           'a Function, but %v was given.',
         routeDef.handler,
       );
+    if (routeDef.meta != null) {
+      if (typeof routeDef.meta !== 'object' || Array.isArray(routeDef.meta))
+        throw new Errorf(
+          'The option "meta" of the Route should be ' +
+            'a plain Object, but %v was given.',
+          routeDef.meta,
+        );
+      this._meta = cloneDeep(routeDef.meta);
+    }
     this._handler = routeDef.handler;
     if (routeDef.preHandler != null) {
       const preHandlerHooks = Array.isArray(routeDef.preHandler)

+ 289 - 204
src/route.spec.js

@@ -35,251 +35,336 @@ describe('Route', function () {
       })();
     });
 
-    it('requires the option "method" to be a non-empty String', function () {
-      const throwable = v => () =>
-        new Route({
-          method: v,
+    describe('the "method" option', function () {
+      it('requires the "method" option 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 was 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(HttpMethod.GET)();
+      });
+
+      it('sets the "method" option in upper case to the "method" property', function () {
+        const route = new Route({
+          method: 'post',
           path: '/',
           handler: () => undefined,
         });
-      const error = v =>
-        format(
-          'The option "method" of the Route should be ' +
-            'a non-empty String, but %s was 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(HttpMethod.GET)();
+        expect(route.method).to.be.eq('POST');
+      });
     });
 
-    it('requires the option "path" to be a non-empty String', function () {
-      const throwable = v => () =>
-        new Route({
+    describe('the "path" option', function () {
+      it('requires the "path" option to be a non-empty String', function () {
+        const throwable = v => () =>
+          new Route({
+            method: HttpMethod.GET,
+            path: v,
+            handler: () => undefined,
+          });
+        const error = v =>
+          format(
+            'The option "path" of the Route should be ' +
+              'a String, but %s was 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('sets the "path" option to the "path" property', function () {
+        const value = '/myPath';
+        const route = new Route({
           method: HttpMethod.GET,
-          path: v,
+          path: value,
           handler: () => undefined,
         });
-      const error = v =>
-        format(
-          'The option "path" of the Route should be ' +
-            'a String, but %s was 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('')();
+        expect(route.path).to.be.eq(value);
+      });
     });
 
-    it('requires the option "handler" to be a non-empty String', function () {
-      const throwable = v => () =>
-        new Route({
-          method: HttpMethod.GET,
-          path: '/',
-          handler: v,
-        });
-      const error = v =>
-        format(
-          'The option "handler" of the Route should be ' +
-            'a Function, but %s was 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)();
-    });
+    describe('the "meta" option', function () {
+      it('requires the "meta" option to be a plain Object', function () {
+        const throwable = v => () =>
+          new Route({
+            method: HttpMethod.GET,
+            path: 'path',
+            handler: () => undefined,
+            meta: v,
+          });
+        const error = v =>
+          format(
+            'The option "meta" of the Route should be ' +
+              'a plain Object, but %s was 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(() => undefined)).to.throw(error('Function'));
+        throwable({foo: 'bar'})();
+        throwable({})();
+        throwable(null)();
+        throwable(undefined)();
+      });
 
-    it('requires the option "preHandler" to be a Function or an Array of Function', function () {
-      const throwable1 = v => () =>
-        new Route({
-          method: HttpMethod.GET,
+      it('sets the "meta" option to the "meta" property as a deep copy', function () {
+        const metaData = {foo: {bar: {baz: 'qux'}}};
+        const route = new Route({
+          method: 'post',
           path: '/',
-          preHandler: v,
           handler: () => undefined,
+          meta: metaData,
         });
-      const error = v =>
-        format(
-          'The hook "preHandler" should be a Function, but %s was 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: HttpMethod.GET,
+        expect(route.meta).to.be.not.eq(metaData);
+        expect(route.meta).to.be.eql(metaData);
+        expect(route.meta.foo).to.be.not.eq(metaData.foo);
+        expect(route.meta.foo).to.be.eql(metaData.foo);
+        expect(route.meta.foo.bar).to.be.not.eq(metaData.foo.bar);
+        expect(route.meta.foo.bar).to.be.eql(metaData.foo.bar);
+      });
+
+      it('sets an empty object to the "meta" property if the "meta" option is not provided', function () {
+        const route = new Route({
+          method: 'post',
           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)();
-    });
+        expect(route.meta).to.be.eql({});
+      });
 
-    it('requires the option "postHandler" to be a Function or an Array of Function', function () {
-      const throwable1 = v => () =>
-        new Route({
-          method: HttpMethod.GET,
+      it('sets an empty object to the "meta" property if the "meta" option is undefined', function () {
+        const route = new Route({
+          method: 'post',
           path: '/',
           handler: () => undefined,
-          postHandler: v,
+          meta: undefined,
         });
-      const error = v =>
-        format(
-          'The hook "postHandler" should be a Function, but %s was 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: HttpMethod.GET,
+        expect(route.meta).to.be.eql({});
+      });
+
+      it('sets an empty object to the "meta" property if the "meta" option is null', function () {
+        const route = new Route({
+          method: 'post',
           path: '/',
           handler: () => undefined,
-          postHandler: [v],
+          meta: null,
         });
-      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)();
+        expect(route.meta).to.be.eql({});
+      });
     });
 
-    it('sets the option "method" in upper case to the "method" property', function () {
-      const route = new Route({
-        method: 'post',
-        path: '/',
-        handler: () => undefined,
+    describe('the "handler" option', function () {
+      it('requires the "handler" option to be a non-empty String', function () {
+        const throwable = v => () =>
+          new Route({
+            method: HttpMethod.GET,
+            path: '/',
+            handler: v,
+          });
+        const error = v =>
+          format(
+            'The option "handler" of the Route should be ' +
+              'a Function, but %s was 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)();
       });
-      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: HttpMethod.GET,
-        path: value,
-        handler: () => undefined,
+      it('sets the "handler" option to the "handler" property', function () {
+        const value = () => undefined;
+        const route = new Route({
+          method: HttpMethod.GET,
+          path: '/',
+          handler: value,
+        });
+        expect(route.handler).to.be.eq(value);
       });
-      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: HttpMethod.GET,
-        path: '/',
-        handler: value,
+    describe('the "preHandler" option', function () {
+      it('requires the "preHandler" option to be a Function or an Array of Function', function () {
+        const throwable1 = v => () =>
+          new Route({
+            method: HttpMethod.GET,
+            path: '/',
+            preHandler: v,
+            handler: () => undefined,
+          });
+        const error = v =>
+          format(
+            'The hook "preHandler" should be a Function, but %s was 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: HttpMethod.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)();
       });
-      expect(route.handler).to.be.eq(value);
-    });
 
-    it('adds a Function to "preHandler" hooks', function () {
-      const value = () => undefined;
-      const route = new Route({
-        method: HttpMethod.GET,
-        path: '/',
-        preHandler: value,
-        handler: () => undefined,
+      it('adds a Function to "preHandler" hooks', function () {
+        const value = () => undefined;
+        const route = new Route({
+          method: HttpMethod.GET,
+          path: '/',
+          preHandler: value,
+          handler: () => undefined,
+        });
+        expect(route.hookRegistry.hasHook(RouterHookType.PRE_HANDLER, value)).to
+          .be.true;
       });
-      expect(route.hookRegistry.hasHook(RouterHookType.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: HttpMethod.GET,
-        path: '/',
-        preHandler: value,
-        handler: () => undefined,
+      it('adds a Function Array to "preHandler" hooks', function () {
+        const value = [() => undefined, () => undefined];
+        const route = new Route({
+          method: HttpMethod.GET,
+          path: '/',
+          preHandler: value,
+          handler: () => undefined,
+        });
+        expect(route.hookRegistry.hasHook(RouterHookType.PRE_HANDLER, value[0]))
+          .to.be.true;
+        expect(route.hookRegistry.hasHook(RouterHookType.PRE_HANDLER, value[1]))
+          .to.be.true;
       });
-      expect(route.hookRegistry.hasHook(RouterHookType.PRE_HANDLER, value[0]))
-        .to.be.true;
-      expect(route.hookRegistry.hasHook(RouterHookType.PRE_HANDLER, value[1]))
-        .to.be.true;
     });
 
-    it('adds a Function to "postHandler" hooks', function () {
-      const value = () => undefined;
-      const route = new Route({
-        method: HttpMethod.GET,
-        path: '/',
-        handler: () => undefined,
-        postHandler: value,
+    describe('the "postHandler" option', function () {
+      it('requires the "postHandler" option to be a Function or an Array of Function', function () {
+        const throwable1 = v => () =>
+          new Route({
+            method: HttpMethod.GET,
+            path: '/',
+            handler: () => undefined,
+            postHandler: v,
+          });
+        const error = v =>
+          format(
+            'The hook "postHandler" should be a Function, but %s was 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: HttpMethod.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)();
       });
-      expect(route.hookRegistry.hasHook(RouterHookType.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: HttpMethod.GET,
-        path: '/',
-        handler: () => undefined,
-        postHandler: value,
+      it('adds a Function to "postHandler" hooks', function () {
+        const value = () => undefined;
+        const route = new Route({
+          method: HttpMethod.GET,
+          path: '/',
+          handler: () => undefined,
+          postHandler: value,
+        });
+        expect(route.hookRegistry.hasHook(RouterHookType.POST_HANDLER, value))
+          .to.be.true;
+      });
+
+      it('adds a Function Array to "postHandler" hooks', function () {
+        const value = [() => undefined, () => undefined];
+        const route = new Route({
+          method: HttpMethod.GET,
+          path: '/',
+          handler: () => undefined,
+          postHandler: value,
+        });
+        expect(
+          route.hookRegistry.hasHook(RouterHookType.POST_HANDLER, value[0]),
+        ).to.be.true;
+        expect(
+          route.hookRegistry.hasHook(RouterHookType.POST_HANDLER, value[1]),
+        ).to.be.true;
       });
-      expect(route.hookRegistry.hasHook(RouterHookType.POST_HANDLER, value[0]))
-        .to.be.true;
-      expect(route.hookRegistry.hasHook(RouterHookType.POST_HANDLER, value[1]))
-        .to.be.true;
     });
   });
 

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

@@ -87,4 +87,18 @@ export declare class TrieRouter extends DebuggableService {
    * @param hook
    */
   addHook(type: RouterHookType, hook: RouterHook): this;
+
+  /**
+   * Add pre-handler hook.
+   *
+   * @param hook
+   */
+  addPreHandler(hook: PreHandlerHook): this;
+
+  /**
+   * Add post-handler hook.
+   *
+   * @param hook
+   */
+  addPostHandler(hook: PostHandlerHook): this;
 }

+ 31 - 9
src/trie-router.js

@@ -1,17 +1,12 @@
-import {ServerResponse} from 'http';
-import {IncomingMessage} from 'http';
-import {isPromise} from './utils/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 {isResponseSent} from './utils/index.js';
-import {RouterHookType} from './hooks/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';
+import {ServerResponse, IncomingMessage} from 'http';
 import {DebuggableService} from './debuggable-service.js';
+import {DataSender, ErrorSender} from './senders/index.js';
+import {cloneDeep, isPromise, isResponseSent} from './utils/index.js';
+import {HookInvoker, HookRegistry, RouterHookType} from './hooks/index.js';
 
 /**
  * Trie router.
@@ -96,6 +91,11 @@ export class TrieRouter extends DebuggableService {
       // нельзя было модифицировать
       const container = new ServiceContainer(this.container);
       const context = new RequestContext(container, req, res);
+      // чтобы мета-данные маршрута были доступны в хуках,
+      // их копия устанавливается в контекст запроса
+      if (route.meta != null) {
+        context.meta = cloneDeep(route.meta);
+      }
       // регистрация контекста запроса в сервис-контейнере
       // для доступа через container.getRegistered(RequestContext)
       container.set(RequestContext, context);
@@ -204,4 +204,26 @@ export class TrieRouter extends DebuggableService {
     this.getService(HookRegistry).addHook(type, hook);
     return this;
   }
+
+  /**
+   * Add pre-handler hook.
+   *
+   * @param {Function} hook
+   * @returns {this}
+   */
+  addPreHandler(hook) {
+    this.getService(HookRegistry).addHook(RouterHookType.PRE_HANDLER, hook);
+    return this;
+  }
+
+  /**
+   * Add post-handler hook.
+   *
+   * @param {Function} hook
+   * @returns {this}
+   */
+  addPostHandler(hook) {
+    this.getService(HookRegistry).addHook(RouterHookType.POST_HANDLER, hook);
+    return this;
+  }
 }

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

@@ -142,6 +142,28 @@ describe('TrieRouter', function () {
       router.requestListener(req, res);
     });
 
+    it('passes the route meta to the request context as a deep copy', function (done) {
+      const router = new TrieRouter();
+      const metaData = {foo: {bar: {baz: 'qux'}}};
+      router.defineRoute({
+        method: HttpMethod.GET,
+        path: '/',
+        meta: metaData,
+        handler: ({meta}) => {
+          expect(meta).to.be.not.eq(metaData);
+          expect(meta).to.be.eql(metaData);
+          expect(meta.foo).to.be.not.eq(metaData.foo);
+          expect(meta.foo).to.be.eql(metaData.foo);
+          expect(meta.foo.bar).to.be.not.eq(metaData.foo.bar);
+          expect(meta.foo.bar).to.be.eql(metaData.foo.bar);
+          done();
+        },
+      });
+      const req = createRequestMock();
+      const res = createResponseMock();
+      router.requestListener(req, res);
+    });
+
     it('uses DataSender to send the response', function (done) {
       const router = new TrieRouter();
       const resBody = 'Lorem Ipsum is simply dummy text.';
@@ -571,4 +593,28 @@ describe('TrieRouter', function () {
       expect(reg.hasHook(type, hook)).to.be.true;
     });
   });
+
+  describe('addPreHandler', function () {
+    it('adds the given pre-handler hook to the HookRegistry and returns itself', function () {
+      const router = new TrieRouter();
+      const reg = router.getService(HookRegistry);
+      const hook = () => undefined;
+      expect(reg.hasHook(RouterHookType.PRE_HANDLER, hook)).to.be.false;
+      const res = router.addPreHandler(hook);
+      expect(res).to.be.eq(router);
+      expect(reg.hasHook(RouterHookType.PRE_HANDLER, hook)).to.be.true;
+    });
+  });
+
+  describe('addPostHandler', function () {
+    it('adds the given post-handler hook to the HookRegistry and returns itself', function () {
+      const router = new TrieRouter();
+      const reg = router.getService(HookRegistry);
+      const hook = () => undefined;
+      expect(reg.hasHook(RouterHookType.POST_HANDLER, hook)).to.be.false;
+      const res = router.addPostHandler(hook);
+      expect(res).to.be.eq(router);
+      expect(reg.hasHook(RouterHookType.POST_HANDLER, hook)).to.be.true;
+    });
+  });
 });

+ 6 - 0
src/utils/clone-deep.d.ts

@@ -0,0 +1,6 @@
+/**
+ * Clone deep.
+ *
+ * @param value
+ */
+export declare function cloneDeep<T>(value: T): T;

+ 33 - 0
src/utils/clone-deep.js

@@ -0,0 +1,33 @@
+/**
+ * Clone deep.
+ *
+ * @param {*} value
+ * @returns {*}
+ */
+export function cloneDeep(value) {
+  // handle primitives, undefined, null, and functions (return them as is)
+  if (value == null || typeof value !== 'object') {
+    return value;
+  }
+  // handle Date objects
+  if (value instanceof Date) {
+    return new Date(value.getTime());
+  }
+  // handle Arrays by recursively cloning each item
+  if (Array.isArray(value)) {
+    return value.map(item => cloneDeep(item));
+  }
+  // handle plain objects (literals) by recursively cloning properties
+  const proto = Object.getPrototypeOf(value);
+  if (proto === Object.prototype || proto === null) {
+    const newObj = {};
+    for (const key in value) {
+      // ensure we only copy own properties
+      if (Object.prototype.hasOwnProperty.call(value, key)) {
+        newObj[key] = cloneDeep(value[key]);
+      }
+    }
+    return newObj;
+  }
+  return value;
+}

+ 194 - 0
src/utils/clone-deep.spec.js

@@ -0,0 +1,194 @@
+import {expect} from 'chai';
+import {cloneDeep} from './clone-deep.js';
+
+describe('cloneDeep', function () {
+  it('returns a deep copy of a given object', function () {
+    const value = {
+      stringProp: 'string',
+      numberProp: 10,
+      booleanProp: true,
+      arrayProp: [1, 2, 3],
+      objectProp: {
+        foo: 'string',
+        bar: 'string',
+      },
+      dateProp: new Date(),
+      nullProp: null,
+    };
+    const result = cloneDeep(value);
+    expect(result).to.be.eql(value);
+    expect(result).to.be.not.eq(value);
+    expect(result.arrayProp).to.be.not.eq(value.arrayProp);
+    expect(result.arrayProp).to.be.eql(value.arrayProp);
+    expect(result.objectProp).to.be.not.eq(value.objectProp);
+    expect(result.objectProp).to.be.eql(value.objectProp);
+    expect(result.dateProp).to.be.not.eq(value.dateProp);
+    expect(result.dateProp.getTime()).to.be.eq(value.dateProp.getTime());
+  });
+
+  describe('primitives and nullish values', function () {
+    it('should return strings as is', function () {
+      expect(cloneDeep('hello')).to.eq('hello');
+    });
+
+    it('should return numbers as is', function () {
+      expect(cloneDeep(123)).to.eq(123);
+      expect(cloneDeep(0)).to.eq(0);
+    });
+
+    it('should return booleans as is', function () {
+      expect(cloneDeep(true)).to.eq(true);
+      expect(cloneDeep(false)).to.eq(false);
+    });
+
+    it('should return null as is', function () {
+      expect(cloneDeep(null)).to.be.null;
+    });
+
+    it('should return undefined as is', function () {
+      expect(cloneDeep(undefined)).to.be.undefined;
+    });
+
+    it('should return symbols as is', function () {
+      const sym = Symbol('test');
+      expect(cloneDeep(sym)).to.eq(sym);
+    });
+  });
+
+  describe('plain objects', function () {
+    it('should create a deep copy of a nested object', function () {
+      const original = {
+        a: 1,
+        b: {
+          c: 2,
+          d: {e: 'hello'},
+        },
+      };
+      const cloned = cloneDeep(original);
+      expect(cloned).to.be.not.eq(original);
+      expect(cloned).to.be.eql(original);
+      expect(cloned.b).to.be.not.eq(original.b);
+      expect(cloned.b.d).to.be.not.eq(original.b.d);
+    });
+
+    it('should correctly clone an object with various data types', function () {
+      const original = {
+        stringProp: 'string',
+        numberProp: 10,
+        booleanProp: true,
+        arrayProp: [1, {id: 2}],
+        objectProp: {foo: 'bar'},
+        dateProp: new Date(),
+        nullProp: null,
+      };
+      const cloned = cloneDeep(original);
+      expect(cloned).to.be.eql(original);
+      expect(cloned).to.be.not.eq(original);
+      expect(cloned.arrayProp).to.be.not.eq(original.arrayProp);
+      expect(cloned.arrayProp[1]).to.be.not.eq(original.arrayProp[1]);
+      expect(cloned.objectProp).to.be.not.eq(original.objectProp);
+      expect(cloned.dateProp).to.be.not.eq(original.dateProp);
+    });
+
+    it('should handle objects created with Object.create(null)', function () {
+      const original = Object.create(null);
+      original.a = 1;
+      const cloned = cloneDeep(original);
+      expect(cloned).to.be.eql(original);
+      expect(cloned).to.be.not.eq(original);
+      expect(Object.getPrototypeOf(cloned)).to.not.be.null;
+    });
+  });
+
+  describe('arrays', function () {
+    it('should create a deep copy of an array with objects', function () {
+      const original = [{a: 1}, {b: 2}];
+      const cloned = cloneDeep(original);
+      expect(cloned).to.be.not.eq(original);
+      expect(cloned).to.be.eql(original);
+      expect(cloned[0]).to.be.not.eq(original[0]);
+    });
+
+    it('should create a deep copy of a nested array', function () {
+      const original = [1, [2, 3, [4]]];
+      const cloned = cloneDeep(original);
+      expect(cloned).to.be.not.eq(original);
+      expect(cloned).to.be.eql(original);
+      expect(cloned[1]).to.be.not.eq(original[1]);
+      expect(cloned[1][2]).to.be.not.eq(original[1][2]);
+    });
+  });
+
+  describe('dates', function () {
+    it('should create a new Date instance with the same time value', function () {
+      const original = new Date();
+      const cloned = cloneDeep(original);
+      expect(cloned).to.be.not.eq(original);
+      expect(cloned.getTime()).to.eq(original.getTime());
+    });
+  });
+
+  describe('complex/reference types (should not be cloned)', function () {
+    it('should return the same instance for functions', function () {
+      const originalFn = () => 42;
+      const cloned = cloneDeep(originalFn);
+      expect(cloned).to.be.eq(originalFn);
+    });
+
+    it('should return the same instance for class instances', function () {
+      class MyClass {
+        constructor(name) {
+          this.name = name;
+        }
+      }
+      const originalInstance = new MyClass('test');
+      const cloned = cloneDeep(originalInstance);
+      expect(cloned).to.be.eq(originalInstance);
+      expect(cloned).to.be.instanceOf(MyClass);
+    });
+
+    it('should NOT clone properties of class instances', function () {
+      class MyClassWithObject {
+        constructor() {
+          this.data = {value: 10};
+        }
+      }
+      const originalInstance = new MyClassWithObject();
+      const cloned = cloneDeep(originalInstance);
+      expect(cloned).to.be.eq(originalInstance);
+      expect(cloned.data).to.be.eq(originalInstance.data);
+    });
+
+    it('should handle objects containing reference types correctly', function () {
+      const fn = () => {};
+      class MyClass {}
+      const instance = new MyClass();
+      const original = {
+        a: 1,
+        myFn: fn,
+        myInstance: instance,
+        nested: {
+          b: 2,
+          myInstanceRef: instance,
+        },
+      };
+      const cloned = cloneDeep(original);
+      expect(cloned).to.not.equal(original);
+      expect(cloned.nested).to.not.equal(original.nested);
+      expect(cloned.myFn).to.be.eq(original.myFn);
+      expect(cloned.myInstance).to.be.eq(original.myInstance);
+      expect(cloned.nested.myInstanceRef).to.be.eq(original.myInstance);
+    });
+  });
+
+  describe('Edge Cases', function () {
+    it('should throw on circular references', function () {
+      const obj = {};
+      obj.a = obj;
+      expect(() => cloneDeep(obj)).to.throw(
+        RangeError,
+        /Maximum call stack size exceeded/,
+      );
+    });
+  });
+});

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

@@ -1,3 +1,4 @@
+export * from './clone-deep.js';
 export * from './is-promise.js';
 export * from './create-error.js';
 export * from './parse-cookies.js';

+ 1 - 0
src/utils/index.js

@@ -1,3 +1,4 @@
+export * from './clone-deep.js';
 export * from './is-promise.js';
 export * from './create-error.js';
 export * from './parse-cookies.js';