@e22m4u/js-http-static-router 0.0.1 → 0.1.0

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/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.1.0",
4
4
  "description": "HTTP-маршрутизатор статичных ресурсов для Node.js",
5
5
  "author": "Mikhail Evstropov <e22m4u@yandex.ru>",
6
6
  "license": "MIT",
@@ -9,10 +9,10 @@
9
9
  "router",
10
10
  "http"
11
11
  ],
12
- "homepage": "https://gitrepos.ru/e22m4u/js-http-static-router",
12
+ "homepage": "https://gitverse.ru/e22m4u/js-http-static-router",
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "git+https://gitrepos.ru/e22m4u/js-http-static-router.git"
15
+ "url": "git+https://gitverse.ru/e22m4u/js-http-static-router.git"
16
16
  },
17
17
  "type": "module",
18
18
  "types": "./src/index.d.ts",
@@ -54,7 +54,7 @@
54
54
  "eslint-config-prettier": "~10.1.8",
55
55
  "eslint-plugin-chai-expect": "~3.1.0",
56
56
  "eslint-plugin-import": "~2.32.0",
57
- "eslint-plugin-jsdoc": "~62.5.2",
57
+ "eslint-plugin-jsdoc": "~62.5.4",
58
58
  "eslint-plugin-mocha": "~11.2.0",
59
59
  "globals": "~17.3.0",
60
60
  "husky": "~9.1.7",
@@ -1,53 +1,56 @@
1
1
  import {ServerResponse} from 'node:http';
2
2
  import {IncomingMessage} from 'node:http';
3
+ import {StaticRouteDefinition} from './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;
9
+ export type HttpStaticRouterOptions = {
10
+ rootDir?: string;
13
11
  };
14
12
 
15
13
  /**
16
14
  * Http static router.
17
15
  */
