Browse Source

feat: adds branch functionality

e22m4u 3 weeks ago
parent
commit
b89b955bc4

+ 71 - 0
README.md

@@ -24,6 +24,7 @@ HTTP маршрутизатор для Node.js на основе
   - [Глобальные хуки](#глобальные-хуки)
   - [Метаданные маршрута](#метаданные-маршрута)
   - [Состояние запроса](#состояние-запроса)
+  - [Ветвление маршрутов](#ветвление-маршрутов)
 - [Отладка](#отладка)
 - [Тестирование](#тестирование)
 - [Лицензия](#лицензия)
@@ -338,6 +339,76 @@ router.defineRoute({
 });
 ```
 
+### Ветвление маршрутов
+
+Механизм ветвления позволяет группировать маршруты по общему префиксу пути.
+Созданная ветка предоставляет методы для объявления маршрутов и создания
+вложенных веток. Параметры ветки объединяются с параметрами родителя.
+Пути объединяются, массивы хуков объединяются, а объект метаданных
+подвергается глубокому слиянию.
+
+```js
+const router = new TrieRouter();
+
+// создание ветки для api
+const apiBranch = router.createBranch({
+  path: '/api',
+  // опционально:
+  //   preHandler: ...
+  //   postHandler: ...
+  //   meta: ...
+});
+
+// маршрут будет доступен по адресу /api/users
+apiBranch.defineRoute({
+  method: HttpMethod.GET,
+  path: '/users',
+  handler: () => 'Users list',
+});
+```
+
+Ветки позволяют задавать общие хуки и метаданные для группы маршрутов.
+Это удобно для реализации проверок авторизации или логирования в рамках
+определенного раздела приложения.
+
+```js
+const adminBranch = router.createBranch({
+  path: '/admin',
+  meta: {access: 'admin'}, // общие метаданные
+  preHandler: (ctx) => {
+    // проверка прав доступа для всей ветки
+  },
+});
+
+adminBranch.defineRoute({
+  method: HttpMethod.GET,
+  path: '/dashboard',
+  handler: (ctx) => {
+    // маршрут наследует префикс /admin и метаданные
+    console.log(ctx.meta); // {access: 'admin'}
+    return 'Dashboard';
+  },
+});
+
+// GET /admin/dashboard
+```
+
+Допускается создание вложенных веток любой глубины.
+
+```js
+    
+const apiBranch = router.createBranch({path: '/api'});
+const v1Branch = apiBranch.createBranch({path: '/v1'});
+
+v1Branch.defineRoute({
+  method: HttpMethod.GET,
+  path: '/status',
+  handler: () => 'API v1 working',
+});
+
+// GET /api/v1/status
+```
+
 ## Отладка
 
 Установка переменной `DEBUG` включает вывод логов.

+ 288 - 9
dist/cjs/index.cjs

@@ -43,6 +43,7 @@ __export(index_exports, {
   METHODS_WITH_BODY: () => METHODS_WITH_BODY,
   QueryParser: () => QueryParser,
   ROOT_PATH: () => ROOT_PATH,
+  ROUTER_HOOK_TYPES: () => ROUTER_HOOK_TYPES,
   RequestContext: () => RequestContext,
   RequestParser: () => RequestParser,
   Route: () => Route,
@@ -63,6 +64,8 @@ __export(index_exports, {
   isReadableStream: () => isReadableStream,
   isResponseSent: () => isResponseSent,
   isWritableStream: () => isWritableStream,
+  mergeDeep: () => mergeDeep,
+  normalizePath: () => normalizePath,
   parseContentType: () => parseContentType,
   parseCookieString: () => parseCookieString,
   parseJsonBody: () => parseJsonBody,
@@ -124,6 +127,31 @@ function cloneDeep(value) {
 }
 __name(cloneDeep, "cloneDeep");
 
+// src/utils/merge-deep.js
+function mergeDeep(target, source) {
+  const isObject = /* @__PURE__ */ __name((item) => {
+    return item && typeof item === "object" && !Array.isArray(item);
+  }, "isObject");
+  if (Array.isArray(target) && Array.isArray(source)) {
+    return [...target, ...source];
+  }
+  if (isObject(target) && isObject(source)) {
+    const result = { ...target };
+    Object.keys(source).forEach((key) => {
+      const targetValue = target[key];
+      const sourceValue = source[key];
+      if (Object.prototype.hasOwnProperty.call(target, key)) {
+        result[key] = mergeDeep(targetValue, sourceValue);
+      } else {
+        result[key] = sourceValue;
+      }
+    });
+    return result;
+  }
+  return source;
+}
+__name(mergeDeep, "mergeDeep");
+
 // src/utils/is-promise.js
 function isPromise(value) {
   if (!value) {
@@ -172,6 +200,16 @@ function toCamelCase(input) {
 }
 __name(toCamelCase, "toCamelCase");
 
+// src/utils/normalize-path.js
+function normalizePath(value, noStartingSlash = false) {
+  if (typeof value !== "string") {
+    return "/";
+  }
+  const res = value.trim().replace(/\/+/g, "/").replace(/(^\/|\/$)/g, "");
+  return noStartingSlash ? res : "/" + res;
+}
+__name(normalizePath, "normalizePath");
+
 // src/utils/is-response-sent.js
 var import_js_format3 = require("@e22m4u/js-format");
 function isResponseSent(response) {
@@ -189,7 +227,7 @@ __name(isResponseSent, "isResponseSent");
 function createRouteMock(options = {}) {
   return new Route({
     method: options.method || HttpMethod.GET,
-    path: options.path || ROOT_PATH,
+    path: options.path || "/",
     handler: options.handler || (() => "OK")
   });
 }
@@ -261,7 +299,7 @@ function fetchRequestBody(request, bodyBytesLimit = 0) {
   }
   if (typeof bodyBytesLimit !== "number") {
     throw new import_js_format5.InvalidArgumentError(
-      'The parameter "bodyBytesLimit" of "fetchRequestBody" must be a number, but %v was given.',
+      'The parameter "bodyBytesLimit" of "fetchRequestBody" must be a Number, but %v was given.',
       bodyBytesLimit
     );
   }
@@ -298,20 +336,22 @@ function fetchRequestBody(request, bodyBytesLimit = 0) {
     const onData = /* @__PURE__ */ __name((chunk) => {
       receivedLength += chunk.length;
       if (bodyBytesLimit && receivedLength > bodyBytesLimit) {
-        request.removeAllListeners();
+        cleanupListeners();
         const error = createError(
           import_http_errors.default.PayloadTooLarge,
           "Request body limit is %v bytes, but %v bytes given.",
           bodyBytesLimit,
           receivedLength
         );
+        request.unpipe();
+        request.destroy();
         reject(error);
         return;
       }
       data.push(chunk);
     }, "onData");
     const onEnd = /* @__PURE__ */ __name(() => {
-      request.removeAllListeners();
+      cleanupListeners();
       if (contentLength && contentLength !== receivedLength) {
         const error = createError(
           import_http_errors.default.BadRequest,
@@ -325,9 +365,14 @@ function fetchRequestBody(request, bodyBytesLimit = 0) {
       resolve(body || void 0);
     }, "onEnd");
     const onError = /* @__PURE__ */ __name((error) => {
-      request.removeAllListeners();
+      cleanupListeners();
       reject((0, import_http_errors.default)(400, error));
     }, "onError");
+    const cleanupListeners = /* @__PURE__ */ __name(() => {
+      request.removeListener("data", onData);
+      request.removeListener("end", onEnd);
+      request.removeListener("error", onError);
+    }, "cleanupListeners");
     request.on("data", onData);
     request.on("end", onEnd);
     request.on("error", onError);
@@ -477,7 +522,7 @@ function createRequestMock(patch) {
     }
   }
   const request = patch.stream || createRequestStream(patch.secure, patch.body, patch.encoding);
-  request.url = createRequestUrl(patch.path || ROOT_PATH, patch.query);
+  request.url = createRequestUrl(patch.path || "/", patch.query);
   request.headers = createRequestHeaders(
     patch.host,
     patch.secure,
@@ -529,7 +574,7 @@ function createRequestUrl(path, query) {
       query
     );
   }
-  let url = (ROOT_PATH + path).replace("//", "/");
+  let url = ("/" + path).replace("//", "/");
   if (typeof query === "object") {
     const qs = import_querystring.default.stringify(query);
     if (qs) {
@@ -731,7 +776,7 @@ function getRequestPathname(request) {
       request
     );
   }
-  return (request.url || ROOT_PATH).replace(/\?.*$/, "");
+  return (request.url || "/").replace(/\?.*$/, "");
 }
 __name(getRequestPathname, "getRequestPathname");
 
@@ -741,6 +786,7 @@ var RouterHookType = {
   PRE_HANDLER: "preHandler",
   POST_HANDLER: "postHandler"
 };
+var ROUTER_HOOK_TYPES = Object.values(RouterHookType);
 var _HookRegistry = class _HookRegistry {
   /**
    * Hooks.
@@ -1466,7 +1512,7 @@ var _RouteRegistry = class _RouteRegistry extends DebuggableService {
       requestPath
     );
     const rawTriePath = `${request.method.toUpperCase()}/${requestPath}`;
-    const triePath = rawTriePath.replace(/\/+/g, ROOT_PATH);
+    const triePath = rawTriePath.replace(/\/+/g, "/");
     const resolved = this._trie.match(triePath);
     if (resolved) {
       const route = resolved.value;
@@ -1826,6 +1872,214 @@ var _ErrorSender = class _ErrorSender extends DebuggableService {
 __name(_ErrorSender, "ErrorSender");
 var ErrorSender = _ErrorSender;
 
+// src/branch/router-branch.js
+var import_js_format20 = require("@e22m4u/js-format");
+
+// src/branch/validate-router-branch-definition.js
+var import_js_format19 = require("@e22m4u/js-format");
+function validateRouterBranchDefinition(branchDef) {
+  if (!branchDef || typeof branchDef !== "object" || Array.isArray(branchDef)) {
+    throw new import_js_format19.InvalidArgumentError(
+      "Branch definition must be an Object, but %v was given.",
+      branchDef
+    );
+  }
+  if (branchDef.method !== void 0) {
+    throw new import_js_format19.InvalidArgumentError(
+      'Option "method" is not supported for the router branch, but %v was given.',
+      branchDef.method
+    );
+  }
+  if (!branchDef.path || typeof branchDef.path !== "string") {
+    throw new import_js_format19.InvalidArgumentError(
+      'Option "path" must be a non-empty String, but %v was given.',
+      branchDef.path
+    );
+  }
+  if (branchDef.handler !== void 0) {
+    throw new import_js_format19.InvalidArgumentError(
+      'Option "handler" is not supported for the router branch, but %v was given.',
+      branchDef.handler
+    );
+  }
+  if (branchDef.preHandler !== void 0) {
+    if (Array.isArray(branchDef.preHandler)) {
+      branchDef.preHandler.forEach((preHandler) => {
+        if (typeof preHandler !== "function") {
+          throw new import_js_format19.InvalidArgumentError(
+            "Route pre-handler must be a Function, but %v was given.",
+            preHandler
+          );
+        }
+      });
+    } else if (typeof branchDef.preHandler !== "function") {
+      throw new import_js_format19.InvalidArgumentError(
+        'Option "preHandler" must be a Function or an Array, but %v was given.',
+        branchDef.preHandler
+      );
+    }
+  }
+  if (branchDef.postHandler !== void 0) {
+    if (Array.isArray(branchDef.postHandler)) {
+      branchDef.postHandler.forEach((postHandler) => {
+        if (typeof postHandler !== "function") {
+          throw new import_js_format19.InvalidArgumentError(
+            "Route post-handler must be a Function, but %v was given.",
+            postHandler
+          );
+        }
+      });
+    } else if (typeof branchDef.postHandler !== "function") {
+      throw new import_js_format19.InvalidArgumentError(
+        'Option "postHandler" must be a Function or an Array, but %v was given.',
+        branchDef.postHandler
+      );
+    }
+  }
+  if (branchDef.meta !== void 0) {
+    if (!branchDef.meta || typeof branchDef.meta !== "object" || Array.isArray(branchDef.meta)) {
+      throw new import_js_format19.InvalidArgumentError(
+        'Option "meta" must be an Object, but %v was given.',
+        branchDef.meta
+      );
+    }
+  }
+}
+__name(validateRouterBranchDefinition, "validateRouterBranchDefinition");
+
+// src/branch/merge-router-branch-definitions.js
+function mergeRouterBranchDefinitions(firstDef, secondDef) {
+  validateRouterBranchDefinition(firstDef);
+  validateRouterBranchDefinition(secondDef);
+  const mergedDef = {};
+  const path = (firstDef.path || "") + "/" + (secondDef.path || "");
+  mergedDef.path = normalizePath(path);
+  if (firstDef.preHandler || secondDef.preHandler) {
+    mergedDef.preHandler = [firstDef.preHandler, secondDef.preHandler].flat().filter(Boolean);
+  }
+  if (firstDef.postHandler || secondDef.postHandler) {
+    mergedDef.postHandler = [firstDef.postHandler, secondDef.postHandler].flat().filter(Boolean);
+  }
+  if (firstDef.meta && !secondDef.meta) {
+    mergedDef.meta = firstDef.meta;
+  } else if (!firstDef.meta && secondDef.meta) {
+    mergedDef.meta = secondDef.meta;
+  } else if (firstDef.meta && secondDef.meta) {
+    mergedDef.meta = mergeDeep(firstDef.meta, secondDef.meta);
+  }
+  return { ...firstDef, ...secondDef, ...mergedDef };
+}
+__name(mergeRouterBranchDefinitions, "mergeRouterBranchDefinitions");
+
+// src/branch/router-branch.js
+var _RouterBranch = class _RouterBranch extends DebuggableService {
+  /**
+   * Router.
+   *
+   * @type {TrieRouter}
+   */
+  _router;
+  /**
+   * Get router.
+   *
+   * @type {TrieRouter}
+   */
+  getRouter() {
+    return this._router;
+  }
+  /**
+   * Branch definition.
+   *
+   * @type {RouterBranchDefinition}
+   */
+  _definition;
+  /**
+   * Get branch definition.
+   *
+   * @type {RouterBranchDefinition}
+   */
+  getDefinition() {
+    return { ...this._definition };
+  }
+  /**
+   * Parent branch.
+   *
+   * @type {RouterBranch|undefined}
+   */
+  _parentBranch;
+  /**
+   * Get parent branch.
+   *
+   * @returns {RouterBranch|undefined}
+   */
+  getParentBranch() {
+    return this._parentBranch;
+  }
+  /**
+   * Constructor.
+   *
+   * @param {TrieRouter} router
+   * @param {RouterBranchDefinition} branchDef
+   * @param {RouterBranch} [parentBranch]
+   */
+  constructor(router, branchDef, parentBranch) {
+    super(router.container);
+    if (!(router instanceof TrieRouter)) {
+      throw new import_js_format20.InvalidArgumentError(
+        'Parameter "router" must be a TrieRouter instance, but %v was given.',
+        router
+      );
+    }
+    this._router = router;
+    if (parentBranch !== void 0 && !(parentBranch instanceof _RouterBranch)) {
+      throw new import_js_format20.InvalidArgumentError(
+        'Parameter "parentBranch" must be a RouterBranch instance, but %v was given.',
+        parentBranch
+      );
+    }
+    this._parentBranch = parentBranch;
+    if (parentBranch) {
+      this._definition = mergeRouterBranchDefinitions(
+        parentBranch.getDefinition(),
+        branchDef
+      );
+    } else {
+      validateRouterBranchDefinition(branchDef);
+      this._definition = branchDef;
+    }
+    this.ctorDebug("Branch %v created.", normalizePath(branchDef.path, true));
+    this.ctorDebug("Branch path was %v.", this._definition.path);
+  }
+  /**
+   * Define route.
+   *
+   * @param {import('../route/index.js').RouteDefinition} routeDef
+   * @returns {Route}
+   */
+  defineRoute(routeDef) {
+    validateRouteDefinition(routeDef);
+    const { method, handler, ...routeDefAsBranchDef } = routeDef;
+    const mergedDef = mergeRouterBranchDefinitions(
+      this._definition,
+      routeDefAsBranchDef
+    );
+    mergedDef.method = method;
+    mergedDef.handler = handler;
+    return this._router.defineRoute(mergedDef);
+  }
+  /**
+   * Create branch.
+   *
+   * @param {RouterBranch} branchDef
+   * @returns {RouterBranch}
+   */
+  createBranch(branchDef) {
+    return new _RouterBranch(this._router, branchDef, this);
+  }
+};
+__name(_RouterBranch, "RouterBranch");
+var RouterBranch = _RouterBranch;
+
 // src/trie-router.js
 var _TrieRouter = class _TrieRouter extends DebuggableService {
   /**
@@ -1859,6 +2113,28 @@ var _TrieRouter = class _TrieRouter extends DebuggableService {
   defineRoute(routeDef) {
     return this.getService(RouteRegistry).defineRoute(routeDef);
   }
+  /**
+   * Create branch.
+   *
+   * Example:
+   * ```js
+   * const router = new TrieRouter();
+   * const apiBranch = router.createBranch({path: 'api'});
+   *
+   * // GET /api/hello
+   * apiBranch.defineRoute({
+   *   method: HttpMethod.GET,
+   *   path: '/hello',
+   *   handler: () => 'Hello World!',
+   * });
+   * ```
+   *
+   * @param {import('./branch/index.js').RouterBranchDefinition} branchDef
+   * @returns {import('./branch/index.js').RouterBranchDefinition}
+   */
+  createBranch(branchDef) {
+    return new RouterBranch(this, branchDef);
+  }
   /**
    * Request listener.
    *
@@ -2022,6 +2298,7 @@ var TrieRouter = _TrieRouter;
   METHODS_WITH_BODY,
   QueryParser,
   ROOT_PATH,
+  ROUTER_HOOK_TYPES,
   RequestContext,
   RequestParser,
   Route,
@@ -2042,6 +2319,8 @@ var TrieRouter = _TrieRouter;
   isReadableStream,
   isResponseSent,
   isWritableStream,
+  mergeDeep,
+  normalizePath,
   parseContentType,
   parseCookieString,
   parseJsonBody,

+ 3 - 0
src/branch/index.d.ts

@@ -0,0 +1,3 @@
+export * from './router-branch.js';
+export * from './merge-router-branch-definitions.js';
+export * from './validate-router-branch-definition.js';

+ 3 - 0
src/branch/index.js

@@ -0,0 +1,3 @@
+export * from './router-branch.js';
+export * from './merge-router-branch-definitions.js';
+export * from './validate-router-branch-definition.js';

+ 165 - 0
src/branch/merge-router-branch-definition.spec.js

@@ -0,0 +1,165 @@
+import {expect} from 'chai';
+import {ROOT_PATH} from '../constants.js';
+import {mergeRouterBranchDefinitions} from './merge-router-branch-definitions.js';
+
+describe('mergeRouterBranchDefinitions', function () {
+  it('should validate the "firstDef" parameter', function () {
+    const throwable = () =>
+      mergeRouterBranchDefinitions(123, {path: ROOT_PATH});
+    expect(throwable).to.throw(
+      'Branch definition must be an Object, but 123 was given.',
+    );
+  });
+
+  it('should validate the "secondDef" parameter', function () {
+    const throwable = () =>
+      mergeRouterBranchDefinitions({path: ROOT_PATH}, 123);
+    expect(throwable).to.throw(
+      'Branch definition must be an Object, but 123 was given.',
+    );
+  });
+
+  it('should concatenate the "path" option with the correct order', function () {
+    const res = mergeRouterBranchDefinitions({path: 'foo'}, {path: 'bar'});
+    expect(res).to.be.eql({path: '/foo/bar'});
+  });
+
+  it('should not duplicate slashes in the "path" option', function () {
+    const res = mergeRouterBranchDefinitions({path: '/'}, {path: '/'});
+    expect(res).to.be.eql({path: '/'});
+  });
+
+  it('should merge the "preHandler" option with a function value', function () {
+    const preHandler1 = () => undefined;
+    const preHandler2 = () => undefined;
+    const res = mergeRouterBranchDefinitions(
+      {
+        path: ROOT_PATH,
+        preHandler: preHandler1,
+      },
+      {
+        path: ROOT_PATH,
+        preHandler: preHandler2,
+      },
+    );
+    expect(res).to.be.eql({
+      path: ROOT_PATH,
+      preHandler: [preHandler1, preHandler2],
+    });
+  });
+
+  it('should merge the "preHandler" option with an array value', function () {
+    const preHandler1 = () => undefined;
+    const preHandler2 = () => undefined;
+    const res = mergeRouterBranchDefinitions(
+      {
+        path: ROOT_PATH,
+        preHandler: [preHandler1],
+      },
+      {
+        path: ROOT_PATH,
+        preHandler: [preHandler2],
+      },
+    );
+    expect(res).to.be.eql({
+      path: ROOT_PATH,
+      preHandler: [preHandler1, preHandler2],
+    });
+  });
+
+  it('should merge the "postHandler" option with a function value', function () {
+    const preHandler1 = () => undefined;
+    const preHandler2 = () => undefined;
+    const res = mergeRouterBranchDefinitions(
+      {
+        path: ROOT_PATH,
+        postHandler: preHandler1,
+      },
+      {
+        path: ROOT_PATH,
+        postHandler: preHandler2,
+      },
+    );
+    expect(res).to.be.eql({
+      path: ROOT_PATH,
+      postHandler: [preHandler1, preHandler2],
+    });
+  });
+
+  it('should merge the "postHandler" option with an array value', function () {
+    const preHandler1 = () => undefined;
+    const preHandler2 = () => undefined;
+    const res = mergeRouterBranchDefinitions(
+      {
+        path: ROOT_PATH,
+        postHandler: [preHandler1],
+      },
+      {
+        path: ROOT_PATH,
+        postHandler: [preHandler2],
+      },
+    );
+    expect(res).to.be.eql({
+      path: ROOT_PATH,
+      postHandler: [preHandler1, preHandler2],
+    });
+  });
+
+  it('should use the "meta" option from the first definition', function () {
+    const meta = {foo: 'bar'};
+    const res = mergeRouterBranchDefinitions(
+      {path: ROOT_PATH, meta},
+      {path: ROOT_PATH},
+    );
+    expect(res).to.be.eql({path: ROOT_PATH, meta});
+  });
+
+  it('should use the "meta" option from the second definition', function () {
+    const meta = {foo: 'bar'};
+    const res = mergeRouterBranchDefinitions(
+      {path: ROOT_PATH},
+      {path: ROOT_PATH, meta},
+    );
+    expect(res).to.be.eql({path: ROOT_PATH, meta});
+  });
+
+  it('should merge the "meta" option when both definitions are provided', function () {
+    const meta1 = {foo: 1};
+    const meta2 = {bar: 2};
+    const res = mergeRouterBranchDefinitions(
+      {path: ROOT_PATH, meta: meta1},
+      {path: ROOT_PATH, meta: meta2},
+    );
+    expect(res).to.be.eql({path: ROOT_PATH, meta: {foo: 1, bar: 2}});
+  });
+
+  it('should merge arrays in the "meta" option', function () {
+    const meta1 = {foo: [1, {bar: 2}]};
+    const meta2 = {foo: [3, {baz: 4}]};
+    const expectedMeta = {foo: [1, {bar: 2}, 3, {baz: 4}]};
+    const res = mergeRouterBranchDefinitions(
+      {path: ROOT_PATH, meta: meta1},
+      {path: ROOT_PATH, meta: meta2},
+    );
+    expect(res).to.be.eql({path: ROOT_PATH, meta: expectedMeta});
+  });
+
+  it('should merge the "meta" option with a deep recursion', function () {
+    const meta1 = {foo: {bar: 10}};
+    const meta2 = {foo: {baz: 20}};
+    const expectedMeta = {foo: {bar: 10, baz: 20}};
+    const res = mergeRouterBranchDefinitions(
+      {path: ROOT_PATH, meta: meta1},
+      {path: ROOT_PATH, meta: meta2},
+    );
+    expect(res).to.be.eql({path: ROOT_PATH, meta: expectedMeta});
+  });
+
+  it('should add extra properties of definitions to the result', function () {
+    const res = mergeRouterBranchDefinitions(
+      {path: ROOT_PATH, extra1: 10},
+      {path: ROOT_PATH, extra2: 20},
+    );
+    expect(res).to.be.eql({path: ROOT_PATH, extra1: 10, extra2: 20});
+  });
+});

+ 12 - 0
src/branch/merge-router-branch-definitions.d.ts

@@ -0,0 +1,12 @@
+import {RouterBranchDefinition} from './router-branch.js';
+
+/**
+ * Merge router branch definitions.
+ *
+ * @param firstDef
+ * @param secondDef
+ */
+export function mergeRouterBranchDefinitions(
+  firstDef: RouterBranchDefinition,
+  secondDef: RouterBranchDefinition,
+): RouterBranchDefinition;

+ 39 - 0
src/branch/merge-router-branch-definitions.js

@@ -0,0 +1,39 @@
+import {mergeDeep, normalizePath} from '../utils/index.js';
+import {validateRouterBranchDefinition} from './validate-router-branch-definition.js';
+
+/**
+ * Merge router branch definitions.
+ *
+ * @param {import('./router-branch.js').RouterBranchDefinition} firstDef
+ * @param {import('./router-branch.js').RouterBranchDefinition} secondDef
+ * @returns {import('./router-branch.js').RouterBranchDefinition}
+ */
+export function mergeRouterBranchDefinitions(firstDef, secondDef) {
+  validateRouterBranchDefinition(firstDef);
+  validateRouterBranchDefinition(secondDef);
+  const mergedDef = {};
+  // path
+  const path = (firstDef.path || '') + '/' + (secondDef.path || '');
+  mergedDef.path = normalizePath(path);
+  // pre-handler
+  if (firstDef.preHandler || secondDef.preHandler) {
+    mergedDef.preHandler = [firstDef.preHandler, secondDef.preHandler]
+      .flat()
+      .filter(Boolean);
+  }
+  // post-handler
+  if (firstDef.postHandler || secondDef.postHandler) {
+    mergedDef.postHandler = [firstDef.postHandler, secondDef.postHandler]
+      .flat()
+      .filter(Boolean);
+  }
+  // meta
+  if (firstDef.meta && !secondDef.meta) {
+    mergedDef.meta = firstDef.meta;
+  } else if (!firstDef.meta && secondDef.meta) {
+    mergedDef.meta = secondDef.meta;
+  } else if (firstDef.meta && secondDef.meta) {
+    mergedDef.meta = mergeDeep(firstDef.meta, secondDef.meta);
+  }
+  return {...firstDef, ...secondDef, ...mergedDef};
+}

+ 70 - 0
src/branch/router-branch.d.ts

@@ -0,0 +1,70 @@
+import {TrieRouter} from '../trie-router.js';
+import {ServiceContainer} from '@e22m4u/js-service';
+import {DebuggableService} from '../debuggable-service.js';
+
+import {
+  Route,
+  RouteMeta,
+  RoutePreHandler,
+  RouteDefinition,
+  RoutePostHandler,
+} from '../route/index.js';
+
+/**
+ * Router branch definition.
+ */
+export type RouterBranchDefinition = {
+  path: string;
+  preHandler?: RoutePreHandler | RoutePreHandler[];
+  postHandler?: RoutePostHandler | RoutePostHandler[];
+  meta?: RouteMeta;
+};
+
+/**
+ * Router branch.
+ */
+export declare class RouterBranch extends DebuggableService {
+  /**
+   * Get router.
+   */
+  getRouter(): TrieRouter;
+
+  /**
+   * Get parent branch.
+   */
+  getParentBranch(): RouterBranch | undefined;
+
+  /**
+   * Get definition.
+   */
+  getDefinition(): RouterBranchDefinition;
+
+  /**
+   * Constructor.
+   *
+   * @param container
+   * @param router
+   * @param branchDef
+   * @param parentBranch
+   */
+  constructor(
+    container: ServiceContainer,
+    router: TrieRouter,
+    branchDef: RouterBranchDefinition,
+    parentBranch?: RouterBranch,
+  );
+
+  /**
+   * Define route.
+   *
+   * @param routeDef
+   */
+  defineRoute(routeDef: RouteDefinition): Route;
+
+  /**
+   * Create branch.
+   *
+   * @param branchDef
+   */
+  createBranch(branchDef: RouterBranchDefinition): RouterBranch;
+}

+ 138 - 0
src/branch/router-branch.js

@@ -0,0 +1,138 @@
+import {Route} from '../route/index.js';
+import {TrieRouter} from '../trie-router.js';
+import {InvalidArgumentError} from '@e22m4u/js-format';
+import {normalizePath} from '../utils/normalize-path.js';
+import {DebuggableService} from '../debuggable-service.js';
+import {validateRouteDefinition} from '../route/validate-route-definition.js';
+import {mergeRouterBranchDefinitions} from './merge-router-branch-definitions.js';
+import {validateRouterBranchDefinition} from './validate-router-branch-definition.js';
+
+/**
+ * @typedef {import('./request-context.js').RequestContext} RequestContext
+ * @typedef {import('../route/index.js').RoutePreHandler} RoutePreHandler
+ * @typedef {import('../route/index.js').RoutePostHandler} RoutePostHandler
+ * @typedef {{
+ *   path: string,
+ *   preHandler?: RoutePreHandler|(RoutePreHandler[]),
+ *   postHandler?: RoutePostHandler|(RoutePostHandler[]),
+ *   meta?: object,
+ * }} RouterBranchDefinition
+ */
+
+/**
+ * Router branch.
+ */
+export class RouterBranch extends DebuggableService {
+  /**
+   * Router.
+   *
+   * @type {TrieRouter}
+   */
+  _router;
+
+  /**
+   * Get router.
+   *
+   * @type {TrieRouter}
+   */
+  getRouter() {
+    return this._router;
+  }
+
+  /**
+   * Branch definition.
+   *
+   * @type {RouterBranchDefinition}
+   */
+  _definition;
+
+  /**
+   * Get branch definition.
+   *
+   * @type {RouterBranchDefinition}
+   */
+  getDefinition() {
+    return {...this._definition};
+  }
+
+  /**
+   * Parent branch.
+   *
+   * @type {RouterBranch|undefined}
+   */
+  _parentBranch;
+
+  /**
+   * Get parent branch.
+   *
+   * @returns {RouterBranch|undefined}
+   */
+  getParentBranch() {
+    return this._parentBranch;
+  }
+
+  /**
+   * Constructor.
+   *
+   * @param {TrieRouter} router
+   * @param {RouterBranchDefinition} branchDef
+   * @param {RouterBranch} [parentBranch]
+   */
+  constructor(router, branchDef, parentBranch) {
+    super(router.container);
+    if (!(router instanceof TrieRouter)) {
+      throw new InvalidArgumentError(
+        'Parameter "router" must be a TrieRouter instance, but %v was given.',
+        router,
+      );
+    }
+    this._router = router;
+    if (parentBranch !== undefined && !(parentBranch instanceof RouterBranch)) {
+      throw new InvalidArgumentError(
+        'Parameter "parentBranch" must be a RouterBranch instance, ' +
+          'but %v was given.',
+        parentBranch,
+      );
+    }
+    this._parentBranch = parentBranch;
+    if (parentBranch) {
+      this._definition = mergeRouterBranchDefinitions(
+        parentBranch.getDefinition(),
+        branchDef,
+      );
+    } else {
+      validateRouterBranchDefinition(branchDef);
+      this._definition = branchDef;
+    }
+    this.ctorDebug('Branch %v created.', normalizePath(branchDef.path, true));
+    this.ctorDebug('Branch path was %v.', this._definition.path);
+  }
+
+  /**
+   * Define route.
+   *
+   * @param {import('../route/index.js').RouteDefinition} routeDef
+   * @returns {Route}
+   */
+  defineRoute(routeDef) {
+    validateRouteDefinition(routeDef);
+    const {method, handler, ...routeDefAsBranchDef} = routeDef;
+    const mergedDef = mergeRouterBranchDefinitions(
+      this._definition,
+      routeDefAsBranchDef,
+    );
+    mergedDef.method = method;
+    mergedDef.handler = handler;
+    return this._router.defineRoute(mergedDef);
+  }
+
+  /**
+   * Create branch.
+   *
+   * @param {RouterBranch} branchDef
+   * @returns {RouterBranch}
+   */
+  createBranch(branchDef) {
+    return new RouterBranch(this._router, branchDef, this);
+  }
+}

+ 88 - 0
src/branch/router-branch.spec.js

@@ -0,0 +1,88 @@
+import {expect} from 'chai';
+import {ROOT_PATH} from '../constants.js';
+import {TrieRouter} from '../trie-router.js';
+import {RouterBranch} from './router-branch.js';
+import {HttpMethod, Route} from '../route/route.js';
+
+describe('RouterBranch', function () {
+  describe('constructor', function () {
+    it('should use a service container from a given router', function () {
+      const router = new TrieRouter();
+      const S = new RouterBranch(router, {path: ROOT_PATH});
+      expect(S.container).to.be.eq(router.container);
+    });
+
+    it('should merge a parent definition with a given definition', function () {
+      const router = new TrieRouter();
+      const parent = router.createBranch({path: 'foo'});
+      const S = new RouterBranch(router, {path: 'bar'}, parent);
+      expect(S.getDefinition().path).to.be.eq('/foo/bar');
+    });
+  });
+
+  describe('getRouter', function () {
+    it('should return the router instance that was provided to the constructor', function () {
+      const router = new TrieRouter();
+      const S = new RouterBranch(router, {path: ROOT_PATH});
+      expect(S.getRouter()).to.be.eq(router);
+    });
+  });
+
+  describe('getDefinition', function () {
+    it('should return the branch definition that was provided to the constructor', function () {
+      const router = new TrieRouter();
+      const branchDef = {path: ROOT_PATH};
+      const S = new RouterBranch(router, branchDef);
+      expect(S.getDefinition()).to.be.eql(branchDef);
+    });
+  });
+
+  describe('getParentBranch', function () {
+    it('should return the parent branch that was provided to the constructor', function () {
+      const router = new TrieRouter();
+      const parent = router.createBranch({path: ROOT_PATH});
+      const S = new RouterBranch(router, {path: ROOT_PATH}, parent);
+      expect(S.getParentBranch()).to.be.eq(parent);
+    });
+  });
+
+  describe('defineRoute', function () {
+    it('should return a Route instance', function () {
+      const router = new TrieRouter();
+      const S = new RouterBranch(router, {path: 'foo'});
+      const res = S.defineRoute({
+        method: HttpMethod.GET,
+        path: 'bar',
+        handler: () => undefined,
+      });
+      expect(res).to.be.instanceOf(Route);
+    });
+
+    it('should combine a branch path with a route path', function () {
+      const router = new TrieRouter();
+      const S = new RouterBranch(router, {path: 'foo'});
+      const res = S.defineRoute({
+        method: HttpMethod.GET,
+        path: 'bar',
+        handler: () => undefined,
+      });
+      expect(res.path).to.be.eq('/foo/bar');
+    });
+  });
+
+  describe('createBranch', function () {
+    it('should return a RouterBranch instance', function () {
+      const router = new TrieRouter();
+      const S = new RouterBranch(router, {path: 'foo'});
+      const res = S.createBranch({path: 'bar'});
+      expect(res).to.be.instanceOf(RouterBranch);
+    });
+
+    it('should combine a current path with a new path', function () {
+      const router = new TrieRouter();
+      const S = new RouterBranch(router, {path: 'foo'});
+      const res = S.createBranch({path: 'bar'});
+      expect(res.getDefinition().path).to.be.eq('/foo/bar');
+    });
+  });
+});

+ 10 - 0
src/branch/validate-router-branch-definition.d.ts

@@ -0,0 +1,10 @@
+import {RouterBranchDefinition} from './router-branch.js';
+
+/**
+ * Validate router branch definition.
+ *
+ * @param branchDef
+ */
+export function validateRouterBranchDefinition(
+  branchDef: RouterBranchDefinition,
+): void;

+ 81 - 0
src/branch/validate-router-branch-definition.js

@@ -0,0 +1,81 @@
+import {InvalidArgumentError} from '@e22m4u/js-format';
+
+/**
+ * Validate router branch definition.
+ *
+ * @param {import('./router-branch.js').RouterBranchDefinition} branchDef
+ */
+export function validateRouterBranchDefinition(branchDef) {
+  if (!branchDef || typeof branchDef !== 'object' || Array.isArray(branchDef)) {
+    throw new InvalidArgumentError(
+      'Branch definition must be an Object, but %v was given.',
+      branchDef,
+    );
+  }
+  if (branchDef.method !== undefined) {
+    throw new InvalidArgumentError(
+      'Option "method" is not supported for the router branch, ' +
+        'but %v was given.',
+      branchDef.method,
+    );
+  }
+  if (!branchDef.path || typeof branchDef.path !== 'string') {
+    throw new InvalidArgumentError(
+      'Option "path" must be a non-empty String, but %v was given.',
+      branchDef.path,
+    );
+  }
+  if (branchDef.handler !== undefined) {
+    throw new InvalidArgumentError(
+      'Option "handler" is not supported for the router branch, ' +
+        'but %v was given.',
+      branchDef.handler,
+    );
+  }
+  if (branchDef.preHandler !== undefined) {
+    if (Array.isArray(branchDef.preHandler)) {
+      branchDef.preHandler.forEach(preHandler => {
+        if (typeof preHandler !== 'function') {
+          throw new InvalidArgumentError(
+            'Route pre-handler must be a Function, but %v was given.',
+            preHandler,
+          );
+        }
+      });
+    } else if (typeof branchDef.preHandler !== 'function') {
+      throw new InvalidArgumentError(
+        'Option "preHandler" must be a Function or an Array, but %v was given.',
+        branchDef.preHandler,
+      );
+    }
+  }
+  if (branchDef.postHandler !== undefined) {
+    if (Array.isArray(branchDef.postHandler)) {
+      branchDef.postHandler.forEach(postHandler => {
+        if (typeof postHandler !== 'function') {
+          throw new InvalidArgumentError(
+            'Route post-handler must be a Function, but %v was given.',
+            postHandler,
+          );
+        }
+      });
+    } else if (typeof branchDef.postHandler !== 'function') {
+      throw new InvalidArgumentError(
+        'Option "postHandler" must be a Function or an Array, but %v was given.',
+        branchDef.postHandler,
+      );
+    }
+  }
+  if (branchDef.meta !== undefined) {
+    if (
+      !branchDef.meta ||
+      typeof branchDef.meta !== 'object' ||
+      Array.isArray(branchDef.meta)
+    ) {
+      throw new InvalidArgumentError(
+        'Option "meta" must be an Object, but %v was given.',
+        branchDef.meta,
+      );
+    }
+  }
+}

+ 178 - 0
src/branch/validate-router-branch-definition.spec.js

@@ -0,0 +1,178 @@
+import {expect} from 'chai';
+import {format} from '@e22m4u/js-format';
+import {validateRouterBranchDefinition} from './validate-router-branch-definition.js';
+import {ROOT_PATH} from '../constants.js';
+
+describe('validateRouterBranchDefinition', function () {
+  it('should require the "routeDef" parameter to be an Object', function () {
+    const throwable = v => () => validateRouterBranchDefinition(v);
+    const error = v =>
+      format('Branch definition must be an Object, 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('Array'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    expect(throwable(() => undefined)).to.throw(error('Function'));
+    throwable({path: ROOT_PATH})();
+  });
+
+  it('should throw an error if the "method" option is provided', function () {
+    const throwable = () =>
+      validateRouterBranchDefinition({
+        path: ROOT_PATH,
+        method: 123,
+      });
+    expect(throwable).to.throw(
+      'Option "method" is not supported for the router branch, ' +
+        'but 123 was given.',
+    );
+  });
+
+  it('should require the "path" option to be a non-empty String', function () {
+    const throwable = v => () => validateRouterBranchDefinition({path: v});
+    const error = v =>
+      format('Option "path" must 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({})).to.throw(error('Object'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable(() => undefined)).to.throw(error('Function'));
+    throwable('str')();
+  });
+
+  it('should throw an error if the "handler" option is provided', function () {
+    const throwable = () =>
+      validateRouterBranchDefinition({
+        path: ROOT_PATH,
+        handler: 123,
+      });
+    expect(throwable).to.throw(
+      'Option "handler" is not supported for the router branch, ' +
+        'but 123 was given.',
+    );
+  });
+
+  it('should require the "preHandler" option to be a Function or an Array of Function', function () {
+    const throwable = v => () =>
+      validateRouterBranchDefinition({
+        path: ROOT_PATH,
+        preHandler: v,
+      });
+    const error = v =>
+      format(
+        'Option "preHandler" must be a Function ' +
+          'or an Array, 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({})).to.throw(error('Object'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable([])();
+    throwable(() => undefined)();
+    throwable(undefined)();
+  });
+
+  it('should require an array of the "preHandler" option to contain a Function', function () {
+    const throwable = v => () =>
+      validateRouterBranchDefinition({
+        path: ROOT_PATH,
+        preHandler: [v],
+      });
+    const error = v =>
+      format('Route pre-handler must 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({})).to.throw(error('Object'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    throwable(() => undefined)();
+  });
+
+  it('should require the "postHandler" option to be a Function or an Array of Function', function () {
+    const throwable = v => () =>
+      validateRouterBranchDefinition({
+        path: ROOT_PATH,
+        postHandler: v,
+      });
+    const error = v =>
+      format(
+        'Option "postHandler" must be a Function ' +
+          'or an Array, 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({})).to.throw(error('Object'));
+    expect(throwable(null)).to.throw(error('null'));
+    throwable([])();
+    throwable(() => undefined)();
+    throwable(undefined)();
+  });
+
+  it('should require an array of the "postHandler" option to contain a Function', function () {
+    const throwable = v => () =>
+      validateRouterBranchDefinition({
+        path: ROOT_PATH,
+        postHandler: [v],
+      });
+    const error = v =>
+      format('Route post-handler must 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({})).to.throw(error('Object'));
+    expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
+    throwable(() => undefined)();
+  });
+
+  it('should require the "meta" option to be an Object', function () {
+    const throwable = v => () =>
+      validateRouterBranchDefinition({
+        path: ROOT_PATH,
+        meta: v,
+      });
+    const error = v =>
+      format('Option "meta" must be an Object, 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([])).to.throw(error('Array'));
+    expect(throwable(null)).to.throw(error('null'));
+    expect(throwable(() => undefined)).to.throw(error('Function'));
+    throwable({foo: 'bar'})();
+    throwable({})();
+    throwable(undefined)();
+  });
+});

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

@@ -16,6 +16,11 @@ export declare const RouterHookType: {
 export type RouterHookType =
   (typeof RouterHookType)[keyof typeof RouterHookType];
 
+/**
+ * Router hook types.
+ */
+export const ROUTER_HOOK_TYPES: RouterHookType[];
+
 /**
  * Router hook.
  */

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

@@ -13,6 +13,13 @@ export const RouterHookType = {
   POST_HANDLER: 'postHandler',
 };
 
+/**
+ * Router hook types.
+ *
+ * @type {string[]}
+ */
+export const ROUTER_HOOK_TYPES = Object.values(RouterHookType);
+
 /**
  * Hook registry.
  */

+ 1 - 2
src/route-registry.js

@@ -1,5 +1,4 @@
 import {Route} from './route/index.js';
-import {ROOT_PATH} from './constants.js';
 import {PathTrie} from '@e22m4u/js-path-trie';
 import {getRequestPathname} from './utils/index.js';
 import {ServiceContainer} from '@e22m4u/js-service';
@@ -69,7 +68,7 @@ export class RouteRegistry extends DebuggableService {
     const rawTriePath = `${request.method.toUpperCase()}/${requestPath}`;
     // маршрут формируется с удалением дубликатов косой черты
     // "OPTIONS//api/users/login" => "OPTIONS/api/users/login"
-    const triePath = rawTriePath.replace(/\/+/g, ROOT_PATH);
+    const triePath = rawTriePath.replace(/\/+/g, '/');
     const resolved = this._trie.match(triePath);
     if (resolved) {
       const route = resolved.value;

+ 1 - 1
src/route/validate-route-definition.spec.js

@@ -195,7 +195,7 @@ describe('validateRouteDefinition', function () {
     throwable(() => undefined)();
   });
 
-  it('should require the "meta" option to be a plain Object', function () {
+  it('should require the "meta" option to be an Object', function () {
     const throwable = v => () =>
       validateRouteDefinition({
         method: HttpMethod.GET,

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

@@ -2,6 +2,7 @@ import {RequestListener} from 'http';
 import {Route} from './route/index.js';
 import {RouteDefinition} from './route/index.js';
 import {DebuggableService} from './debuggable-service.js';
+import {RouterBranch, RouterBranchDefinition} from './branch/index.js';
 
 import {
   RouterHook,
@@ -43,6 +44,26 @@ export declare class TrieRouter extends DebuggableService {
    */
   defineRoute(routeDef: RouteDefinition): Route;
 
+  /**
+   * Create branch.
+   *
+   * Example:
+   * ```js
+   * const router = new TrieRouter();
+   * const apiBranch = router.createBranch({path: 'api'});
+   *
+   * // GET /api/hello
+   * apiBranch.defineRoute({
+   *   method: HttpMethod.GET,
+   *   path: '/hello',
+   *   handler: () => 'Hello World!',
+   * });
+   * ```
+   *
+   * @param branchDef
+   */
+  createBranch(branchDef: RouterBranchDefinition): RouterBranch;
+
   /**
    * Request listener.
    *

+ 24 - 0
src/trie-router.js

@@ -7,6 +7,7 @@ import {DebuggableService} from './debuggable-service.js';
 import {DataSender, ErrorSender} from './senders/index.js';
 import {HookInvoker, HookRegistry, RouterHookType} from './hooks/index.js';
 import {isPromise, isResponseSent, getRequestPathname} from './utils/index.js';
+import {RouterBranch} from './branch/router-branch.js';
 
 /**
  * Trie router.
@@ -44,6 +45,29 @@ export class TrieRouter extends DebuggableService {
     return this.getService(RouteRegistry).defineRoute(routeDef);
   }
 
+  /**
+   * Create branch.
+   *
+   * Example:
+   * ```js
+   * const router = new TrieRouter();
+   * const apiBranch = router.createBranch({path: 'api'});
+   *
+   * // GET /api/hello
+   * apiBranch.defineRoute({
+   *   method: HttpMethod.GET,
+   *   path: '/hello',
+   *   handler: () => 'Hello World!',
+   * });
+   * ```
+   *
+   * @param {import('./branch/index.js').RouterBranchDefinition} branchDef
+   * @returns {import('./branch/index.js').RouterBranchDefinition}
+   */
+  createBranch(branchDef) {
+    return new RouterBranch(this, branchDef);
+  }
+
   /**
    * Request listener.
    *

+ 40 - 24
src/trie-router.spec.js

@@ -4,13 +4,14 @@ import {TrieRouter} from './trie-router.js';
 import {Route, HttpMethod} from './route/index.js';
 import {RequestContext} from './request-context.js';
 import {ServerResponse, IncomingMessage} from 'http';
+import {RouterBranch} from './branch/router-branch.js';
 import {DataSender, ErrorSender} from './senders/index.js';
 import {HookRegistry, RouterHookType} from './hooks/index.js';
 import {createRequestMock, createResponseMock} from './utils/index.js';
 
 describe('TrieRouter', function () {
   describe('defineRoute', function () {
-    it('returns the Route instance', function () {
+    it('should return a Route instance', function () {
       const router = new TrieRouter();
       const path = '/path';
       const handler = () => 'ok';
@@ -22,13 +23,28 @@ describe('TrieRouter', function () {
     });
   });
 
+  describe('createBranch', function () {
+    it('should return a RouterBranch instance', function () {
+      const router = new TrieRouter();
+      const res = router.createBranch({path: ROOT_PATH});
+      expect(res).to.be.instanceOf(RouterBranch);
+    });
+
+    it('should pass the "path" option to a router branch', function () {
+      const router = new TrieRouter();
+      const branchDef = {path: 'foo'};
+      const res = router.createBranch(branchDef);
+      expect(res.getDefinition().path).to.be.eq(branchDef.path);
+    });
+  });
+
   describe('requestListener', function () {
     it('should be a function', function () {
       const router = new TrieRouter();
       expect(typeof router.requestListener).to.be.eq('function');
     });
 
-    it('provides the request context to the route handler', function (done) {
+    it('should provide the request context to the route handler', function (done) {
       const router = new TrieRouter();
       router.defineRoute({
         method: HttpMethod.GET,
@@ -43,7 +59,7 @@ describe('TrieRouter', function () {
       router.requestListener(req, res);
     });
 
-    it('provides path parameters to the request context', function (done) {
+    it('should provide path parameters to the request context', function (done) {
       const router = new TrieRouter();
       router.defineRoute({
         method: HttpMethod.GET,
@@ -58,7 +74,7 @@ describe('TrieRouter', function () {
       router.requestListener(req, res);
     });
 
-    it('provides query parameters to the request context', function (done) {
+    it('should provide query parameters to the request context', function (done) {
       const router = new TrieRouter();
       router.defineRoute({
         method: HttpMethod.GET,
@@ -73,7 +89,7 @@ describe('TrieRouter', function () {
       router.requestListener(req, res);
     });
 
-    it('provides parsed cookies to the request context', function (done) {
+    it('should provide parsed cookies to the request context', function (done) {
       const router = new TrieRouter();
       router.defineRoute({
         method: HttpMethod.GET,
@@ -88,7 +104,7 @@ describe('TrieRouter', function () {
       router.requestListener(req, res);
     });
 
-    it('provides the plain text body to the request context', function (done) {
+    it('should provide the plain text body to the request context', function (done) {
       const router = new TrieRouter();
       const body = 'Lorem Ipsum is simply dummy text.';
       router.defineRoute({
@@ -104,7 +120,7 @@ describe('TrieRouter', function () {
       router.requestListener(req, res);
     });
 
-    it('provides the parsed JSON body to the request context', function (done) {
+    it('should provide the parsed JSON body to the request context', function (done) {
       const router = new TrieRouter();
       const data = {p1: 'foo', p2: 'bar'};
       router.defineRoute({
@@ -120,7 +136,7 @@ describe('TrieRouter', function () {
       router.requestListener(req, res);
     });
 
-    it('provides request headers to the request context', function (done) {
+    it('should provide request headers to the request context', function (done) {
       const router = new TrieRouter();
       router.defineRoute({
         method: HttpMethod.GET,
@@ -138,7 +154,7 @@ describe('TrieRouter', function () {
       router.requestListener(req, res);
     });
 
-    it('provides the route to the request context', function (done) {
+    it('should provide the route to the request context', function (done) {
       const router = new TrieRouter();
       const metaData = {foo: {bar: {baz: 'qux'}}};
       const currentRoute = router.defineRoute({
@@ -155,7 +171,7 @@ describe('TrieRouter', function () {
       router.requestListener(req, res);
     });
 
-    it('provides access to route meta via the request context', function (done) {
+    it('should provide access to route meta via the request context', function (done) {
       const router = new TrieRouter();
       const metaData = {role: 'admin'};
       router.defineRoute({
@@ -172,7 +188,7 @@ describe('TrieRouter', function () {
       router.requestListener(req, res);
     });
 
-    it('uses the DataSender to send the server response', function (done) {
+    it('should use the DataSender to send the server response', function (done) {
       const router = new TrieRouter();
       const resBody = 'Lorem Ipsum is simply dummy text.';
       router.defineRoute({
@@ -192,7 +208,7 @@ describe('TrieRouter', function () {
       router.requestListener(req, res);
     });
 
-    it('uses the ErrorSender to send the server response', function (done) {
+    it('should use the ErrorSender to send the server response', function (done) {
       const router = new TrieRouter();
       const error = new Error();
       router.defineRoute({
@@ -216,7 +232,7 @@ describe('TrieRouter', function () {
     });
 
     describe('hooks', function () {
-      it('invokes entire "preHandler" hooks before the route handler', async function () {
+      it('should invoke entire "preHandler" hooks before the route handler', async function () {
         const router = new TrieRouter();
         const order = [];
         const body = 'OK';
@@ -244,7 +260,7 @@ describe('TrieRouter', function () {
         expect(order).to.be.eql(['preHandler1', 'preHandler2', 'handler']);
       });
 
-      it('invokes entire "preHandler" hooks after the route handler', async function () {
+      it('should invoke entire "preHandler" hooks after the route handler', async function () {
         const router = new TrieRouter();
         const order = [];
         const body = 'OK';
@@ -272,7 +288,7 @@ describe('TrieRouter', function () {
         expect(order).to.be.eql(['handler', 'postHandler1', 'postHandler2']);
       });
 
-      it('provides the request context to the "preHandler" hooks', async function () {
+      it('should provide the request context to the "preHandler" hooks', async function () {
         const router = new TrieRouter();
         const order = [];
         const body = 'OK';
@@ -303,7 +319,7 @@ describe('TrieRouter', function () {
         expect(order).to.be.eql(['preHandler1', 'preHandler2', 'handler']);
       });
 
-      it('provides the request context and return value from the route handler to the "postHandler" hooks', async function () {
+      it('should provide 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';
@@ -338,7 +354,7 @@ describe('TrieRouter', function () {
         expect(order).to.be.eql(['handler', 'postHandler1', 'postHandler2']);
       });
 
-      it('invokes the route handler if entire "preHandler" hooks returns undefined or null', async function () {
+      it('should invoke the route handler if entire "preHandler" hooks returns undefined or null', async function () {
         const router = new TrieRouter();
         const order = [];
         const body = 'OK';
@@ -368,7 +384,7 @@ describe('TrieRouter', function () {
         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 () {
+      it('should send 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';
@@ -398,7 +414,7 @@ describe('TrieRouter', function () {
         expect(order).to.be.eql(['handler', 'postHandler1', 'postHandler2']);
       });
 
-      it('sends a return value from the "preHandler" hook in the first priority', async function () {
+      it('should send a return value from the "preHandler" hook in the first priority', async function () {
         const router = new TrieRouter();
         const order = [];
         const preHandlerBody = 'foo';
@@ -430,7 +446,7 @@ describe('TrieRouter', function () {
         expect(order).to.be.eql(['preHandler']);
       });
 
-      it('sends a return value from the "postHandler" hook in the second priority', async function () {
+      it('should send a return value from the "postHandler" hook in the second priority', async function () {
         const router = new TrieRouter();
         const order = [];
         const handlerBody = 'foo';
@@ -459,7 +475,7 @@ describe('TrieRouter', function () {
         expect(order).to.be.eql(['preHandler', 'handler', 'postHandler']);
       });
 
-      it('sends a return value from the root handler in the third priority', async function () {
+      it('should send a return value from the root handler in the third priority', async function () {
         const router = new TrieRouter();
         const order = [];
         const body = 'OK';
@@ -590,7 +606,7 @@ describe('TrieRouter', function () {
   });
 
   describe('addHook', function () {
-    it('adds the given hook to the HookRegistry and returns itself', function () {
+    it('should add the given hook to the HookRegistry and returns itself', function () {
       const router = new TrieRouter();
       const reg = router.getService(HookRegistry);
       const type = RouterHookType.PRE_HANDLER;
@@ -603,7 +619,7 @@ describe('TrieRouter', function () {
   });
 
   describe('addPreHandler', function () {
-    it('adds the given pre-handler hook to the HookRegistry and returns itself', function () {
+    it('should add the given pre-handler hook to the HookRegistry and returns itself', function () {
       const router = new TrieRouter();
       const reg = router.getService(HookRegistry);
       const hook = () => undefined;
@@ -615,7 +631,7 @@ describe('TrieRouter', function () {
   });
 
   describe('addPostHandler', function () {
-    it('adds the given post-handler hook to the HookRegistry and returns itself', function () {
+    it('should add the given post-handler hook to the HookRegistry and returns itself', function () {
       const router = new TrieRouter();
       const reg = router.getService(HookRegistry);
       const hook = () => undefined;

+ 2 - 3
src/utils/create-request-mock.js

@@ -2,7 +2,6 @@ import {Socket} from 'net';
 import {TLSSocket} from 'tls';
 import {IncomingMessage} from 'http';
 import queryString from 'querystring';
-import {ROOT_PATH} from '../constants.js';
 import {InvalidArgumentError} from '@e22m4u/js-format';
 import {isReadableStream} from './is-readable-stream.js';
 import {createCookieString} from './create-cookie-string.js';
@@ -150,7 +149,7 @@ export function createRequestMock(patch) {
   const request =
     patch.stream ||
     createRequestStream(patch.secure, patch.body, patch.encoding);
-  request.url = createRequestUrl(patch.path || ROOT_PATH, patch.query);
+  request.url = createRequestUrl(patch.path || '/', patch.query);
   request.headers = createRequestHeaders(
     patch.host,
     patch.secure,
@@ -229,7 +228,7 @@ function createRequestUrl(path, query) {
       query,
     );
   }
-  let url = (ROOT_PATH + path).replace('//', '/');
+  let url = ('/' + path).replace('//', '/');
   if (typeof query === 'object') {
     const qs = queryString.stringify(query);
     if (qs) {

+ 2 - 3
src/utils/create-request-mock.spec.js

@@ -3,7 +3,6 @@ import {Stream} from 'stream';
 import {TLSSocket} from 'tls';
 import {expect} from 'chai';
 import {format} from '@e22m4u/js-format';
-import {ROOT_PATH} from '../constants.js';
 import {createRequestMock} from './create-request-mock.js';
 import {CHARACTER_ENCODING_LIST} from './fetch-request-body.js';
 
@@ -272,7 +271,7 @@ describe('createRequestMock', function () {
 
   it('uses the default path "/" without a query string', function () {
     const req = createRequestMock();
-    expect(req.url).to.be.eq(ROOT_PATH);
+    expect(req.url).to.be.eq('/');
   });
 
   it('uses by default only the "host" header', function () {
@@ -380,7 +379,7 @@ describe('createRequestMock', function () {
 
   it('the parameter "host" does not affect the url', async function () {
     const req = createRequestMock({host: 'myHost'});
-    expect(req.url).to.be.eq(ROOT_PATH);
+    expect(req.url).to.be.eq('/');
     expect(req.headers['host']).to.be.eq('myHost');
   });
 

+ 1 - 2
src/utils/create-route-mock.js

@@ -1,4 +1,3 @@
-import {ROOT_PATH} from '../constants.js';
 import {Route, HttpMethod} from '../route/index.js';
 
 /**
@@ -17,7 +16,7 @@ import {Route, HttpMethod} from '../route/index.js';
 export function createRouteMock(options = {}) {
   return new Route({
     method: options.method || HttpMethod.GET,
-    path: options.path || ROOT_PATH,
+    path: options.path || '/',
     handler: options.handler || (() => 'OK'),
   });
 }

+ 1 - 2
src/utils/create-route-mock.spec.js

@@ -1,5 +1,4 @@
 import {expect} from 'chai';
-import {ROOT_PATH} from '../constants.js';
 import {HttpMethod, Route} from '../route/index.js';
 import {createRouteMock} from './create-route-mock.js';
 
@@ -8,7 +7,7 @@ describe('createRouteMock', function () {
     const res = createRouteMock();
     expect(res).to.be.instanceof(Route);
     expect(res.method).to.be.eq(HttpMethod.GET);
-    expect(res.path).to.be.eq(ROOT_PATH);
+    expect(res.path).to.be.eq('/');
     expect(res.handler()).to.be.eq('OK');
   });
 

+ 20 - 4
src/utils/fetch-request-body.js

@@ -36,7 +36,7 @@ export function fetchRequestBody(request, bodyBytesLimit = 0) {
   if (typeof bodyBytesLimit !== 'number') {
     throw new InvalidArgumentError(
       'The parameter "bodyBytesLimit" of "fetchRequestBody" ' +
-        'must be a number, but %v was given.',
+        'must be a Number, but %v was given.',
       bodyBytesLimit,
     );
   }
@@ -82,13 +82,20 @@ export function fetchRequestBody(request, bodyBytesLimit = 0) {
     const onData = chunk => {
       receivedLength += chunk.length;
       if (bodyBytesLimit && receivedLength > bodyBytesLimit) {
-        request.removeAllListeners();
+        cleanupListeners();
         const error = createError(
           HttpErrors.PayloadTooLarge,
           'Request body limit is %v bytes, but %v bytes given.',
           bodyBytesLimit,
           receivedLength,
         );
+        // после удаления слушателей поток продолжает быть
+        // в состоянии resume (flowing mode), данные будут
+        // считываться в никуда, и чтобы сэкономить трафик
+        // и ресурсы сервера при превышении лимита,
+        // выполняется уничтожение потока запроса
+        request.unpipe();
+        request.destroy();
         reject(error);
         return;
       }
@@ -98,7 +105,7 @@ export function fetchRequestBody(request, bodyBytesLimit = 0) {
     // обработчики событий, и сравнить полученный объем
     // данных с заявленным в заголовке "content-length"
     const onEnd = () => {
-      request.removeAllListeners();
+      cleanupListeners();
       if (contentLength && contentLength !== receivedLength) {
         const error = createError(
           HttpErrors.BadRequest,
@@ -119,9 +126,18 @@ export function fetchRequestBody(request, bodyBytesLimit = 0) {
     // и отклоняется ожидающий Promise
     // ошибкой с кодом 400
     const onError = error => {
-      request.removeAllListeners();
+      cleanupListeners();
       reject(HttpErrors(400, error));
     };
+    // запрос может иметь слушателей, установленных самим Node.js
+    // сервером или другими инструментами, которые подписались
+    // на close, aborted или error, потому нельзя использовать
+    // метод removeAllListeners
+    const cleanupListeners = () => {
+      request.removeListener('data', onData);
+      request.removeListener('end', onEnd);
+      request.removeListener('error', onError);
+    };
     // добавление обработчиков прослушивающих
     // события входящего запроса и возобновление
     // потока данных

+ 2 - 2
src/utils/fetch-request-body.spec.js

@@ -25,13 +25,13 @@ describe('fetchRequestBody', function () {
     throwable(createRequestMock())();
   });
 
-  it('requires the parameter "bodyBytesLimit" to be an IncomingMessage instance', function () {
+  it('requires the parameter "bodyBytesLimit" to be a Number', function () {
     const req = createRequestMock();
     const throwable = v => () => fetchRequestBody(req, v);
     const error = v =>
       format(
         'The parameter "bodyBytesLimit" of "fetchRequestBody" ' +
-          'must be a number, but %s was given.',
+          'must be a Number, but %s was given.',
         v,
       );
     expect(throwable('str')).to.throw(error('"str"'));

+ 1 - 2
src/utils/get-request-pathname.js

@@ -1,4 +1,3 @@
-import {ROOT_PATH} from '../constants.js';
 import {InvalidArgumentError} from '@e22m4u/js-format';
 
 /**
@@ -20,5 +19,5 @@ export function getRequestPathname(request) {
       request,
     );
   }
-  return (request.url || ROOT_PATH).replace(/\?.*$/, '');
+  return (request.url || '/').replace(/\?.*$/, '');
 }

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

@@ -1,7 +1,9 @@
 export * from './clone-deep.js';
+export * from './merge-deep.js';
 export * from './is-promise.js';
 export * from './create-error.js';
 export * from './to-camel-case.js';
+export * from './normalize-path.js';
 export * from './is-response-sent.js';
 export * from './create-route-mock.js';
 export * from './is-readable-stream.js';

+ 2 - 0
src/utils/index.js

@@ -1,7 +1,9 @@
 export * from './clone-deep.js';
+export * from './merge-deep.js';
 export * from './is-promise.js';
 export * from './create-error.js';
 export * from './to-camel-case.js';
+export * from './normalize-path.js';
 export * from './is-response-sent.js';
 export * from './create-route-mock.js';
 export * from './is-readable-stream.js';

+ 11 - 0
src/utils/merge-deep.d.ts

@@ -0,0 +1,11 @@
+/**
+ * Глубокое объединение двух объектов.
+ *
+ * Если оба значения массивы, то выполняется объединение.
+ * Если оба значения объекты, то выполняется объединение.
+ * В остальных случаях значение из source перезаписывает target.
+ *
+ * @param target
+ * @param source
+ */
+export function mergeDeep<T, U>(target: T, source: U): T & U;

+ 45 - 0
src/utils/merge-deep.js

@@ -0,0 +1,45 @@
+/**
+ * Глубокое объединение двух объектов.
+ *
+ * Если оба значения массивы, то выполняется объединение.
+ * Если оба значения объекты, то выполняется объединение.
+ * В остальных случаях значение из source перезаписывает target.
+ *
+ * @param {*} target
+ * @param {*} source
+ * @returns {*}
+ */
+export function mergeDeep(target, source) {
+  const isObject = item => {
+    return item && typeof item === 'object' && !Array.isArray(item);
+  };
+  // если оба значения массивы, то возвращается
+  // новый массив с их объединением
+  if (Array.isArray(target) && Array.isArray(source)) {
+    return [...target, ...source];
+  }
+  // если оба значения объекты,
+  // то выполняется глубокое слияние
+  if (isObject(target) && isObject(source)) {
+    // создание поверхностной копии первого объекта
+    const result = {...target};
+    // обход ключей второго объекта
+    Object.keys(source).forEach(key => {
+      const targetValue = target[key];
+      const sourceValue = source[key];
+      // если ключ есть и в первом объекте,
+      // то выполняется объединение их значений
+      if (Object.prototype.hasOwnProperty.call(target, key)) {
+        result[key] = mergeDeep(targetValue, sourceValue);
+      } else {
+        // если ключа нет в первом объекте,
+        // то берется значение из второго
+        result[key] = sourceValue;
+      }
+    });
+    return result;
+  }
+  // во всех остальных случаях значение
+  // из source перезаписывает target
+  return source;
+}

+ 162 - 0
src/utils/merge-deep.spec.js

@@ -0,0 +1,162 @@
+import {expect} from 'chai';
+import {mergeDeep} from './merge-deep.js';
+
+describe('mergeDeep', function () {
+  describe('basic object merging', function () {
+    it('should merge two flat objects with different keys', function () {
+      const target = {a: 1};
+      const source = {b: 2};
+      const result = mergeDeep(target, source);
+      expect(result).to.deep.equal({a: 1, b: 2});
+    });
+
+    it('should overwrite values in target with values from source', function () {
+      const target = {a: 1, b: 10};
+      const source = {a: 2};
+      const result = mergeDeep(target, source);
+      expect(result).to.deep.equal({a: 2, b: 10});
+    });
+
+    it('should return source if target is empty', function () {
+      const target = {};
+      const source = {a: 1};
+      const result = mergeDeep(target, source);
+      expect(result).to.deep.equal({a: 1});
+    });
+  });
+
+  describe('nested object merging', function () {
+    it('should recursively merge nested objects', function () {
+      const target = {
+        settings: {
+          theme: 'dark',
+          notifications: {email: true},
+        },
+      };
+      const source = {
+        settings: {
+          notifications: {sms: true},
+        },
+      };
+      const result = mergeDeep(target, source);
+      expect(result).to.deep.equal({
+        settings: {
+          theme: 'dark',
+          notifications: {
+            email: true,
+            sms: true,
+          },
+        },
+      });
+    });
+
+    it('should overwrite nested primitive values', function () {
+      const target = {config: {verbose: true}};
+      const source = {config: {verbose: false}};
+      const result = mergeDeep(target, source);
+      expect(result.config.verbose).to.be.false;
+    });
+  });
+
+  describe('array handling', function () {
+    it('should concatenate arrays when both values are arrays', function () {
+      const target = {list: [1, 2]};
+      const source = {list: [3, 4]};
+      const result = mergeDeep(target, source);
+      expect(result.list).to.deep.equal([1, 2, 3, 4]);
+      expect(Array.isArray(result.list)).to.be.true;
+    });
+
+    it('should concatenate arrays at the root level', function () {
+      const target = ['a'];
+      const source = ['b'];
+      const result = mergeDeep(target, source);
+      expect(result).to.deep.equal(['a', 'b']);
+    });
+
+    it('should concatenate nested arrays', function () {
+      const target = {data: {ids: [1]}};
+      const source = {data: {ids: [2]}};
+      const result = mergeDeep(target, source);
+      expect(result.data.ids).to.deep.equal([1, 2]);
+    });
+  });
+
+  describe('type mismatches and edge cases', function () {
+    it('should overwrite an object with a primitive value', function () {
+      const target = {a: {nested: true}};
+      const source = {a: 5};
+      const result = mergeDeep(target, source);
+      expect(result).to.deep.equal({a: 5});
+    });
+
+    it('should overwrite a primitive with an object', function () {
+      const target = {a: 5};
+      const source = {a: {nested: true}};
+      const result = mergeDeep(target, source);
+      expect(result).to.deep.equal({a: {nested: true}});
+    });
+
+    it('should overwrite an array with an object', function () {
+      const target = {a: [1, 2]};
+      const source = {a: {val: 1}};
+      const result = mergeDeep(target, source);
+      expect(result).to.deep.equal({a: {val: 1}});
+    });
+
+    it('should overwrite an object with an array', function () {
+      const target = {a: {val: 1}};
+      const source = {a: [1, 2]};
+      const result = mergeDeep(target, source);
+      expect(result).to.deep.equal({a: [1, 2]});
+    });
+
+    it('should handle null values correctly', function () {
+      const target = {a: 1};
+      const source = {a: null};
+      const result = mergeDeep(target, source);
+      expect(result).to.deep.equal({a: null});
+    });
+  });
+
+  describe('immutability', function () {
+    it('should not mutate the original target object', function () {
+      const target = {a: 1, nested: {x: 1}};
+      const source = {b: 2, nested: {y: 2}};
+      const targetClone = JSON.parse(JSON.stringify(target));
+      mergeDeep(target, source);
+      expect(target).to.deep.equal(targetClone);
+    });
+
+    it('should not mutate the original source object', function () {
+      const target = {a: 1};
+      const source = {b: 2, nested: {y: 2}};
+      const sourceClone = JSON.parse(JSON.stringify(source));
+      mergeDeep(target, source);
+      expect(source).to.deep.equal(sourceClone);
+    });
+
+    it('should return a new object reference', function () {
+      const target = {a: 1};
+      const source = {b: 2};
+      const result = mergeDeep(target, source);
+      expect(result).to.not.equal(target);
+      expect(result).to.not.equal(source);
+    });
+
+    it('should create new references for nested merged objects', function () {
+      const target = {nested: {a: 1}};
+      const source = {nested: {b: 2}};
+      const result = mergeDeep(target, source);
+      expect(result.nested).to.not.equal(target.nested);
+    });
+
+    it('should create new references for concatenated arrays', function () {
+      const target = {list: [1]};
+      const source = {list: [2]};
+      const result = mergeDeep(target, source);
+      expect(result.list).to.not.equal(target.list);
+      expect(result.list).to.not.equal(source.list);
+    });
+  });
+});

+ 12 - 0
src/utils/normalize-path.d.ts

@@ -0,0 +1,12 @@
+/**
+ * Normalize path.
+ *
+ * Заменяет любые повторяющиеся слеши на один.
+ * Удаляет пробельные символы в начале и конце.
+ * Удаляет слеш в конце строки.
+ * Гарантирует слеш в начале строки (по умолчанию).
+ *
+ * @param value
+ * @param noStartingSlash
+ */
+export function normalizePath(value: string, noStartingSlash?: boolean): string;

+ 22 - 0
src/utils/normalize-path.js

@@ -0,0 +1,22 @@
+/**
+ * Normalize path.
+ *
+ * Заменяет любые повторяющиеся слеши на один.
+ * Удаляет пробельные символы в начале и конце.
+ * Удаляет слеш в конце строки.
+ * Гарантирует слеш в начале строки (по умолчанию).
+ *
+ * @param {string} value
+ * @param {boolean} [noStartingSlash]
+ * @returns {string}
+ */
+export function normalizePath(value, noStartingSlash = false) {
+  if (typeof value !== 'string') {
+    return '/';
+  }
+  const res = value
+    .trim()
+    .replace(/\/+/g, '/')
+    .replace(/(^\/|\/$)/g, '');
+  return noStartingSlash ? res : '/' + res;
+}

+ 56 - 0
src/utils/normalize-path.spec.js

@@ -0,0 +1,56 @@
+import {expect} from 'chai';
+import {normalizePath} from './normalize-path.js';
+
+describe('normalizePath', function () {
+  describe('input validation', function () {
+    it('should return a root path "/" if value is null', function () {
+      expect(normalizePath(null)).to.equal('/');
+    });
+
+    it('should return a root path "/" if value is undefined', function () {
+      expect(normalizePath(undefined)).to.equal('/');
+    });
+
+    it('should return a root path "/" if value is a number', function () {
+      expect(normalizePath(123)).to.equal('/');
+    });
+
+    it('should return a root path "/" if value is an object', function () {
+      expect(normalizePath({})).to.equal('/');
+    });
+  });
+
+  describe('path normalization', function () {
+    it('should replace multiple slashes with a single slash', function () {
+      expect(normalizePath('//api///users//')).to.equal('/api/users');
+    });
+
+    it('should trim a given string but preserve whitespace characters', function () {
+      expect(normalizePath(' /my folder/ ')).to.equal('/my folder');
+      expect(normalizePath('path\twith\ntabs')).to.equal('/path\twith\ntabs');
+    });
+
+    it('should remove leading and trailing slashes before applying the final format', function () {
+      expect(normalizePath('/foo/bar/')).to.equal('/foo/bar');
+    });
+
+    it('should handle an empty string by returning "/" by default', function () {
+      expect(normalizePath('')).to.equal('/');
+    });
+  });
+
+  describe('the "noStartingSlash" option', function () {
+    it('should always prepend a leading slash when the option is false', function () {
+      expect(normalizePath('foo/bar', false)).to.equal('/foo/bar');
+    });
+
+    it('should not prepend a leading slash when the option is true', function () {
+      expect(normalizePath('/foo/bar/', true)).to.equal('foo/bar');
+    });
+
+    it('should return an empty string if the input results in an empty path', function () {
+      expect(normalizePath('', true)).to.equal('');
+      expect(normalizePath('///', true)).to.equal('');
+    });
+  });
+});

+ 1 - 1
src/utils/parse-cookie-string.spec.js

@@ -3,7 +3,7 @@ import {format} from '@e22m4u/js-format';
 import {parseCookieString} from './parse-cookie-string.js';
 
 describe('parseCookieString', function () {
-  it('should require the first parameter to be an IncomingMessage instance', function () {
+  it('should require the first parameter to be a String', function () {
     const throwable = v => () => parseCookieString(v);
     const error = v =>
       format(