Browse Source

chore: initial commit

e22m4u 7 hours ago
commit
d4c4ea6fed

+ 9 - 0
.c8rc

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

+ 5 - 0
.commitlintrc

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

+ 13 - 0
.editorconfig

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

+ 18 - 0
.gitignore

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

+ 1 - 0
.husky/commit-msg

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

+ 6 - 0
.husky/pre-commit

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

+ 4 - 0
.mocharc.json

@@ -0,0 +1,4 @@
+{
+  "extension": ["js"],
+  "spec": "src/**/*.spec.js"
+}

+ 7 - 0
.prettierrc

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

+ 21 - 0
LICENSE

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

+ 79 - 0
README.md

@@ -0,0 +1,79 @@
+## @e22m4u/js-http-static-router
+
+HTTP-маршрутизатор статичных ресурсов для Node.js.
+
+## Установка
+
+```bash
+npm install @e22m4u/js-http-static-router
+```
+
+Модуль поддерживает ESM и CommonJS стандарты.
+
+*ESM*
+
+```js
+import {HttpStaticRouter} from '@e22m4u/js-http-static-router';
+```
+
+*CommonJS*
+
+```js
+const {HttpStaticRouter} = require('@e22m4u/js-http-static-router');
+```
+
+## Использование
+
+```js
+import http from 'http';
+import {HttpStaticRouter} from '@e22m4u/js-http-static-router';
+
+// создание экземпляра маршрутизатора
+const staticRouter = new HttpStaticRouter();
+
+// определение директории "../static"
+// доступной по адресу "/static"
+staticRouter.addRoute(
+  '/static',                          // путь маршрута
+  `${import.meta.dirname}/../static`, // файловый путь
+);
+
+// объявление файла "./static/file.txt"
+// доступным по адресу "/static"
+staticRouter.addRoute(
+  '/file.txt',
+  `${import.meta.dirname}/static/file.txt`,
+);
+
+// создание HTTP сервера и подключение обработчика
+const server = new http.Server();
+server.on('request', (req, res) => {
+  // если статический маршрут найден,
+  // выполняется поиск и отдача файла
+  const staticRoute = staticRouter.matchRoute(req);
+  if (staticRoute) {
+    return staticRouter.sendFileByRoute(staticRoute, req, res);
+  }
+  // в противном случае запрос обрабатывается
+  // основной логикой приложения
+  res.writeHead(200, {'Content-Type': 'text/plain'});
+  res.end('Hello from App!');
+});
+
+server.listen(3000, () => {
+  console.log('Server is running on http://localhost:3000');
+  console.log('Try to open:');
+  console.log('http://localhost:3000/static/');
+  console.log('http://localhost:3000/favicon.ico');
+});
+```
+
+## Тесты
+
+```bash
+npm run test
+```
+
+## Лицензия
+
+MIT

+ 16 - 0
build-cjs.js

@@ -0,0 +1,16 @@
+import * as esbuild from 'esbuild';
+import packageJson from './package.json' with {type: 'json'};
+
+await esbuild.build({
+  entryPoints: ['src/index.js'],
+  outfile: 'dist/cjs/index.cjs',
+  format: 'cjs',
+  platform: 'node',
+  target: ['node12'],
+  bundle: true,
+  keepNames: true,
+  external: [
+    ...Object.keys(packageJson.peerDependencies || {}),
+    ...Object.keys(packageJson.dependencies || {}),
+  ],
+});

+ 41 - 0
eslint.config.js

@@ -0,0 +1,41 @@
+import globals from 'globals';
+import eslintJs from '@eslint/js';
+import eslintJsdocPlugin from 'eslint-plugin-jsdoc';
+import eslintMochaPlugin from 'eslint-plugin-mocha';
+import eslintImportPlugin from 'eslint-plugin-import';
+import eslintPrettierConfig from 'eslint-config-prettier';
+import eslintChaiExpectPlugin from 'eslint-plugin-chai-expect';
+
+export default [{
+  languageOptions: {
+    globals: {
+      ...globals.node,
+      ...globals.es2021,
+      ...globals.mocha,
+    },
+  },
+  plugins: {
+    'jsdoc': eslintJsdocPlugin,
+    'mocha': eslintMochaPlugin,
+    'import': eslintImportPlugin,
+    'chai-expect': eslintChaiExpectPlugin,
+  },
+  rules: {
+    ...eslintJs.configs.recommended.rules,
+    ...eslintPrettierConfig.rules,
+    ...eslintImportPlugin.flatConfigs.recommended.rules,
+    ...eslintMochaPlugin.configs.recommended.rules,
+    ...eslintChaiExpectPlugin.configs['recommended-flat'].rules,
+    ...eslintJsdocPlugin.configs['flat/recommended-error'].rules,
+    "curly": "error",
+    'no-duplicate-imports': 'error',
+    'import/export': 0,
+    'jsdoc/reject-any-type': 0,
+    'jsdoc/reject-function-type': 0,
+    'jsdoc/require-param-description': 0,
+    'jsdoc/require-returns-description': 0,
+    'jsdoc/require-property-description': 0,
+    'jsdoc/tag-lines': ['error', 'any', {startLines: 1}],
+  },
+  files: ['src/**/*.js'],
+}];

