@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 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
@@ -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 {import('@e22m4u/js-service').ServiceContainer} container
150
+ * @param {object} options
73
151
  */
74
- constructor(container) {
75
- super(container, {
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 import_js_format.InvalidArgumentError(
99
- "Static resource path does not exist %v.",
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}$`) : new RegExp(`^${escapedRemotePath}(?:$|\\/)`);
108
- const route = { remotePath, resourcePath, regexp, isFile };
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 {import('http').IncomingMessage} req
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 {object} route
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 (/[^/]$/.test(reqPath)) {
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 = reqUrl.replace(/\/{2,}/g, "/");
169
- res.writeHead(302, { location: `${normalizedPath}/${search}` });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e22m4u/js-http-static-router",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "HTTP-маршрутизатор статичных ресурсов для Node.js",
5
5
  "author": "Mikhail Evstropov <e22m4u@yandex.ru>",
6
6
  "license": "MIT",
@@ -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
- * Static file route.
7
+ * Http static router options.
7
8
  */
8
- export type HttpStaticRoute = {
9
- remotePath: string;
10
- resourcePath: string;
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 container
20
+ * @param options
23
21
  */
24
- constructor(container?: ServiceContainer);
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 {escapeRegexp, normalizePath} from './utils/index.js';
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 {import('@e22m4u/js-service').ServiceContainer} container
32
+ * @param {object} options
23
33
  */
24
- constructor(container) {
25
- super(container, {
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
- 'Static resource path does not exist %v.',
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 = {remotePath, resourcePath, regexp, isFile};
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 {import('http').IncomingMessage} req
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 {object} route
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
- // так как в html обычно используются относительные пути,
139
- // то адрес директории статических ресурсов должен завершаться
140
- // косой чертой, чтобы файлы стилей и изображений загружались
141
- // именно из нее, а не обращались на уровень выше
142
- if (/[^/]$/.test(reqPath)) {
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 = 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});
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
- * 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();
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
@@ -1 +1,2 @@
1
+ export * from './http-static-route.js';
1
2
  export * from './http-static-router.js';
package/src/index.js CHANGED
@@ -1 +1,2 @@
1
+ export * from './http-static-route.js';
1
2
  export * from './http-static-router.js';