18
- export class HttpStaticRouter extends DebuggableService {
16
+ export declare class HttpStaticRouter extends DebuggableService {
19
17
  /**
20
18
  * Constructor.
21
- *
22
- * @param container
19
+ *
20
+ * @param container
23
21
  */
24
- constructor(container?: ServiceContainer);
22
+ constructor(container: ServiceContainer);
25
23
 
26
24
  /**
27
- * Add route.
25
+ * Constructor.
26
+ *
27
+ * @param options
28
+ */
29
+ constructor(options: HttpStaticRouterOptions);
30
+
31
+ /**
32
+ * Constructor.
28
33
  *
29
- * @param remotePath
30
- * @param resourcePath
34
+ * @param container
35
+ * @param options
31
36
  */
32
- addRoute(remotePath: string, resourcePath: string): this;
37
+ constructor(container: ServiceContainer, options: HttpStaticRouterOptions);
33
38
 
34
39
  /**
35
- * Match route.
40
+ * Define route.
36
41
  *
37
- * @param req
42
+ * @param routeDef
38
43
  */
39
- matchRoute(req: IncomingMessage): HttpStaticRoute | undefined;
44
+ defineRoute(routeDef: StaticRouteDefinition): this;
40
45
 
41
46
  /**
42
- * Send file by route.
47
+ * Handle request.
43
48
  *
44
- * @param route
45
- * @param req
46
- * @param res
49
+ * @param request
50
+ * @param response
47
51
  */
48
- sendFileByRoute(
49
- route: HttpStaticRoute,
50
- req: IncomingMessage,
51
- res: ServerResponse,
52
- ): void;
52
+ handleRequest(
53
+ request: IncomingMessage,
54
+ response: ServerResponse,
55
+ ): Promise<boolean>;
53
56
  }
@@ -1,9 +1,17 @@
1
1
  import path from 'path';
2
2
  import mimeTypes from 'mime-types';
3
+ import {IncomingMessage} from 'http';
3
4
  import fs, {createReadStream} from 'fs';
4
- import {escapeRegexp, normalizePath} from './utils/index.js';
5
- import {DebuggableService} from '@e22m4u/js-service';
5
+ import {StaticRoute} from './static-route.js';
6
+ import {getPathnameFromUrl} from './utils/index.js';
6
7
  import {InvalidArgumentError} from '@e22m4u/js-format';
8
+ import {DebuggableService, isServiceContainer} from '@e22m4u/js-service';
9
+
10
+ /**
11
+ * @typedef {object} FileInfo
12
+ * @property {string} path
13
+ * @property {number} size
14
+ */
7
15
 
8
16
  /**
9
17
  * Http static router.
@@ -13,54 +21,108 @@ export class HttpStaticRouter extends DebuggableService {
13
21
  * Routes.
14
22
  *
15
23
  * @protected
24
+ * @type {StaticRoute[]}
16
25
  */
17
26
  _routes = [];
18
27
 
28
+ /**
29
+ * Options.
30
+ *
31
+ * @type {import('./http-static-router.js').HttpStaticRouterOptions}
32
+ * @protected
33
+ */
34
+ _options = {};
35
+
19
36
  /**
20
37
  * Constructor.
21
38
  *
22
- * @param {import('@e22m4u/js-service').ServiceContainer} container
39
+ * @param {import('@e22m4u/js-service').ServiceContainer|import('./http-static-router.js').HttpStaticRouterOptions} containerOrOptions
40
+ * @param {import('./http-static-router.js').HttpStaticRouterOptions} options
23
41
  */
24
- constructor(container) {
25
- super(container, {
42
+ constructor(containerOrOptions, options) {
43
+ const debugOptions = {
26
44
  noEnvironmentNamespace: true,
27
45
  namespace: 'jsHttpStaticRouter',
28
- });
46
+ };
47
+ if (isServiceContainer(containerOrOptions)) {
48
+ super(containerOrOptions, debugOptions);
49
+ } else if (containerOrOptions !== undefined) {
50
+ if (
51
+ !containerOrOptions ||
52
+ typeof containerOrOptions !== 'object' ||
53
+ Array.isArray(containerOrOptions)
54
+ ) {
55
+ throw new InvalidArgumentError(
56
+ 'Parameter "containerOrOptions" must be an Object ' +
57
+ 'or an instance of ServiceContainer, but %v was given.',
58
+ options,
59
+ );
60
+ }
61
+ super(undefined, debugOptions);
62
+ if (options === undefined) {
63
+ options = containerOrOptions;
64
+ containerOrOptions = undefined;
65
+ }
66
+ } else {
67
+ super(undefined, debugOptions);
68
+ }
69
+ // options
70
+ if (options !== undefined) {
71
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
72
+ throw new InvalidArgumentError(
73
+ 'Parameter "options" must be an Object, but %v was given.',
74
+ options,
75
+ );
76
+ }
77
+ // options.rootDir
78
+ if (options.rootDir !== undefined) {
79
+ if (typeof options.rootDir !== 'string') {
80
+ throw new InvalidArgumentError(
81
+ 'Option "rootDir" must be a String, but %v was given.',
82
+ options.rootDir,
83
+ );
84
+ }
85
+ if (!path.isAbsolute(options.rootDir)) {
86
+ throw new InvalidArgumentError(
87
+ 'Option "rootDir" must be an absolute path, but %v was given.',
88
+ options.rootDir,
89
+ );
90
+ }
91
+ }
92
+ this._options = {...options};
93
+ }
94
+ Object.freeze(this._options);
29
95
  }
30
96
 
31
97
  /**
32
- * Add route.
98
+ * Define route.
33
99
  *
34
- * @param {string} remotePath
35
- * @param {string} resourcePath
36
- * @returns {object}
100
+ * @param {import('./static-route.js').StaticRouteDefinition} routeDef
101
+ * @returns {this}
37
102
  */
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);
103
+ defineRoute(routeDef) {
104
+ if (!routeDef || typeof routeDef !== 'object' || Array.isArray(routeDef)) {
51
105
  throw new InvalidArgumentError(
52
- 'Static resource path does not exist %v.',
53
- resourcePath,
106
+ 'Parameter "routeDef" must be an Object, but %v was given.',
107
+ routeDef,
108
+ );
109
+ }
110
+ if (
111
+ this._options.rootDir !== undefined &&
112
+ !path.isAbsolute(routeDef.resourcePath)
113
+ ) {
114
+ routeDef = {...routeDef};
115
+ routeDef.resourcePath = path.join(
116
+ this._options.rootDir,
117
+ routeDef.resourcePath,
54
118
  );
55
119
  }
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};
120
+ const debug = this.getDebuggerFor(this.defineRoute);
121
+ const route = new StaticRoute(routeDef);
122
+ debug('Adding a new route.');
123
+ debug('Resource path is %v.', route.resourcePath);
124
+ debug('Remote path is %v.', route.remotePath);
125
+ debug('Resource type is %s.', route.isFile ? 'File' : 'Folder');
64
126
  this._routes.push(route);
65
127
  // самые длинные пути проверяются первыми,
66
128
  // чтобы избежать коллизий при поиске маршрута
@@ -69,141 +131,188 @@ export class HttpStaticRouter extends DebuggableService {
69
131
  }
70
132
 