+ 41 - 0
example/server.js

@@ -0,0 +1,41 @@
+import http from 'http';
+import {HttpStaticRouter} from '@e22m4u/js-http-static-router';
+
+// создание экземпляра маршрутизатора
+const staticRouter = new HttpStaticRouter();
+
+// определение директории "../static"
+// доступной по адресу "/static"
+staticRouter.addRoute(
+  '/static',                       // путь маршрута
+  `${import.meta.dirname}/static`, // файловый путь
+);
+
+// объявление файла "./static/file.txt"
+// доступным по адресу "/static"
+staticRouter.addRoute(
+  '/file.txt',
+  `${import.meta.dirname}/static/file.txt`,
+);
+
+// создание HTTP сервера и подключение обработчика
+const server = new http.Server();
+server.on('request', (req, res) => {
+  // если статический маршрут найден,
+  // выполняется поиск и отдача файла
+  const staticRoute = staticRouter.matchRoute(req);
+  if (staticRoute) {
+    return staticRouter.sendFileByRoute(staticRoute, req, res);
+  }
+  // в противном случае запрос обрабатывается
+  // основной логикой приложения
+  res.writeHead(200, {'Content-Type': 'text/plain'});
+  res.end('Hello from App!');
+});
+
+server.listen(3000, () => {
+  console.log('Server is running on http://localhost:3000');
+  console.log('Try to open:');
+  console.log('http://localhost:3000/static/');
+  console.log('http://localhost:3000/file.txt');
+});

+ 9 - 0
example/static/file.txt

@@ -0,0 +1,9 @@
+⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣤⣶⣶⣶⣶⣦⣤⡀⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠀⠀⠀⠀⢀⣀⣤⣤⣄⣶⣿⠟⠛⠉⠀⠀⠀⢀⣹⣿⡇⠀⠀⠀⠀⠀⠀
+⠀⠀⠀⠀⢀⣤⣾⣿⡟⠛⠛⠛⠉⠀⠀⠀⠀⠒⠒⠛⠿⠿⠿⠶⣿⣷⣢⣄⡀⠀
+⠀⠀⠀⢠⣿⡟⠉⠈⣻⣦⠀⠀⣠⡴⠶⢶⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⣮⣦
+⠀⠀⢰⣿⠿⣿⡶⠾⢻⡿⠀⠠⣿⣄⣠⣼⣿⡇⠀⠈⠒⢶⣤⣤⣤⣤⣤⣴⣾⡿
+⠀⠀⣾⣿⠀⠉⠛⠒⠋⠀⠀⠀⠻⢿⣉⣠⠟⠀⠀⠀⠀⠀⠉⠻⣿⣋⠙⠉⠁⠀
+⠀⠀⣿⡿⠷⠲⢶⣄⠀⠀⠀⠀⠀⣀⣤⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣷⣦⠀⠀⠀
+⠛⠛⢿⣅⣀⣀⣀⣿⠶⠶⠶⢤⣾⠋⠀⠀⠙⣷⣄⣀⣀⣀⣀⡀⠀⠘⣿⣆⠀⠀
+⠀⠀⠀⠈⠉⠉⠉⠁⠀⠀⠀⠀⠈⠛⠛⠶⠾⠋⠉⠉⠉⠉⠉⠉⠉⠉⠛⠛⠛⠛

+ 10 - 0
example/static/index.html

@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Index Page</title>
+</head>
+<body>
+    <h1>Index Page</h1>
+    <p>at /static/index.html</p>
+</body>
+</html>

