@e22m4u/js-http-static-router 0.1.2 → 0.2.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.
Files changed (43) hide show
  1. package/.mocharc.json +2 -1
  2. package/README.md +265 -17
  3. package/build-cjs.js +1 -1
  4. package/dist/cjs/index.cjs +114 -76
  5. package/example/server.js +23 -13
  6. package/mocha.setup.js +4 -0
  7. package/package.json +10 -8
  8. package/src/http-static-router.d.ts +7 -2
  9. package/src/http-static-router.js +73 -61
  10. package/src/http-static-router.spec.js +899 -0
  11. package/src/static-route.js +18 -8
  12. package/src/types.d.ts +7 -0
  13. package/src/utils/create-cookie-string.d.ts +6 -0
  14. package/src/utils/create-cookie-string.js +28 -0
  15. package/src/utils/create-cookie-string.spec.js +32 -0
  16. package/src/utils/create-error.d.ts +14 -0
  17. package/src/utils/create-error.js +29 -0
  18. package/src/utils/create-error.spec.js +42 -0
  19. package/src/utils/create-request-mock.d.ts +35 -0
  20. package/src/utils/create-request-mock.js +483 -0
  21. package/src/utils/create-request-mock.spec.js +646 -0
  22. package/src/utils/create-response-mock.d.ts +18 -0
  23. package/src/utils/create-response-mock.js +131 -0
  24. package/src/utils/create-response-mock.spec.js +150 -0
  25. package/src/utils/fetch-request-body.d.ts +26 -0
  26. package/src/utils/fetch-request-body.js +148 -0
  27. package/src/utils/fetch-request-body.spec.js +209 -0
  28. package/src/utils/get-pathname-from-url.d.ts +1 -1
  29. package/src/utils/{get-request-pathname.spec.js → get-pathname-from-url.spec.js} +1 -1
  30. package/src/utils/index.d.ts +8 -0
  31. package/src/utils/index.js +8 -0
  32. package/src/utils/is-readable-stream.d.ts +9 -0
  33. package/src/utils/is-readable-stream.js +12 -0
  34. package/src/utils/is-readable-stream.spec.js +23 -0
  35. package/src/utils/parse-content-type.d.ts +15 -0
  36. package/src/utils/parse-content-type.js +34 -0
  37. package/src/utils/parse-content-type.spec.js +79 -0
  38. package/src/utils/parse-cookie-string.d.ts +19 -0
  39. package/src/utils/parse-cookie-string.js +36 -0
  40. package/src/utils/parse-cookie-string.spec.js +45 -0
  41. package/{example/static → static}/index.html +2 -2
  42. /package/{example/static/nested/file.txt → static/assets/nested/heart.txt} +0 -0
  43. /package/{example/static/file.txt → static/assets/rabbit.txt} +0 -0
@@ -1,6 +1,5 @@
1
1
  import path from 'path';
2
2
  import mimeTypes from 'mime-types';
3
- import {IncomingMessage} from 'http';
4
3
  import fs, {createReadStream} from 'fs';
5
4
  import {StaticRoute} from './static-route.js';
6
5
  import {getPathnameFromUrl} from './utils/index.js';
