zero-http 0.2.4 → 0.3.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 (89) hide show
  1. package/README.md +1250 -283
  2. package/documentation/config/db.js +25 -0
  3. package/documentation/config/middleware.js +44 -0
  4. package/documentation/config/tls.js +12 -0
  5. package/documentation/controllers/cookies.js +34 -0
  6. package/documentation/controllers/tasks.js +108 -0
  7. package/documentation/full-server.js +28 -177
  8. package/documentation/models/Task.js +21 -0
  9. package/documentation/public/data/api.json +404 -24
  10. package/documentation/public/data/docs.json +1139 -0
  11. package/documentation/public/data/examples.json +80 -2
  12. package/documentation/public/data/options.json +23 -8
  13. package/documentation/public/index.html +138 -99
  14. package/documentation/public/scripts/app.js +1 -3
  15. package/documentation/public/scripts/custom-select.js +189 -0
  16. package/documentation/public/scripts/data-sections.js +233 -250
  17. package/documentation/public/scripts/playground.js +270 -0
  18. package/documentation/public/scripts/ui.js +4 -3
  19. package/documentation/public/styles.css +56 -5
  20. package/documentation/public/vendor/icons/compress.svg +17 -17
  21. package/documentation/public/vendor/icons/database.svg +21 -0
  22. package/documentation/public/vendor/icons/env.svg +21 -0
  23. package/documentation/public/vendor/icons/fetch.svg +11 -14
  24. package/documentation/public/vendor/icons/security.svg +15 -0
  25. package/documentation/public/vendor/icons/sse.svg +12 -13
  26. package/documentation/public/vendor/icons/static.svg +12 -26
  27. package/documentation/public/vendor/icons/stream.svg +7 -13
  28. package/documentation/public/vendor/icons/validate.svg +17 -0
  29. package/documentation/routes/api.js +41 -0
  30. package/documentation/routes/core.js +20 -0
  31. package/documentation/routes/playground.js +29 -0
  32. package/documentation/routes/realtime.js +49 -0
  33. package/documentation/routes/uploads.js +71 -0
  34. package/index.js +62 -1
  35. package/lib/app.js +200 -8
  36. package/lib/body/json.js +28 -5
  37. package/lib/body/multipart.js +29 -1
  38. package/lib/body/raw.js +1 -1
  39. package/lib/body/sendError.js +1 -0
  40. package/lib/body/text.js +1 -1
  41. package/lib/body/typeMatch.js +6 -2
  42. package/lib/body/urlencoded.js +5 -2
  43. package/lib/debug.js +345 -0
  44. package/lib/env/index.js +440 -0
  45. package/lib/errors.js +231 -0
  46. package/lib/http/request.js +219 -1
  47. package/lib/http/response.js +410 -6
  48. package/lib/middleware/compress.js +39 -6
  49. package/lib/middleware/cookieParser.js +237 -0
  50. package/lib/middleware/cors.js +13 -2
  51. package/lib/middleware/csrf.js +135 -0
  52. package/lib/middleware/errorHandler.js +90 -0
  53. package/lib/middleware/helmet.js +176 -0
  54. package/lib/middleware/index.js +7 -2
  55. package/lib/middleware/rateLimit.js +12 -1
  56. package/lib/middleware/requestId.js +54 -0
  57. package/lib/middleware/static.js +95 -11
  58. package/lib/middleware/timeout.js +72 -0
  59. package/lib/middleware/validator.js +257 -0
  60. package/lib/orm/adapters/json.js +215 -0
  61. package/lib/orm/adapters/memory.js +383 -0
  62. package/lib/orm/adapters/mongo.js +444 -0
  63. package/lib/orm/adapters/mysql.js +272 -0
  64. package/lib/orm/adapters/postgres.js +394 -0
  65. package/lib/orm/adapters/sql-base.js +142 -0
  66. package/lib/orm/adapters/sqlite.js +311 -0
  67. package/lib/orm/index.js +276 -0
  68. package/lib/orm/model.js +895 -0
  69. package/lib/orm/query.js +807 -0
  70. package/lib/orm/schema.js +172 -0
  71. package/lib/router/index.js +136 -47
  72. package/lib/sse/stream.js +15 -3
  73. package/lib/ws/connection.js +19 -3
  74. package/lib/ws/handshake.js +3 -0
  75. package/lib/ws/index.js +3 -1
  76. package/lib/ws/room.js +222 -0
  77. package/package.json +15 -5
  78. package/types/app.d.ts +120 -0
  79. package/types/env.d.ts +80 -0
  80. package/types/errors.d.ts +147 -0
  81. package/types/fetch.d.ts +43 -0
  82. package/types/index.d.ts +135 -0
  83. package/types/middleware.d.ts +292 -0
  84. package/types/orm.d.ts +610 -0
  85. package/types/request.d.ts +99 -0
  86. package/types/response.d.ts +142 -0
  87. package/types/router.d.ts +78 -0
  88. package/types/sse.d.ts +78 -0
  89. package/types/websocket.d.ts +119 -0