+ 10 - 0
example/static/nested/index.html

@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Index Page</title>
+</head>
+<body>
+    <h1>Index Page</h1>
+    <p>at /static/nested/index.html</p>
+</body>
+</html>

+ 66 - 0
package.json

@@ -0,0 +1,66 @@
+{
+  "name": "@e22m4u/js-http-static-router",
+  "version": "0.0.0",
+  "description": "HTTP-маршрутизатор статичных ресурсов для Node.js",
+  "author": "Mikhail Evstropov <e22m4u@yandex.ru>",
+  "license": "MIT",
+  "keywords": [
+    "static",
+    "router",
+    "http"
+  ],
+  "homepage": "https://gitrepos.ru/e22m4u/js-http-static-router",
+  "repository": {
+    "type": "git",
+    "url": "git+https://gitrepos.ru/e22m4u/js-http-static-router.git"
+  },
+  "type": "module",
+  "types": "./src/index.d.ts",
+  "module": "./src/index.js",
+  "main": "./dist/cjs/index.cjs",
+  "exports": {
+    "types": "./src/index.d.ts",
+    "import": "./src/index.js",
+    "require": "./dist/cjs/index.cjs"
+  },
+  "engines": {
+    "node": ">=12"
+  },
+  "scripts": {
+    "lint": "tsc && eslint ./src",
+    "lint:fix": "tsc && eslint ./src --fix",
+    "format": "prettier --write \"./src/**/*.js\"",
+    "test": "npm run lint && c8 --reporter=text-summary mocha --bail",
+    "test:coverage": "npm run lint && c8 --reporter=text mocha --bail",
+    "build:cjs": "rimraf ./dist/cjs && node build-cjs.js",
+    "example": "node ./example/server.js",
+    "prepare": "husky"
+  },
+  "dependencies": {
+    "@e22m4u/js-format": "~0.2.1",
+    "@e22m4u/js-service": "~0.4.6",
+    "mime-types": "~3.0.1"
+  },
+  "devDependencies": {
+    "@commitlint/cli": "~20.3.1",
+    "@commitlint/config-conventional": "~20.3.1",
+    "@eslint/js": "~9.39.2",
+    "@types/chai": "~5.2.3",
+    "@types/mocha": "~10.0.10",
+    "c8": "~10.1.3",
+    "chai": "~6.2.2",
+    "esbuild": "~0.27.2",
+    "eslint": "~9.39.2",
+    "eslint-config-prettier": "~10.1.8",
+    "eslint-plugin-chai-expect": "~3.1.0",
+    "eslint-plugin-import": "~2.32.0",
+    "eslint-plugin-jsdoc": "~62.0.0",
+    "eslint-plugin-mocha": "~11.2.0",
+    "globals": "~17.0.0",
+    "husky": "~9.1.7",
+    "mocha": "~11.7.5",
+    "prettier": "~3.7.4",
+    "rimraf": "~6.1.2",
+    "typescript": "~5.9.3"
+  }
+}

+ 53 - 0
src/http-static-router.d.ts

@@ -0,0 +1,53 @@
+import {ServerResponse} from 'node:http';
+import {IncomingMessage} from 'node:http';
+import {DebuggableService, ServiceContainer} from '@e22m4u/js-service';
+
+/**
+ * Static file route.
+ */
+export type HttpStaticRoute = {
+  remotePath: string;
+  resourcePath: string;
+  regexp: RegExp;
+  isFile: boolean;
+};
+
+/**
+ * Http static router.
+ */
+export class HttpStaticRouter extends DebuggableService {
+  /**
+   * Constructor.
+   * 
+   * @param container 
+   */
+  constructor(container?: ServiceContainer);
+
+  /**
+   * Add route.
+   *
+   * @param remotePath
+   * @param resourcePath
+   */
+  addRoute(remotePath: string, resourcePath: string): this;
+
+  /**
+   * Match route.
+   *
+   * @param req
+   */
+  matchRoute(req: IncomingMessage): HttpStaticRoute | undefined;
+
+  /**
+   * Send file by route.
+   *
+   * @param route
+   * @param req
+   * @param res
+   */
+  sendFileByRoute(
+    route: HttpStaticRoute,
+    req: IncomingMessage,
+    res: ServerResponse,
+  ): void;
+}

+ 209 - 0
src/http-static-router.js

