@e22m4u/js-http-static-router 0.1.2 → 0.2.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.
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 +111 -73
  5. package/example/server.js +23 -13
  6. package/mocha.setup.js +4 -0
  7. package/package.json +4 -2
  8. package/src/http-static-router.d.ts +7 -2
  9. package/src/http-static-router.js +70 -58
  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
@@ -0,0 +1,483 @@
1
+ import {Socket} from 'net';
2
+ import {TLSSocket} from 'tls';
3
+ import queryString from 'querystring';
4
+ import {IncomingMessage} from 'http';
5
+ import {InvalidArgumentError} from '@e22m4u/js-format';
6
+ import {isReadableStream} from './is-readable-stream.js';
7
+ import {parseCookieString} from './parse-cookie-string.js';
8
+ import {createCookieString} from './create-cookie-string.js';
9
+ import {CHARACTER_ENCODING_LIST} from './fetch-request-body.js';
10
+
11
+ /**
12
+ * Supported options.
13
+ */
14
+ const SUPPORTED_OPTIONS = [
15
+ 'host',
16
+ 'method',
17
+ 'secure',
18
+ 'url',
19
+ 'path',
20
+ 'query',
21
+ 'cookies',
22
+ 'headers',
23
+ 'body',
24
+ 'stream',
25
+ 'encoding',
26
+ ];
27
+
28
+ /**
29
+ * Create request mock.
30
+ *
31
+ * @param {import('./create-request-mock.js').RequestOptions} [options]
32
+ * @returns {import('http').IncomingMessage}
33
+ */
34
+ export function createRequestMock(options) {
35
+ if (options !== undefined) {
36
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
37
+ throw new InvalidArgumentError(
38
+ 'Parameter "options" must be an Object, but %v was given.',
39
+ options,
40
+ );
41
+ }
42
+ Object.keys(options).forEach(optionName => {
43
+ if (!SUPPORTED_OPTIONS.includes(optionName)) {
44
+ throw new InvalidArgumentError(
45
+ 'Option %v is not supported.',
46
+ optionName,
47
+ );
48
+ }
49
+ });
50
+ // options.host
51
+ if (options.host !== undefined && typeof options.host !== 'string') {
52
+ throw new InvalidArgumentError(
53
+ 'Option "host" must be a String, but %v was given.',
54
+ options.host,
55
+ );
56
+ }
57
+ // options.method
58
+ if (options.method !== undefined && typeof options.method !== 'string') {
59
+ throw new InvalidArgumentError(
60
+ 'Option "method" must be a String, but %v was given.',
61
+ options.method,
62
+ );
63
+ }
64
+ // options.secure
65
+ if (options.secure !== undefined && typeof options.secure !== 'boolean') {
66
+ throw new InvalidArgumentError(
67
+ 'Option "secure" must be a Boolean, but %v was given.',
68
+ options.secure,
69
+ );
70
+ }
71
+ // option.url
72
+ if (options.url !== undefined) {
73
+ if (typeof options.url !== 'string') {
74
+ throw new InvalidArgumentError(
75
+ 'Option "url" must be a String, but %v was given.',
76
+ options.url,
77
+ );
78
+ }
79
+ if (options.url.indexOf('#') !== -1) {
80
+ throw new InvalidArgumentError(
81
+ 'Option "url" must not contain "#", but %v was given.',
82
+ options.url,
83
+ );
84
+ }
85
+ }
86
+ // options.path
87
+ if (options.path !== undefined) {
88
+ if (typeof options.path !== 'string') {
89
+ throw new InvalidArgumentError(
90
+ 'Option "path" must be a String, but %v was given.',
91
+ options.path,
92
+ );
93
+ }
94
+ // contain #
95
+ if (options.path.indexOf('#') !== -1) {
96
+ throw new InvalidArgumentError(
97
+ 'Option "path" must not contain "#", but %v was given.',
98
+ options.path,
99
+ );
100
+ }
101
+ // contain ?
102
+ if (options.path.indexOf('?') !== -1) {
103
+ throw new InvalidArgumentError(
104
+ 'Option "path" must not contain "?", but %v was given.',
105
+ options.path,
106
+ );
107
+ }
108
+ // not starting with /
109
+ if (!options.path.startsWith('/')) {
110
+ throw new InvalidArgumentError(
111
+ 'Option "path" must start with "/", but %v was given.',
112
+ options.path,
113
+ );
114
+ }
115
+ }
116
+ // options.query
117
+ if (options.query !== undefined) {
118
+ if (
119
+ options.query === null ||
120
+ (typeof options.query !== 'string' &&
121
+ typeof options.query !== 'object') ||
122
+ Array.isArray(options.query)
123
+ ) {
124
+ throw new InvalidArgumentError(
125
+ 'Option "query" must be a String or an Object, but %v was given.',
126
+ options.query,
127
+ );
128
+ }
129
+ }
130
+ // options.cookies
131
+ if (options.cookies !== undefined) {
132
+ if (
133
+ !options.cookies ||
134
+ typeof options.cookies !== 'object' ||
135
+ Array.isArray(options.cookies)
136
+ ) {
137
+ throw new InvalidArgumentError(
138
+ 'Option "cookies" must be an Object, but %v was given.',
139
+ options.cookies,
140
+ );
141
+ }
142
+ // options.cookies[k]
143
+ Object.keys(options.cookies).forEach(cookieName => {
144
+ const cookieValue = options.cookies[cookieName];
145
+ if (cookieValue !== undefined && typeof cookieValue !== 'string') {
146
+ throw new InvalidArgumentError(
147
+ 'Cookie %v must be a String, but %v was given.',
148
+ cookieName,
149
+ cookieValue,
150
+ );
151
+ }
152
+ });
153
+ }
154
+ // options.headers
155
+ if (options.headers !== undefined) {
156
+ if (
157
+ !options.headers ||
158
+ typeof options.headers !== 'object' ||
159
+ Array.isArray(options.headers)
160
+ ) {
161
+ throw new InvalidArgumentError(
162
+ 'Option "headers" must be an Object, but %v was given.',
163
+ options.headers,
164
+ );
165
+ }
166
+ // options.headers[k]
167
+ Object.keys(options.headers).forEach(headerName => {
168
+ const headerValue = options.headers[headerName];
169
+ if (headerValue !== undefined) {
170
+ if (typeof headerValue !== 'string' && !Array.isArray(headerValue)) {
171
+ throw new InvalidArgumentError(
172
+ 'Header %v must be a String or an Array, but %v was given.',
173
+ headerName,
174
+ headerValue,
175
+ );
176
+ }
177
+ // options.headers[k][n]
178
+ if (Array.isArray(headerValue)) {
179
+ headerValue.forEach((headerEl, index) => {
180
+ if (typeof headerEl !== 'string') {
181
+ throw new InvalidArgumentError(
182
+ 'Element %d of the header %v must be a String, ' +
183
+ 'but %v was given.',
184
+ index,
185
+ headerName,
186
+ headerEl,
187
+ );
188
+ }
189
+ });
190
+ }
191
+ }
192
+ });
193
+ }
194
+ // options.stream
195
+ if (options.stream !== undefined && !isReadableStream(options.stream)) {
196
+ throw new InvalidArgumentError(
197
+ 'Option "stream" must be a Stream, but %v was given.',
198
+ options.stream,
199
+ );
200
+ }
201
+ // options.encoding
202
+ if (options.encoding !== undefined) {
203
+ if (typeof options.encoding !== 'string') {
204
+ throw new InvalidArgumentError(
205
+ 'Option "encoding" must be a String, but %v was given.',
206
+ options.encoding,
207
+ );
208
+ }
209
+ if (!CHARACTER_ENCODING_LIST.includes(options.encoding)) {
210
+ throw new InvalidArgumentError(
211
+ 'Character encoding %v is not supported.',
212
+ options.encoding,
213
+ );
214
+ }
215
+ }
216
+ // если определен url, выполняется
217
+ // проверка на несовместимые опции
218
+ if (options.url !== undefined) {
219
+ if (options.path !== undefined) {
220
+ throw new InvalidArgumentError(
221
+ 'The "url" and "path" options cannot be used together.',
222
+ );
223
+ }
224
+ if (options.query !== undefined) {
225
+ throw new InvalidArgumentError(
226
+ 'The "url" and "query" options cannot be used together.',
227
+ );
228
+ }
229
+ }
230
+ // если передан поток, выполняется
231
+ // проверка на несовместимые опции
232
+ if (options.stream !== undefined) {
233
+ if (options.secure !== undefined) {
234
+ throw new InvalidArgumentError(
235
+ 'The "stream" and "secure" options cannot be used together.',
236
+ );
237
+ }
238
+ if (options.body !== undefined) {
239
+ throw new InvalidArgumentError(
240
+ 'The "stream" and "body" options cannot be used together.',
241
+ );
242
+ }
243
+ if (options.encoding !== undefined) {
244
+ throw new InvalidArgumentError(
245
+ 'The "stream" and "encoding" options cannot be used together.',
246
+ );
247
+ }
248
+ }
249
+ }
250
+ options = options || {};
251
+ let request;
252
+ if (options.stream) {
253
+ // перенаправление данных из переданного потока
254
+ // в новый IncomingMessage, чтобы сохранить
255
+ // работу проверки instanceof
256
+ const socket = new Socket();
257
+ request = new IncomingMessage(socket);
258
+ options.stream.on('data', chunk => request.push(chunk));
259
+ options.stream.on('end', () => request.push(null));
260
+ options.stream.on('error', err => request.emit('error', err));
261
+ } else {
262
+ request = createRequestStream(
263
+ options.secure,
264
+ options.body,
265
+ options.encoding,
266
+ );
267
+ }
268
+ // добавление свойств сокета
269
+ // для определения IP адреса
270
+ Object.defineProperty(request.socket, 'remoteAddress', {value: '127.0.0.1'});
271
+ Object.defineProperty(request.socket, 'localAddress', {value: '127.0.0.1'});
272
+ // определение остальных свойств
273
+ // экземпляра IncomingMessage
274
+ request.httpVersion = '1.1';
275
+ request.url = '/';
276
+ if (options.url !== undefined) {
277
+ request.url = options.url;
278
+ } else if (options.path !== undefined || options.query !== undefined) {
279
+ request.url = createRequestUrl(options.path, options.query);
280
+ }
281
+ request.headers = createRequestHeaders(
282
+ options.host,
283
+ options.secure,
284
+ options.body,
285
+ options.cookies,
286
+ options.encoding,
287
+ options.headers,
288
+ );
289
+ request.method = (options.method || 'get').toUpperCase();
290
+ return request;
291
+ }
292
+
293
+ /**
294
+ * Create request stream.
295
+ *
296
+ * @param {boolean|undefined} secure
297
+ * @param {*} body
298
+ * @param {string|undefined} encoding
299
+ * @returns {import('http').IncomingMessage}
300
+ */
301
+ function createRequestStream(secure, body, encoding) {
302
+ if (encoding !== undefined && typeof encoding !== 'string') {
303
+ throw new InvalidArgumentError(
304
+ 'Parameter "encoding" must be a String, but %v was given.',
305
+ encoding,
306
+ );
307
+ }
308
+ encoding = encoding || 'utf-8';
309
+ // для безопасного подключения
310
+ // использует обертка TLSSocket
311
+ let socket = new Socket();
312
+ // при использовании опции "secure"
313
+ // создается новый экземпляр TLSSocket
314
+ if (secure) {
315
+ socket = new TLSSocket(socket);
316
+ }
317
+ const request = new IncomingMessage(socket);
318
+ // если тело определено, то данные
319
+ // передаются в текущий запрос
320
+ if (body != null) {
321
+ if (typeof body === 'string') {
322
+ request.push(body, encoding);
323
+ } else if (Buffer.isBuffer(body)) {
324
+ request.push(body);
325
+ } else {
326
+ request.push(JSON.stringify(body), encoding);
327
+ }
328
+ }
329
+ // передача "null" определяет
330
+ // конец данных
331
+ request.push(null);
332
+ return request;
333
+ }
334
+
335
+ /**
336
+ * Create request url.
337
+ *
338
+ * @param {string|undefined} path
339
+ * @param {string|object|undefined} query
340
+ * @returns {string}
341
+ */
342
+ function createRequestUrl(path, query) {
343
+ if (path !== undefined && typeof path !== 'string') {
344
+ throw new InvalidArgumentError(
345
+ 'Parameter "path" must be a String, but %v was given.',
346
+ path,
347
+ );
348
+ }
349
+ if (query !== undefined) {
350
+ if (
351
+ query === null ||
352
+ (typeof query !== 'string' && typeof query !== 'object') ||
353
+ Array.isArray(query)
354
+ ) {
355
+ throw new InvalidArgumentError(
356
+ 'Parameter "query" must be a String or an Object, but %v was given.',
357
+ query,
358
+ );
359
+ }
360
+ }
361
+ let res = path !== undefined ? path : '/';
362
+ if (typeof query === 'object') {
363
+ const qs = queryString.stringify(query);
364
+ if (qs) {
365
+ res += `?${qs}`;
366
+ }
367
+ } else if (typeof query === 'string' && query !== '' && query !== '?') {
368
+ res += `?${query.replace(/^\?/, '')}`;
369
+ }
370
+ return res;
371
+ }
372
+
373
+ /**
374
+ * Create request headers.
375
+ *
376
+ * @param {string|undefined} host
377
+ * @param {boolean|undefined} secure
378
+ * @param {*} body
379
+ * @param {object|undefined} cookies
380
+ * @param {string|undefined} encoding
381
+ * @param {object|undefined} headers
382
+ * @returns {object}
383
+ */
384
+ function createRequestHeaders(host, secure, body, cookies, encoding, headers) {
385
+ if (host !== undefined && typeof host !== 'string') {
386
+ throw new InvalidArgumentError(
387
+ 'Parameter "host" must be a non-empty String, but %v was given.',
388
+ host,
389
+ );
390
+ }
391
+ host = host || 'localhost';
392
+ if (secure !== undefined && typeof secure !== 'boolean') {
393
+ throw new InvalidArgumentError(
394
+ 'Parameter "secure" must be a Boolean, but %v was given.',
395
+ secure,
396
+ );
397
+ }
398
+ secure = Boolean(secure);
399
+ if (cookies !== undefined) {
400
+ if (!cookies || typeof cookies !== 'object' || Array.isArray(cookies)) {
401
+ throw new InvalidArgumentError(
402
+ 'Parameter "cookies" must be an Object, but %v was given.',
403
+ cookies,
404
+ );
405
+ }
406
+ }
407
+ if (headers !== undefined) {
408
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
409
+ throw new InvalidArgumentError(
410
+ 'Parameter "headers" must be an Object, but %v was given.',
411
+ headers,
412
+ );
413
+ }
414
+ }
415
+ headers = headers || {};
416
+ if (encoding !== undefined && typeof encoding !== 'string') {
417
+ throw new InvalidArgumentError(
418
+ 'Parameter "encoding" must be a String, but %v was given.',
419
+ encoding,
420
+ );
421
+ }
422
+ encoding = encoding || 'utf-8';
423
+ const res = {};
424
+ Object.keys(headers).forEach(headerName => {
425
+ res[headerName.toLowerCase()] = headers[headerName];
426
+ });
427
+ if (res.host === undefined) {
428
+ res['host'] = host;
429
+ }
430
+ if (secure) {
431
+ res['x-forwarded-proto'] = 'https';
432
+ }
433
+ // формирование заголовка Cookie используя
434
+ // существующие данные заголовка и объекта,
435
+ // переданного в параметр данной функции
436
+ if (typeof cookies === 'object' && Object.keys(cookies).length) {
437
+ if (res['cookie']) {
438
+ const existedCookies = parseCookieString(res['cookie']);
439
+ res['cookie'] = createCookieString({...existedCookies, ...cookies});
440
+ } else {
441
+ res['cookie'] = createCookieString(cookies);
442
+ }
443
+ }
444
+ // установка заголовка "content-type"
445
+ // в зависимости от тела запроса
446
+ if (body != null && !('content-type' in res)) {
447
+ if (typeof body === 'string') {
448
+ res['content-type'] = 'text/plain';
449
+ } else if (Buffer.isBuffer(body)) {
450
+ res['content-type'] = 'application/octet-stream';
451
+ } else if (
452
+ typeof body === 'object' ||
453
+ typeof body === 'boolean' ||
454
+ typeof body === 'number'
455
+ ) {
456
+ res['content-type'] = 'application/json';
457
+ }
458
+ }
459
+ // подсчет количества байт тела
460
+ // для заголовка "content-length"
461
+ if (
462
+ body != null &&
463
+ res['transfer-encoding'] == null &&
464
+ res['content-length'] == null
465
+ ) {
466
+ if (typeof body === 'string') {
467
+ const length = Buffer.byteLength(body, encoding);
468
+ res['content-length'] = String(length);
469
+ } else if (Buffer.isBuffer(body)) {
470
+ const length = Buffer.byteLength(body);
471
+ res['content-length'] = String(length);
472
+ } else if (
473
+ typeof body === 'object' ||
474
+ typeof body === 'boolean' ||
475
+ typeof body === 'number'
476
+ ) {
477
+ const json = JSON.stringify(body);
478
+ const length = Buffer.byteLength(json, encoding);
479
+ res['content-length'] = String(length);
480
+ }
481
+ }
482
+ return res;
483
+ }