@zero-server/sdk 0.9.1 → 0.9.2
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/LICENSE +21 -21
- package/README.md +460 -443
- package/index.js +414 -412
- package/lib/app.js +1172 -1172
- package/lib/auth/authorize.js +399 -399
- package/lib/auth/enrollment.js +367 -367
- package/lib/auth/index.js +57 -57
- package/lib/auth/jwt.js +731 -731
- package/lib/auth/oauth.js +362 -362
- package/lib/auth/session.js +588 -588
- package/lib/auth/trustedDevice.js +409 -409
- package/lib/auth/twoFactor.js +1150 -1150
- package/lib/auth/webauthn.js +946 -946
- package/lib/body/index.js +14 -14
- package/lib/body/json.js +109 -109
- package/lib/body/multipart.js +440 -440
- package/lib/body/raw.js +71 -71
- package/lib/body/rawBuffer.js +160 -160
- package/lib/body/sendError.js +25 -25
- package/lib/body/text.js +75 -75
- package/lib/body/typeMatch.js +41 -41
- package/lib/body/urlencoded.js +235 -235
- package/lib/cli.js +845 -845
- package/lib/cluster.js +666 -666
- package/lib/debug.js +372 -372
- package/lib/env/index.js +465 -465
- package/lib/errors.js +683 -683
- package/lib/fetch/index.js +256 -256
- package/lib/grpc/balancer.js +378 -378
- package/lib/grpc/call.js +708 -708
- package/lib/grpc/client.js +764 -764
- package/lib/grpc/codec.js +1221 -1221
- package/lib/grpc/credentials.js +398 -398
- package/lib/grpc/frame.js +262 -262
- package/lib/grpc/health.js +287 -287
- package/lib/grpc/index.js +121 -121
- package/lib/grpc/metadata.js +461 -461
- package/lib/grpc/proto.js +821 -821
- package/lib/grpc/reflection.js +590 -590
- package/lib/grpc/server.js +445 -445
- package/lib/grpc/status.js +118 -118
- package/lib/grpc/watch.js +173 -173
- package/lib/http/index.js +10 -10
- package/lib/http/request.js +727 -727
- package/lib/http/response.js +799 -799
- package/lib/lifecycle.js +557 -557
- package/lib/middleware/compress.js +230 -230
- package/lib/middleware/cookieParser.js +237 -237
- package/lib/middleware/cors.js +93 -93
- package/lib/middleware/csrf.js +137 -137
- package/lib/middleware/errorHandler.js +101 -101
- package/lib/middleware/helmet.js +175 -175
- package/lib/middleware/index.js +19 -17
- package/lib/middleware/logger.js +74 -74
- package/lib/middleware/rateLimit.js +88 -88
- package/lib/middleware/requestId.js +53 -53
- package/lib/middleware/static.js +326 -326
- package/lib/middleware/timeout.js +71 -71
- package/lib/middleware/validator.js +255 -255
- package/lib/observe/health.js +326 -326
- package/lib/observe/index.js +50 -50
- package/lib/observe/logger.js +359 -359
- package/lib/observe/metrics.js +805 -805
- package/lib/observe/tracing.js +592 -592
- package/lib/orm/adapters/json.js +290 -290
- package/lib/orm/adapters/memory.js +764 -764
- package/lib/orm/adapters/mongo.js +764 -764
- package/lib/orm/adapters/mysql.js +933 -933
- package/lib/orm/adapters/postgres.js +1144 -1144
- package/lib/orm/adapters/redis.js +1534 -1534
- package/lib/orm/adapters/sql-base.js +212 -212
- package/lib/orm/adapters/sqlite.js +858 -858
- package/lib/orm/audit.js +649 -649
- package/lib/orm/cache.js +394 -394
- package/lib/orm/geo.js +387 -387
- package/lib/orm/index.js +784 -784
- package/lib/orm/migrate.js +432 -432
- package/lib/orm/model.js +1706 -1706
- package/lib/orm/plugin.js +375 -375
- package/lib/orm/procedures.js +836 -836
- package/lib/orm/profiler.js +233 -233
- package/lib/orm/query.js +1772 -1772
- package/lib/orm/replicas.js +241 -241
- package/lib/orm/schema.js +307 -307
- package/lib/orm/search.js +380 -380
- package/lib/orm/seed/data/commerce.js +136 -136
- package/lib/orm/seed/data/internet.js +111 -111
- package/lib/orm/seed/data/locations.js +204 -204
- package/lib/orm/seed/data/names.js +338 -338
- package/lib/orm/seed/data/person.js +128 -128
- package/lib/orm/seed/data/phone.js +211 -211
- package/lib/orm/seed/data/words.js +134 -134
- package/lib/orm/seed/factory.js +178 -178
- package/lib/orm/seed/fake.js +1186 -1186
- package/lib/orm/seed/index.js +18 -18
- package/lib/orm/seed/rng.js +70 -70
- package/lib/orm/seed/seeder.js +124 -124
- package/lib/orm/seed/unique.js +68 -68
- package/lib/orm/snapshot.js +366 -366
- package/lib/orm/tenancy.js +605 -605
- package/lib/orm/views.js +350 -350
- package/lib/router/index.js +436 -436
- package/lib/sse/index.js +8 -8
- package/lib/sse/stream.js +349 -349
- package/lib/ws/connection.js +451 -451
- package/lib/ws/handshake.js +125 -125
- package/lib/ws/index.js +14 -14
- package/lib/ws/room.js +223 -223
- package/package.json +73 -73
- package/types/app.d.ts +223 -223
- package/types/auth.d.ts +520 -520
- package/types/cluster.d.ts +75 -75
- package/types/env.d.ts +80 -80
- package/types/errors.d.ts +316 -316
- package/types/fetch.d.ts +43 -43
- package/types/grpc.d.ts +432 -432
- package/types/index.d.ts +384 -384
- package/types/lifecycle.d.ts +60 -60
- package/types/middleware.d.ts +320 -320
- package/types/observe.d.ts +304 -304
- package/types/orm.d.ts +1887 -1887
- package/types/request.d.ts +109 -109
- package/types/response.d.ts +157 -157
- package/types/router.d.ts +78 -78
- package/types/sse.d.ts +78 -78
- package/types/websocket.d.ts +126 -126
package/lib/http/response.js
CHANGED
|
@@ -1,799 +1,799 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module http/response
|
|
3
|
-
* @description Lightweight wrapper around Node's `ServerResponse`.
|
|
4
|
-
* Provides chainable helpers for status, headers, body output,
|
|
5
|
-
* and HTTP/2 server push.
|
|
6
|
-
*/
|
|
7
|
-
const fs = require('fs');
|
|
8
|
-
const nodePath = require('path');
|
|
9
|
-
const SSEStream = require('../sse/stream');
|
|
10
|
-
const log = require('../debug')('zero:http');
|
|
11
|
-
|
|
12
|
-
/** HTTP status code reason phrases. */
|
|
13
|
-
const STATUS_CODES = {
|
|
14
|
-
200: 'OK', 201: 'Created', 204: 'No Content', 301: 'Moved Permanently',
|
|
15
|
-
302: 'Found', 304: 'Not Modified', 400: 'Bad Request', 401: 'Unauthorized',
|
|
16
|
-
403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed',
|
|
17
|
-
408: 'Request Timeout', 409: 'Conflict', 413: 'Payload Too Large',
|
|
18
|
-
415: 'Unsupported Media Type', 422: 'Unprocessable Entity',
|
|
19
|
-
429: 'Too Many Requests', 500: 'Internal Server Error',
|
|
20
|
-
502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout',
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/** Extension → MIME-type for sendFile/download. */
|
|
24
|
-
const MIME_MAP = {
|
|
25
|
-
'.html': 'text/html', '.htm': 'text/html', '.css': 'text/css',
|
|
26
|
-
'.js': 'application/javascript', '.mjs': 'application/javascript',
|
|
27
|
-
'.json': 'application/json', '.txt': 'text/plain', '.xml': 'application/xml',
|
|
28
|
-
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
29
|
-
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
|
30
|
-
'.ico': 'image/x-icon', '.pdf': 'application/pdf', '.zip': 'application/zip',
|
|
31
|
-
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg',
|
|
32
|
-
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Wrapped HTTP response.
|
|
37
|
-
*
|
|
38
|
-
* @property {import('http').ServerResponse} raw - Original Node response.
|
|
39
|
-
*/
|
|
40
|
-
class Response
|
|
41
|
-
{
|
|
42
|
-
/**
|
|
43
|
-
* @constructor
|
|
44
|
-
* @param {import('http').ServerResponse} res - Raw Node server response.
|
|
45
|
-
*/
|
|
46
|
-
constructor(res)
|
|
47
|
-
{
|
|
48
|
-
this.raw = res;
|
|
49
|
-
/** @type {number} */
|
|
50
|
-
this._status = 200;
|
|
51
|
-
/** @type {Object<string, string>} */
|
|
52
|
-
this._headers = {};
|
|
53
|
-
/** @type {boolean} */
|
|
54
|
-
this._sent = false;
|
|
55
|
-
/** Request-scoped locals store. */
|
|
56
|
-
this.locals = {};
|
|
57
|
-
/**
|
|
58
|
-
* Reference to the parent App instance.
|
|
59
|
-
* Set by `app.handle()`.
|
|
60
|
-
* @type {import('../app')|null}
|
|
61
|
-
*/
|
|
62
|
-
this.app = null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Set HTTP status code. Chainable.
|
|
67
|
-
*
|
|
68
|
-
* @param {number} code - HTTP status code (e.g. 200, 404).
|
|
69
|
-
* @returns {Response} `this` for chaining.
|
|
70
|
-
*/
|
|
71
|
-
status(code) { this._status = code; return this; }
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Set a response header. Chainable.
|
|
75
|
-
*
|
|
76
|
-
* @param {string} name - Header name.
|
|
77
|
-
* @param {string} value - Header value.
|
|
78
|
-
* @returns {Response} `this` for chaining.
|
|
79
|
-
*/
|
|
80
|
-
set(name, value)
|
|
81
|
-
{
|
|
82
|
-
// Prevent CRLF header injection
|
|
83
|
-
const sName = String(name);
|
|
84
|
-
const sValue = String(value);
|
|
85
|
-
if (/[\r\n]/.test(sName) || /[\r\n]/.test(sValue))
|
|
86
|
-
{
|
|
87
|
-
throw new Error('Header values must not contain CR or LF characters');
|
|
88
|
-
}
|
|
89
|
-
this._headers[sName] = sValue;
|
|
90
|
-
return this;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Get a previously-set response header (case-insensitive).
|
|
95
|
-
*
|
|
96
|
-
* @param {string} name - Header name.
|
|
97
|
-
* @returns {string|undefined} Header value, or `undefined` if not set.
|
|
98
|
-
*/
|
|
99
|
-
get(name)
|
|
100
|
-
{
|
|
101
|
-
const lower = name.toLowerCase();
|
|
102
|
-
const keys = Object.keys(this._headers);
|
|
103
|
-
for (let i = 0; i < keys.length; i++)
|
|
104
|
-
{
|
|
105
|
-
if (keys[i].toLowerCase() === lower) return this._headers[keys[i]];
|
|
106
|
-
}
|
|
107
|
-
return undefined;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Set the Content-Type header.
|
|
112
|
-
* Accepts a shorthand alias (`'json'`, `'html'`, `'text'`, etc.) or
|
|
113
|
-
* a full MIME string. Chainable.
|
|
114
|
-
*
|
|
115
|
-
* @param {string} ct - MIME type or shorthand alias.
|
|
116
|
-
* @returns {Response} `this` for chaining.
|
|
117
|
-
*/
|
|
118
|
-
type(ct)
|
|
119
|
-
{
|
|
120
|
-
const map = {
|
|
121
|
-
json: 'application/json',
|
|
122
|
-
html: 'text/html',
|
|
123
|
-
text: 'text/plain',
|
|
124
|
-
xml: 'application/xml',
|
|
125
|
-
form: 'application/x-www-form-urlencoded',
|
|
126
|
-
bin: 'application/octet-stream',
|
|
127
|
-
};
|
|
128
|
-
this._headers['Content-Type'] = map[ct] || ct;
|
|
129
|
-
return this;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Send a response body and finalise the response.
|
|
134
|
-
* Auto-detects Content-Type (Buffer → octet-stream, string → text or
|
|
135
|
-
* HTML, object → JSON) when not explicitly set.
|
|
136
|
-
*
|
|
137
|
-
* @param {string|Buffer|object|null} body - Response payload.
|
|
138
|
-
* @returns {void}
|
|
139
|
-
*/
|
|
140
|
-
send(body)
|
|
141
|
-
{
|
|
142
|
-
if (this._sent) return;
|
|
143
|
-
log.debug('send %d', this._status);
|
|
144
|
-
const res = this.raw;
|
|
145
|
-
|
|
146
|
-
const hdrKeys = Object.keys(this._headers);
|
|
147
|
-
for (let i = 0; i < hdrKeys.length; i++) res.setHeader(hdrKeys[i], this._headers[hdrKeys[i]]);
|
|
148
|
-
res.statusCode = this._status;
|
|
149
|
-
|
|
150
|
-
if (body === undefined || body === null)
|
|
151
|
-
{
|
|
152
|
-
res.end();
|
|
153
|
-
this._sent = true;
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Auto-detect Content-Type if not already set
|
|
158
|
-
const hasContentType = Object.keys(this._headers).some(k => k.toLowerCase() === 'content-type');
|
|
159
|
-
|
|
160
|
-
if (Buffer.isBuffer(body))
|
|
161
|
-
{
|
|
162
|
-
if (!hasContentType) res.setHeader('Content-Type', 'application/octet-stream');
|
|
163
|
-
res.end(body);
|
|
164
|
-
}
|
|
165
|
-
else if (typeof body === 'string')
|
|
166
|
-
{
|
|
167
|
-
if (!hasContentType)
|
|
168
|
-
{
|
|
169
|
-
// Heuristic: if it looks like HTML, set text/html
|
|
170
|
-
// Avoid trimStart() allocation — scan for first non-whitespace char
|
|
171
|
-
let isHTML = false;
|
|
172
|
-
for (let i = 0; i < body.length; i++)
|
|
173
|
-
{
|
|
174
|
-
const c = body.charCodeAt(i);
|
|
175
|
-
if (c === 32 || c === 9 || c === 10 || c === 13) continue;
|
|
176
|
-
isHTML = c === 60; // '<'
|
|
177
|
-
break;
|
|
178
|
-
}
|
|
179
|
-
res.setHeader('Content-Type', isHTML ? 'text/html' : 'text/plain');
|
|
180
|
-
}
|
|
181
|
-
res.end(body);
|
|
182
|
-
}
|
|
183
|
-
else
|
|
184
|
-
{
|
|
185
|
-
// Object / array → JSON
|
|
186
|
-
if (!hasContentType) res.setHeader('Content-Type', 'application/json');
|
|
187
|
-
let json;
|
|
188
|
-
try { json = JSON.stringify(body); }
|
|
189
|
-
catch (e)
|
|
190
|
-
{
|
|
191
|
-
log.error('JSON.stringify failed: %s', e.message);
|
|
192
|
-
res.setHeader('Content-Type', 'application/json');
|
|
193
|
-
this._status = 500;
|
|
194
|
-
res.statusCode = 500;
|
|
195
|
-
json = JSON.stringify({ error: 'Failed to serialize response body' });
|
|
196
|
-
}
|
|
197
|
-
res.end(json);
|
|
198
|
-
}
|
|
199
|
-
this._sent = true;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Send a JSON response. Sets `Content-Type: application/json`.
|
|
204
|
-
*
|
|
205
|
-
* @param {*} obj - Value to serialise as JSON.
|
|
206
|
-
* @returns {void}
|
|
207
|
-
*/
|
|
208
|
-
json(obj) { this.set('Content-Type', 'application/json'); return this.send(obj); }
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Send a plain-text response. Sets `Content-Type: text/plain`.
|
|
212
|
-
*
|
|
213
|
-
* @param {string} str - Text payload.
|
|
214
|
-
* @returns {void}
|
|
215
|
-
*/
|
|
216
|
-
text(str) { this.set('Content-Type', 'text/plain'); return this.send(String(str)); }
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Send an HTML response. Sets `Content-Type: text/html`.
|
|
220
|
-
*
|
|
221
|
-
* @param {string} str - HTML payload.
|
|
222
|
-
* @returns {void}
|
|
223
|
-
*/
|
|
224
|
-
html(str) { this.set('Content-Type', 'text/html'); return this.send(String(str)); }
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Send only the status code with the standard reason phrase as body.
|
|
228
|
-
* @param {number} code - HTTP status code.
|
|
229
|
-
* @returns {void}
|
|
230
|
-
*/
|
|
231
|
-
sendStatus(code)
|
|
232
|
-
{
|
|
233
|
-
this._status = code;
|
|
234
|
-
const body = STATUS_CODES[code] || String(code);
|
|
235
|
-
this.type('text').send(body);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Append a value to a header. If the header already exists,
|
|
240
|
-
* creates a comma-separated list.
|
|
241
|
-
* @param {string} name - Header name.
|
|
242
|
-
* @param {string} value - Header value to append.
|
|
243
|
-
* @returns {Response} `this` for chaining.
|
|
244
|
-
*/
|
|
245
|
-
append(name, value)
|
|
246
|
-
{
|
|
247
|
-
const sValue = String(value);
|
|
248
|
-
if (/[\r\n]/.test(sValue))
|
|
249
|
-
{
|
|
250
|
-
throw new Error('Header values must not contain CR or LF characters');
|
|
251
|
-
}
|
|
252
|
-
const existing = this.get(name);
|
|
253
|
-
if (existing)
|
|
254
|
-
{
|
|
255
|
-
this._headers[name] = existing + ', ' + sValue;
|
|
256
|
-
}
|
|
257
|
-
else
|
|
258
|
-
{
|
|
259
|
-
this._headers[name] = sValue;
|
|
260
|
-
}
|
|
261
|
-
return this;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Add the given field to the Vary response header.
|
|
266
|
-
* @param {string} field - Header field name to add to Vary.
|
|
267
|
-
* @returns {Response} `this` for chaining.
|
|
268
|
-
*/
|
|
269
|
-
vary(field)
|
|
270
|
-
{
|
|
271
|
-
const existing = this.get('Vary') || '';
|
|
272
|
-
if (existing === '*') return this;
|
|
273
|
-
if (field === '*') { this.set('Vary', '*'); return this; }
|
|
274
|
-
const fields = existing ? existing.split(/\s*,\s*/) : [];
|
|
275
|
-
if (!fields.some(f => f.toLowerCase() === field.toLowerCase()))
|
|
276
|
-
{
|
|
277
|
-
fields.push(field);
|
|
278
|
-
}
|
|
279
|
-
this.set('Vary', fields.join(', '));
|
|
280
|
-
return this;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Whether headers have been sent to the client.
|
|
285
|
-
* @type {boolean}
|
|
286
|
-
*/
|
|
287
|
-
get headersSent()
|
|
288
|
-
{
|
|
289
|
-
return this.raw.headersSent;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Send a file as the response. Streams the file with proper Content-Type.
|
|
294
|
-
* @param {string} filePath - Path to the file.
|
|
295
|
-
* @param {object} [opts] - Configuration options.
|
|
296
|
-
* @param {object} [opts.headers] - Additional headers to set.
|
|
297
|
-
* @param {string} [opts.root] - Root directory for relative paths.
|
|
298
|
-
* @param {Function} [cb] - Callback `(err) => void`.
|
|
299
|
-
* @returns {void}
|
|
300
|
-
*/
|
|
301
|
-
sendFile(filePath, opts, cb)
|
|
302
|
-
{
|
|
303
|
-
if (this._sent) return;
|
|
304
|
-
if (typeof opts === 'function') { cb = opts; opts = {}; }
|
|
305
|
-
if (!opts) opts = {};
|
|
306
|
-
|
|
307
|
-
let fullPath = opts.root ? nodePath.resolve(opts.root, filePath) : nodePath.resolve(filePath);
|
|
308
|
-
|
|
309
|
-
// Prevent path traversal
|
|
310
|
-
if (opts.root)
|
|
311
|
-
{
|
|
312
|
-
const resolvedRoot = nodePath.resolve(opts.root);
|
|
313
|
-
if (!fullPath.startsWith(resolvedRoot + nodePath.sep) && fullPath !== resolvedRoot)
|
|
314
|
-
{
|
|
315
|
-
const err = new Error('Forbidden');
|
|
316
|
-
err.status = 403;
|
|
317
|
-
if (cb) return cb(err);
|
|
318
|
-
return this.status(403).json({ error: 'Forbidden' });
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
if (fullPath.indexOf('\0') !== -1)
|
|
323
|
-
{
|
|
324
|
-
const err = new Error('Bad Request');
|
|
325
|
-
err.status = 400;
|
|
326
|
-
if (cb) return cb(err);
|
|
327
|
-
return this.status(400).json({ error: 'Bad Request' });
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
fs.stat(fullPath, (err, stat) =>
|
|
331
|
-
{
|
|
332
|
-
if (err || !stat.isFile())
|
|
333
|
-
{
|
|
334
|
-
const e = err || new Error('Not Found');
|
|
335
|
-
e.status = 404;
|
|
336
|
-
if (cb) return cb(e);
|
|
337
|
-
return this.status(404).json({ error: 'Not Found' });
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const ext = nodePath.extname(fullPath).toLowerCase();
|
|
341
|
-
const ct = MIME_MAP[ext] || 'application/octet-stream';
|
|
342
|
-
|
|
343
|
-
const raw = this.raw;
|
|
344
|
-
const hk = Object.keys(this._headers);
|
|
345
|
-
for (let i = 0; i < hk.length; i++) raw.setHeader(hk[i], this._headers[hk[i]]);
|
|
346
|
-
if (opts.headers)
|
|
347
|
-
{
|
|
348
|
-
const ok = Object.keys(opts.headers);
|
|
349
|
-
for (let i = 0; i < ok.length; i++) raw.setHeader(ok[i], opts.headers[ok[i]]);
|
|
350
|
-
}
|
|
351
|
-
raw.setHeader('Content-Type', ct);
|
|
352
|
-
raw.setHeader('Content-Length', stat.size);
|
|
353
|
-
raw.statusCode = this._status;
|
|
354
|
-
|
|
355
|
-
const stream = fs.createReadStream(fullPath);
|
|
356
|
-
stream.on('error', (e) =>
|
|
357
|
-
{
|
|
358
|
-
if (cb) return cb(e);
|
|
359
|
-
try { raw.statusCode = 500; raw.end(); } catch (ex) { }
|
|
360
|
-
});
|
|
361
|
-
stream.on('end', () =>
|
|
362
|
-
{
|
|
363
|
-
this._sent = true;
|
|
364
|
-
if (cb) cb(null);
|
|
365
|
-
});
|
|
366
|
-
stream.pipe(raw);
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Prompt a file download. Sets Content-Disposition: attachment.
|
|
372
|
-
* @param {string} filePath - Path to the file.
|
|
373
|
-
* @param {string} [filename] - Override the download filename.
|
|
374
|
-
* @param {Function} [cb] - Callback on complete/error.
|
|
375
|
-
* @returns {void}
|
|
376
|
-
*/
|
|
377
|
-
download(filePath, filename, cb)
|
|
378
|
-
{
|
|
379
|
-
if (typeof filename === 'function') { cb = filename; filename = undefined; }
|
|
380
|
-
const name = filename || nodePath.basename(filePath);
|
|
381
|
-
this.set('Content-Disposition', `attachment; filename="${name.replace(/"/g, '\\"')}"`);
|
|
382
|
-
this.sendFile(filePath, {}, cb);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Set a cookie on the response.
|
|
387
|
-
*
|
|
388
|
-
* @param {string} name - Cookie name.
|
|
389
|
-
* @param {*} value - Cookie value (strings, objects/arrays auto-serialise as JSON cookies).
|
|
390
|
-
* @param {object} [opts] - Configuration options.
|
|
391
|
-
* @param {string} [opts.domain] - Cookie domain.
|
|
392
|
-
* @param {string} [opts.path='/'] - Cookie path.
|
|
393
|
-
* @param {Date|number} [opts.expires] - Expiration date.
|
|
394
|
-
* @param {number} [opts.maxAge] - Max-Age in seconds.
|
|
395
|
-
* @param {boolean} [opts.httpOnly=true] - HTTP-only flag (default: true for security).
|
|
396
|
-
* @param {boolean} [opts.secure] - Secure flag.
|
|
397
|
-
* @param {string} [opts.sameSite='Lax'] - SameSite attribute (Strict, Lax, None).
|
|
398
|
-
* @param {boolean} [opts.signed] - Sign the cookie value using req.secret.
|
|
399
|
-
* @param {string} [opts.priority] - Priority attribute (Low, Medium, High).
|
|
400
|
-
* @param {boolean} [opts.partitioned] - Partitioned/CHIPS attribute.
|
|
401
|
-
* @returns {Response} `this` for chaining.
|
|
402
|
-
*
|
|
403
|
-
* @example
|
|
404
|
-
* res.cookie('name', 'value');
|
|
405
|
-
* res.cookie('prefs', { theme: 'dark' }); // auto JSON cookie
|
|
406
|
-
* res.cookie('token', 'abc', { signed: true }); // auto-signed
|
|
407
|
-
* res.cookie('sid', 'xyz', { secure: true, sameSite: 'Strict' });
|
|
408
|
-
*/
|
|
409
|
-
cookie(name, value, opts = {})
|
|
410
|
-
{
|
|
411
|
-
if (/[=;,\s]/.test(name))
|
|
412
|
-
{
|
|
413
|
-
throw new Error('Cookie name must not contain =, ;, comma, or whitespace');
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Auto-serialize objects/arrays as JSON cookies (j: prefix)
|
|
417
|
-
let val = value;
|
|
418
|
-
if (typeof val === 'object' && val !== null && !(val instanceof Date))
|
|
419
|
-
{
|
|
420
|
-
val = 'j:' + JSON.stringify(val);
|
|
421
|
-
}
|
|
422
|
-
else
|
|
423
|
-
{
|
|
424
|
-
val = String(val);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Auto-sign when opts.signed is true
|
|
428
|
-
if (opts.signed)
|
|
429
|
-
{
|
|
430
|
-
const secret = (this._req && this._req.secret) || (opts.secret);
|
|
431
|
-
if (!secret) throw new Error('cookieParser(secret) required for signed cookies');
|
|
432
|
-
const crypto = require('crypto');
|
|
433
|
-
const sig = crypto
|
|
434
|
-
.createHmac('sha256', secret)
|
|
435
|
-
.update(val)
|
|
436
|
-
.digest('base64')
|
|
437
|
-
.replace(/=+$/, '');
|
|
438
|
-
val = `s:${val}.${sig}`;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(val)}`;
|
|
442
|
-
|
|
443
|
-
if (opts.domain) cookie += `; Domain=${opts.domain}`;
|
|
444
|
-
cookie += `; Path=${opts.path || '/'}`;
|
|
445
|
-
|
|
446
|
-
if (opts.maxAge !== undefined)
|
|
447
|
-
{
|
|
448
|
-
cookie += `; Max-Age=${Math.floor(opts.maxAge)}`;
|
|
449
|
-
}
|
|
450
|
-
else if (opts.expires)
|
|
451
|
-
{
|
|
452
|
-
const expires = opts.expires instanceof Date ? opts.expires : new Date(opts.expires);
|
|
453
|
-
cookie += `; Expires=${expires.toUTCString()}`;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
if (opts.httpOnly !== false) cookie += '; HttpOnly';
|
|
457
|
-
if (opts.secure) cookie += '; Secure';
|
|
458
|
-
cookie += `; SameSite=${opts.sameSite || 'Lax'}`;
|
|
459
|
-
if (opts.priority) cookie += `; Priority=${opts.priority}`;
|
|
460
|
-
if (opts.partitioned) cookie += '; Partitioned';
|
|
461
|
-
|
|
462
|
-
const raw = this.raw;
|
|
463
|
-
const existing = raw.getHeader('Set-Cookie');
|
|
464
|
-
if (existing)
|
|
465
|
-
{
|
|
466
|
-
const arr = Array.isArray(existing) ? existing : [existing];
|
|
467
|
-
arr.push(cookie);
|
|
468
|
-
raw.setHeader('Set-Cookie', arr);
|
|
469
|
-
}
|
|
470
|
-
else
|
|
471
|
-
{
|
|
472
|
-
raw.setHeader('Set-Cookie', cookie);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
return this;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Clear a cookie by setting it to expire in the past.
|
|
480
|
-
* @param {string} name - Cookie name.
|
|
481
|
-
* @param {object} [opts] - Must match path/domain of the original cookie.
|
|
482
|
-
* @param {string} [opts.path='/'] - Cookie path (must match the original).
|
|
483
|
-
* @param {string} [opts.domain] - Cookie domain (must match the original).
|
|
484
|
-
* @param {boolean} [opts.secure] - Secure flag.
|
|
485
|
-
* @param {string} [opts.sameSite] - SameSite attribute.
|
|
486
|
-
* @returns {Response} `this` for chaining.
|
|
487
|
-
*/
|
|
488
|
-
clearCookie(name, opts = {})
|
|
489
|
-
{
|
|
490
|
-
return this.cookie(name, '', { ...opts, expires: new Date(0), maxAge: 0 });
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Respond with content-negotiated output based on the request Accept header.
|
|
495
|
-
* Calls the handler matching the best accepted type.
|
|
496
|
-
*
|
|
497
|
-
* @param {Object<string, Function>} types - Map of MIME types to handler functions.
|
|
498
|
-
*
|
|
499
|
-
* @example
|
|
500
|
-
* res.format({
|
|
501
|
-
* 'text/html': () => res.html('<h1>Hello</h1>'),
|
|
502
|
-
* 'application/json': () => res.json({ hello: 'world' }),
|
|
503
|
-
* default: () => res.status(406).send('Not Acceptable'),
|
|
504
|
-
* });
|
|
505
|
-
*/
|
|
506
|
-
format(types)
|
|
507
|
-
{
|
|
508
|
-
const req = this._req;
|
|
509
|
-
const accept = (req && req.headers && req.headers['accept']) || '*/*';
|
|
510
|
-
|
|
511
|
-
for (const [type, handler] of Object.entries(types))
|
|
512
|
-
{
|
|
513
|
-
if (type === 'default') continue;
|
|
514
|
-
if (accept === '*/*' || accept.indexOf('*/*') !== -1 || accept.indexOf(type) !== -1)
|
|
515
|
-
{
|
|
516
|
-
this.type(type);
|
|
517
|
-
return handler();
|
|
518
|
-
}
|
|
519
|
-
// Check main-type wildcard (e.g. text/*)
|
|
520
|
-
const mainType = type.split('/')[0];
|
|
521
|
-
if (accept.indexOf(mainType + '/*') !== -1)
|
|
522
|
-
{
|
|
523
|
-
this.type(type);
|
|
524
|
-
return handler();
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
if (types.default) return types.default();
|
|
529
|
-
this.status(406).json({ error: 'Not Acceptable' });
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* Set the Link response header with the given links.
|
|
534
|
-
*
|
|
535
|
-
* @param {Object<string, string>} links - Map of rel → URL.
|
|
536
|
-
* @returns {Response} `this` for chaining.
|
|
537
|
-
*
|
|
538
|
-
* @example
|
|
539
|
-
* res.links({
|
|
540
|
-
* next: '/api/users?page=2',
|
|
541
|
-
* last: '/api/users?page=5',
|
|
542
|
-
* });
|
|
543
|
-
* // Link: </api/users?page=2>; rel="next", </api/users?page=5>; rel="last"
|
|
544
|
-
*/
|
|
545
|
-
links(links)
|
|
546
|
-
{
|
|
547
|
-
const parts = Object.entries(links).map(([rel, url]) => `<${url}>; rel="${rel}"`);
|
|
548
|
-
const existing = this.get('Link');
|
|
549
|
-
const value = existing ? existing + ', ' + parts.join(', ') : parts.join(', ');
|
|
550
|
-
this.set('Link', value);
|
|
551
|
-
return this;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* Set the Location response header.
|
|
556
|
-
*
|
|
557
|
-
* @param {string} url - The URL to set.
|
|
558
|
-
* @returns {Response} `this` for chaining.
|
|
559
|
-
*/
|
|
560
|
-
location(url)
|
|
561
|
-
{
|
|
562
|
-
this.set('Location', url);
|
|
563
|
-
return this;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Redirect to the given URL with an optional status code (default 302).
|
|
568
|
-
* @param {number|string} statusOrUrl - Status code or URL.
|
|
569
|
-
* @param {string} [url] - URL if first arg was status code.
|
|
570
|
-
* @returns {void}
|
|
571
|
-
*/
|
|
572
|
-
redirect(statusOrUrl, url)
|
|
573
|
-
{
|
|
574
|
-
if (this._sent) return;
|
|
575
|
-
let code = 302;
|
|
576
|
-
let target = statusOrUrl;
|
|
577
|
-
if (typeof statusOrUrl === 'number') { code = statusOrUrl; target = url; }
|
|
578
|
-
this._status = code;
|
|
579
|
-
this.set('Location', target);
|
|
580
|
-
this.send('');
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// -- HTTP/2 Server Push -------------------------
|
|
584
|
-
|
|
585
|
-
/**
|
|
586
|
-
* Push a resource to the client via HTTP/2 server push.
|
|
587
|
-
* No-op on HTTP/1.x connections (returns `null`).
|
|
588
|
-
*
|
|
589
|
-
* Server push pre-loads assets (CSS, JS, images) before the client
|
|
590
|
-
* requests them, eliminating one round trip for critical resources.
|
|
591
|
-
*
|
|
592
|
-
* @param {string} path - Path of the resource to push (e.g. `'/styles.css'`).
|
|
593
|
-
* @param {object} [opts] - Push options.
|
|
594
|
-
* @param {string} [opts.filePath] - Absolute file path to stream. If omitted, returns the push stream for manual writing.
|
|
595
|
-
* @param {object} [opts.headers] - Extra response headers for the pushed resource.
|
|
596
|
-
* @param {string} [opts.contentType] - Content-Type override (auto-detected from file extension if `filePath` is given).
|
|
597
|
-
* @param {number} [opts.status=200] - Status code for the pushed response.
|
|
598
|
-
* @returns {import('http2').ServerHttp2Stream|null} The push stream, or `null` if push is not supported.
|
|
599
|
-
*
|
|
600
|
-
* @example | Push a CSS File
|
|
601
|
-
* app.get('/', (req, res) => {
|
|
602
|
-
* res.push('/styles.css', { filePath: path.join(__dirname, 'public/styles.css') });
|
|
603
|
-
* res.html('<link rel="stylesheet" href="/styles.css"><h1>Hello</h1>');
|
|
604
|
-
* });
|
|
605
|
-
*
|
|
606
|
-
* @example | Manual Push Stream
|
|
607
|
-
* app.get('/', (req, res) => {
|
|
608
|
-
* const pushStream = res.push('/api/data', {
|
|
609
|
-
* contentType: 'application/json',
|
|
610
|
-
* });
|
|
611
|
-
* if (pushStream) {
|
|
612
|
-
* pushStream.end(JSON.stringify({ preloaded: true }));
|
|
613
|
-
* }
|
|
614
|
-
* res.html('<h1>Data preloaded</h1>');
|
|
615
|
-
* });
|
|
616
|
-
*/
|
|
617
|
-
push(path, opts = {})
|
|
618
|
-
{
|
|
619
|
-
const raw = this.raw;
|
|
620
|
-
|
|
621
|
-
// HTTP/2 responses have a .stream property with pushStream
|
|
622
|
-
if (!raw.stream || typeof raw.stream.pushStream !== 'function')
|
|
623
|
-
{
|
|
624
|
-
log.debug('push skipped: not an HTTP/2 connection');
|
|
625
|
-
return null;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// Check if client accepts pushes (RST_STREAM protection)
|
|
629
|
-
if (raw.stream.destroyed || raw.stream.closed)
|
|
630
|
-
{
|
|
631
|
-
log.debug('push skipped: stream already closed');
|
|
632
|
-
return null;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
const pushHeaders = {
|
|
636
|
-
':path': path,
|
|
637
|
-
':method': 'GET',
|
|
638
|
-
':scheme': raw.socket?.encrypted ? 'https' : 'http',
|
|
639
|
-
':authority': this._req?.headers?.[':authority'] || this._req?.headers?.['host'] || 'localhost',
|
|
640
|
-
...(opts.headers || {}),
|
|
641
|
-
};
|
|
642
|
-
|
|
643
|
-
let pushStream = null;
|
|
644
|
-
|
|
645
|
-
raw.stream.pushStream(pushHeaders, (err, stream) =>
|
|
646
|
-
{
|
|
647
|
-
if (err)
|
|
648
|
-
{
|
|
649
|
-
log.debug('push failed for %s: %s', path, err.message);
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
pushStream = stream;
|
|
654
|
-
|
|
655
|
-
// Handle RST_STREAM from client (browser doesn't want the push)
|
|
656
|
-
stream.on('error', (e) =>
|
|
657
|
-
{
|
|
658
|
-
if (e.code === 'ERR_HTTP2_STREAM_CANCEL') return; // Normal — client cancelled
|
|
659
|
-
log.debug('push stream error for %s: %s', path, e.message);
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
const ext = nodePath.extname(path).toLowerCase();
|
|
663
|
-
const ct = opts.contentType || MIME_MAP[ext] || 'application/octet-stream';
|
|
664
|
-
const status = opts.status || 200;
|
|
665
|
-
|
|
666
|
-
if (opts.filePath)
|
|
667
|
-
{
|
|
668
|
-
// Stream from file
|
|
669
|
-
fs.stat(opts.filePath, (statErr, stat) =>
|
|
670
|
-
{
|
|
671
|
-
if (statErr)
|
|
672
|
-
{
|
|
673
|
-
log.debug('push file not found: %s', opts.filePath);
|
|
674
|
-
try { stream.close(); } catch (_) {}
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
if (stream.destroyed || stream.closed)
|
|
679
|
-
{
|
|
680
|
-
log.debug('push stream closed before file send for %s', path);
|
|
681
|
-
return;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
try
|
|
685
|
-
{
|
|
686
|
-
stream.respond({
|
|
687
|
-
':status': status,
|
|
688
|
-
'content-type': ct,
|
|
689
|
-
'content-length': stat.size,
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
catch (_)
|
|
693
|
-
{
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
const fileStream = fs.createReadStream(opts.filePath);
|
|
698
|
-
fileStream.on('error', () => { try { stream.close(); } catch (_) {} });
|
|
699
|
-
fileStream.pipe(stream);
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
else
|
|
703
|
-
{
|
|
704
|
-
// Return stream for manual writing
|
|
705
|
-
stream.respond({
|
|
706
|
-
':status': status,
|
|
707
|
-
'content-type': ct,
|
|
708
|
-
});
|
|
709
|
-
}
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
return pushStream;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/**
|
|
716
|
-
* Check if the current connection supports HTTP/2 server push.
|
|
717
|
-
* @type {boolean}
|
|
718
|
-
*/
|
|
719
|
-
get supportsPush()
|
|
720
|
-
{
|
|
721
|
-
const raw = this.raw;
|
|
722
|
-
return !!(raw.stream && typeof raw.stream.pushStream === 'function');
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// -- Server-Sent Events (SSE) ---------------------
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* Open a Server-Sent Events stream. Sets the correct headers and
|
|
729
|
-
* returns an SSE controller object with methods for pushing events.
|
|
730
|
-
*
|
|
731
|
-
* The connection stays open until the client disconnects or you call
|
|
732
|
-
* `sse.close()`.
|
|
733
|
-
*
|
|
734
|
-
* @param {object} [opts] - Configuration options.
|
|
735
|
-
* @param {number} [opts.retry] - Reconnection interval hint (ms) sent to client.
|
|
736
|
-
* @param {object} [opts.headers] - Additional headers to set on the response.
|
|
737
|
-
* @param {number} [opts.keepAlive=0] - Auto keep-alive interval in ms. `0` to disable.
|
|
738
|
-
* @param {string} [opts.keepAliveComment='ping'] - Comment text for keep-alive messages.
|
|
739
|
-
* @param {boolean} [opts.autoId=false] - Auto-increment event IDs on every `.send()` / `.event()`.
|
|
740
|
-
* @param {number} [opts.startId=1] - Starting value for auto-IDs.
|
|
741
|
-
* @param {number} [opts.pad=0] - Bytes of initial padding (helps flush proxy buffers).
|
|
742
|
-
* @param {number} [opts.status=200] - HTTP status code for the SSE response.
|
|
743
|
-
* @returns {SSEStream} SSE controller.
|
|
744
|
-
*
|
|
745
|
-
* @example
|
|
746
|
-
* app.get('/events', (req, res) => {
|
|
747
|
-
* const sse = res.sse({ retry: 5000, keepAlive: 30000, autoId: true });
|
|
748
|
-
* sse.send('hello'); // id: 1, data: hello
|
|
749
|
-
* sse.event('update', { x: 1 }); // id: 2, event: update
|
|
750
|
-
* sse.comment('debug note'); // : debug note
|
|
751
|
-
* sse.on('close', () => console.log('gone'));
|
|
752
|
-
* });
|
|
753
|
-
*/
|
|
754
|
-
sse(opts = {})
|
|
755
|
-
{
|
|
756
|
-
if (this._sent) return null;
|
|
757
|
-
this._sent = true;
|
|
758
|
-
|
|
759
|
-
const raw = this.raw;
|
|
760
|
-
const statusCode = opts.status || 200;
|
|
761
|
-
const sseHeaders = {
|
|
762
|
-
'Content-Type': 'text/event-stream',
|
|
763
|
-
'Cache-Control': 'no-cache',
|
|
764
|
-
'X-Accel-Buffering': 'no',
|
|
765
|
-
...(opts.headers || {}),
|
|
766
|
-
};
|
|
767
|
-
// HTTP/2 doesn't use hop-by-hop Connection header
|
|
768
|
-
if (!(raw.stream && typeof raw.stream.pushStream === 'function'))
|
|
769
|
-
{
|
|
770
|
-
sseHeaders['Connection'] = 'keep-alive';
|
|
771
|
-
}
|
|
772
|
-
raw.writeHead(statusCode, sseHeaders);
|
|
773
|
-
|
|
774
|
-
// Initial padding to push past proxy buffers (e.g. 2 KB)
|
|
775
|
-
if (opts.pad && opts.pad > 0)
|
|
776
|
-
{
|
|
777
|
-
raw.write(': ' + ' '.repeat(opts.pad) + '\n\n');
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
if (opts.retry)
|
|
781
|
-
{
|
|
782
|
-
raw.write(`retry: ${opts.retry}\n\n`);
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// Capture the Last-Event-ID header from the request if available
|
|
786
|
-
const lastEventId = this._headers['_sse_last_event_id'] || null;
|
|
787
|
-
|
|
788
|
-
return new SSEStream(raw, {
|
|
789
|
-
keepAlive: opts.keepAlive || 0,
|
|
790
|
-
keepAliveComment: opts.keepAliveComment || 'ping',
|
|
791
|
-
autoId: !!opts.autoId,
|
|
792
|
-
startId: opts.startId || 1,
|
|
793
|
-
lastEventId,
|
|
794
|
-
secure: !!(raw.socket && raw.socket.encrypted),
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
module.exports = Response;
|
|
1
|
+
/**
|
|
2
|
+
* @module http/response
|
|
3
|
+
* @description Lightweight wrapper around Node's `ServerResponse`.
|
|
4
|
+
* Provides chainable helpers for status, headers, body output,
|
|
5
|
+
* and HTTP/2 server push.
|
|
6
|
+
*/
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const nodePath = require('path');
|
|
9
|
+
const SSEStream = require('../sse/stream');
|
|
10
|
+
const log = require('../debug')('zero:http');
|
|
11
|
+
|
|
12
|
+
/** HTTP status code reason phrases. */
|
|
13
|
+
const STATUS_CODES = {
|
|
14
|
+
200: 'OK', 201: 'Created', 204: 'No Content', 301: 'Moved Permanently',
|
|
15
|
+
302: 'Found', 304: 'Not Modified', 400: 'Bad Request', 401: 'Unauthorized',
|
|
16
|
+
403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed',
|
|
17
|
+
408: 'Request Timeout', 409: 'Conflict', 413: 'Payload Too Large',
|
|
18
|
+
415: 'Unsupported Media Type', 422: 'Unprocessable Entity',
|
|
19
|
+
429: 'Too Many Requests', 500: 'Internal Server Error',
|
|
20
|
+
502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Extension → MIME-type for sendFile/download. */
|
|
24
|
+
const MIME_MAP = {
|
|
25
|
+
'.html': 'text/html', '.htm': 'text/html', '.css': 'text/css',
|
|
26
|
+
'.js': 'application/javascript', '.mjs': 'application/javascript',
|
|
27
|
+
'.json': 'application/json', '.txt': 'text/plain', '.xml': 'application/xml',
|
|
28
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
29
|
+
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
|
30
|
+
'.ico': 'image/x-icon', '.pdf': 'application/pdf', '.zip': 'application/zip',
|
|
31
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg',
|
|
32
|
+
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wrapped HTTP response.
|
|
37
|
+
*
|
|
38
|
+
* @property {import('http').ServerResponse} raw - Original Node response.
|
|
39
|
+
*/
|
|
40
|
+
class Response
|
|
41
|
+
{
|
|
42
|
+
/**
|
|
43
|
+
* @constructor
|
|
44
|
+
* @param {import('http').ServerResponse} res - Raw Node server response.
|
|
45
|
+
*/
|
|
46
|
+
constructor(res)
|
|
47
|
+
{
|
|
48
|
+
this.raw = res;
|
|
49
|
+
/** @type {number} */
|
|
50
|
+
this._status = 200;
|
|
51
|
+
/** @type {Object<string, string>} */
|
|
52
|
+
this._headers = {};
|
|
53
|
+
/** @type {boolean} */
|
|
54
|
+
this._sent = false;
|
|
55
|
+
/** Request-scoped locals store. */
|
|
56
|
+
this.locals = {};
|
|
57
|
+
/**
|
|
58
|
+
* Reference to the parent App instance.
|
|
59
|
+
* Set by `app.handle()`.
|
|
60
|
+
* @type {import('../app')|null}
|
|
61
|
+
*/
|
|
62
|
+
this.app = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Set HTTP status code. Chainable.
|
|
67
|
+
*
|
|
68
|
+
* @param {number} code - HTTP status code (e.g. 200, 404).
|
|
69
|
+
* @returns {Response} `this` for chaining.
|
|
70
|
+
*/
|
|
71
|
+
status(code) { this._status = code; return this; }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Set a response header. Chainable.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} name - Header name.
|
|
77
|
+
* @param {string} value - Header value.
|
|
78
|
+
* @returns {Response} `this` for chaining.
|
|
79
|
+
*/
|
|
80
|
+
set(name, value)
|
|
81
|
+
{
|
|
82
|
+
// Prevent CRLF header injection
|
|
83
|
+
const sName = String(name);
|
|
84
|
+
const sValue = String(value);
|
|
85
|
+
if (/[\r\n]/.test(sName) || /[\r\n]/.test(sValue))
|
|
86
|
+
{
|
|
87
|
+
throw new Error('Header values must not contain CR or LF characters');
|
|
88
|
+
}
|
|
89
|
+
this._headers[sName] = sValue;
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get a previously-set response header (case-insensitive).
|
|
95
|
+
*
|
|
96
|
+
* @param {string} name - Header name.
|
|
97
|
+
* @returns {string|undefined} Header value, or `undefined` if not set.
|
|
98
|
+
*/
|
|
99
|
+
get(name)
|
|
100
|
+
{
|
|
101
|
+
const lower = name.toLowerCase();
|
|
102
|
+
const keys = Object.keys(this._headers);
|
|
103
|
+
for (let i = 0; i < keys.length; i++)
|
|
104
|
+
{
|
|
105
|
+
if (keys[i].toLowerCase() === lower) return this._headers[keys[i]];
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Set the Content-Type header.
|
|
112
|
+
* Accepts a shorthand alias (`'json'`, `'html'`, `'text'`, etc.) or
|
|
113
|
+
* a full MIME string. Chainable.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} ct - MIME type or shorthand alias.
|
|
116
|
+
* @returns {Response} `this` for chaining.
|
|
117
|
+
*/
|
|
118
|
+
type(ct)
|
|
119
|
+
{
|
|
120
|
+
const map = {
|
|
121
|
+
json: 'application/json',
|
|
122
|
+
html: 'text/html',
|
|
123
|
+
text: 'text/plain',
|
|
124
|
+
xml: 'application/xml',
|
|
125
|
+
form: 'application/x-www-form-urlencoded',
|
|
126
|
+
bin: 'application/octet-stream',
|
|
127
|
+
};
|
|
128
|
+
this._headers['Content-Type'] = map[ct] || ct;
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Send a response body and finalise the response.
|
|
134
|
+
* Auto-detects Content-Type (Buffer → octet-stream, string → text or
|
|
135
|
+
* HTML, object → JSON) when not explicitly set.
|
|
136
|
+
*
|
|
137
|
+
* @param {string|Buffer|object|null} body - Response payload.
|
|
138
|
+
* @returns {void}
|
|
139
|
+
*/
|
|
140
|
+
send(body)
|
|
141
|
+
{
|
|
142
|
+
if (this._sent) return;
|
|
143
|
+
log.debug('send %d', this._status);
|
|
144
|
+
const res = this.raw;
|
|
145
|
+
|
|
146
|
+
const hdrKeys = Object.keys(this._headers);
|
|
147
|
+
for (let i = 0; i < hdrKeys.length; i++) res.setHeader(hdrKeys[i], this._headers[hdrKeys[i]]);
|
|
148
|
+
res.statusCode = this._status;
|
|
149
|
+
|
|
150
|
+
if (body === undefined || body === null)
|
|
151
|
+
{
|
|
152
|
+
res.end();
|
|
153
|
+
this._sent = true;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Auto-detect Content-Type if not already set
|
|
158
|
+
const hasContentType = Object.keys(this._headers).some(k => k.toLowerCase() === 'content-type');
|
|
159
|
+
|
|
160
|
+
if (Buffer.isBuffer(body))
|
|
161
|
+
{
|
|
162
|
+
if (!hasContentType) res.setHeader('Content-Type', 'application/octet-stream');
|
|
163
|
+
res.end(body);
|
|
164
|
+
}
|
|
165
|
+
else if (typeof body === 'string')
|
|
166
|
+
{
|
|
167
|
+
if (!hasContentType)
|
|
168
|
+
{
|
|
169
|
+
// Heuristic: if it looks like HTML, set text/html
|
|
170
|
+
// Avoid trimStart() allocation — scan for first non-whitespace char
|
|
171
|
+
let isHTML = false;
|
|
172
|
+
for (let i = 0; i < body.length; i++)
|
|
173
|
+
{
|
|
174
|
+
const c = body.charCodeAt(i);
|
|
175
|
+
if (c === 32 || c === 9 || c === 10 || c === 13) continue;
|
|
176
|
+
isHTML = c === 60; // '<'
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
res.setHeader('Content-Type', isHTML ? 'text/html' : 'text/plain');
|
|
180
|
+
}
|
|
181
|
+
res.end(body);
|
|
182
|
+
}
|
|
183
|
+
else
|
|
184
|
+
{
|
|
185
|
+
// Object / array → JSON
|
|
186
|
+
if (!hasContentType) res.setHeader('Content-Type', 'application/json');
|
|
187
|
+
let json;
|
|
188
|
+
try { json = JSON.stringify(body); }
|
|
189
|
+
catch (e)
|
|
190
|
+
{
|
|
191
|
+
log.error('JSON.stringify failed: %s', e.message);
|
|
192
|
+
res.setHeader('Content-Type', 'application/json');
|
|
193
|
+
this._status = 500;
|
|
194
|
+
res.statusCode = 500;
|
|
195
|
+
json = JSON.stringify({ error: 'Failed to serialize response body' });
|
|
196
|
+
}
|
|
197
|
+
res.end(json);
|
|
198
|
+
}
|
|
199
|
+
this._sent = true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Send a JSON response. Sets `Content-Type: application/json`.
|
|
204
|
+
*
|
|
205
|
+
* @param {*} obj - Value to serialise as JSON.
|
|
206
|
+
* @returns {void}
|
|
207
|
+
*/
|
|
208
|
+
json(obj) { this.set('Content-Type', 'application/json'); return this.send(obj); }
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Send a plain-text response. Sets `Content-Type: text/plain`.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} str - Text payload.
|
|
214
|
+
* @returns {void}
|
|
215
|
+
*/
|
|
216
|
+
text(str) { this.set('Content-Type', 'text/plain'); return this.send(String(str)); }
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Send an HTML response. Sets `Content-Type: text/html`.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} str - HTML payload.
|
|
222
|
+
* @returns {void}
|
|
223
|
+
*/
|
|
224
|
+
html(str) { this.set('Content-Type', 'text/html'); return this.send(String(str)); }
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Send only the status code with the standard reason phrase as body.
|
|
228
|
+
* @param {number} code - HTTP status code.
|
|
229
|
+
* @returns {void}
|
|
230
|
+
*/
|
|
231
|
+
sendStatus(code)
|
|
232
|
+
{
|
|
233
|
+
this._status = code;
|
|
234
|
+
const body = STATUS_CODES[code] || String(code);
|
|
235
|
+
this.type('text').send(body);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Append a value to a header. If the header already exists,
|
|
240
|
+
* creates a comma-separated list.
|
|
241
|
+
* @param {string} name - Header name.
|
|
242
|
+
* @param {string} value - Header value to append.
|
|
243
|
+
* @returns {Response} `this` for chaining.
|
|
244
|
+
*/
|
|
245
|
+
append(name, value)
|
|
246
|
+
{
|
|
247
|
+
const sValue = String(value);
|
|
248
|
+
if (/[\r\n]/.test(sValue))
|
|
249
|
+
{
|
|
250
|
+
throw new Error('Header values must not contain CR or LF characters');
|
|
251
|
+
}
|
|
252
|
+
const existing = this.get(name);
|
|
253
|
+
if (existing)
|
|
254
|
+
{
|
|
255
|
+
this._headers[name] = existing + ', ' + sValue;
|
|
256
|
+
}
|
|
257
|
+
else
|
|
258
|
+
{
|
|
259
|
+
this._headers[name] = sValue;
|
|
260
|
+
}
|
|
261
|
+
return this;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Add the given field to the Vary response header.
|
|
266
|
+
* @param {string} field - Header field name to add to Vary.
|
|
267
|
+
* @returns {Response} `this` for chaining.
|
|
268
|
+
*/
|
|
269
|
+
vary(field)
|
|
270
|
+
{
|
|
271
|
+
const existing = this.get('Vary') || '';
|
|
272
|
+
if (existing === '*') return this;
|
|
273
|
+
if (field === '*') { this.set('Vary', '*'); return this; }
|
|
274
|
+
const fields = existing ? existing.split(/\s*,\s*/) : [];
|
|
275
|
+
if (!fields.some(f => f.toLowerCase() === field.toLowerCase()))
|
|
276
|
+
{
|
|
277
|
+
fields.push(field);
|
|
278
|
+
}
|
|
279
|
+
this.set('Vary', fields.join(', '));
|
|
280
|
+
return this;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Whether headers have been sent to the client.
|
|
285
|
+
* @type {boolean}
|
|
286
|
+
*/
|
|
287
|
+
get headersSent()
|
|
288
|
+
{
|
|
289
|
+
return this.raw.headersSent;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Send a file as the response. Streams the file with proper Content-Type.
|
|
294
|
+
* @param {string} filePath - Path to the file.
|
|
295
|
+
* @param {object} [opts] - Configuration options.
|
|
296
|
+
* @param {object} [opts.headers] - Additional headers to set.
|
|
297
|
+
* @param {string} [opts.root] - Root directory for relative paths.
|
|
298
|
+
* @param {Function} [cb] - Callback `(err) => void`.
|
|
299
|
+
* @returns {void}
|
|
300
|
+
*/
|
|
301
|
+
sendFile(filePath, opts, cb)
|
|
302
|
+
{
|
|
303
|
+
if (this._sent) return;
|
|
304
|
+
if (typeof opts === 'function') { cb = opts; opts = {}; }
|
|
305
|
+
if (!opts) opts = {};
|
|
306
|
+
|
|
307
|
+
let fullPath = opts.root ? nodePath.resolve(opts.root, filePath) : nodePath.resolve(filePath);
|
|
308
|
+
|
|
309
|
+
// Prevent path traversal
|
|
310
|
+
if (opts.root)
|
|
311
|
+
{
|
|
312
|
+
const resolvedRoot = nodePath.resolve(opts.root);
|
|
313
|
+
if (!fullPath.startsWith(resolvedRoot + nodePath.sep) && fullPath !== resolvedRoot)
|
|
314
|
+
{
|
|
315
|
+
const err = new Error('Forbidden');
|
|
316
|
+
err.status = 403;
|
|
317
|
+
if (cb) return cb(err);
|
|
318
|
+
return this.status(403).json({ error: 'Forbidden' });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (fullPath.indexOf('\0') !== -1)
|
|
323
|
+
{
|
|
324
|
+
const err = new Error('Bad Request');
|
|
325
|
+
err.status = 400;
|
|
326
|
+
if (cb) return cb(err);
|
|
327
|
+
return this.status(400).json({ error: 'Bad Request' });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
fs.stat(fullPath, (err, stat) =>
|
|
331
|
+
{
|
|
332
|
+
if (err || !stat.isFile())
|
|
333
|
+
{
|
|
334
|
+
const e = err || new Error('Not Found');
|
|
335
|
+
e.status = 404;
|
|
336
|
+
if (cb) return cb(e);
|
|
337
|
+
return this.status(404).json({ error: 'Not Found' });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const ext = nodePath.extname(fullPath).toLowerCase();
|
|
341
|
+
const ct = MIME_MAP[ext] || 'application/octet-stream';
|
|
342
|
+
|
|
343
|
+
const raw = this.raw;
|
|
344
|
+
const hk = Object.keys(this._headers);
|
|
345
|
+
for (let i = 0; i < hk.length; i++) raw.setHeader(hk[i], this._headers[hk[i]]);
|
|
346
|
+
if (opts.headers)
|
|
347
|
+
{
|
|
348
|
+
const ok = Object.keys(opts.headers);
|
|
349
|
+
for (let i = 0; i < ok.length; i++) raw.setHeader(ok[i], opts.headers[ok[i]]);
|
|
350
|
+
}
|
|
351
|
+
raw.setHeader('Content-Type', ct);
|
|
352
|
+
raw.setHeader('Content-Length', stat.size);
|
|
353
|
+
raw.statusCode = this._status;
|
|
354
|
+
|
|
355
|
+
const stream = fs.createReadStream(fullPath);
|
|
356
|
+
stream.on('error', (e) =>
|
|
357
|
+
{
|
|
358
|
+
if (cb) return cb(e);
|
|
359
|
+
try { raw.statusCode = 500; raw.end(); } catch (ex) { }
|
|
360
|
+
});
|
|
361
|
+
stream.on('end', () =>
|
|
362
|
+
{
|
|
363
|
+
this._sent = true;
|
|
364
|
+
if (cb) cb(null);
|
|
365
|
+
});
|
|
366
|
+
stream.pipe(raw);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Prompt a file download. Sets Content-Disposition: attachment.
|
|
372
|
+
* @param {string} filePath - Path to the file.
|
|
373
|
+
* @param {string} [filename] - Override the download filename.
|
|
374
|
+
* @param {Function} [cb] - Callback on complete/error.
|
|
375
|
+
* @returns {void}
|
|
376
|
+
*/
|
|
377
|
+
download(filePath, filename, cb)
|
|
378
|
+
{
|
|
379
|
+
if (typeof filename === 'function') { cb = filename; filename = undefined; }
|
|
380
|
+
const name = filename || nodePath.basename(filePath);
|
|
381
|
+
this.set('Content-Disposition', `attachment; filename="${name.replace(/"/g, '\\"')}"`);
|
|
382
|
+
this.sendFile(filePath, {}, cb);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Set a cookie on the response.
|
|
387
|
+
*
|
|
388
|
+
* @param {string} name - Cookie name.
|
|
389
|
+
* @param {*} value - Cookie value (strings, objects/arrays auto-serialise as JSON cookies).
|
|
390
|
+
* @param {object} [opts] - Configuration options.
|
|
391
|
+
* @param {string} [opts.domain] - Cookie domain.
|
|
392
|
+
* @param {string} [opts.path='/'] - Cookie path.
|
|
393
|
+
* @param {Date|number} [opts.expires] - Expiration date.
|
|
394
|
+
* @param {number} [opts.maxAge] - Max-Age in seconds.
|
|
395
|
+
* @param {boolean} [opts.httpOnly=true] - HTTP-only flag (default: true for security).
|
|
396
|
+
* @param {boolean} [opts.secure] - Secure flag.
|
|
397
|
+
* @param {string} [opts.sameSite='Lax'] - SameSite attribute (Strict, Lax, None).
|
|
398
|
+
* @param {boolean} [opts.signed] - Sign the cookie value using req.secret.
|
|
399
|
+
* @param {string} [opts.priority] - Priority attribute (Low, Medium, High).
|
|
400
|
+
* @param {boolean} [opts.partitioned] - Partitioned/CHIPS attribute.
|
|
401
|
+
* @returns {Response} `this` for chaining.
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* res.cookie('name', 'value');
|
|
405
|
+
* res.cookie('prefs', { theme: 'dark' }); // auto JSON cookie
|
|
406
|
+
* res.cookie('token', 'abc', { signed: true }); // auto-signed
|
|
407
|
+
* res.cookie('sid', 'xyz', { secure: true, sameSite: 'Strict' });
|
|
408
|
+
*/
|
|
409
|
+
cookie(name, value, opts = {})
|
|
410
|
+
{
|
|
411
|
+
if (/[=;,\s]/.test(name))
|
|
412
|
+
{
|
|
413
|
+
throw new Error('Cookie name must not contain =, ;, comma, or whitespace');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Auto-serialize objects/arrays as JSON cookies (j: prefix)
|
|
417
|
+
let val = value;
|
|
418
|
+
if (typeof val === 'object' && val !== null && !(val instanceof Date))
|
|
419
|
+
{
|
|
420
|
+
val = 'j:' + JSON.stringify(val);
|
|
421
|
+
}
|
|
422
|
+
else
|
|
423
|
+
{
|
|
424
|
+
val = String(val);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Auto-sign when opts.signed is true
|
|
428
|
+
if (opts.signed)
|
|
429
|
+
{
|
|
430
|
+
const secret = (this._req && this._req.secret) || (opts.secret);
|
|
431
|
+
if (!secret) throw new Error('cookieParser(secret) required for signed cookies');
|
|
432
|
+
const crypto = require('crypto');
|
|
433
|
+
const sig = crypto
|
|
434
|
+
.createHmac('sha256', secret)
|
|
435
|
+
.update(val)
|
|
436
|
+
.digest('base64')
|
|
437
|
+
.replace(/=+$/, '');
|
|
438
|
+
val = `s:${val}.${sig}`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(val)}`;
|
|
442
|
+
|
|
443
|
+
if (opts.domain) cookie += `; Domain=${opts.domain}`;
|
|
444
|
+
cookie += `; Path=${opts.path || '/'}`;
|
|
445
|
+
|
|
446
|
+
if (opts.maxAge !== undefined)
|
|
447
|
+
{
|
|
448
|
+
cookie += `; Max-Age=${Math.floor(opts.maxAge)}`;
|
|
449
|
+
}
|
|
450
|
+
else if (opts.expires)
|
|
451
|
+
{
|
|
452
|
+
const expires = opts.expires instanceof Date ? opts.expires : new Date(opts.expires);
|
|
453
|
+
cookie += `; Expires=${expires.toUTCString()}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (opts.httpOnly !== false) cookie += '; HttpOnly';
|
|
457
|
+
if (opts.secure) cookie += '; Secure';
|
|
458
|
+
cookie += `; SameSite=${opts.sameSite || 'Lax'}`;
|
|
459
|
+
if (opts.priority) cookie += `; Priority=${opts.priority}`;
|
|
460
|
+
if (opts.partitioned) cookie += '; Partitioned';
|
|
461
|
+
|
|
462
|
+
const raw = this.raw;
|
|
463
|
+
const existing = raw.getHeader('Set-Cookie');
|
|
464
|
+
if (existing)
|
|
465
|
+
{
|
|
466
|
+
const arr = Array.isArray(existing) ? existing : [existing];
|
|
467
|
+
arr.push(cookie);
|
|
468
|
+
raw.setHeader('Set-Cookie', arr);
|
|
469
|
+
}
|
|
470
|
+
else
|
|
471
|
+
{
|
|
472
|
+
raw.setHeader('Set-Cookie', cookie);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return this;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Clear a cookie by setting it to expire in the past.
|
|
480
|
+
* @param {string} name - Cookie name.
|
|
481
|
+
* @param {object} [opts] - Must match path/domain of the original cookie.
|
|
482
|
+
* @param {string} [opts.path='/'] - Cookie path (must match the original).
|
|
483
|
+
* @param {string} [opts.domain] - Cookie domain (must match the original).
|
|
484
|
+
* @param {boolean} [opts.secure] - Secure flag.
|
|
485
|
+
* @param {string} [opts.sameSite] - SameSite attribute.
|
|
486
|
+
* @returns {Response} `this` for chaining.
|
|
487
|
+
*/
|
|
488
|
+
clearCookie(name, opts = {})
|
|
489
|
+
{
|
|
490
|
+
return this.cookie(name, '', { ...opts, expires: new Date(0), maxAge: 0 });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Respond with content-negotiated output based on the request Accept header.
|
|
495
|
+
* Calls the handler matching the best accepted type.
|
|
496
|
+
*
|
|
497
|
+
* @param {Object<string, Function>} types - Map of MIME types to handler functions.
|
|
498
|
+
*
|
|
499
|
+
* @example
|
|
500
|
+
* res.format({
|
|
501
|
+
* 'text/html': () => res.html('<h1>Hello</h1>'),
|
|
502
|
+
* 'application/json': () => res.json({ hello: 'world' }),
|
|
503
|
+
* default: () => res.status(406).send('Not Acceptable'),
|
|
504
|
+
* });
|
|
505
|
+
*/
|
|
506
|
+
format(types)
|
|
507
|
+
{
|
|
508
|
+
const req = this._req;
|
|
509
|
+
const accept = (req && req.headers && req.headers['accept']) || '*/*';
|
|
510
|
+
|
|
511
|
+
for (const [type, handler] of Object.entries(types))
|
|
512
|
+
{
|
|
513
|
+
if (type === 'default') continue;
|
|
514
|
+
if (accept === '*/*' || accept.indexOf('*/*') !== -1 || accept.indexOf(type) !== -1)
|
|
515
|
+
{
|
|
516
|
+
this.type(type);
|
|
517
|
+
return handler();
|
|
518
|
+
}
|
|
519
|
+
// Check main-type wildcard (e.g. text/*)
|
|
520
|
+
const mainType = type.split('/')[0];
|
|
521
|
+
if (accept.indexOf(mainType + '/*') !== -1)
|
|
522
|
+
{
|
|
523
|
+
this.type(type);
|
|
524
|
+
return handler();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (types.default) return types.default();
|
|
529
|
+
this.status(406).json({ error: 'Not Acceptable' });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Set the Link response header with the given links.
|
|
534
|
+
*
|
|
535
|
+
* @param {Object<string, string>} links - Map of rel → URL.
|
|
536
|
+
* @returns {Response} `this` for chaining.
|
|
537
|
+
*
|
|
538
|
+
* @example
|
|
539
|
+
* res.links({
|
|
540
|
+
* next: '/api/users?page=2',
|
|
541
|
+
* last: '/api/users?page=5',
|
|
542
|
+
* });
|
|
543
|
+
* // Link: </api/users?page=2>; rel="next", </api/users?page=5>; rel="last"
|
|
544
|
+
*/
|
|
545
|
+
links(links)
|
|
546
|
+
{
|
|
547
|
+
const parts = Object.entries(links).map(([rel, url]) => `<${url}>; rel="${rel}"`);
|
|
548
|
+
const existing = this.get('Link');
|
|
549
|
+
const value = existing ? existing + ', ' + parts.join(', ') : parts.join(', ');
|
|
550
|
+
this.set('Link', value);
|
|
551
|
+
return this;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Set the Location response header.
|
|
556
|
+
*
|
|
557
|
+
* @param {string} url - The URL to set.
|
|
558
|
+
* @returns {Response} `this` for chaining.
|
|
559
|
+
*/
|
|
560
|
+
location(url)
|
|
561
|
+
{
|
|
562
|
+
this.set('Location', url);
|
|
563
|
+
return this;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Redirect to the given URL with an optional status code (default 302).
|
|
568
|
+
* @param {number|string} statusOrUrl - Status code or URL.
|
|
569
|
+
* @param {string} [url] - URL if first arg was status code.
|
|
570
|
+
* @returns {void}
|
|
571
|
+
*/
|
|
572
|
+
redirect(statusOrUrl, url)
|
|
573
|
+
{
|
|
574
|
+
if (this._sent) return;
|
|
575
|
+
let code = 302;
|
|
576
|
+
let target = statusOrUrl;
|
|
577
|
+
if (typeof statusOrUrl === 'number') { code = statusOrUrl; target = url; }
|
|
578
|
+
this._status = code;
|
|
579
|
+
this.set('Location', target);
|
|
580
|
+
this.send('');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// -- HTTP/2 Server Push -------------------------
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Push a resource to the client via HTTP/2 server push.
|
|
587
|
+
* No-op on HTTP/1.x connections (returns `null`).
|
|
588
|
+
*
|
|
589
|
+
* Server push pre-loads assets (CSS, JS, images) before the client
|
|
590
|
+
* requests them, eliminating one round trip for critical resources.
|
|
591
|
+
*
|
|
592
|
+
* @param {string} path - Path of the resource to push (e.g. `'/styles.css'`).
|
|
593
|
+
* @param {object} [opts] - Push options.
|
|
594
|
+
* @param {string} [opts.filePath] - Absolute file path to stream. If omitted, returns the push stream for manual writing.
|
|
595
|
+
* @param {object} [opts.headers] - Extra response headers for the pushed resource.
|
|
596
|
+
* @param {string} [opts.contentType] - Content-Type override (auto-detected from file extension if `filePath` is given).
|
|
597
|
+
* @param {number} [opts.status=200] - Status code for the pushed response.
|
|
598
|
+
* @returns {import('http2').ServerHttp2Stream|null} The push stream, or `null` if push is not supported.
|
|
599
|
+
*
|
|
600
|
+
* @example | Push a CSS File
|
|
601
|
+
* app.get('/', (req, res) => {
|
|
602
|
+
* res.push('/styles.css', { filePath: path.join(__dirname, 'public/styles.css') });
|
|
603
|
+
* res.html('<link rel="stylesheet" href="/styles.css"><h1>Hello</h1>');
|
|
604
|
+
* });
|
|
605
|
+
*
|
|
606
|
+
* @example | Manual Push Stream
|
|
607
|
+
* app.get('/', (req, res) => {
|
|
608
|
+
* const pushStream = res.push('/api/data', {
|
|
609
|
+
* contentType: 'application/json',
|
|
610
|
+
* });
|
|
611
|
+
* if (pushStream) {
|
|
612
|
+
* pushStream.end(JSON.stringify({ preloaded: true }));
|
|
613
|
+
* }
|
|
614
|
+
* res.html('<h1>Data preloaded</h1>');
|
|
615
|
+
* });
|
|
616
|
+
*/
|
|
617
|
+
push(path, opts = {})
|
|
618
|
+
{
|
|
619
|
+
const raw = this.raw;
|
|
620
|
+
|
|
621
|
+
// HTTP/2 responses have a .stream property with pushStream
|
|
622
|
+
if (!raw.stream || typeof raw.stream.pushStream !== 'function')
|
|
623
|
+
{
|
|
624
|
+
log.debug('push skipped: not an HTTP/2 connection');
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Check if client accepts pushes (RST_STREAM protection)
|
|
629
|
+
if (raw.stream.destroyed || raw.stream.closed)
|
|
630
|
+
{
|
|
631
|
+
log.debug('push skipped: stream already closed');
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const pushHeaders = {
|
|
636
|
+
':path': path,
|
|
637
|
+
':method': 'GET',
|
|
638
|
+
':scheme': raw.socket?.encrypted ? 'https' : 'http',
|
|
639
|
+
':authority': this._req?.headers?.[':authority'] || this._req?.headers?.['host'] || 'localhost',
|
|
640
|
+
...(opts.headers || {}),
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
let pushStream = null;
|
|
644
|
+
|
|
645
|
+
raw.stream.pushStream(pushHeaders, (err, stream) =>
|
|
646
|
+
{
|
|
647
|
+
if (err)
|
|
648
|
+
{
|
|
649
|
+
log.debug('push failed for %s: %s', path, err.message);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
pushStream = stream;
|
|
654
|
+
|
|
655
|
+
// Handle RST_STREAM from client (browser doesn't want the push)
|
|
656
|
+
stream.on('error', (e) =>
|
|
657
|
+
{
|
|
658
|
+
if (e.code === 'ERR_HTTP2_STREAM_CANCEL') return; // Normal — client cancelled
|
|
659
|
+
log.debug('push stream error for %s: %s', path, e.message);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const ext = nodePath.extname(path).toLowerCase();
|
|
663
|
+
const ct = opts.contentType || MIME_MAP[ext] || 'application/octet-stream';
|
|
664
|
+
const status = opts.status || 200;
|
|
665
|
+
|
|
666
|
+
if (opts.filePath)
|
|
667
|
+
{
|
|
668
|
+
// Stream from file
|
|
669
|
+
fs.stat(opts.filePath, (statErr, stat) =>
|
|
670
|
+
{
|
|
671
|
+
if (statErr)
|
|
672
|
+
{
|
|
673
|
+
log.debug('push file not found: %s', opts.filePath);
|
|
674
|
+
try { stream.close(); } catch (_) {}
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (stream.destroyed || stream.closed)
|
|
679
|
+
{
|
|
680
|
+
log.debug('push stream closed before file send for %s', path);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
try
|
|
685
|
+
{
|
|
686
|
+
stream.respond({
|
|
687
|
+
':status': status,
|
|
688
|
+
'content-type': ct,
|
|
689
|
+
'content-length': stat.size,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
catch (_)
|
|
693
|
+
{
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const fileStream = fs.createReadStream(opts.filePath);
|
|
698
|
+
fileStream.on('error', () => { try { stream.close(); } catch (_) {} });
|
|
699
|
+
fileStream.pipe(stream);
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
else
|
|
703
|
+
{
|
|
704
|
+
// Return stream for manual writing
|
|
705
|
+
stream.respond({
|
|
706
|
+
':status': status,
|
|
707
|
+
'content-type': ct,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
return pushStream;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Check if the current connection supports HTTP/2 server push.
|
|
717
|
+
* @type {boolean}
|
|
718
|
+
*/
|
|
719
|
+
get supportsPush()
|
|
720
|
+
{
|
|
721
|
+
const raw = this.raw;
|
|
722
|
+
return !!(raw.stream && typeof raw.stream.pushStream === 'function');
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// -- Server-Sent Events (SSE) ---------------------
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Open a Server-Sent Events stream. Sets the correct headers and
|
|
729
|
+
* returns an SSE controller object with methods for pushing events.
|
|
730
|
+
*
|
|
731
|
+
* The connection stays open until the client disconnects or you call
|
|
732
|
+
* `sse.close()`.
|
|
733
|
+
*
|
|
734
|
+
* @param {object} [opts] - Configuration options.
|
|
735
|
+
* @param {number} [opts.retry] - Reconnection interval hint (ms) sent to client.
|
|
736
|
+
* @param {object} [opts.headers] - Additional headers to set on the response.
|
|
737
|
+
* @param {number} [opts.keepAlive=0] - Auto keep-alive interval in ms. `0` to disable.
|
|
738
|
+
* @param {string} [opts.keepAliveComment='ping'] - Comment text for keep-alive messages.
|
|
739
|
+
* @param {boolean} [opts.autoId=false] - Auto-increment event IDs on every `.send()` / `.event()`.
|
|
740
|
+
* @param {number} [opts.startId=1] - Starting value for auto-IDs.
|
|
741
|
+
* @param {number} [opts.pad=0] - Bytes of initial padding (helps flush proxy buffers).
|
|
742
|
+
* @param {number} [opts.status=200] - HTTP status code for the SSE response.
|
|
743
|
+
* @returns {SSEStream} SSE controller.
|
|
744
|
+
*
|
|
745
|
+
* @example
|
|
746
|
+
* app.get('/events', (req, res) => {
|
|
747
|
+
* const sse = res.sse({ retry: 5000, keepAlive: 30000, autoId: true });
|
|
748
|
+
* sse.send('hello'); // id: 1, data: hello
|
|
749
|
+
* sse.event('update', { x: 1 }); // id: 2, event: update
|
|
750
|
+
* sse.comment('debug note'); // : debug note
|
|
751
|
+
* sse.on('close', () => console.log('gone'));
|
|
752
|
+
* });
|
|
753
|
+
*/
|
|
754
|
+
sse(opts = {})
|
|
755
|
+
{
|
|
756
|
+
if (this._sent) return null;
|
|
757
|
+
this._sent = true;
|
|
758
|
+
|
|
759
|
+
const raw = this.raw;
|
|
760
|
+
const statusCode = opts.status || 200;
|
|
761
|
+
const sseHeaders = {
|
|
762
|
+
'Content-Type': 'text/event-stream',
|
|
763
|
+
'Cache-Control': 'no-cache',
|
|
764
|
+
'X-Accel-Buffering': 'no',
|
|
765
|
+
...(opts.headers || {}),
|
|
766
|
+
};
|
|
767
|
+
// HTTP/2 doesn't use hop-by-hop Connection header
|
|
768
|
+
if (!(raw.stream && typeof raw.stream.pushStream === 'function'))
|
|
769
|
+
{
|
|
770
|
+
sseHeaders['Connection'] = 'keep-alive';
|
|
771
|
+
}
|
|
772
|
+
raw.writeHead(statusCode, sseHeaders);
|
|
773
|
+
|
|
774
|
+
// Initial padding to push past proxy buffers (e.g. 2 KB)
|
|
775
|
+
if (opts.pad && opts.pad > 0)
|
|
776
|
+
{
|
|
777
|
+
raw.write(': ' + ' '.repeat(opts.pad) + '\n\n');
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (opts.retry)
|
|
781
|
+
{
|
|
782
|
+
raw.write(`retry: ${opts.retry}\n\n`);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Capture the Last-Event-ID header from the request if available
|
|
786
|
+
const lastEventId = this._headers['_sse_last_event_id'] || null;
|
|
787
|
+
|
|
788
|
+
return new SSEStream(raw, {
|
|
789
|
+
keepAlive: opts.keepAlive || 0,
|
|
790
|
+
keepAliveComment: opts.keepAliveComment || 'ping',
|
|
791
|
+
autoId: !!opts.autoId,
|
|
792
|
+
startId: opts.startId || 1,
|
|
793
|
+
lastEventId,
|
|
794
|
+
secure: !!(raw.socket && raw.socket.encrypted),
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
module.exports = Response;
|