71
133
  /**
72
- * Match route.
134
+ * Handle request.
73
135
  *
74
- * @param {import('http').IncomingMessage} req
75
- * @returns {object|undefined}
136
+ * @param {import('http').IncomingMessage} request
137
+ * @param {import('http').ServerResponse} response
138
+ * @returns {Promise<boolean>}
76
139
  */
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;
140
+ async handleRequest(request, response) {
141
+ const fileInfo = await this._findFileForRequest(request);
142
+ if (fileInfo !== undefined) {
143
+ this._sendFile(request, response, fileInfo);
144
+ return true;
85
145
  }
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;
146
+ return false;
97
147
  }
98
148
 
99
149
  /**
100
- * Send file by route.
150
+ * Find file for request.
101
151
  *
102
- * @param {object} route
103
- * @param {import('http').IncomingMessage} req
104
- * @param {import('http').ServerResponse} res
152
+ * @param {import('http').IncomingMessage} request
153
+ * @returns {Promise<FileInfo|undefined>|undefined}
105
154
  */
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);
155
+ async _findFileForRequest(request) {
156
+ if (!(request instanceof IncomingMessage)) {
157
+ throw new InvalidArgumentError(
158
+ 'Parameter "request" must be an instance of IncomingMessage, ' +
159
+ 'but %v was given.',
160
+ request,
161
+ );
121
162
  }
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');
163
+ const debug = this.getDebuggerFor(this._findFileForRequest);
164
+ debug('File finding for an incoming request.');
165
+ debug('Incoming request %s %v.', request.method, request.url);
166
+ let requestPath;
167
+ try {
168
+ requestPath = decodeURIComponent(getPathnameFromUrl(request.url || ''));
169
+ } catch {
170
+ debug('Invalid URL encoding .');
129
171
  return;
130
172
  }
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;
173
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
174
+ debug('Method not allowed.');
175
+ return;
176
+ }
177
+ if (requestPath.includes('//')) {
178
+ debug('Request path contains duplicate slashes.');
179
+ return;
180
+ }
181
+ if (!this._routes.length) {
182
+ debug('No registered routes.');
183
+ return;
184
+ }
185
+ debug('Walking through %v routes.', this._routes.length);
186
+ for (const route of this._routes) {
187
+ const isMatched = route.regexp.test(requestPath);
188
+ if (isMatched) {
189
+ debug('Matched route %v.', route.remotePath);
190
+ // если ресурс ссылается на папку, то из адреса запроса
191
+ // извлекается дополнительная часть (если присутствует),
192
+ // и формируется целевой путь файловой системы
193
+ let targetPath = route.resourcePath;
194
+ if (!route.isFile) {
195
+ // извлечение относительного пути в дополнение к адресу
196
+ // ресурса путем удаления из адреса запроса той части,
197
+ // которая была указана при объявлении маршрута
198
+ const relativePath = requestPath.replace(route.regexp, '');
199
+ // объединение адреса ресурса
200
+ // с дополнительной частью
201
+ targetPath = path.join(route.resourcePath, relativePath);
149
202
  }
150
- // если адрес запроса содержит дублирующие слеши,
151
- // то адрес нормализуется и выполняется редирект
152
- if (/\/{2,}/.test(reqUrl)) {
153
- const normalizedUrl = reqUrl.replace(/\/{2,}/g, '/');
154
- res.writeHead(302, {location: normalizedUrl});
155
- res.end();
203
+ // если обнаружена попытка выхода за пределы
204
+ // директории маршрута, то возвращается undefined
205
+ targetPath = path.resolve(targetPath);
206
+ const resourceRoot = path.resolve(route.resourcePath);
207
+ if (
208
+ targetPath !== resourceRoot &&
209
+ !targetPath.startsWith(resourceRoot + path.sep)
210
+ ) {
156
211
  return;
157
212
  }
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;
213
+ // с определением размера файла одновременно выполняется
214
+ // отсечение отсутствующих файлов и директорий, в таких
215
+ // случаях значение размера будет является undefined
216
+ const fileSize = await new Promise(resolve => {
217
+ fs.stat(targetPath, (statsError, stats) => {
218
+ if (statsError || stats.isDirectory()) {
219
+ resolve(undefined);
220
+ return;
221
+ }
222
+ resolve(stats.size);
223
+ return;
224
+ });
225
+ });
226
+ // если размер файла определен, то поиск
227
+ // прерывается и возвращается информация
228
+ if (fileSize !== undefined) {
229
+ // если файл найден, но запрос заканчивается
230
+ // на слеш, то файл должен быть проигнорирован
231
+ if (requestPath.endsWith('/')) {
232
+ continue;
233
+ }
234
+ debug('File found %v.', targetPath);
235
+ return {path: targetPath, size: fileSize};
182
236
  }
183
- fileStream.pipe(res);
237
+ }
238
+ }
239
+ debug('File not found.');
240
+ }
241
+
242
+ /**
243
+ * Send file.
244
+ *
245
+ * @param {import('http').IncomingMessage} request
246
+ * @param {import('http').ServerResponse} response
247
+ * @param {FileInfo} fileInfo
248
+ */
249
+ _sendFile(request, response, fileInfo) {
250
+ const debug = this.getDebuggerFor(this._sendFile);
251
+ debug('File sending for an incoming request.');
252
+ debug('Incoming request %s %v.', request.method, request.url);
253
+ debug('File path %v.', fileInfo.path);
254
+ debug('File size %v bytes.', fileInfo.size);
255
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
256
+ debug('Method not allowed.');
257
+ return;
258
+ }
259
+ // формирование заголовка "content-type"
260
+ // в зависимости от расширения файла
261
+ const extname = path.extname(fileInfo.path);
262
+ const contentType =
263
+ mimeTypes.contentType(extname) || 'application/octet-stream';
264
+ // файл читается и отправляется частями,
265
+ // что значительно снижает использование памяти
266
+ const fileStream = createReadStream(fileInfo.path);
267
+ fileStream.on('error', error => {
268
+ debug('Unable to open a file stream.');
269
+ this._handleFsError(error, response);
270
+ });
271
+ // отправка заголовка 200, только после
272
+ // этого начинается отдача файла
273
+ fileStream.on('open', () => {
274
+ response.writeHead(200, {
275
+ 'Content-Type': contentType,
276
+ 'Content-Length': fileInfo.size,
184
277
  });
278
+ // для HEAD запроса отправляются
279
+ // только заголовки (без тела)
280
+ if (request.method === 'HEAD') {
281
+ response.end();
282
+ debug('Response has been sent without a body for the HEAD request.');
283
+ // важно закрыть файловый поток, чтобы операционная
284
+ // система не исчерпала лимит открытых файлов
285
+ fileStream.destroy();
286
+ return;
287
+ }
288
+ fileStream.pipe(response);
289
+ });
290
+ request.on('close', () => {
291
+ debug('File has been sent.');
292
+ fileStream.destroy();
185
293
  });