@@ -41,7 +40,7 @@ export class HttpStaticRouter extends DebuggableService {
41
40
  */
42
41
  constructor(containerOrOptions, options) {
43
42
  const debugOptions = {
44
- noEnvironmentNamespace: true,
43
+ noGlobalNamespace: true,
45
44
  namespace: 'jsHttpStaticRouter',
46
45
  };
47
46
  if (isServiceContainer(containerOrOptions)) {
@@ -53,18 +52,18 @@ export class HttpStaticRouter extends DebuggableService {
53
52
  Array.isArray(containerOrOptions)
54
53
  ) {
55
54
  throw new InvalidArgumentError(
56
- 'Parameter "containerOrOptions" must be an Object ' +
57
- 'or an instance of ServiceContainer, but %v was given.',
58
- options,
55
+ 'First parameter must be an Object or an instance ' +
56
+ 'of ServiceContainer, but %v was given.',
57
+ containerOrOptions,
59
58
  );
60
59
  }
61
- super(undefined, debugOptions);
60
+ super(debugOptions);
62
61
  if (options === undefined) {
63
62
  options = containerOrOptions;
64
63
  containerOrOptions = undefined;
65
64
  }
66
65
  } else {
67
- super(undefined, debugOptions);
66
+ super(debugOptions);
68
67
  }
69
68
  // options
70
69
  if (options !== undefined) {
@@ -98,15 +97,23 @@ export class HttpStaticRouter extends DebuggableService {
98
97
  * Define route.
99
98
  *
100
99
  * @param {import('./static-route.js').StaticRouteDefinition} routeDef
101
- * @returns {this}
100
+ * @returns {import('./static-route.js').StaticRoute}
102
101
  */
103
102
  defineRoute(routeDef) {
103
+ // options
104
104
  if (!routeDef || typeof routeDef !== 'object' || Array.isArray(routeDef)) {
105
105
  throw new InvalidArgumentError(
106
106
  'Parameter "routeDef" must be an Object, but %v was given.',
107
107
  routeDef,
108
108
  );
109
109
  }
110
+ // options.resourcePath
111
+ if (typeof routeDef.resourcePath !== 'string') {
112
+ throw new InvalidArgumentError(
113
+ 'Option "resourcePath" must be a String, but %v was given.',
114
+ routeDef.resourcePath,
115
+ );
116
+ }
110
117
  if (
111
118
  this._options.baseDir !== undefined &&
112
119
  !path.isAbsolute(routeDef.resourcePath)
@@ -117,22 +124,37 @@ export class HttpStaticRouter extends DebuggableService {
117
124
  routeDef.resourcePath,
118
125
  );
119
126
  }
127
+ if (
128
+ this._options.baseDir === undefined &&
129
+ !path.isAbsolute(routeDef.resourcePath)
130
+ ) {
131
+ throw new InvalidArgumentError(
132
+ 'Option "resourcePath" must be an absolute path when the router ' +
133
+ 'option "basePath" is not specified, but %v was given.',
134
+ routeDef.resourcePath,
135
+ );
136
+ }
120
137
  const debug = this.getDebuggerFor(this.defineRoute);
121
138
  const route = new StaticRoute(routeDef);
122
139
  debug('Adding a new route.');
123
- debug('Resource path is %v.', route.resourcePath);
124
140
  debug('Remote path is %v.', route.remotePath);
141
+ debug('Resource path is %v.', route.resourcePath);
125
142
  debug('Resource type is %s.', route.isFile ? 'File' : 'Folder');
126
143
  this._routes.push(route);
127
144
  // самые длинные пути проверяются первыми,
128
145
  // чтобы избежать коллизий при поиске маршрута
129
146
  this._routes.sort((a, b) => b.remotePath.length - a.remotePath.length);
130
- return this;
147
+ return route;
131
148
  }
132
149
 
133
150
  /**
134
151
  * Handle request.
135
152
  *
153
+ * Метод возвращает Promise, который разрешается как:
154
+ * - false, если маршрут не совпал, файл не найден
155
+ * или метод запроса не поддерживается;
156
+ * - true, во всех остальных случаях;
157
+ *
136
158
  * @param {import('http').IncomingMessage} request
137
159
  * @param {import('http').ServerResponse} response
138
160
  * @returns {Promise<boolean>}
@@ -140,7 +162,7 @@ export class HttpStaticRouter extends DebuggableService {
140
162
  async handleRequest(request, response) {
141
163
  const fileInfo = await this._findFileForRequest(request);
142
164
  if (fileInfo !== undefined) {
143
- this._sendFile(request, response, fileInfo);
165
+ await this._sendFile(request, response, fileInfo);
144
166
  return true;
145
167
  }
146
168
  return false;
@@ -153,13 +175,6 @@ export class HttpStaticRouter extends DebuggableService {
153
175
  * @returns {Promise<FileInfo|undefined>|undefined}
154
176
  */
155
177
  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
- );
162
- }
163
178
  const debug = this.getDebuggerFor(this._findFileForRequest);
164
179
  debug('File finding for an incoming request.');
165
180
  debug('Incoming request %s %v.', request.method, request.url);
@@ -184,21 +199,22 @@ export class HttpStaticRouter extends DebuggableService {
184
199
  }
185
200
  debug('Walking through %v routes.', this._routes.length);
186
201
  for (const route of this._routes) {
187
- const isMatched = route.regexp.test(requestPath);
202
+ const isMatched = route.regexp.test(requestPath || '/');
188
203
  if (isMatched) {
189
204
  debug('Matched route %v.', route.remotePath);
190
205
  // если ресурс ссылается на папку, то из адреса запроса
191
206
  // извлекается дополнительная часть (если присутствует),
192
207
  // и формируется целевой путь файловой системы
193
208
  let targetPath = route.resourcePath;
209
+ let extraPath = ''; // дополнительная часть адреса
194
210
  if (!route.isFile) {
195
211
  // извлечение относительного пути в дополнение к адресу
196
212
  // ресурса путем удаления из адреса запроса той части,
197
213
  // которая была указана при объявлении маршрута
198
- const relativePath = requestPath.replace(route.regexp, '');
214
+ extraPath = requestPath.replace(route.regexp, '');
199
215
  // объединение адреса ресурса
200
216
  // с дополнительной частью
201
- targetPath = path.join(route.resourcePath, relativePath);
217
+ targetPath = path.join(route.resourcePath, extraPath);
202
218
  }
203
219
  // если обнаружена попытка выхода за пределы
204
220
  // директории маршрута, то возвращается undefined
@@ -226,17 +242,17 @@ export class HttpStaticRouter extends DebuggableService {
226
242
  // если размер файла определен, то поиск
227
243
  // прерывается и возвращается информация
228
244
  if (fileSize !== undefined) {
229
- // если файл найден, но запрос заканчивается
230
- // на слеш, то файл должен быть проигнорирован
231
- if (requestPath.endsWith('/')) {
245
+ // если файл найден, и дополнительная часть запроса
246
+ // заканчивается на слеш, то файл игнорируется
247
+ if (extraPath && extraPath.endsWith('/')) {
232
248
  continue;
233
249
  }
234
- debug('File found %v.', targetPath);
250
+ debug('Found file %v.', targetPath);
235
251
  return {path: targetPath, size: fileSize};
236
252
  }
237
253
  }
238
254
  }
239
- debug('File not found.');
255
+ debug('File was not found.');
240
256
  }
241
257
 
242
258
  /**
@@ -245,17 +261,16 @@ export class HttpStaticRouter extends DebuggableService {
245
261
  * @param {import('http').IncomingMessage} request
246
262
  * @param {import('http').ServerResponse} response
247
263
  * @param {FileInfo} fileInfo
264
+ * @returns {Promise<void>}
248
265
  */
249
- _sendFile(request, response, fileInfo) {
266
+ async _sendFile(request, response, fileInfo) {
267
+ let resolve;
268
+ const promise = new Promise(res => (resolve = res));
250
269
  const debug = this.getDebuggerFor(this._sendFile);
251
270
  debug('File sending for an incoming request.');
252
271
  debug('Incoming request %s %v.', request.method, request.url);
253
272
  debug('File path %v.', fileInfo.path);
254
273
  debug('File size %v bytes.', fileInfo.size);
255
- if (request.method !== 'GET' && request.method !== 'HEAD') {
256
- debug('Method not allowed.');
257
- return;
258
- }
259
274
  // формирование заголовка "content-type"
260
275
  // в зависимости от расширения файла
261
276
  const extname = path.extname(fileInfo.path);
@@ -266,15 +281,24 @@ export class HttpStaticRouter extends DebuggableService {
266
281
  const fileStream = createReadStream(fileInfo.path);
267
282
  fileStream.on('error', error => {
268
283
  debug('Unable to open a file stream.');
269
- this._handleFsError(error, response);
284
+ console.error(error);
285
+ if (response.headersSent) {
286
+ response.destroy();
287
+ resolve();
288
+ return;
289
+ }
290
+ response.statusCode = 500;
291
+ response.setHeader('Content-Type', 'text/plain; charset=utf-8');
292
+ response.write('500 Internal Server Error');
293
+ response.end();
294
+ resolve();
270
295
  });
271
296
  // отправка заголовка 200, только после
272
297
  // этого начинается отдача файла
273
298
  fileStream.on('open', () => {
274
- response.writeHead(200, {
275
- 'Content-Type': contentType,
276
- 'Content-Length': fileInfo.size,
277
- });
299
+ response.statusCode = 200;
300
+ response.setHeader('Content-Type', contentType);
301
+ response.setHeader('Content-Length', fileInfo.size);
278
302
  // для HEAD запроса отправляются
279
303
  // только заголовки (без тела)
280
304
  if (request.method === 'HEAD') {
@@ -288,31 +312,19 @@ export class HttpStaticRouter extends DebuggableService {
288
312
  fileStream.pipe(response);
289
313
  });
290
314
  request.on('close', () => {
291
- debug('File has been sent.');
292
- fileStream.destroy();
315
+ // если ответ еще не закончен,
316
+ // значит клиент оборвал соединение
317
+ if (!response.writableFinished) {
318
+ debug('Request closed prematurely by the client.');
319
+ fileStream.destroy();
320
+ resolve();
321
+ }
293
322
  });
294
- }
295
-
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
- }
323
+ // успешное завершение отправки ответа
324
+ response.on('finish', () => {
325
+ debug('File has been sent successfully.');
326
+ resolve();
327
+ });
328
+ return promise;
317
329
  }
318
330
  }