@@ -3,7 +3,33 @@
3
3
  * @description Lightweight wrapper around Node's `ServerResponse`.
4
4
  * Provides chainable helpers for status, headers, and body output.
5
5
  */
6
+ const fs = require('fs');
7
+ const nodePath = require('path');
6
8
  const SSEStream = require('../sse/stream');
9
+ const log = require('../debug')('zero:http');
10
+
11
+ /** HTTP status code reason phrases. */
12
+ const STATUS_CODES = {
13
+ 200: 'OK', 201: 'Created', 204: 'No Content', 301: 'Moved Permanently',
14
+ 302: 'Found', 304: 'Not Modified', 400: 'Bad Request', 401: 'Unauthorized',
15
+ 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed',
16
+ 408: 'Request Timeout', 409: 'Conflict', 413: 'Payload Too Large',
17
+ 415: 'Unsupported Media Type', 422: 'Unprocessable Entity',
18
+ 429: 'Too Many Requests', 500: 'Internal Server Error',
19
+ 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout',
20
+ };
21
+
22
+ /** Extension → MIME-type for sendFile/download. */
23
+ const MIME_MAP = {
24
+ '.html': 'text/html', '.htm': 'text/html', '.css': 'text/css',
25
+ '.js': 'application/javascript', '.mjs': 'application/javascript',
26
+ '.json': 'application/json', '.txt': 'text/plain', '.xml': 'application/xml',
27
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
28
+ '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
29
+ '.ico': 'image/x-icon', '.pdf': 'application/pdf', '.zip': 'application/zip',
30
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg',
31
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
32
+ };
7
33
 
8
34
  /**
9
35
  * Wrapped HTTP response.
@@ -24,6 +50,14 @@ class Response
24
50
  this._headers = {};
25
51
  /** @type {boolean} */
26
52
  this._sent = false;
53
+ /** Request-scoped locals store. */
54
+ this.locals = {};
55
+ /**
56
+ * Reference to the parent App instance.
57
+ * Set by `app.handle()`.
58
+ * @type {import('../app')|null}
59
+ */
60
+ this.app = null;
27
61
  }
28
62
 
29
63
  /**
@@ -41,7 +75,18 @@ class Response
41
75
  * @param {string} value - Header value.
42
76
  * @returns {Response} `this` for chaining.
43
77
  */
44
- set(name, value) { this._headers[name] = value; return this; }
78
+ set(name, value)
79
+ {
80
+ // Prevent CRLF header injection
81
+ const sName = String(name);
82
+ const sValue = String(value);
83
+ if (/[\r\n]/.test(sName) || /[\r\n]/.test(sValue))
84
+ {
85
+ throw new Error('Header values must not contain CR or LF characters');
86
+ }
87
+ this._headers[sName] = sValue;
88
+ return this;
89
+ }
45
90
 
