@e22m4u/js-http-static-router 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.c8rc ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "all": true,
3
+ "include": [
4
+ "src/**/*.js"
5
+ ],
6
+ "exclude": [
7
+ "src/**/*.spec.js"
8
+ ]
9
+ }
package/.commitlintrc ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": [
3
+ "@commitlint/config-conventional"
4
+ ]
5
+ }
package/.editorconfig ADDED
@@ -0,0 +1,13 @@
1
+ # EditorConfig is awesome: https://EditorConfig.org
2
+
3
+ # top-most EditorConfig file
4
+ root = true
5
+
6
+ # Unix-style newlines with a newline ending every file
7
+ [*]
8
+ end_of_line = lf
9
+ insert_final_newline = true
10
+ charset = utf-8
11
+ indent_style = space
12
+ indent_size = 2
13
+ max_line_length = 80
@@ -0,0 +1 @@
1
+ npx --no -- commitlint --edit $1
@@ -0,0 +1,6 @@
1
+ npm run lint:fix
2
+ npm run format
3
+ npm run test
4
+ npm run build:cjs
5
+
6
+ git add -A
package/.mocharc.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extension": ["js"],
3
+ "spec": "src/**/*.spec.js"
4
+ }
package/.prettierrc ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "bracketSpacing": false,
3
+ "singleQuote": true,
4
+ "printWidth": 80,
5
+ "trailingComma": "all",
6
+ "arrowParens": "avoid"
7
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023-2025 Mikhail Evstropov <e22m4u@yandex.ru>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ ## @e22m4u/js-http-static-router
2
+
3
+ HTTP-маршрутизатор статичных ресурсов для Node.js.
4
+
5
+ ## Установка
6
+
7
+ ```bash
8
+ npm install @e22m4u/js-http-static-router
9
+ ```
10
+
11
+ Модуль поддерживает ESM и CommonJS стандарты.
12
+
13
+ *ESM*
14
+
15
+ ```js
16
+ import {HttpStaticRouter} from '@e22m4u/js-http-static-router';
17
+ ```
18
+
19
+ *CommonJS*
20
+
21
+ ```js
22
+ const {HttpStaticRouter} = require('@e22m4u/js-http-static-router');
23
+ ```
24
+
25
+ ## Использование
26
+
27
+ ```js
28
+ import http from 'http';
29
+ import {HttpStaticRouter} from '@e22m4u/js-http-static-router';
30
+
31
+ // создание экземпляра маршрутизатора
32
+ const staticRouter = new HttpStaticRouter();
33
+
34
+ // определение директории "../static"
35
+ // доступной по адресу "/static"
36
+ staticRouter.addRoute(
37
+ '/static', // путь маршрута
38
+ `${import.meta.dirname}/../static`, // файловый путь
39
+ );
40
+
41
+ // объявление файла "./static/file.txt"
42
+ // доступным по адресу "/static"
43
+ staticRouter.addRoute(
44
+ '/file.txt',
45
+ `${import.meta.dirname}/static/file.txt`,
46
+ );
47
+
48
+ // создание HTTP сервера и подключение обработчика
49
+ const server = new http.Server();
50
+ server.on('request', (req, res) => {
51
+ // если статический маршрут найден,
52
+ // выполняется поиск и отдача файла
53
+ const staticRoute = staticRouter.matchRoute(req);
54
+ if (staticRoute) {
55
+ return staticRouter.sendFileByRoute(staticRoute, req, res);
56
+ }
57
+ // в противном случае запрос обрабатывается
58
+ // основной логикой приложения
59
+ res.writeHead(200, {'Content-Type': 'text/plain'});
60
+ res.end('Hello from App!');
61
+ });
62
+
63
+ server.listen(3000, () => {
64
+ console.log('Server is running on http://localhost:3000');
65
+ console.log('Try to open:');
66
+ console.log('http://localhost:3000/static/');
67
+ console.log('http://localhost:3000/file.txt');
68
+ });
69
+ ```
70
+
71
+ ## Тесты
72
+
73
+ ```bash
74
+ npm run test
75
+ ```
76
+
77
+ ## Лицензия
78
+
79
+ MIT
package/build-cjs.js ADDED
@@ -0,0 +1,16 @@
1
+ import * as esbuild from 'esbuild';
2
+ import packageJson from './package.json' with {type: 'json'};
3
+
4
+ await esbuild.build({
5
+ entryPoints: ['src/index.js'],
6
+ outfile: 'dist/cjs/index.cjs',
7
+ format: 'cjs',
8
+ platform: 'node',
9
+ target: ['node12'],
10
+ bundle: true,
11
+ keepNames: true,
12
+ external: [
13
+ ...Object.keys(packageJson.peerDependencies || {}),
14
+ ...Object.keys(packageJson.dependencies || {}),
15
+ ],
16
+ });
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
22
+ // If the importer is in node compatibility mode or this is not an ESM
23
+ // file that has been converted to a CommonJS file using a Babel-
24
+ // compatible transform (i.e. "__esModule" has not been set), then set
25
+ // "default" to the CommonJS "module.exports" for node compatibility.
26
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
27
+ mod
28
+ ));
29
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
+
31
+ // src/index.js
32
+ var index_exports = {};
33
+ __export(index_exports, {
34
+ HttpStaticRouter: () => HttpStaticRouter
35
+ });
36
+ module.exports = __toCommonJS(index_exports);
37
+
38
+ // src/http-static-router.js
39
+ var import_path = __toESM(require("path"), 1);
40
+ var import_mime_types = __toESM(require("mime-types"), 1);
41
+ var import_fs = __toESM(require("fs"), 1);
42
+
43
+ // src/utils/escape-regexp.js
44
+ function escapeRegexp(input) {
45
+ return String(input).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ }
47
+ __name(escapeRegexp, "escapeRegexp");
48
+
49
+ // src/utils/normalize-path.js
50
+ function normalizePath(value, noStartingSlash = false) {
51
+ if (typeof value !== "string") {
52
+ return "/";
53
+ }
54
+ const res = value.trim().replace(/\/+/g, "/").replace(/(^\/|\/$)/g, "");
55
+ return noStartingSlash ? res : "/" + res;
56
+ }
57
+ __name(normalizePath, "normalizePath");
58
+
59
+ // src/http-static-router.js
60
+ var import_js_service = require("@e22m4u/js-service");
61
+ var import_js_format = require("@e22m4u/js-format");
62
+ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.DebuggableService {
63
+ /**
64
+ * Routes.
65
+ *
66
+ * @protected
67
+ */
68
+ _routes = [];
69
+ /**
70
+ * Constructor.
71
+ *
72
+ * @param {import('@e22m4u/js-service').ServiceContainer} container
73
+ */
74
+ constructor(container) {
75
+ super(container, {
76
+ noEnvironmentNamespace: true,
77
+ namespace: "jsHttpStaticRouter"
78
+ });
79
+ }
80
+ /**
81
+ * Add route.
82
+ *
83
+ * @param {string} remotePath
84
+ * @param {string} resourcePath
85
+ * @returns {object}
86
+ */
87
+ addRoute(remotePath, resourcePath) {
88
+ const debug = this.getDebuggerFor(this.addRoute);
89
+ resourcePath = import_path.default.resolve(resourcePath);
90
+ debug("Adding a new route.");
91
+ debug("Resource path is %v.", resourcePath);
92
+ debug("Remote path is %v.", remotePath);
93
+ let stats;
94
+ try {
95
+ stats = import_fs.default.statSync(resourcePath);
96
+ } catch (error) {
97
+ console.error(error);
98
+ throw new import_js_format.InvalidArgumentError(
99
+ "Static resource path does not exist %v.",
100
+ resourcePath
101
+ );
102
+ }
103
+ const isFile = stats.isFile();
104
+ debug("Resource type is %s.", isFile ? "File" : "Folder");
105
+ const normalizedRemotePath = normalizePath(remotePath);
106
+ const escapedRemotePath = escapeRegexp(normalizedRemotePath);
107
+ const regexp = isFile ? new RegExp(`^${escapedRemotePath}$`) : new RegExp(`^${escapedRemotePath}(?:$|\\/)`);
108
+ const route = { remotePath, resourcePath, regexp, isFile };
109
+ this._routes.push(route);
110
+ this._routes.sort((a, b) => b.remotePath.length - a.remotePath.length);
111
+ return this;
112
+ }
113
+ /**
114
+ * Match route.
115
+ *
116
+ * @param {import('http').IncomingMessage} req
117
+ * @returns {object|undefined}
118
+ */
119
+ matchRoute(req) {
120
+ const debug = this.getDebuggerFor(this.matchRoute);
121
+ debug("Matching routes with incoming request.");
122
+ const url = (req.url || "/").replace(/\?.*$/, "");
123
+ debug("Incoming request is %s %v.", req.method, url);
124
+ if (req.method !== "GET" && req.method !== "HEAD") {
125
+ debug("Method not allowed.");
126
+ return;
127
+ }
128
+ debug("Walking through %v routes.", this._routes.length);
129
+ const route = this._routes.find((route2) => {
130
+ const res = route2.regexp.test(url);
131
+ const phrase = res ? "matched" : "not matched";
132
+ debug("Resource %v %s.", route2.resourcePath, phrase);
133
+ return res;
134
+ });
135
+ route ? debug("Resource %v matched.", route.resourcePath) : debug("No route matched.");
136
+ return route;
137
+ }
138
+ /**
139
+ * Send file by route.
140
+ *
141
+ * @param {object} route
142
+ * @param {import('http').IncomingMessage} req
143
+ * @param {import('http').ServerResponse} res
144
+ */
145
+ sendFileByRoute(route, req, res) {
146
+ const reqUrl = req.url || "/";
147
+ const reqPath = reqUrl.replace(/\?.*$/, "");
148
+ let targetPath = route.resourcePath;
149
+ if (!route.isFile) {
150
+ const relativePath = reqPath.replace(route.regexp, "");
151
+ targetPath = import_path.default.join(route.resourcePath, relativePath);
152
+ }
153
+ targetPath = import_path.default.resolve(targetPath);
154
+ const resourceRoot = import_path.default.resolve(route.resourcePath);
155
+ if (!targetPath.startsWith(resourceRoot)) {
156
+ res.writeHead(403, { "content-type": "text/plain" });
157
+ res.end("403 Forbidden");
158
+ return;
159
+ }
160
+ import_fs.default.stat(targetPath, (statsError, stats) => {
161
+ if (statsError) {
162
+ return _handleFsError(statsError, res);
163
+ }
164
+ if (stats.isDirectory()) {
165
+ if (/[^/]$/.test(reqPath)) {
166
+ const searchMatch = reqUrl.match(/\?.*$/);
167
+ const search = searchMatch ? searchMatch[0] : "";
168
+ const normalizedPath = reqUrl.replace(/\/{2,}/g, "/");
169
+ res.writeHead(302, { location: `${normalizedPath}/${search}` });
170
+ res.end();
171
+ return;
172
+ }
173
+ if (/\/{2,}/.test(reqUrl)) {
174
+ const normalizedUrl = reqUrl.replace(/\/{2,}/g, "/");
175
+ res.writeHead(302, { location: normalizedUrl });
176
+ res.end();
177
+ return;
178
+ }
179
+ targetPath = import_path.default.join(targetPath, "index.html");
180
+ }
181
+ const extname = import_path.default.extname(targetPath);
182
+ const contentType = import_mime_types.default.contentType(extname) || "application/octet-stream";
183
+ const fileStream = (0, import_fs.createReadStream)(targetPath);
184
+ fileStream.on("error", (error) => {
185
+ _handleFsError(error, res);
186
+ });
187
+ fileStream.on("open", () => {
188
+ res.writeHead(200, { "content-type": contentType });
189
+ if (req.method === "HEAD") {
190
+ res.end();
191
+ return;
192
+ }
193
+ fileStream.pipe(res);
194
+ });
195
+ });
196
+ }
197
+ };
198
+ __name(_HttpStaticRouter, "HttpStaticRouter");
199
+ var HttpStaticRouter = _HttpStaticRouter;
200
+ function _handleFsError(error, res) {
201
+ if (res.headersSent) {
202
+ return;
203
+ }
204
+ if ("code" in error && error.code === "ENOENT") {
205
+ res.writeHead(404, { "content-type": "text/plain" });
206
+ res.write("404 Not Found");
207
+ res.end();
208
+ } else {
209
+ res.writeHead(500, { "content-type": "text/plain" });
210
+ res.write("500 Internal Server Error");
211
+ res.end();
212
+ }
213
+ }
214
+ __name(_handleFsError, "_handleFsError");
215
+ // Annotate the CommonJS export names for ESM import in node:
216
+ 0 && (module.exports = {
217
+ HttpStaticRouter
218
+ });
@@ -0,0 +1,41 @@
1
+ import globals from 'globals';
2
+ import eslintJs from '@eslint/js';
3
+ import eslintJsdocPlugin from 'eslint-plugin-jsdoc';
4
+ import eslintMochaPlugin from 'eslint-plugin-mocha';
5
+ import eslintImportPlugin from 'eslint-plugin-import';
6
+ import eslintPrettierConfig from 'eslint-config-prettier';
7
+ import eslintChaiExpectPlugin from 'eslint-plugin-chai-expect';
8
+
9
+ export default [{
10
+ languageOptions: {
11
+ globals: {
12
+ ...globals.node,
13
+ ...globals.es2021,
14
+ ...globals.mocha,
15
+ },
16
+ },
17
+ plugins: {
18
+ 'jsdoc': eslintJsdocPlugin,
19
+ 'mocha': eslintMochaPlugin,
20
+ 'import': eslintImportPlugin,
21
+ 'chai-expect': eslintChaiExpectPlugin,
22
+ },
23
+ rules: {
24
+ ...eslintJs.configs.recommended.rules,
25
+ ...eslintPrettierConfig.rules,
26
+ ...eslintImportPlugin.flatConfigs.recommended.rules,
27
+ ...eslintMochaPlugin.configs.recommended.rules,
28
+ ...eslintChaiExpectPlugin.configs['recommended-flat'].rules,
29
+ ...eslintJsdocPlugin.configs['flat/recommended-error'].rules,
30
+ "curly": "error",
31
+ 'no-duplicate-imports': 'error',
32
+ 'import/export': 0,
33
+ 'jsdoc/reject-any-type': 0,
34
+ 'jsdoc/reject-function-type': 0,
35
+ 'jsdoc/require-param-description': 0,
36
+ 'jsdoc/require-returns-description': 0,
37
+ 'jsdoc/require-property-description': 0,
38
+ 'jsdoc/tag-lines': ['error', 'any', {startLines: 1}],
39
+ },
40
+ files: ['src/**/*.js'],
41
+ }];
@@ -0,0 +1,41 @@
1
+ import http from 'http';
2
+ import {HttpStaticRouter} from '@e22m4u/js-http-static-router';
3
+
4
+ // создание экземпляра маршрутизатора
5
+ const staticRouter = new HttpStaticRouter();
6
+
7
+ // определение директории "../static"
8
+ // доступной по адресу "/static"
9
+ staticRouter.addRoute(
10
+ '/static', // путь маршрута
11
+ `${import.meta.dirname}/static`, // файловый путь
12
+ );
13
+
14
+ // объявление файла "./static/file.txt"
15
+ // доступным по адресу "/static"
16
+ staticRouter.addRoute(
17
+ '/file.txt',
18
+ `${import.meta.dirname}/static/file.txt`,
19
+ );
20
+
21
+ // создание HTTP сервера и подключение обработчика
22
+ const server = new http.Server();
23
+ server.on('request', (req, res) => {
24
+ // если статический маршрут найден,
25
+ // выполняется поиск и отдача файла
26
+ const staticRoute = staticRouter.matchRoute(req);
27
+ if (staticRoute) {
28
+ return staticRouter.sendFileByRoute(staticRoute, req, res);
29
+ }
30
+ // в противном случае запрос обрабатывается
31
+ // основной логикой приложения
32
+ res.writeHead(200, {'Content-Type': 'text/plain'});
33
+ res.end('Hello from App!');
34
+ });
35
+
36
+ server.listen(3000, () => {
37
+ console.log('Server is running on http://localhost:3000');
38
+ console.log('Try to open:');
39
+ console.log('http://localhost:3000/static/');
40
+ console.log('http://localhost:3000/file.txt');
41
+ });
@@ -0,0 +1,9 @@
1
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣤⣶⣶⣶⣶⣦⣤⡀⠀⠀⠀⠀⠀⠀
2
+ ⠀⠀⠀⠀⠀⠀⠀⢀⣀⣤⣤⣄⣶⣿⠟⠛⠉⠀⠀⠀⢀⣹⣿⡇⠀⠀⠀⠀⠀⠀
3
+ ⠀⠀⠀⠀⢀⣤⣾⣿⡟⠛⠛⠛⠉⠀⠀⠀⠀⠒⠒⠛⠿⠿⠿⠶⣿⣷⣢⣄⡀⠀
4
+ ⠀⠀⠀⢠⣿⡟⠉⠈⣻⣦⠀⠀⣠⡴⠶⢶⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⣮⣦
5
+ ⠀⠀⢰⣿⠿⣿⡶⠾⢻⡿⠀⠠⣿⣄⣠⣼⣿⡇⠀⠈⠒⢶⣤⣤⣤⣤⣤⣴⣾⡿
6
+ ⠀⠀⣾⣿⠀⠉⠛⠒⠋⠀⠀⠀⠻⢿⣉⣠⠟⠀⠀⠀⠀⠀⠉⠻⣿⣋⠙⠉⠁⠀
7
+ ⠀⠀⣿⡿⠷⠲⢶⣄⠀⠀⠀⠀⠀⣀⣤⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣷⣦⠀⠀⠀
8
+ ⠛⠛⢿⣅⣀⣀⣀⣿⠶⠶⠶⢤⣾⠋⠀⠀⠙⣷⣄⣀⣀⣀⣀⡀⠀⠘⣿⣆⠀⠀
9
+ ⠀⠀⠀⠈⠉⠉⠉⠁⠀⠀⠀⠀⠈⠛⠛⠶⠾⠋⠉⠉⠉⠉⠉⠉⠉⠉⠛⠛⠛⠛
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Index Page</title>
5
+ </head>
6
+ <body>
7
+ <h1>Index Page</h1>
8
+ <p>at /static/index.html</p>
9
+ </body>
10
+ </html>
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Index Page</title>
5
+ </head>
6
+ <body>
7
+ <h1>Index Page</h1>
8
+ <p>at /static/nested/index.html</p>
9
+ </body>
10
+ </html>
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@e22m4u/js-http-static-router",
3
+ "version": "0.0.1",
4
+ "description": "HTTP-маршрутизатор статичных ресурсов для Node.js",
5
+ "author": "Mikhail Evstropov <e22m4u@yandex.ru>",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "static",
9
+ "router",
10
+ "http"
11
+ ],
12
+ "homepage": "https://gitrepos.ru/e22m4u/js-http-static-router",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://gitrepos.ru/e22m4u/js-http-static-router.git"
16
+ },
17
+ "type": "module",
18
+ "types": "./src/index.d.ts",
19
+ "module": "./src/index.js",
20
+ "main": "./dist/cjs/index.cjs",
21
+ "exports": {
22
+ "types": "./src/index.d.ts",
23
+ "import": "./src/index.js",
24
+ "require": "./dist/cjs/index.cjs"
25
+ },
26
+ "engines": {
27
+ "node": ">=12"
28
+ },
29
+ "scripts": {
30
+ "lint": "tsc && eslint ./src",
31
+ "lint:fix": "tsc && eslint ./src --fix",
32
+ "format": "prettier --write \"./src/**/*.js\"",
33
+ "test": "npm run lint && c8 --reporter=text-summary mocha --bail",
34
+ "test:coverage": "npm run lint && c8 --reporter=text mocha --bail",
35
+ "build:cjs": "rimraf ./dist/cjs && node build-cjs.js",
36
+ "example": "node ./example/server.js",
37
+ "prepare": "husky"
38
+ },
39
+ "dependencies": {
40
+ "@e22m4u/js-format": "~0.3.2",
41
+ "@e22m4u/js-service": "~0.5.1",
42
+ "mime-types": "~3.0.2"
43
+ },
44
+ "devDependencies": {
45
+ "@commitlint/cli": "~20.4.1",
46
+ "@commitlint/config-conventional": "~20.4.1",
47
+ "@eslint/js": "~9.39.2",
48
+ "@types/chai": "~5.2.3",
49
+ "@types/mocha": "~10.0.10",
50
+ "c8": "~10.1.3",
51
+ "chai": "~6.2.2",
52
+ "esbuild": "~0.27.3",
53
+ "eslint": "~9.39.2",
54
+ "eslint-config-prettier": "~10.1.8",
55
+ "eslint-plugin-chai-expect": "~3.1.0",
56
+ "eslint-plugin-import": "~2.32.0",
57
+ "eslint-plugin-jsdoc": "~62.5.2",
58
+ "eslint-plugin-mocha": "~11.2.0",
59
+ "globals": "~17.3.0",
60
+ "husky": "~9.1.7",
61
+ "mocha": "~11.7.5",
62
+ "prettier": "~3.8.1",
63
+ "rimraf": "~6.1.2",
64
+ "typescript": "~5.9.3"
65
+ }
66
+ }
@@ -0,0 +1,53 @@
1
+ import {ServerResponse} from 'node:http';
2
+ import {IncomingMessage} from 'node:http';
3
+ import {DebuggableService, ServiceContainer} from '@e22m4u/js-service';
4
+
5
+ /**
6
+ * Static file route.
7
+ */
8
+ export type HttpStaticRoute = {
9
+ remotePath: string;
10
+ resourcePath: string;
11
+ regexp: RegExp;
12
+ isFile: boolean;
13
+ };
14
+
15
+ /**
16
+ * Http static router.
17
+ */
18
+ export class HttpStaticRouter extends DebuggableService {
19
+ /**
20
+ * Constructor.
21
+ *
22
+ * @param container
23
+ */
24
+ constructor(container?: ServiceContainer);
25
+
26
+ /**
27
+ * Add route.
28
+ *
29
+ * @param remotePath
30
+ * @param resourcePath
31
+ */
32
+ addRoute(remotePath: string, resourcePath: string): this;
33
+
34
+ /**
35
+ * Match route.
36
+ *
37
+ * @param req
38
+ */
39
+ matchRoute(req: IncomingMessage): HttpStaticRoute | undefined;
40
+
41
+ /**
42
+ * Send file by route.
43
+ *
44
+ * @param route
45
+ * @param req
46
+ * @param res
47
+ */
48
+ sendFileByRoute(
49
+ route: HttpStaticRoute,
50
+ req: IncomingMessage,
51
+ res: ServerResponse,
52
+ ): void;
53
+ }
@@ -0,0 +1,209 @@
1
+ import path from 'path';
2
+ import mimeTypes from 'mime-types';
3
+ import fs, {createReadStream} from 'fs';
4
+ import {escapeRegexp, normalizePath} from './utils/index.js';
5
+ import {DebuggableService} from '@e22m4u/js-service';
6
+ import {InvalidArgumentError} from '@e22m4u/js-format';
7
+
8
+ /**
9
+ * Http static router.
10
+ */
11
+ export class HttpStaticRouter extends DebuggableService {
12
+ /**
13
+ * Routes.
14
+ *
15
+ * @protected
16
+ */
17
+ _routes = [];
18
+
19
+ /**
20
+ * Constructor.
21
+ *
22
+ * @param {import('@e22m4u/js-service').ServiceContainer} container
23
+ */
24
+ constructor(container) {
25
+ super(container, {
26
+ noEnvironmentNamespace: true,
27
+ namespace: 'jsHttpStaticRouter',
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Add route.
33
+ *
34
+ * @param {string} remotePath
35
+ * @param {string} resourcePath
36
+ * @returns {object}
37
+ */
38
+ addRoute(remotePath, resourcePath) {
39
+ const debug = this.getDebuggerFor(this.addRoute);
40
+ resourcePath = path.resolve(resourcePath);
41
+ debug('Adding a new route.');
42
+ debug('Resource path is %v.', resourcePath);
43
+ debug('Remote path is %v.', remotePath);
44
+ let stats;
45
+ try {
46
+ stats = fs.statSync(resourcePath);
47
+ } catch (error) {
48
+ // если ресурс не существует в момент старта,
49
+ // это может быть ошибкой конфигурации
50
+ console.error(error);
51
+ throw new InvalidArgumentError(
52
+ 'Static resource path does not exist %v.',
53
+ resourcePath,
54
+ );
55
+ }
56
+ const isFile = stats.isFile();
57
+ debug('Resource type is %s.', isFile ? 'File' : 'Folder');
58
+ const normalizedRemotePath = normalizePath(remotePath);
59
+ const escapedRemotePath = escapeRegexp(normalizedRemotePath);
60
+ const regexp = isFile
61
+ ? new RegExp(`^${escapedRemotePath}$`)
62
+ : new RegExp(`^${escapedRemotePath}(?:$|\\/)`);
63
+ const route = {remotePath, resourcePath, regexp, isFile};
64
+ this._routes.push(route);
65
+ // самые длинные пути проверяются первыми,
66
+ // чтобы избежать коллизий при поиске маршрута
67
+ this._routes.sort((a, b) => b.remotePath.length - a.remotePath.length);
68
+ return this;
69
+ }
70
+
71
+ /**
72
+ * Match route.
73
+ *
74
+ * @param {import('http').IncomingMessage} req
75
+ * @returns {object|undefined}
76
+ */
77
+ matchRoute(req) {
78
+ const debug = this.getDebuggerFor(this.matchRoute);
79
+ debug('Matching routes with incoming request.');
80
+ const url = (req.url || '/').replace(/\?.*$/, '');
81
+ debug('Incoming request is %s %v.', req.method, url);
82
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
83
+ debug('Method not allowed.');
84
+ return;
85
+ }
86
+ debug('Walking through %v routes.', this._routes.length);
87
+ const route = this._routes.find(route => {
88
+ const res = route.regexp.test(url);
89
+ const phrase = res ? 'matched' : 'not matched';
90
+ debug('Resource %v %s.', route.resourcePath, phrase);
91
+ return res;
92
+ });
93
+ route
94
+ ? debug('Resource %v matched.', route.resourcePath)
95
+ : debug('No route matched.');
96
+ return route;
97
+ }
98
+
99
+ /**
100
+ * Send file by route.
101
+ *
102
+ * @param {object} route
103
+ * @param {import('http').IncomingMessage} req
104
+ * @param {import('http').ServerResponse} res
105
+ */
106
+ sendFileByRoute(route, req, res) {
107
+ const reqUrl = req.url || '/';
108
+ const reqPath = reqUrl.replace(/\?.*$/, '');
109
+ // если ресурс ссылается на папку, то из адреса запроса
110
+ // извлекается дополнительная часть (если присутствует),
111
+ // и добавляется к адресу ресурса
112
+ let targetPath = route.resourcePath;
113
+ if (!route.isFile) {
114
+ // извлечение относительного пути в дополнение к адресу
115
+ // ресурса путем удаления из адреса запроса той части,
116
+ // которая была указана при объявлении маршрута
117
+ const relativePath = reqPath.replace(route.regexp, '');
118
+ // объединение адреса ресурса
119
+ // с дополнительной частью
120
+ targetPath = path.join(route.resourcePath, relativePath);
121
+ }
122
+ // если обнаружена попытка выхода за пределы
123
+ // корневой директории, то выбрасывается ошибка
124
+ targetPath = path.resolve(targetPath);
125
+ const resourceRoot = path.resolve(route.resourcePath);
126
+ if (!targetPath.startsWith(resourceRoot)) {
127
+ res.writeHead(403, {'content-type': 'text/plain'});
128
+ res.end('403 Forbidden');
129
+ return;
130
+ }
131
+ // подстановка индекс-файла (для папок),
132
+ // установка заголовков и отправка потока
133
+ fs.stat(targetPath, (statsError, stats) => {
134
+ if (statsError) {
135
+ return _handleFsError(statsError, res);
136
+ }
137
+ if (stats.isDirectory()) {
138
+ // так как в html обычно используются относительные пути,
139
+ // то адрес директории статических ресурсов должен завершаться
140
+ // косой чертой, чтобы файлы стилей и изображений загружались
141
+ // именно из нее, а не обращались на уровень выше
142
+ if (/[^/]$/.test(reqPath)) {
143
+ const searchMatch = reqUrl.match(/\?.*$/);
144
+ const search = searchMatch ? searchMatch[0] : '';
145
+ const normalizedPath = reqUrl.replace(/\/{2,}/g, '/');
146
+ res.writeHead(302, {location: `${normalizedPath}/${search}`});
147
+ res.end();
148
+ return;
149
+ }
150
+ // если адрес запроса содержит дублирующие слеши,
151
+ // то адрес нормализуется и выполняется редирект
152
+ if (/\/{2,}/.test(reqUrl)) {
153
+ const normalizedUrl = reqUrl.replace(/\/{2,}/g, '/');
154
+ res.writeHead(302, {location: normalizedUrl});
155
+ res.end();
156
+ return;
157
+ }
158
+ // если целевой путь указывает на папку,
159
+ // то подставляется index.html
160
+ targetPath = path.join(targetPath, 'index.html');
161
+ }
162
+ // формирование заголовка "content-type"
163
+ // в зависимости от расширения файла
164
+ const extname = path.extname(targetPath);
165
+ const contentType =
166
+ mimeTypes.contentType(extname) || 'application/octet-stream';
167
+ // файл читается и отправляется частями,
168
+ // что значительно снижает использование памяти
169
+ const fileStream = createReadStream(targetPath);
170
+ fileStream.on('error', error => {
171
+ _handleFsError(error, res);
172
+ });
173
+ // отправка заголовка 200, только после
174
+ // этого начинается отдача файла
175
+ fileStream.on('open', () => {
176
+ res.writeHead(200, {'content-type': contentType});
177
+ // для HEAD запроса отправляются
178
+ // только заголовки (без тела)
179
+ if (req.method === 'HEAD') {
180
+ res.end();
181
+ return;
182
+ }
183
+ fileStream.pipe(res);
184
+ });
185
+ });
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Handle filesystem error.
191
+ *
192
+ * @param {object} error
193
+ * @param {object} res
194
+ * @returns {undefined}
195
+ */
196
+ function _handleFsError(error, res) {
197
+ if (res.headersSent) {
198
+ return;
199
+ }
200
+ if ('code' in error && error.code === 'ENOENT') {
201
+ res.writeHead(404, {'content-type': 'text/plain'});
202
+ res.write('404 Not Found');
203
+ res.end();
204
+ } else {
205
+ res.writeHead(500, {'content-type': 'text/plain'});
206
+ res.write('500 Internal Server Error');
207
+ res.end();
208
+ }
209
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './http-static-router.js';
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './http-static-router.js';
package/src/types.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A callable type with the "new" operator
3
+ * that allows class and constructor types.
4
+ */
5
+ export interface Constructor<T = object> {
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ new (...args: any[]): T;
8
+ }
9
+
10
+ /**
11
+ * An object prototype that excludes
12
+ * function and scalar values.
13
+ */
14
+ export type Prototype<T = object> = T &
15
+ object & {bind?: never} & {
16
+ call?: never;
17
+ } & {prototype?: object};
18
+
19
+ /**
20
+ * A function type without class and constructor.
21
+ */
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ export type Callable<T = unknown> = (...args: any[]) => T;
24
+
25
+ /**
26
+ * Makes a specific property of T as optional.
27
+ */
28
+ export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
29
+
30
+ /**
31
+ * A part of the Flatten type.
32
+ */
33
+ export type Identity<T> = T;
34
+
35
+ /**
36
+ * Makes T more human-readable.
37
+ */
38
+ export type Flatten<T> = Identity<{[k in keyof T]: T[k]}>;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Экранирует специальные символы в строке
3
+ * для использования в регулярном выражении.
4
+ *
5
+ * @param input
6
+ */
7
+ export function escapeRegexp(input: string | number): string;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Экранирует специальные символы в строке
3
+ * для использования в регулярном выражении.
4
+ *
5
+ * @param {*} input
6
+ * @returns {string}
7
+ */
8
+ export function escapeRegexp(input) {
9
+ // $& означает всю совпавшую строку.
10
+ return String(input).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
11
+ }
@@ -0,0 +1,78 @@
1
+ import {expect} from 'chai';
2
+ import {escapeRegexp} from './escape-regexp.js';
3
+
4
+ describe('escapeRegexp', function () {
5
+ it('should not change a string with no special characters', function () {
6
+ // проверка, что обычная строка без спецсимволов не изменяется
7
+ const input = 'hello world';
8
+ const expected = 'hello world';
9
+ expect(escapeRegexp(input)).to.equal(expected);
10
+ });
11
+
12
+ it('should escape all special regex characters', function () {
13
+ // проверка, что все специальные символы для RegExp корректно экранируются
14
+ const input = '.*+?^${}()|[]\\';
15
+ const expected = '\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\';
16
+ expect(escapeRegexp(input)).to.equal(expected);
17
+ });
18
+
19
+ it('should escape a string containing a URL', function () {
20
+ // проверка экранирования в строке, которая является URL-адресом
21
+ const input = 'http://example.com?query=a+b';
22
+ const expected = 'http://example\\.com\\?query=a\\+b';
23
+ expect(escapeRegexp(input)).to.equal(expected);
24
+ });
25
+
26
+ it('should escape characters within a mixed string', function () {
27
+ // проверка, что символы экранируются правильно внутри обычной строки
28
+ const input = 'a (very) important string [_v2.0_]';
29
+ const expected = 'a \\(very\\) important string \\[_v2\\.0_\\]';
30
+ expect(escapeRegexp(input)).to.equal(expected);
31
+ });
32
+
33
+ it('should correctly escape backslashes', function () {
34
+ // отдельно проверка правильного экранирования обратных слэшей
35
+ const input = 'C:\\Users\\Test';
36
+ const expected = 'C:\\\\Users\\\\Test';
37
+ expect(escapeRegexp(input)).to.equal(expected);
38
+ });
39
+
40
+ it('should handle an empty string', function () {
41
+ // проверка, что пустая строка обрабатывается корректно
42
+ const input = '';
43
+ const expected = '';
44
+ expect(escapeRegexp(input)).to.equal(expected);
45
+ });
46
+
47
+ it('should convert non-string input to a string and escape it', function () {
48
+ // тест с числом, которое содержит специальный для RegExp символ '.'
49
+ const inputNumber = 123.45;
50
+ const expectedNumber = '123\\.45';
51
+ expect(escapeRegexp(inputNumber)).to.equal(expectedNumber);
52
+
53
+ // тест с null.
54
+ const inputNull = null;
55
+ const expectedNull = 'null';
56
+ expect(escapeRegexp(inputNull)).to.equal(expectedNull);
57
+
58
+ // тест с undefined.
59
+ const inputUndefined = undefined;
60
+ const expectedUndefined = 'undefined';
61
+ expect(escapeRegexp(inputUndefined)).to.equal(expectedUndefined);
62
+ });
63
+
64
+ it('should correctly create a usable RegExp object after escaping', function () {
65
+ // проверка, что после экранирования мы можем
66
+ // создать рабочее регулярное выражение
67
+ const dangerousString = 'search(v1.0)';
68
+ const escapedString = escapeRegexp(dangerousString);
69
+ const regex = new RegExp(escapedString);
70
+ // проверка, что экранированная строка имеет ожидаемый вид
71
+ expect(escapedString).to.equal('search\\(v1\\.0\\)');
72
+ // созданный RegExp должен находить точное совпадение с исходной строкой
73
+ expect(regex.test('search(v1.0)')).to.be.true;
74
+ // и он не должен находить совпадения там, где символы могут
75
+ // быть неверно интерпретированы как операторы
76
+ expect(regex.test('search(v1a0)')).to.be.false;
77
+ });
78
+ });
@@ -0,0 +1,2 @@
1
+ export * from './escape-regexp.js';
2
+ export * from './normalize-path.js';
@@ -0,0 +1,2 @@
1
+ export * from './escape-regexp.js';
2
+ export * from './normalize-path.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Normalize path.
3
+ *
4
+ * Заменяет любые повторяющиеся слеши на один.
5
+ * Удаляет пробельные символы в начале и конце.
6
+ * Удаляет слеш в конце строки.
7
+ * Гарантирует слеш в начале строки (по умолчанию).
8
+ *
9
+ * @param value
10
+ * @param noStartingSlash
11
+ */
12
+ export function normalizePath(value: string, noStartingSlash?: boolean): string;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Normalize path.
3
+ *
4
+ * Заменяет любые повторяющиеся слеши на один.
5
+ * Удаляет пробельные символы в начале и конце.
6
+ * Удаляет слеш в конце строки.
7
+ * Гарантирует слеш в начале строки (по умолчанию).
8
+ *
9
+ * @param {string} value
10
+ * @param {boolean} [noStartingSlash]
11
+ * @returns {string}
12
+ */
13
+ export function normalizePath(value, noStartingSlash = false) {
14
+ if (typeof value !== 'string') {
15
+ return '/';
16
+ }
17
+ const res = value
18
+ .trim()
19
+ .replace(/\/+/g, '/')
20
+ .replace(/(^\/|\/$)/g, '');
21
+ return noStartingSlash ? res : '/' + res;
22
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "target": "es2022",
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "noEmit": true,
8
+ "allowJs": true
9
+ },
10
+ "include": [
11
+ "./src/**/*.ts",
12
+ "./src/**/*.js"
13
+ ]
14
+ }