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