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.
- package/README.md +1250 -283
- package/documentation/config/db.js +25 -0
- package/documentation/config/middleware.js +44 -0
- package/documentation/config/tls.js +12 -0
- package/documentation/controllers/cookies.js +34 -0
- package/documentation/controllers/tasks.js +108 -0
- package/documentation/full-server.js +28 -177
- package/documentation/models/Task.js +21 -0
- package/documentation/public/data/api.json +404 -24
- package/documentation/public/data/docs.json +1139 -0
- package/documentation/public/data/examples.json +80 -2
- package/documentation/public/data/options.json +23 -8
- package/documentation/public/index.html +138 -99
- package/documentation/public/scripts/app.js +1 -3
- package/documentation/public/scripts/custom-select.js +189 -0
- package/documentation/public/scripts/data-sections.js +233 -250
- package/documentation/public/scripts/playground.js +270 -0
- package/documentation/public/scripts/ui.js +4 -3
- package/documentation/public/styles.css +56 -5
- package/documentation/public/vendor/icons/compress.svg +17 -17
- package/documentation/public/vendor/icons/database.svg +21 -0
- package/documentation/public/vendor/icons/env.svg +21 -0
- package/documentation/public/vendor/icons/fetch.svg +11 -14
- package/documentation/public/vendor/icons/security.svg +15 -0
- package/documentation/public/vendor/icons/sse.svg +12 -13
- package/documentation/public/vendor/icons/static.svg +12 -26
- package/documentation/public/vendor/icons/stream.svg +7 -13
- package/documentation/public/vendor/icons/validate.svg +17 -0
- package/documentation/routes/api.js +41 -0
- package/documentation/routes/core.js +20 -0
- package/documentation/routes/playground.js +29 -0
- package/documentation/routes/realtime.js +49 -0
- package/documentation/routes/uploads.js +71 -0
- package/index.js +62 -1
- package/lib/app.js +200 -8
- package/lib/body/json.js +28 -5
- package/lib/body/multipart.js +29 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/sendError.js +1 -0
- package/lib/body/text.js +1 -1
- package/lib/body/typeMatch.js +6 -2
- package/lib/body/urlencoded.js +5 -2
- package/lib/debug.js +345 -0
- package/lib/env/index.js +440 -0
- package/lib/errors.js +231 -0
- package/lib/http/request.js +219 -1
- package/lib/http/response.js +410 -6
- package/lib/middleware/compress.js +39 -6
- package/lib/middleware/cookieParser.js +237 -0
- package/lib/middleware/cors.js +13 -2
- package/lib/middleware/csrf.js +135 -0
- package/lib/middleware/errorHandler.js +90 -0
- package/lib/middleware/helmet.js +176 -0
- package/lib/middleware/index.js +7 -2
- package/lib/middleware/rateLimit.js +12 -1
- package/lib/middleware/requestId.js +54 -0
- package/lib/middleware/static.js +95 -11
- package/lib/middleware/timeout.js +72 -0
- package/lib/middleware/validator.js +257 -0
- package/lib/orm/adapters/json.js +215 -0
- package/lib/orm/adapters/memory.js +383 -0
- package/lib/orm/adapters/mongo.js +444 -0
- package/lib/orm/adapters/mysql.js +272 -0
- package/lib/orm/adapters/postgres.js +394 -0
- package/lib/orm/adapters/sql-base.js +142 -0
- package/lib/orm/adapters/sqlite.js +311 -0
- package/lib/orm/index.js +276 -0
- package/lib/orm/model.js +895 -0
- package/lib/orm/query.js +807 -0
- package/lib/orm/schema.js +172 -0
- package/lib/router/index.js +136 -47
- package/lib/sse/stream.js +15 -3
- package/lib/ws/connection.js +19 -3
- package/lib/ws/handshake.js +3 -0
- package/lib/ws/index.js +3 -1
- package/lib/ws/room.js +222 -0
- package/package.json +15 -5
- package/types/app.d.ts +120 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +147 -0
- package/types/fetch.d.ts +43 -0
- package/types/index.d.ts +135 -0
- package/types/middleware.d.ts +292 -0
- package/types/orm.d.ts +610 -0
- package/types/request.d.ts +99 -0
- package/types/response.d.ts +142 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/websocket.d.ts +119 -0
package/lib/http/response.js
CHANGED
|
@@ -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)
|
|
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
|
|
55
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|