@e22m4u/js-http-static-router 0.0.1 → 0.0.2
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/README.md +20 -0
- package/dist/cjs/index.cjs +198 -37
- package/example/server.js +1 -1
- package/package.json +1 -1
- package/src/http-static-route.d.ts +47 -0
- package/src/http-static-route.js +73 -0
- package/src/http-static-router.d.ts +7 -9
- package/src/http-static-router.js +157 -50
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
package/README.md
CHANGED
|
@@ -68,6 +68,26 @@ server.listen(3000, () => {
|
|
|
68
68
|
});
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
### trailingSlash
|
|
72
|
+
|
|
73
|
+
Так как в HTML обычно используются относительные пути, чтобы файлы
|
|
74
|
+
стилей и изображений загружались относительно текущего уровня вложенности,
|
|
75
|
+
а не обращались на уровень выше, может потребоваться параметр `trailingSlash`
|
|
76
|
+
для принудительного добавления косой черты в конце адреса.
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import http from 'http';
|
|
80
|
+
import {HttpStaticRouter} from '@e22m4u/js-http-static-router';
|
|
81
|
+
|
|
82
|
+
const staticRouter = new HttpStaticRouter({
|
|
83
|
+
trailingSlash: true, // <= добавлять косую черту (для директорий)
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// теперь, при обращении к директориям без закрывающего
|
|
87
|
+
// слеша будет выполняться принудительный редирект (302)
|
|
88
|
+
// /dir => /dir/
|
|
89
|
+
```
|
|
90
|
+
|
|
71
91
|
## Тесты
|
|
72
92
|
|
|
73
93
|
```bash
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -31,14 +31,86 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
// src/index.js
|
|
32
32
|
var index_exports = {};
|
|
33
33
|
__export(index_exports, {
|
|
34
|
+
HttpStaticRoute: () => HttpStaticRoute,
|
|
34
35
|
HttpStaticRouter: () => HttpStaticRouter
|
|
35
36
|
});
|
|
36
37
|
module.exports = __toCommonJS(index_exports);
|
|
37
38
|
|
|
39
|
+
// src/http-static-route.js
|
|
40
|
+
var import_js_format = require("@e22m4u/js-format");
|
|
41
|
+
var _HttpStaticRoute = class _HttpStaticRoute {
|
|
42
|
+
/**
|
|
43
|
+
* Remote path.
|
|
44
|
+
*
|
|
45
|
+
* @type {string}
|
|
46
|
+
*/
|
|
47
|
+
remotePath;
|
|
48
|
+
/**
|
|
49
|
+
* Resource path.
|
|
50
|
+
*
|
|
51
|
+
* @type {string}
|
|
52
|
+
*/
|
|
53
|
+
resourcePath;
|
|
54
|
+
/**
|
|
55
|
+
* RegExp.
|
|
56
|
+
*
|
|
57
|
+
* @type {RegExp}
|
|
58
|
+
*/
|
|
59
|
+
regexp;
|
|
60
|
+
/**
|
|
61
|
+
* Is file.
|
|
62
|
+
*
|
|
63
|
+
* @type {boolean}
|
|
64
|
+
*/
|
|
65
|
+
isFile;
|
|
66
|
+
/**
|
|
67
|
+
* Constructor.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} remotePath
|
|
70
|
+
* @param {string} resourcePath
|
|
71
|
+
* @param {RegExp} regexp
|
|
72
|
+
* @param {boolean} isFile
|
|
73
|
+
*/
|
|
74
|
+
constructor(remotePath, resourcePath, regexp, isFile) {
|
|
75
|
+
if (typeof remotePath !== "string") {
|
|
76
|
+
throw new import_js_format.InvalidArgumentError(
|
|
77
|
+
'Parameter "remotePath" must be a String, but %v was given.',
|
|
78
|
+
remotePath
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (typeof resourcePath !== "string") {
|
|
82
|
+
throw new import_js_format.InvalidArgumentError(
|
|
83
|
+
'Parameter "resourcePath" must be a String, but %v was given.',
|
|
84
|
+
resourcePath
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (!(regexp instanceof RegExp)) {
|
|
88
|
+
throw new import_js_format.InvalidArgumentError(
|
|
89
|
+
'Parameter "regexp" must be an instance of RegExp, but %v was given.',
|
|
90
|
+
regexp
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (typeof isFile !== "boolean") {
|
|
94
|
+
throw new import_js_format.InvalidArgumentError(
|
|
95
|
+
'Parameter "isFile" must be a String, but %v was given.',
|
|
96
|
+
isFile
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
this.remotePath = remotePath;
|
|
100
|
+
this.resourcePath = resourcePath;
|
|
101
|
+
this.regexp = regexp;
|
|
102
|
+
this.isFile = isFile;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
__name(_HttpStaticRoute, "HttpStaticRoute");
|
|
106
|
+
var HttpStaticRoute = _HttpStaticRoute;
|
|
107
|
+
|
|
38
108
|
// src/http-static-router.js
|
|
39
109
|
var import_path = __toESM(require("path"), 1);
|
|
40
110
|
var import_mime_types = __toESM(require("mime-types"), 1);
|
|
41
111
|
var import_fs = __toESM(require("fs"), 1);
|
|
112
|
+
var import_http = require("http");
|
|
113
|
+
var import_js_format2 = require("@e22m4u/js-format");
|
|
42
114
|
|
|
43
115
|
// src/utils/escape-regexp.js
|
|
44
116
|
function escapeRegexp(input) {
|
|
@@ -58,24 +130,48 @@ __name(normalizePath, "normalizePath");
|
|
|
58
130
|
|
|
59
131
|
// src/http-static-router.js
|
|
60
132
|
var import_js_service = require("@e22m4u/js-service");
|
|
61
|
-
var import_js_format = require("@e22m4u/js-format");
|
|
62
133
|
var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.DebuggableService {
|
|
63
134
|
/**
|
|
64
135
|
* Routes.
|
|
65
136
|
*
|
|
66
137
|
* @protected
|
|
138
|
+
* @type {HttpStaticRoute[]}
|
|
67
139
|
*/
|
|
68
140
|
_routes = [];
|
|
141
|
+
/**
|
|
142
|
+
* Options.
|
|
143
|
+
*
|
|
144
|
+
* @type {object}
|
|
145
|
+
*/
|
|
146
|
+
_options = {};
|
|
69
147
|
/**
|
|
70
148
|
* Constructor.
|
|
71
149
|
*
|
|
72
|
-
* @param {
|
|
150
|
+
* @param {object} options
|
|
73
151
|
*/
|
|
74
|
-
constructor(
|
|
75
|
-
|
|
152
|
+
constructor(options = {}) {
|
|
153
|
+
if ((0, import_js_service.isServiceContainer)(options)) {
|
|
154
|
+
options = {};
|
|
155
|
+
}
|
|
156
|
+
super(void 0, {
|
|
76
157
|
noEnvironmentNamespace: true,
|
|
77
158
|
namespace: "jsHttpStaticRouter"
|
|
78
159
|
});
|
|
160
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
|
161
|
+
throw new import_js_format2.InvalidArgumentError(
|
|
162
|
+
'Parameter "options" must be an Object, but %v was given.',
|
|
163
|
+
options
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (options.trailingSlash !== void 0) {
|
|
167
|
+
if (typeof options.trailingSlash !== "boolean") {
|
|
168
|
+
throw new import_js_format2.InvalidArgumentError(
|
|
169
|
+
'Option "trailingSlash" must be a Boolean, but %v was given.',
|
|
170
|
+
options.trailingSlash
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
this._options = { ...options };
|
|
79
175
|
}
|
|
80
176
|
/**
|
|
81
177
|
* Add route.
|
|
@@ -85,6 +181,18 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
|
|
|
85
181
|
* @returns {object}
|
|
86
182
|
*/
|
|
87
183
|
addRoute(remotePath, resourcePath) {
|
|
184
|
+
if (typeof remotePath !== "string") {
|
|
185
|
+
throw new import_js_format2.InvalidArgumentError(
|
|
186
|
+
"Remote path must be a String, but %v was given.",
|
|
187
|
+
remotePath
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (typeof resourcePath !== "string") {
|
|
191
|
+
throw new import_js_format2.InvalidArgumentError(
|
|
192
|
+
"Resource path must be a String, but %v was given.",
|
|
193
|
+
resourcePath
|
|
194
|
+
);
|
|
195
|
+
}
|
|
88
196
|
const debug = this.getDebuggerFor(this.addRoute);
|
|
89
197
|
resourcePath = import_path.default.resolve(resourcePath);
|
|
90
198
|
debug("Adding a new route.");
|
|
@@ -95,8 +203,8 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
|
|
|
95
203
|
stats = import_fs.default.statSync(resourcePath);
|
|
96
204
|
} catch (error) {
|
|
97
205
|
console.error(error);
|
|
98
|
-
throw new
|
|
99
|
-
"
|
|
206
|
+
throw new import_js_format2.InvalidArgumentError(
|
|
207
|
+
"Resource path %v does not exist.",
|
|
100
208
|
resourcePath
|
|
101
209
|
);
|
|
102
210
|
}
|
|
@@ -104,8 +212,8 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
|
|
|
104
212
|
debug("Resource type is %s.", isFile ? "File" : "Folder");
|
|
105
213
|
const normalizedRemotePath = normalizePath(remotePath);
|
|
106
214
|
const escapedRemotePath = escapeRegexp(normalizedRemotePath);
|
|
107
|
-
const regexp = isFile ? new RegExp(`^${escapedRemotePath}
|
|
108
|
-
const route =
|
|
215
|
+
const regexp = isFile ? new RegExp(`^${escapedRemotePath}/*$`) : new RegExp(`^${escapedRemotePath}(?:$|\\/)`);
|
|
216
|
+
const route = new HttpStaticRoute(remotePath, resourcePath, regexp, isFile);
|
|
109
217
|
this._routes.push(route);
|
|
110
218
|
this._routes.sort((a, b) => b.remotePath.length - a.remotePath.length);
|
|
111
219
|
return this;
|
|
@@ -113,10 +221,16 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
|
|
|
113
221
|
/**
|
|
114
222
|
* Match route.
|
|
115
223
|
*
|
|
116
|
-
* @param {
|
|
224
|
+
* @param {IncomingMessage} req
|
|
117
225
|
* @returns {object|undefined}
|
|
118
226
|
*/
|
|
119
227
|
matchRoute(req) {
|
|
228
|
+
if (!(req instanceof import_http.IncomingMessage)) {
|
|
229
|
+
throw new import_js_format2.InvalidArgumentError(
|
|
230
|
+
'Parameter "req" must be an instance of IncomingMessage, but %v was given.',
|
|
231
|
+
req
|
|
232
|
+
);
|
|
233
|
+
}
|
|
120
234
|
const debug = this.getDebuggerFor(this.matchRoute);
|
|
121
235
|
debug("Matching routes with incoming request.");
|
|
122
236
|
const url = (req.url || "/").replace(/\?.*$/, "");
|
|
@@ -138,13 +252,45 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
|
|
|
138
252
|
/**
|
|
139
253
|
* Send file by route.
|
|
140
254
|
*
|
|
141
|
-
* @param {
|
|
255
|
+
* @param {HttpStaticRoute} route
|
|
142
256
|
* @param {import('http').IncomingMessage} req
|
|
143
257
|
* @param {import('http').ServerResponse} res
|
|
144
258
|
*/
|
|
145
259
|
sendFileByRoute(route, req, res) {
|
|
260
|
+
if (!(route instanceof HttpStaticRoute)) {
|
|
261
|
+
throw new import_js_format2.InvalidArgumentError(
|
|
262
|
+
'Parameter "route" must be an instance of HttpStaticRoute, but %v was given.',
|
|
263
|
+
route
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (!(req instanceof import_http.IncomingMessage)) {
|
|
267
|
+
throw new import_js_format2.InvalidArgumentError(
|
|
268
|
+
'Parameter "req" must be an instance of IncomingMessage, but %v was given.',
|
|
269
|
+
req
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
if (!(res instanceof import_http.ServerResponse)) {
|
|
273
|
+
throw new import_js_format2.InvalidArgumentError(
|
|
274
|
+
'Parameter "res" must be an instance of ServerResponse, but %v was given.',
|
|
275
|
+
res
|
|
276
|
+
);
|
|
277
|
+
}
|
|
146
278
|
const reqUrl = req.url || "/";
|
|
147
279
|
const reqPath = reqUrl.replace(/\?.*$/, "");
|
|
280
|
+
if (!this._options.trailingSlash && reqPath !== "/" && /\/$/.test(reqPath)) {
|
|
281
|
+
const searchMatch = reqUrl.match(/\?.*$/);
|
|
282
|
+
const search = searchMatch ? searchMatch[0] : "";
|
|
283
|
+
const normalizedPath = reqPath.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
|
|
284
|
+
res.writeHead(302, { location: `${normalizedPath}${search}` });
|
|
285
|
+
res.end();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (/\/{2,}/.test(reqUrl)) {
|
|
289
|
+
const normalizedUrl = reqUrl.replace(/\/{2,}/g, "/");
|
|
290
|
+
res.writeHead(302, { location: normalizedUrl });
|
|
291
|
+
res.end();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
148
294
|
let targetPath = route.resourcePath;
|
|
149
295
|
if (!route.isFile) {
|
|
150
296
|
const relativePath = reqPath.replace(route.regexp, "");
|
|
@@ -159,30 +305,35 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
|
|
|
159
305
|
}
|
|
160
306
|
import_fs.default.stat(targetPath, (statsError, stats) => {
|
|
161
307
|
if (statsError) {
|
|
162
|
-
return _handleFsError(statsError, res);
|
|
308
|
+
return this._handleFsError(statsError, res);
|
|
163
309
|
}
|
|
164
310
|
if (stats.isDirectory()) {
|
|
165
|
-
if (
|
|
311
|
+
if (this._options.trailingSlash) {
|
|
312
|
+
if (/[^/]$/.test(reqPath)) {
|
|
313
|
+
const searchMatch = reqUrl.match(/\?.*$/);
|
|
314
|
+
const search = searchMatch ? searchMatch[0] : "";
|
|
315
|
+
const normalizedPath = reqPath.replace(/\/{2,}/g, "/");
|
|
316
|
+
res.writeHead(302, { location: `${normalizedPath}/${search}` });
|
|
317
|
+
res.end();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
targetPath = import_path.default.join(targetPath, "index.html");
|
|
322
|
+
} else {
|
|
323
|
+
if (reqPath !== "/" && /\/$/.test(reqPath)) {
|
|
166
324
|
const searchMatch = reqUrl.match(/\?.*$/);
|
|
167
325
|
const search = searchMatch ? searchMatch[0] : "";
|
|
168
|
-
const normalizedPath =
|
|
169
|
-
res.writeHead(302, { location: `${normalizedPath}
|
|
326
|
+
const normalizedPath = reqPath.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
|
|
327
|
+
res.writeHead(302, { location: `${normalizedPath}${search}` });
|
|
170
328
|
res.end();
|
|
171
329
|
return;
|
|
172
330
|
}
|
|
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
331
|
}
|
|
181
332
|
const extname = import_path.default.extname(targetPath);
|
|
182
333
|
const contentType = import_mime_types.default.contentType(extname) || "application/octet-stream";
|
|
183
334
|
const fileStream = (0, import_fs.createReadStream)(targetPath);
|
|
184
335
|
fileStream.on("error", (error) => {
|
|
185
|
-
_handleFsError(error, res);
|
|
336
|
+
this._handleFsError(error, res);
|
|
186
337
|
});
|
|
187
338
|
fileStream.on("open", () => {
|
|
188
339
|
res.writeHead(200, { "content-type": contentType });
|
|
@@ -192,27 +343,37 @@ var _HttpStaticRouter = class _HttpStaticRouter extends import_js_service.Debugg
|
|
|
192
343
|
}
|
|
193
344
|
fileStream.pipe(res);
|
|
194
345
|
});
|
|
346
|
+
req.on("close", () => {
|
|
347
|
+
fileStream.destroy();
|
|
348
|
+
});
|
|
195
349
|
});
|
|
196
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* Handle filesystem error.
|
|
353
|
+
*
|
|
354
|
+
* @param {object} error
|
|
355
|
+
* @param {object} res
|
|
356
|
+
* @returns {undefined}
|
|
357
|
+
*/
|
|
358
|
+
_handleFsError(error, res) {
|
|
359
|
+
if (res.headersSent) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if ("code" in error && error.code === "ENOENT") {
|
|
363
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
364
|
+
res.write("404 Not Found");
|
|
365
|
+
res.end();
|
|
366
|
+
} else {
|
|
367
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
368
|
+
res.write("500 Internal Server Error");
|
|
369
|
+
res.end();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
197
372
|
};
|
|
198
373
|
__name(_HttpStaticRouter, "HttpStaticRouter");
|
|
199
374
|
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
375
|
// Annotate the CommonJS export names for ESM import in node:
|
|
216
376
|
0 && (module.exports = {
|
|
377
|
+
HttpStaticRoute,
|
|
217
378
|
HttpStaticRouter
|
|
218
379
|
});
|
package/example/server.js
CHANGED
|
@@ -36,6 +36,6 @@ server.on('request', (req, res) => {
|
|
|
36
36
|
server.listen(3000, () => {
|
|
37
37
|
console.log('Server is running on http://localhost:3000');
|
|
38
38
|
console.log('Try to open:');
|
|
39
|
-
console.log('http://localhost:3000/static
|
|
39
|
+
console.log('http://localhost:3000/static');
|
|
40
40
|
console.log('http://localhost:3000/file.txt');
|
|
41
41
|
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static file route.
|
|
3
|
+
*/
|
|
4
|
+
export class HttpStaticRoute {
|
|
5
|
+
/**
|
|
6
|
+
* Remote path.
|
|
7
|
+
*
|
|
8
|
+
* @type {string}
|
|
9
|
+
*/
|
|
10
|
+
readonly remotePath: string;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resource path.
|
|
14
|
+
*
|
|
15
|
+
* @type {string}
|
|
16
|
+
*/
|
|
17
|
+
readonly resourcePath: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* RegExp.
|
|
21
|
+
*
|
|
22
|
+
* @type {RegExp}
|
|
23
|
+
*/
|
|
24
|
+
readonly regexp: RegExp;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Is file.
|
|
28
|
+
*
|
|
29
|
+
* @type {boolean}
|
|
30
|
+
*/
|
|
31
|
+
readonly isFile: boolean;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Constructor.
|
|
35
|
+
*
|
|
36
|
+
* @param remotePath
|
|
37
|
+
* @param resourcePath
|
|
38
|
+
* @param regexp
|
|
39
|
+
* @param isFile
|
|
40
|
+
*/
|
|
41
|
+
constructor(
|
|
42
|
+
remotePath: string,
|
|
43
|
+
resourcePath: string,
|
|
44
|
+
regexp: RegExp,
|
|
45
|
+
isFile: boolean,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {InvalidArgumentError} from '@e22m4u/js-format';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Static file route.
|
|
5
|
+
*/
|
|
6
|
+
export class HttpStaticRoute {
|
|
7
|
+
/**
|
|
8
|
+
* Remote path.
|
|
9
|
+
*
|
|
10
|
+
* @type {string}
|
|
11
|
+
*/
|
|
12
|
+
remotePath;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resource path.
|
|
16
|
+
*
|
|
17
|
+
* @type {string}
|
|
18
|
+
*/
|
|
19
|
+
resourcePath;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* RegExp.
|
|
23
|
+
*
|
|
24
|
+
* @type {RegExp}
|
|
25
|
+
*/
|
|
26
|
+
regexp;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Is file.
|
|
30
|
+
*
|
|
31
|
+
* @type {boolean}
|
|
32
|
+
*/
|
|
33
|
+
isFile;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Constructor.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} remotePath
|
|
39
|
+
* @param {string} resourcePath
|
|
40
|
+
* @param {RegExp} regexp
|
|
41
|
+
* @param {boolean} isFile
|
|
42
|
+
*/
|
|
43
|
+
constructor(remotePath, resourcePath, regexp, isFile) {
|
|
44
|
+
if (typeof remotePath !== 'string') {
|
|
45
|
+
throw new InvalidArgumentError(
|
|
46
|
+
'Parameter "remotePath" must be a String, but %v was given.',
|
|
47
|
+
remotePath,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (typeof resourcePath !== 'string') {
|
|
51
|
+
throw new InvalidArgumentError(
|
|
52
|
+
'Parameter "resourcePath" must be a String, but %v was given.',
|
|
53
|
+
resourcePath,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (!(regexp instanceof RegExp)) {
|
|
57
|
+
throw new InvalidArgumentError(
|
|
58
|
+
'Parameter "regexp" must be an instance of RegExp, but %v was given.',
|
|
59
|
+
regexp,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (typeof isFile !== 'boolean') {
|
|
63
|
+
throw new InvalidArgumentError(
|
|
64
|
+
'Parameter "isFile" must be a String, but %v was given.',
|
|
65
|
+
isFile,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
this.remotePath = remotePath;
|
|
69
|
+
this.resourcePath = resourcePath;
|
|
70
|
+
this.regexp = regexp;
|
|
71
|
+
this.isFile = isFile;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import {ServerResponse} from 'node:http';
|
|
2
2
|
import {IncomingMessage} from 'node:http';
|
|
3
|
+
import {HttpStaticRoute} from './http-static-route.js';
|
|
3
4
|
import {DebuggableService, ServiceContainer} from '@e22m4u/js-service';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
*
|
|
7
|
+
* Http static router options.
|
|
7
8
|
*/
|
|
8
|
-
export type
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
regexp: RegExp;
|
|
12
|
-
isFile: boolean;
|
|
13
|
-
};
|
|
9
|
+
export type HttpStaticRouterOptions = {
|
|
10
|
+
trailingSlash?: boolean;
|
|
11
|
+
}
|
|
14
12
|
|
|
15
13
|
/**
|
|
16
14
|
* Http static router.
|
|
@@ -19,9 +17,9 @@ export class HttpStaticRouter extends DebuggableService {
|
|
|
19
17
|
/**
|
|
20
18
|
* Constructor.
|
|
21
19
|
*
|
|
22
|
-
* @param
|
|
20
|
+
* @param options
|
|
23
21
|
*/
|
|
24
|
-
constructor(
|
|
22
|
+
constructor(options?: HttpStaticRouterOptions);
|
|
25
23
|
|
|
26
24
|
/**
|
|
27
25
|
* Add route.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import mimeTypes from 'mime-types';
|
|
3
3
|
import fs, {createReadStream} from 'fs';
|
|
4
|
-
import {
|
|
5
|
-
import {DebuggableService} from '@e22m4u/js-service';
|
|
4
|
+
import {IncomingMessage, ServerResponse} from 'http';
|
|
6
5
|
import {InvalidArgumentError} from '@e22m4u/js-format';
|
|
6
|
+
import {HttpStaticRoute} from './http-static-route.js';
|
|
7
|
+
import {escapeRegexp, normalizePath} from './utils/index.js';
|
|
8
|
+
import {DebuggableService, isServiceContainer} from '@e22m4u/js-service';
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Http static router.
|
|
@@ -13,19 +15,45 @@ export class HttpStaticRouter extends DebuggableService {
|
|
|
13
15
|
* Routes.
|
|
14
16
|
*
|
|
15
17
|
* @protected
|
|
18
|
+
* @type {HttpStaticRoute[]}
|
|
16
19
|
*/
|
|
17
20
|
_routes = [];
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Options.
|
|
24
|
+
*
|
|
25
|
+
* @type {object}
|
|
26
|
+
*/
|
|
27
|
+
_options = {};
|
|
28
|
+
|
|
19
29
|
/**
|
|
20
30
|
* Constructor.
|
|
21
31
|
*
|
|
22
|
-
* @param {
|
|
32
|
+
* @param {object} options
|
|
23
33
|
*/
|
|
24
|
-
constructor(
|
|
25
|
-
|
|
34
|
+
constructor(options = {}) {
|
|
35
|
+
if (isServiceContainer(options)) {
|
|
36
|
+
options = {};
|
|
37
|
+
}
|
|
38
|
+
super(undefined, {
|
|
26
39
|
noEnvironmentNamespace: true,
|
|
27
40
|
namespace: 'jsHttpStaticRouter',
|
|
28
41
|
});
|
|
42
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
43
|
+
throw new InvalidArgumentError(
|
|
44
|
+
'Parameter "options" must be an Object, but %v was given.',
|
|
45
|
+
options,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (options.trailingSlash !== undefined) {
|
|
49
|
+
if (typeof options.trailingSlash !== 'boolean') {
|
|
50
|
+
throw new InvalidArgumentError(
|
|
51
|
+
'Option "trailingSlash" must be a Boolean, but %v was given.',
|
|
52
|
+
options.trailingSlash,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
this._options = {...options};
|
|
29
57
|
}
|
|
30
58
|
|
|
31
59
|
/**
|
|
@@ -36,6 +64,18 @@ export class HttpStaticRouter extends DebuggableService {
|
|
|
36
64
|
* @returns {object}
|
|
37
65
|
*/
|
|
38
66
|
addRoute(remotePath, resourcePath) {
|
|
67
|
+
if (typeof remotePath !== 'string') {
|
|
68
|
+
throw new InvalidArgumentError(
|
|
69
|
+
'Remote path must be a String, but %v was given.',
|
|
70
|
+
remotePath,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (typeof resourcePath !== 'string') {
|
|
74
|
+
throw new InvalidArgumentError(
|
|
75
|
+
'Resource path must be a String, but %v was given.',
|
|
76
|
+
resourcePath,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
39
79
|
const debug = this.getDebuggerFor(this.addRoute);
|
|
40
80
|
resourcePath = path.resolve(resourcePath);
|
|
41
81
|
debug('Adding a new route.');
|
|
@@ -49,7 +89,7 @@ export class HttpStaticRouter extends DebuggableService {
|
|
|
49
89
|
// это может быть ошибкой конфигурации
|
|
50
90
|
console.error(error);
|
|
51
91
|
throw new InvalidArgumentError(
|
|
52
|
-
'
|
|
92
|
+
'Resource path %v does not exist.',
|
|
53
93
|
resourcePath,
|
|
54
94
|
);
|
|
55
95
|
}
|
|
@@ -58,9 +98,9 @@ export class HttpStaticRouter extends DebuggableService {
|
|
|
58
98
|
const normalizedRemotePath = normalizePath(remotePath);
|
|
59
99
|
const escapedRemotePath = escapeRegexp(normalizedRemotePath);
|
|
60
100
|
const regexp = isFile
|
|
61
|
-
? new RegExp(`^${escapedRemotePath}
|
|
101
|
+
? new RegExp(`^${escapedRemotePath}/*$`)
|
|
62
102
|
: new RegExp(`^${escapedRemotePath}(?:$|\\/)`);
|
|
63
|
-
const route =
|
|
103
|
+
const route = new HttpStaticRoute(remotePath, resourcePath, regexp, isFile);
|
|
64
104
|
this._routes.push(route);
|
|
65
105
|
// самые длинные пути проверяются первыми,
|
|
66
106
|
// чтобы избежать коллизий при поиске маршрута
|
|
@@ -71,10 +111,17 @@ export class HttpStaticRouter extends DebuggableService {
|
|
|
71
111
|
/**
|
|
72
112
|
* Match route.
|
|
73
113
|
*
|
|
74
|
-
* @param {
|
|
114
|
+
* @param {IncomingMessage} req
|
|
75
115
|
* @returns {object|undefined}
|
|
76
116
|
*/
|
|
77
117
|
matchRoute(req) {
|
|
118
|
+
if (!(req instanceof IncomingMessage)) {
|
|
119
|
+
throw new InvalidArgumentError(
|
|
120
|
+
'Parameter "req" must be an instance of IncomingMessage, ' +
|
|
121
|
+
'but %v was given.',
|
|
122
|
+
req,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
78
125
|
const debug = this.getDebuggerFor(this.matchRoute);
|
|
79
126
|
debug('Matching routes with incoming request.');
|
|
80
127
|
const url = (req.url || '/').replace(/\?.*$/, '');
|
|
@@ -99,13 +146,59 @@ export class HttpStaticRouter extends DebuggableService {
|
|
|
99
146
|
/**
|
|
100
147
|
* Send file by route.
|
|
101
148
|
*
|
|
102
|
-
* @param {
|
|
149
|
+
* @param {HttpStaticRoute} route
|
|
103
150
|
* @param {import('http').IncomingMessage} req
|
|
104
151
|
* @param {import('http').ServerResponse} res
|
|
105
152
|
*/
|
|
106
153
|
sendFileByRoute(route, req, res) {
|
|
154
|
+
if (!(route instanceof HttpStaticRoute)) {
|
|
155
|
+
throw new InvalidArgumentError(
|
|
156
|
+
'Parameter "route" must be an instance of HttpStaticRoute, ' +
|
|
157
|
+
'but %v was given.',
|
|
158
|
+
route,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (!(req instanceof IncomingMessage)) {
|
|
162
|
+
throw new InvalidArgumentError(
|
|
163
|
+
'Parameter "req" must be an instance of IncomingMessage, ' +
|
|
164
|
+
'but %v was given.',
|
|
165
|
+
req,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (!(res instanceof ServerResponse)) {
|
|
169
|
+
throw new InvalidArgumentError(
|
|
170
|
+
'Parameter "res" must be an instance of ServerResponse, ' +
|
|
171
|
+
'but %v was given.',
|
|
172
|
+
res,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
107
175
|
const reqUrl = req.url || '/';
|
|
108
176
|
const reqPath = reqUrl.replace(/\?.*$/, '');
|
|
177
|
+
// если параметр "trailingSlash" не активен, и адрес запроса
|
|
178
|
+
// не указывает на корень, но содержит косую черту в конце пути,
|
|
179
|
+
// то косая черта принудительно удаляется и выполняется редирект
|
|
180
|
+
if (
|
|
181
|
+
!this._options.trailingSlash &&
|
|
182
|
+
reqPath !== '/' &&
|
|
183
|
+
/\/$/.test(reqPath)
|
|
184
|
+
) {
|
|
185
|
+
const searchMatch = reqUrl.match(/\?.*$/);
|
|
186
|
+
const search = searchMatch ? searchMatch[0] : '';
|
|
187
|
+
const normalizedPath = reqPath
|
|
188
|
+
.replace(/\/{2,}/g, '/') // удаление дублирующих слешей
|
|
189
|
+
.replace(/\/+$/, ''); // удаление завершающего слеша
|
|
190
|
+
res.writeHead(302, {location: `${normalizedPath}${search}`});
|
|
191
|
+
res.end();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// если адрес запроса содержит дублирующие слеши,
|
|
195
|
+
// то адрес нормализуется и выполняется редирект
|
|
196
|
+
if (/\/{2,}/.test(reqUrl)) {
|
|
197
|
+
const normalizedUrl = reqUrl.replace(/\/{2,}/g, '/');
|
|
198
|
+
res.writeHead(302, {location: normalizedUrl});
|
|
199
|
+
res.end();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
109
202
|
// если ресурс ссылается на папку, то из адреса запроса
|
|
110
203
|
// извлекается дополнительная часть (если присутствует),
|
|
111
204
|
// и добавляется к адресу ресурса
|
|
@@ -132,32 +225,43 @@ export class HttpStaticRouter extends DebuggableService {
|
|
|
132
225
|
// установка заголовков и отправка потока
|
|
133
226
|
fs.stat(targetPath, (statsError, stats) => {
|
|
134
227
|
if (statsError) {
|
|
135
|
-
return _handleFsError(statsError, res);
|
|
228
|
+
return this._handleFsError(statsError, res);
|
|
136
229
|
}
|
|
137
230
|
if (stats.isDirectory()) {
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
231
|
+
// если активен параметр "trailingSlash", и адрес директории
|
|
232
|
+
// не содержит косую черту в конце пути, то косая черта
|
|
233
|
+
// добавляется принудительно и выполняется редирект
|
|
234
|
+
if (this._options.trailingSlash) {
|
|
235
|
+
// так как в html обычно используются относительные пути,
|
|
236
|
+
// то адрес директории статических ресурсов должен завершаться
|
|
237
|
+
// косой чертой, чтобы файлы стилей и изображений загружались
|
|
238
|
+
// из текущего уровня, а не обращались на уровень выше
|
|
239
|
+
if (/[^/]$/.test(reqPath)) {
|
|
240
|
+
const searchMatch = reqUrl.match(/\?.*$/);
|
|
241
|
+
const search = searchMatch ? searchMatch[0] : '';
|
|
242
|
+
const normalizedPath = reqPath.replace(/\/{2,}/g, '/');
|
|
243
|
+
res.writeHead(302, {location: `${normalizedPath}/${search}`});
|
|
244
|
+
res.end();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// если целевой путь указывает на папку,
|
|
249
|
+
// то подставляется index.html
|
|
250
|
+
targetPath = path.join(targetPath, 'index.html');
|
|
251
|
+
} else {
|
|
252
|
+
// если адрес файла не указывает на корень и в конце пути
|
|
253
|
+
// содержит косую черту, то косая черта принудительно
|
|
254
|
+
// удаляется и выполняется редирект
|
|
255
|
+
if (reqPath !== '/' && /\/$/.test(reqPath)) {
|
|
143
256
|
const searchMatch = reqUrl.match(/\?.*$/);
|
|
144
257
|
const search = searchMatch ? searchMatch[0] : '';
|
|
145
|
-
const normalizedPath =
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
// если адрес запроса содержит дублирующие слеши,
|
|
151
|
-
// то адрес нормализуется и выполняется редирект
|
|
152
|
-
if (/\/{2,}/.test(reqUrl)) {
|
|
153
|
-
const normalizedUrl = reqUrl.replace(/\/{2,}/g, '/');
|
|
154
|
-
res.writeHead(302, {location: normalizedUrl});
|
|
258
|
+
const normalizedPath = reqPath
|
|
259
|
+
.replace(/\/{2,}/g, '/') // удаление дублирующих слешей
|
|
260
|
+
.replace(/\/+$/, ''); // удаление завершающего слеша
|
|
261
|
+
res.writeHead(302, {location: `${normalizedPath}${search}`});
|
|
155
262
|
res.end();
|
|
156
263
|
return;
|
|
157
264
|
}
|
|
158
|
-
// если целевой путь указывает на папку,
|
|
159
|
-
// то подставляется index.html
|
|
160
|
-
targetPath = path.join(targetPath, 'index.html');
|
|
161
265
|
}
|
|
162
266
|
// формирование заголовка "content-type"
|
|
163
267
|
// в зависимости от расширения файла
|
|
@@ -168,7 +272,7 @@ export class HttpStaticRouter extends DebuggableService {
|
|
|
168
272
|
// что значительно снижает использование памяти
|
|
169
273
|
const fileStream = createReadStream(targetPath);
|
|
170
274
|
fileStream.on('error', error => {
|
|
171
|
-
_handleFsError(error, res);
|
|
275
|
+
this._handleFsError(error, res);
|
|
172
276
|
});
|
|
173
277
|
// отправка заголовка 200, только после
|
|
174
278
|
// этого начинается отдача файла
|
|
@@ -182,28 +286,31 @@ export class HttpStaticRouter extends DebuggableService {
|
|
|
182
286
|
}
|
|
183
287
|
fileStream.pipe(res);
|
|
184
288
|
});
|
|
289
|
+
req.on('close', () => {
|
|
290
|
+
fileStream.destroy();
|
|
291
|
+
});
|
|
185
292
|
});
|
|
186
293
|
}
|
|
187
|
-
}
|
|
188
294
|
|
|
189
|
-
/**
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Handle filesystem error.
|
|
297
|
+
*
|
|
298
|
+
* @param {object} error
|
|
299
|
+
* @param {object} res
|
|
300
|
+
* @returns {undefined}
|
|
301
|
+
*/
|
|
302
|
+
_handleFsError(error, res) {
|
|
303
|
+
if (res.headersSent) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if ('code' in error && error.code === 'ENOENT') {
|
|
307
|
+
res.writeHead(404, {'content-type': 'text/plain'});
|
|
308
|
+
res.write('404 Not Found');
|
|
309
|
+
res.end();
|
|
310
|
+
} else {
|
|
311
|
+
res.writeHead(500, {'content-type': 'text/plain'});
|
|
312
|
+
res.write('500 Internal Server Error');
|
|
313
|
+
res.end();
|
|
314
|
+
}
|
|
208
315
|
}
|
|
209
316
|
}
|
package/src/index.d.ts
CHANGED
package/src/index.js
CHANGED