@@ -0,0 +1,209 @@
+import path from 'path';
+import mimeTypes from 'mime-types';
+import fs, {createReadStream} from 'fs';
+import {escapeRegexp, normalizePath} from './utils/index.js';
+import {DebuggableService} from '@e22m4u/js-service';
+import {InvalidArgumentError} from '@e22m4u/js-format';
+
+/**
+ * Http static router.
+ */
+export class HttpStaticRouter extends DebuggableService {
+  /**
+   * Routes.
+   *
+   * @protected
+   */
+  _routes = [];
+
+  /**
+   * Constructor.
+   *
+   * @param {import('@e22m4u/js-service').ServiceContainer} container
+   */
+  constructor(container) {
+    super(container, {
+      noEnvironmentNamespace: true,
+      namespace: 'jsHttpStaticRouter',
+    });
+  }
+
+  /**
+   * Add route.
+   *
+   * @param {string} remotePath
+   * @param {string} resourcePath
+   * @returns {object}
+   */
+  addRoute(remotePath, resourcePath) {
+    const debug = this.getDebuggerFor(this.addRoute);
+    resourcePath = path.resolve(resourcePath);
+    debug('Adding a new route.');
+    debug('Resource path is %v.', resourcePath);
+    debug('Remote path is %v.', remotePath);
+    let stats;
+    try {
+      stats = fs.statSync(resourcePath);
+    } catch (error) {
+      // если ресурс не существует в момент старта,
+      // это может быть ошибкой конфигурации
+      console.error(error);
+      throw new InvalidArgumentError(
+        'Static resource path does not exist %v.',
+        resourcePath,
+      );
+    }
+    const isFile = stats.isFile();
+    debug('Resource type is %s.', isFile ? 'File' : 'Folder');
+    const normalizedRemotePath = normalizePath(remotePath);
+    const escapedRemotePath = escapeRegexp(normalizedRemotePath);
+    const regexp = isFile
+      ? new RegExp(`^${escapedRemotePath}$`)
+      : new RegExp(`^${escapedRemotePath}(?:$|\\/)`);
+    const route = {remotePath, resourcePath, regexp, isFile};
+    this._routes.push(route);
+    // самые длинные пути проверяются первыми,
+    // чтобы избежать коллизий при поиске маршрута
+    this._routes.sort((a, b) => b.remotePath.length - a.remotePath.length);
+    return this;
+  }
+
+  /**
+   * Match route.
+   *
+   * @param {import('http').IncomingMessage} req
+   * @returns {object|undefined}
+   */
+  matchRoute(req) {
+    const debug = this.getDebuggerFor(this.matchRoute);
+    debug('Matching routes with incoming request.');
+    const url = (req.url || '/').replace(/\?.*$/, '');
+    debug('Incoming request is %s %v.', req.method, url);
+    if (req.method !== 'GET' && req.method !== 'HEAD') {
+      debug('Method not allowed.');
+      return;
+    }
+    debug('Walking through %v routes.', this._routes.length);
+    const route = this._routes.find(route => {
+      const res = route.regexp.test(url);
+      const phrase = res ? 'matched' : 'not matched';
+      debug('Resource %v %s.', route.resourcePath, phrase);
+      return res;
+    });
+    route
+      ? debug('Resource %v matched.', route.resourcePath)
+      : debug('No route matched.');
+    return route;
+  }
+
+  /**
+   * Send file by route.
+   *
+   * @param {object} route
+   * @param {import('http').IncomingMessage} req
+   * @param {import('http').ServerResponse} res
+   */
+  sendFileByRoute(route, req, res) {
+    const reqUrl = req.url || '/';
+    const reqPath = reqUrl.replace(/\?.*$/, '');
+    // если ресурс ссылается на папку, то из адреса запроса
+    // извлекается дополнительная часть (если присутствует),
+    // и добавляется к адресу ресурса
+    let targetPath = route.resourcePath;
+    if (!route.isFile) {
+      // извлечение относительного пути в дополнение к адресу
+      // ресурса путем удаления из адреса запроса той части,
+      // которая была указана при объявлении маршрута
+      const relativePath = reqPath.replace(route.regexp, '');
+      // объединение адреса ресурса
+      // с дополнительной частью
+      targetPath = path.join(route.resourcePath, relativePath);
+    }
+    // если обнаружена попытка выхода за пределы
+    // корневой директории, то выбрасывается ошибка
+    targetPath = path.resolve(targetPath);
+    const resourceRoot = path.resolve(route.resourcePath);
+    if (!targetPath.startsWith(resourceRoot)) {
+      res.writeHead(403, {'content-type': 'text/plain'});
+      res.end('403 Forbidden');
+      return;
+    }
+    // подстановка индекс-файла (для папок),
+    // установка заголовков и отправка потока
+    fs.stat(targetPath, (statsError, stats) => {
+      if (statsError) {
+        return _handleFsError(statsError, res);
+      }
+      if (stats.isDirectory()) {
+        // так как в html обычно используются относительные пути,
+        // то адрес директории статических ресурсов должен завершаться
+        // косой чертой, чтобы файлы стилей и изображений загружались
+        // именно из нее, а не обращались на уровень выше
+        if (/[^/]$/.test(reqPath)) {
+          const searchMatch = reqUrl.match(/\?.*$/);
+          const search = searchMatch ? searchMatch[0] : '';
+          const normalizedPath = reqUrl.replace(/\/{2,}/g, '/');
+          res.writeHead(302, {location: `${normalizedPath}/${search}`});
+          res.end();
+          return;
+        }
+        // если адрес запроса содержит дублирующие слеши,
+        // то адрес нормализуется и выполняется редирект
+        if (/\/{2,}/.test(reqUrl)) {
+          const normalizedUrl = reqUrl.replace(/\/{2,}/g, '/');
+          res.writeHead(302, {location: normalizedUrl});
+          res.end();
+          return;
+        }
+        // если целевой путь указывает на папку,
+        // то подставляется index.html
+        targetPath = path.join(targetPath, 'index.html');
+      }
+      // формирование заголовка "content-type"
+      // в зависимости от расширения файла
+      const extname = path.extname(targetPath);
+      const contentType =
+        mimeTypes.contentType(extname) || 'application/octet-stream';
+      // файл читается и отправляется частями,
+      // что значительно снижает использование памяти
+      const fileStream = createReadStream(targetPath);
+      fileStream.on('error', error => {
+        _handleFsError(error, res);
+      });
+      // отправка заголовка 200, только после
+      // этого начинается отдача файла
+      fileStream.on('open', () => {
+        res.writeHead(200, {'content-type': contentType});
+        // для HEAD запроса отправляются
+        // только заголовки (без тела)
+        if (req.method === 'HEAD') {
+          res.end();
+          return;
+        }
+        fileStream.pipe(res);
+      });
+    });
+  }
+}
+
+/**
+ * Handle filesystem error.
+ *
+ * @param {object} error
+ * @param {object} res
+ * @returns {undefined}
+ */
+function _handleFsError(error, res) {
+  if (res.headersSent) {
+    return;
+  }
+  if ('code' in error && error.code === 'ENOENT') {
+    res.writeHead(404, {'content-type': 'text/plain'});
+    res.write('404 Not Found');
+    res.end();
+  } else {
+    res.writeHead(500, {'content-type': 'text/plain'});
+    res.write('500 Internal Server Error');
+    res.end();
+  }
+}

