@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 +9 -0
- package/.commitlintrc +5 -0
- package/.editorconfig +13 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +6 -0
- package/.mocharc.json +4 -0
- package/.prettierrc +7 -0
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/build-cjs.js +16 -0
- package/dist/cjs/index.cjs +218 -0
- package/eslint.config.js +41 -0
- package/example/server.js +41 -0
- package/example/static/file.txt +9 -0
- package/example/static/index.html +10 -0
- package/example/static/nested/index.html +10 -0
- package/package.json +66 -0
- package/src/http-static-router.d.ts +53 -0
- package/src/http-static-router.js +209 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/types.d.ts +38 -0
- package/src/utils/escape-regexp.d.ts +7 -0
- package/src/utils/escape-regexp.js +11 -0
- package/src/utils/escape-regexp.spec.js +78 -0
- package/src/utils/index.d.ts +2 -0
- package/src/utils/index.js +2 -0
- package/src/utils/normalize-path.d.ts +12 -0
- package/src/utils/normalize-path.js +22 -0
- package/tsconfig.json +14 -0
package/.c8rc
ADDED
package/.commitlintrc
ADDED
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
|
package/.mocharc.json
ADDED
package/.prettierrc
ADDED
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
|
+
});
|
package/eslint.config.js
ADDED
|
@@ -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
|
+
⠀⠀⠀⠈⠉⠉⠉⠁⠀⠀⠀⠀⠈⠛⠛⠶⠾⠋⠉⠉⠉⠉⠉⠉⠉⠉⠛⠛⠛⠛
|
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,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,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