186
294
  }
187
- }
188
295
 
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();
296
+ /**
297
+ * Handle filesystem error.
298
+ *
299
+ * @param {object} error
300
+ * @param {object} response
301
+ * @returns {undefined}
302
+ */
303
+ _handleFsError(error, response) {
304
+ if (response.headersSent) {
305
+ response.destroy();
306
+ return;
307
+ }
308
+ if ('code' in error && error.code === 'ENOENT') {
309
+ response.writeHead(404, {'Content-Type': 'text/plain'});
310
+ response.write('404 Not Found');
311
+ response.end();
312
+ } else {
313
+ response.writeHead(500, {'Content-Type': 'text/plain'});
314
+ response.write('500 Internal Server Error');
315
+ response.end();
316
+ }
208
317
  }
209
318
  }
package/src/index.d.ts CHANGED
@@ -1 +1,2 @@
1
+ export * from './static-route.js';
1
2
  export * from './http-static-router.js';
package/src/index.js CHANGED
@@ -1 +1,2 @@
1
+ export * from './static-route.js';
1
2
  export * from './http-static-router.js';
@@ -0,0 +1,48 @@
1
+
2
+ /**
3
+ * Static route definition.
4
+ */
5
+ export type StaticRouteDefinition = {
6
+ remotePath: string;
7
+ resourcePath: string;
8
+ }
9
+
10
+ /**
11
+ * Static route.
12
+ */
13
+ export class StaticRoute {
14
+ /**
15
+ * Remote path.
16
+ *
17
+ * @type {string}
18
+ */
19
+ readonly remotePath: string;
20
+
21
+ /**
22
+ * Resource path.
23
+ *
24
+ * @type {string}
25
+ */
26
+ readonly resourcePath: string;
27
+
28
+ /**
29
+ * RegExp.
30
+ *
31
+ * @type {RegExp}
32
+ */
33
+ readonly regexp: RegExp;
34
+
35
+ /**
36
+ * Is file.
37
+ *
38
+ * @type {boolean}
39
+ */
40
+ readonly isFile: boolean;
41
+
42
+ /**
43
+ * Constructor.
44
+ *
45
+ * @param routeDef
46
+ */
47
+ constructor(routeDef: StaticRouteDefinition);
48
+ }