+ 1 - 0
src/index.d.ts

@@ -0,0 +1 @@
+export * from './http-static-router.js';

+ 1 - 0
src/index.js

@@ -0,0 +1 @@
+export * from './http-static-router.js';

+ 38 - 0
src/types.d.ts

@@ -0,0 +1,38 @@
+/**
+ * A callable type with the "new" operator
+ * that allows class and constructor types.
+ */
+export interface Constructor<T = object> {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  new (...args: any[]): T;
+}
+
+/**
+ * An object prototype that excludes
+ * function and scalar values.
+ */
+export type Prototype<T = object> = T &
+  object & {bind?: never} & {
+    call?: never;
+  } & {prototype?: object};
+
+/**
+ * A function type without class and constructor.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type Callable<T = unknown> = (...args: any[]) => T;
+
+/**
+ * Makes a specific property of T as optional.
+ */
+export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
+
+/**
+ * A part of the Flatten type.
+ */
+export type Identity<T> = T;
+
+/**
+ * Makes T more human-readable.
+ */
+export type Flatten<T> = Identity<{[k in keyof T]: T[k]}>;

+ 7 - 0
src/utils/escape-regexp.d.ts

@@ -0,0 +1,7 @@
+/**
+ * Экранирует специальные символы в строке
+ * для использования в регулярном выражении.
+ *
+ * @param input
+ */
+export function escapeRegexp(input: string | number): string;