46
91
  /**
47
92
  * Get a previously-set response header (case-insensitive).
@@ -51,8 +96,13 @@ class Response
51
96
  */
52
97
  get(name)
53
98
  {
54
- const key = Object.keys(this._headers).find(k => k.toLowerCase() === name.toLowerCase());
55
- return key ? this._headers[key] : undefined;
99
+ const lower = name.toLowerCase();
100
+ const keys = Object.keys(this._headers);
101
+ for (let i = 0; i < keys.length; i++)
102
+ {
103
+ if (keys[i].toLowerCase() === lower) return this._headers[keys[i]];
104
+ }
105
+ return undefined;
56
106
  }
57
107
 
58
108
  /**
@@ -87,9 +137,11 @@ class Response
87
137
  send(body)
88
138
  {
89
139
  if (this._sent) return;
140
+ log.debug('send %d', this._status);
90
141
  const res = this.raw;
91
142
 
92
- Object.entries(this._headers).forEach(([k, v]) => res.setHeader(k, v));
143
+ const hdrKeys = Object.keys(this._headers);
144
+ for (let i = 0; i < hdrKeys.length; i++) res.setHeader(hdrKeys[i], this._headers[hdrKeys[i]]);
93
145
  res.statusCode = this._status;
94
146
 
95
147
  if (body === undefined || body === null)
@@ -112,7 +164,16 @@ class Response
112
164
  if (!hasContentType)
113
165
  {
114
166
  // Heuristic: if it looks like HTML, set text/html
115
- res.setHeader('Content-Type', body.trimStart().startsWith('<') ? 'text/html' : 'text/plain');
167
+ // Avoid trimStart() allocation scan for first non-whitespace char
168
+ let isHTML = false;
169
+ for (let i = 0; i < body.length; i++)
170
+ {
171
+ const c = body.charCodeAt(i);
172
+ if (c === 32 || c === 9 || c === 10 || c === 13) continue;
173
+ isHTML = c === 60; // '<'
174
+ break;
175
+ }
176
+ res.setHeader('Content-Type', isHTML ? 'text/html' : 'text/plain');
116
177
  }
117
178
  res.end(body);
118
179
  }
@@ -120,7 +181,17 @@ class Response
120
181
  {
121
182
  // Object / array → JSON
122
183
  if (!hasContentType) res.setHeader('Content-Type', 'application/json');
123
- res.end(JSON.stringify(body));
184
+ let json;
185
+ try { json = JSON.stringify(body); }
186
+ catch (e)
187
+ {
188
+ log.error('JSON.stringify failed: %s', e.message);
189
+ res.setHeader('Content-Type', 'application/json');
190
+ this._status = 500;
191
+ res.statusCode = 500;
192
+ json = JSON.stringify({ error: 'Failed to serialize response body' });
193
+ }
194
+ res.end(json);
124
195
  }
125
196
  this._sent = true;
126
197
  }
@@ -146,6 +217,339 @@ class Response
146
217
  */
147
218
  html(str) { this.set('Content-Type', 'text/html'); return this.send(String(str)); }
148
219
 