+ 11 - 0
src/utils/escape-regexp.js

@@ -0,0 +1,11 @@
+/**
+ * Экранирует специальные символы в строке
+ * для использования в регулярном выражении.
+ *
+ * @param {*} input
+ * @returns {string}
+ */
+export function escapeRegexp(input) {
+  // $& означает всю совпавшую строку.
+  return String(input).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}

+ 78 - 0
src/utils/escape-regexp.spec.js

@@ -0,0 +1,78 @@
+import {expect} from 'chai';
+import {escapeRegexp} from './escape-regexp.js';
+
+describe('escapeRegexp', function () {
+  it('should not change a string with no special characters', function () {
+    // проверка, что обычная строка без спецсимволов не изменяется
+    const input = 'hello world';
+    const expected = 'hello world';
+    expect(escapeRegexp(input)).to.equal(expected);
+  });
+
+  it('should escape all special regex characters', function () {
+    // проверка, что все специальные символы для RegExp корректно экранируются
+    const input = '.*+?^${}()|[]\\';
+    const expected = '\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\';
+    expect(escapeRegexp(input)).to.equal(expected);
+  });
+
+  it('should escape a string containing a URL', function () {
+    // проверка экранирования в строке, которая является URL-адресом
+    const input = 'http://example.com?query=a+b';
+    const expected = 'http://example\\.com\\?query=a\\+b';
+    expect(escapeRegexp(input)).to.equal(expected);
+  });
+
+  it('should escape characters within a mixed string', function () {
+    // проверка, что символы экранируются правильно внутри обычной строки
+    const input = 'a (very) important string [_v2.0_]';
+    const expected = 'a \\(very\\) important string \\[_v2\\.0_\\]';
+    expect(escapeRegexp(input)).to.equal(expected);
+  });
+
+  it('should correctly escape backslashes', function () {
+    // отдельно проверка правильного экранирования обратных слэшей
+    const input = 'C:\\Users\\Test';
+    const expected = 'C:\\\\Users\\\\Test';
+    expect(escapeRegexp(input)).to.equal(expected);
+  });
+
+  it('should handle an empty string', function () {
+    // проверка, что пустая строка обрабатывается корректно
+    const input = '';
+    const expected = '';
+    expect(escapeRegexp(input)).to.equal(expected);
+  });
+
+  it('should convert non-string input to a string and escape it', function () {
+    // тест с числом, которое содержит специальный для RegExp символ '.'
+    const inputNumber = 123.45;
+    const expectedNumber = '123\\.45';
+    expect(escapeRegexp(inputNumber)).to.equal(expectedNumber);
+
+    // тест с null.
+    const inputNull = null;
+    const expectedNull = 'null';
+    expect(escapeRegexp(inputNull)).to.equal(expectedNull);
+
+    // тест с undefined.
+    const inputUndefined = undefined;
+    const expectedUndefined = 'undefined';
+    expect(escapeRegexp(inputUndefined)).to.equal(expectedUndefined);
+  });
+
+  it('should correctly create a usable RegExp object after escaping', function () {
+    // проверка, что после экранирования мы можем
+    // создать рабочее регулярное выражение
+    const dangerousString = 'search(v1.0)';
+    const escapedString = escapeRegexp(dangerousString);
+    const regex = new RegExp(escapedString);
+    // проверка, что экранированная строка имеет ожидаемый вид
+    expect(escapedString).to.equal('search\\(v1\\.0\\)');
+    // созданный RegExp должен находить точное совпадение с исходной строкой
+    expect(regex.test('search(v1.0)')).to.be.true;
+    // и он не должен находить совпадения там, где символы могут
+    // быть неверно интерпретированы как операторы
+    expect(regex.test('search(v1a0)')).to.be.false;
+  });
+});

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

@@ -0,0 +1,2 @@
+export * from './escape-regexp.js';
+export * from './normalize-path.js';

+ 2 - 0
src/utils/index.js

@@ -0,0 +1,2 @@
+export * from './escape-regexp.js';
+export * from './normalize-path.js';

+ 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;
+}

+ 14 - 0
tsconfig.json

@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "strict": true,
+    "target": "es2022",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "noEmit": true,
+    "allowJs": true
+  },
+  "include": [
+    "./src/**/*.ts",
+    "./src/**/*.js"
+  ]
+}