220
+ /**
221
+ * Send only the status code with the standard reason phrase as body.
222
+ * @param {number} code - HTTP status code.
223
+ */
224
+ sendStatus(code)
225
+ {
226
+ this._status = code;
227
+ const body = STATUS_CODES[code] || String(code);
228
+ this.type('text').send(body);
229
+ }
230
+
231
+ /**
232
+ * Append a value to a header. If the header already exists,
233
+ * creates a comma-separated list.
234
+ * @param {string} name - Header name.
235
+ * @param {string} value - Header value to append.
236
+ * @returns {Response} `this` for chaining.
237
+ */
238
+ append(name, value)
239
+ {
240
+ const sValue = String(value);
241
+ if (/[\r\n]/.test(sValue))
242
+ {
243
+ throw new Error('Header values must not contain CR or LF characters');
244
+ }
245
+ const existing = this.get(name);
246
+ if (existing)
247
+ {
248
+ this._headers[name] = existing + ', ' + sValue;
249
+ }
250
+ else
251
+ {
252
+ this._headers[name] = sValue;
253
+ }
254
+ return this;
255
+ }
256
+
257
+ /**
258
+ * Add the given field to the Vary response header.
259
+ * @param {string} field - Header field name to add to Vary.
260
+ * @returns {Response} `this` for chaining.
261
+ */
262
+ vary(field)
263
+ {
264
+ const existing = this.get('Vary') || '';
265
+ if (existing === '*') return this;
266
+ if (field === '*') { this.set('Vary', '*'); return this; }
267
+ const fields = existing ? existing.split(/\s*,\s*/) : [];
268
+ if (!fields.some(f => f.toLowerCase() === field.toLowerCase()))
269
+ {
270
+ fields.push(field);
271
+ }
272
+ this.set('Vary', fields.join(', '));
273
+ return this;
274
+ }
275
+
276
+ /**
277
+ * Whether headers have been sent to the client.
278
+ * @type {boolean}
279
+ */
280
+ get headersSent()
281
+ {
282
+ return this.raw.headersSent;
283
+ }
284
+
285
+ /**
286
+ * Send a file as the response. Streams the file with proper Content-Type.
287
+ * @param {string} filePath - Path to the file.
288
+ * @param {object} [opts]
289
+ * @param {object} [opts.headers] - Additional headers to set.
290
+ * @param {string} [opts.root] - Root directory for relative paths.
291
+ * @param {Function} [cb] - Callback `(err) => void`.
292
+ */
293
+ sendFile(filePath, opts, cb)
294
+ {
295
+ if (this._sent) return;
296
+ if (typeof opts === 'function') { cb = opts; opts = {}; }
297
+ if (!opts) opts = {};
298
+
299
+ let fullPath = opts.root ? nodePath.resolve(opts.root, filePath) : nodePath.resolve(filePath);
300
+
301
+ // Prevent path traversal
302
+ if (opts.root)
303
+ {
304
+ const resolvedRoot = nodePath.resolve(opts.root);
305
+ if (!fullPath.startsWith(resolvedRoot + nodePath.sep) && fullPath !== resolvedRoot)
306
+ {
307
+ const err = new Error('Forbidden');
308
+ err.status = 403;
309
+ if (cb) return cb(err);
310
+ return this.status(403).json({ error: 'Forbidden' });
311
+ }
312
+ }
313
+
314
+ if (fullPath.indexOf('\0') !== -1)
315
+ {
316
+ const err = new Error('Bad Request');
317
+ err.status = 400;
318
+ if (cb) return cb(err);
319
+ return this.status(400).json({ error: 'Bad Request' });
320
+ }
321
+
322
+ fs.stat(fullPath, (err, stat) =>
323
+ {
324
+ if (err || !stat.isFile())
325
+ {
326
+ const e = err || new Error('Not Found');
327
+ e.status = 404;
328
+ if (cb) return cb(e);
329
+ return this.status(404).json({ error: 'Not Found' });
330
+ }
331
+
332
+ const ext = nodePath.extname(fullPath).toLowerCase();
333
+ const ct = MIME_MAP[ext] || 'application/octet-stream';
334
+
335
+ const raw = this.raw;
336
+ const hk = Object.keys(this._headers);
337
+ for (let i = 0; i < hk.length; i++) raw.setHeader(hk[i], this._headers[hk[i]]);
338
+ if (opts.headers)
339
+ {
340
+ const ok = Object.keys(opts.headers);
341
+ for (let i = 0; i < ok.length; i++) raw.setHeader(ok[i], opts.headers[ok[i]]);
342
+ }
343
+ raw.setHeader('Content-Type', ct);
344
+ raw.setHeader('Content-Length', stat.size);
345
+ raw.statusCode = this._status;
346
+
347
+ const stream = fs.createReadStream(fullPath);
348
+ stream.on('error', (e) =>
349
+ {
350
+ if (cb) return cb(e);
351
+ try { raw.statusCode = 500; raw.end(); } catch (ex) { }
352
+ });
353
+ stream.on('end', () =>
354
+ {
355
+ this._sent = true;
356
+ if (cb) cb(null);
357
+ });
358
+ stream.pipe(raw);
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Prompt a file download. Sets Content-Disposition: attachment.
364
+ * @param {string} filePath - Path to the file.
365
+ * @param {string} [filename] - Override the download filename.
366
+ * @param {Function} [cb] - Callback on complete/error.
367
+ */
368
+ download(filePath, filename, cb)
369
+ {
370
+ if (typeof filename === 'function') { cb = filename; filename = undefined; }
371
+ const name = filename || nodePath.basename(filePath);
372
+ this.set('Content-Disposition', `attachment; filename="${name.replace(/"/g, '\\"')}"`);
373
+ this.sendFile(filePath, {}, cb);
374
+ }
375
+
376
+ /**
377
+ * Set a cookie on the response.
378
+ *
379
+ * @param {string} name - Cookie name.
380
+ * @param {*} value - Cookie value (strings, objects/arrays auto-serialise as JSON cookies).
381
+ * @param {object} [opts]
382
+ * @param {string} [opts.domain] - Cookie domain.
383
+ * @param {string} [opts.path='/'] - Cookie path.
384
+ * @param {Date|number} [opts.expires] - Expiration date.
385
+ * @param {number} [opts.maxAge] - Max-Age in seconds.
386
+ * @param {boolean} [opts.httpOnly=true] - HTTP-only flag (default: true for security).
387
+ * @param {boolean} [opts.secure] - Secure flag.
388
+ * @param {string} [opts.sameSite='Lax'] - SameSite attribute (Strict, Lax, None).
389
+ * @param {boolean} [opts.signed] - Sign the cookie value using req.secret.
390
+ * @param {string} [opts.priority] - Priority attribute (Low, Medium, High).
391
+ * @param {boolean} [opts.partitioned] - Partitioned/CHIPS attribute.
392
+ * @returns {Response} `this` for chaining.
393
+ *
394
+ * @example
395
+ * res.cookie('name', 'value');
396
+ * res.cookie('prefs', { theme: 'dark' }); // auto JSON cookie
397
+ * res.cookie('token', 'abc', { signed: true }); // auto-signed
398
+ * res.cookie('sid', 'xyz', { secure: true, sameSite: 'Strict' });
399
+ */
400
+ cookie(name, value, opts = {})
401
+ {
402
+ if (/[=;,\s]/.test(name))
403
+ {
404
+ throw new Error('Cookie name must not contain =, ;, comma, or whitespace');
405
+ }
406
+
407
+ // Auto-serialize objects/arrays as JSON cookies (j: prefix)
408
+ let val = value;
409
+ if (typeof val === 'object' && val !== null && !(val instanceof Date))
410
+ {
411
+ val = 'j:' + JSON.stringify(val);
412
+ }
413
+ else
414
+ {
415
+ val = String(val);
416
+ }
417
+
418
+ // Auto-sign when opts.signed is true
419
+ if (opts.signed)
420
+ {
421
+ const secret = (this._req && this._req.secret) || (opts.secret);
422
+ if (!secret) throw new Error('cookieParser(secret) required for signed cookies');
423
+ const crypto = require('crypto');
424
+ const sig = crypto
425
+ .createHmac('sha256', secret)
426
+ .update(val)
427
+ .digest('base64')
428
+ .replace(/=+$/, '');
429
+ val = `s:${val}.${sig}`;
430
+ }
431
+
432
+ let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(val)}`;
433
+
434
+ if (opts.domain) cookie += `; Domain=${opts.domain}`;
435
+ cookie += `; Path=${opts.path || '/'}`;
436
+
437
+ if (opts.maxAge !== undefined)
438
+ {
439
+ cookie += `; Max-Age=${Math.floor(opts.maxAge)}`;
440
+ }
441
+ else if (opts.expires)
442
+ {
443
+ const expires = opts.expires instanceof Date ? opts.expires : new Date(opts.expires);
444
+ cookie += `; Expires=${expires.toUTCString()}`;
445
+ }
446
+
447
+ if (opts.httpOnly !== false) cookie += '; HttpOnly';
448
+ if (opts.secure) cookie += '; Secure';
449
+ cookie += `; SameSite=${opts.sameSite || 'Lax'}`;
450
+ if (opts.priority) cookie += `; Priority=${opts.priority}`;
451
+ if (opts.partitioned) cookie += '; Partitioned';
452
+
453
+ const raw = this.raw;
454
+ const existing = raw.getHeader('Set-Cookie');
455
+ if (existing)
456
+ {
457
+ const arr = Array.isArray(existing) ? existing : [existing];
458
+ arr.push(cookie);
459
+ raw.setHeader('Set-Cookie', arr);
460
+ }
461
+ else
462
+ {
463
+ raw.setHeader('Set-Cookie', cookie);
464
+ }
465
+
466
+ return this;
467
+ }
468
+
469
+ /**
470
+ * Clear a cookie by setting it to expire in the past.
471
+ * @param {string} name - Cookie name.
472
+ * @param {object} [opts] - Must match path/domain of the original cookie.
473
+ * @returns {Response} `this` for chaining.
474
+ */
475
+ clearCookie(name, opts = {})
476
+ {
477
+ return this.cookie(name, '', { ...opts, expires: new Date(0), maxAge: 0 });
478
+ }
479
+
480
+ /**
481
+ * Respond with content-negotiated output based on the request Accept header.
482
+ * Calls the handler matching the best accepted type.
483
+ *
484
+ * @param {Object<string, Function>} types - Map of MIME types to handler functions.
485
+ *
486
+ * @example
487
+ * res.format({
488
+ * 'text/html': () => res.html('<h1>Hello</h1>'),
489
+ * 'application/json': () => res.json({ hello: 'world' }),
490
+ * default: () => res.status(406).send('Not Acceptable'),
491
+ * });
492
+ */
493
+ format(types)
494
+ {
495
+ const req = this._req;
496
+ const accept = (req && req.headers && req.headers['accept']) || '*/*';
497
+
498
+ for (const [type, handler] of Object.entries(types))
499
+ {
500
+ if (type === 'default') continue;
501
+ if (accept === '*/*' || accept.indexOf('*/*') !== -1 || accept.indexOf(type) !== -1)
502
+ {
503
+ this.type(type);
504
+ return handler();
505
+ }
506
+ // Check main-type wildcard (e.g. text/*)
507
+ const mainType = type.split('/')[0];
508
+ if (accept.indexOf(mainType + '/*') !== -1)
509
+ {
510
+ this.type(type);
511
+ return handler();
512
+ }
513
+ }
514
+
515
+ if (types.default) return types.default();
516
+ this.status(406).json({ error: 'Not Acceptable' });
517
+ }
518
+
519
+ /**
520
+ * Set the Link response header with the given links.
521
+ *
522
+ * @param {Object<string, string>} links - Map of rel → URL.
523
+ * @returns {Response} `this` for chaining.
524
+ *
525
+ * @example
526
+ * res.links({
527
+ * next: '/api/users?page=2',
528
+ * last: '/api/users?page=5',
529
+ * });
530
+ * // Link: </api/users?page=2>; rel="next", </api/users?page=5>; rel="last"
531
+ */
532
+ links(links)
533
+ {
534
+ const parts = Object.entries(links).map(([rel, url]) => `<${url}>; rel="${rel}"`);
535
+ const existing = this.get('Link');
536
+ const value = existing ? existing + ', ' + parts.join(', ') : parts.join(', ');
537
+ this.set('Link', value);
538
+ return this;
539
+ }
540
+
541
+ /**
542
+ * Set the Location response header.
543
+ *
544
+ * @param {string} url - The URL to set.
545
+ * @returns {Response} `this` for chaining.
546
+ */
547
+ location(url)
548
+ {
549
+ this.set('Location', url);
550
+ return this;
551
+ }
552
+
149
553
  /**
150
554
  * Redirect to the given URL with an optional status code (default 302).
151
555
  * @param {number|string} statusOrUrl - Status code or URL.
@@ -5,6 +5,7 @@
5
5
  * Zero external dependencies.
6
6
  */
7
7
  const zlib = require('zlib');
8
+ const log = require('../debug')('zero:compress');
8
9
 
9
10
  /**
10
11
  * Default minimum response size (in bytes) to bother compressing.
@@ -45,18 +46,44 @@ function compress(opts = {})
45
46
 
46
47
  /**
47
48
  * Choose the best encoding from the Accept-Encoding header.
48
- * Priority: br > gzip > deflate.
49
+ * Parses quality values (RFC 7231) and picks the highest-priority match.
50
+ * Priority when equal quality: br > gzip > deflate.
49
51
  * @param {string} header
50
52
  * @returns {string|null}
51
53
  */
52
54
  function negotiate(header)
53
55
  {
54
56
  if (!header) return null;
55
- const h = header.toLowerCase();
56
- if (hasBrotli && h.includes('br')) return 'br';
57
- if (h.includes('gzip')) return 'gzip';
58
- if (h.includes('deflate')) return 'deflate';
59
- return null;
57
+ const encodings = { br: 0, gzip: 0, deflate: 0 };
58
+ const parts = header.toLowerCase().split(',');
59
+ for (let i = 0; i < parts.length; i++)
60
+ {
61
+ const part = parts[i].trim();
62
+ const semi = part.indexOf(';');
63
+ const name = (semi !== -1 ? part.substring(0, semi).trim() : part);
64
+ let q = 1;
65
+ if (semi !== -1)
66
+ {
67
+ const qMatch = /q\s*=\s*([0-9.]+)/.exec(part.substring(semi));
68
+ if (qMatch) q = parseFloat(qMatch[1]);
69
+ }
70
+ if (name in encodings) encodings[name] = q;
71
+ }
72
+ // Filter available encodings
73
+ if (!hasBrotli) encodings.br = 0;
74
+ // Pick highest quality; break ties with priority order
75
+ let best = null;
76
+ let bestQ = 0;
77
+ const order = hasBrotli ? ['br', 'gzip', 'deflate'] : ['gzip', 'deflate'];
78
+ for (let i = 0; i < order.length; i++)
79
+ {
80
+ if (encodings[order[i]] > bestQ)
81
+ {
82
+ bestQ = encodings[order[i]];
83
+ best = order[i];
84
+ }
85
+ }
86
+ return best;
60
87
  }
61
88
 
62
89
  /**
@@ -131,9 +158,15 @@ function compress(opts = {})
131
158
  raw.removeHeader('Content-Length');
132
159
  raw.setHeader('Content-Encoding', encoding);
133
160
  raw.setHeader('Vary', 'Accept-Encoding');
161
+ log.debug('compressing with %s', encoding);
134
162
 
135
163
  compressStream.on('data', (chunk) => origWrite(chunk));
136
164
  compressStream.on('end', () => origEnd());
165
+ compressStream.on('error', (err) =>
166
+ { log.error('compression error: %s', err.message); // On compression error, remove encoding header and end raw stream
167
+ try { raw.removeHeader('Content-Encoding'); } catch (e) { }
168
+ try { origEnd(); } catch (e) { }
169
+ });
137
170
  return true;
138
171
  }
139
172