@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.
Files changed (126) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +460 -443
  3. package/index.js +414 -412
  4. package/lib/app.js +1172 -1172
  5. package/lib/auth/authorize.js +399 -399
  6. package/lib/auth/enrollment.js +367 -367
  7. package/lib/auth/index.js +57 -57
  8. package/lib/auth/jwt.js +731 -731
  9. package/lib/auth/oauth.js +362 -362
  10. package/lib/auth/session.js +588 -588
  11. package/lib/auth/trustedDevice.js +409 -409
  12. package/lib/auth/twoFactor.js +1150 -1150
  13. package/lib/auth/webauthn.js +946 -946
  14. package/lib/body/index.js +14 -14
  15. package/lib/body/json.js +109 -109
  16. package/lib/body/multipart.js +440 -440
  17. package/lib/body/raw.js +71 -71
  18. package/lib/body/rawBuffer.js +160 -160
  19. package/lib/body/sendError.js +25 -25
  20. package/lib/body/text.js +75 -75
  21. package/lib/body/typeMatch.js +41 -41
  22. package/lib/body/urlencoded.js +235 -235
  23. package/lib/cli.js +845 -845
  24. package/lib/cluster.js +666 -666
  25. package/lib/debug.js +372 -372
  26. package/lib/env/index.js +465 -465
  27. package/lib/errors.js +683 -683
  28. package/lib/fetch/index.js +256 -256
  29. package/lib/grpc/balancer.js +378 -378
  30. package/lib/grpc/call.js +708 -708
  31. package/lib/grpc/client.js +764 -764
  32. package/lib/grpc/codec.js +1221 -1221
  33. package/lib/grpc/credentials.js +398 -398
  34. package/lib/grpc/frame.js +262 -262
  35. package/lib/grpc/health.js +287 -287
  36. package/lib/grpc/index.js +121 -121
  37. package/lib/grpc/metadata.js +461 -461
  38. package/lib/grpc/proto.js +821 -821
  39. package/lib/grpc/reflection.js +590 -590
  40. package/lib/grpc/server.js +445 -445
  41. package/lib/grpc/status.js +118 -118
  42. package/lib/grpc/watch.js +173 -173
  43. package/lib/http/index.js +10 -10
  44. package/lib/http/request.js +727 -727
  45. package/lib/http/response.js +799 -799
  46. package/lib/lifecycle.js +557 -557
  47. package/lib/middleware/compress.js +230 -230
  48. package/lib/middleware/cookieParser.js +237 -237
  49. package/lib/middleware/cors.js +93 -93
  50. package/lib/middleware/csrf.js +137 -137
  51. package/lib/middleware/errorHandler.js +101 -101
  52. package/lib/middleware/helmet.js +175 -175
  53. package/lib/middleware/index.js +19 -17
  54. package/lib/middleware/logger.js +74 -74
  55. package/lib/middleware/rateLimit.js +88 -88
  56. package/lib/middleware/requestId.js +53 -53
  57. package/lib/middleware/static.js +326 -326
  58. package/lib/middleware/timeout.js +71 -71
  59. package/lib/middleware/validator.js +255 -255
  60. package/lib/observe/health.js +326 -326
  61. package/lib/observe/index.js +50 -50
  62. package/lib/observe/logger.js +359 -359
  63. package/lib/observe/metrics.js +805 -805
  64. package/lib/observe/tracing.js +592 -592
  65. package/lib/orm/adapters/json.js +290 -290
  66. package/lib/orm/adapters/memory.js +764 -764
  67. package/lib/orm/adapters/mongo.js +764 -764
  68. package/lib/orm/adapters/mysql.js +933 -933
  69. package/lib/orm/adapters/postgres.js +1144 -1144
  70. package/lib/orm/adapters/redis.js +1534 -1534
  71. package/lib/orm/adapters/sql-base.js +212 -212
  72. package/lib/orm/adapters/sqlite.js +858 -858
  73. package/lib/orm/audit.js +649 -649
  74. package/lib/orm/cache.js +394 -394
  75. package/lib/orm/geo.js +387 -387
  76. package/lib/orm/index.js +784 -784
  77. package/lib/orm/migrate.js +432 -432
  78. package/lib/orm/model.js +1706 -1706
  79. package/lib/orm/plugin.js +375 -375
  80. package/lib/orm/procedures.js +836 -836
  81. package/lib/orm/profiler.js +233 -233
  82. package/lib/orm/query.js +1772 -1772
  83. package/lib/orm/replicas.js +241 -241
  84. package/lib/orm/schema.js +307 -307
  85. package/lib/orm/search.js +380 -380
  86. package/lib/orm/seed/data/commerce.js +136 -136
  87. package/lib/orm/seed/data/internet.js +111 -111
  88. package/lib/orm/seed/data/locations.js +204 -204
  89. package/lib/orm/seed/data/names.js +338 -338
  90. package/lib/orm/seed/data/person.js +128 -128
  91. package/lib/orm/seed/data/phone.js +211 -211
  92. package/lib/orm/seed/data/words.js +134 -134
  93. package/lib/orm/seed/factory.js +178 -178
  94. package/lib/orm/seed/fake.js +1186 -1186
  95. package/lib/orm/seed/index.js +18 -18
  96. package/lib/orm/seed/rng.js +70 -70
  97. package/lib/orm/seed/seeder.js +124 -124
  98. package/lib/orm/seed/unique.js +68 -68
  99. package/lib/orm/snapshot.js +366 -366
  100. package/lib/orm/tenancy.js +605 -605
  101. package/lib/orm/views.js +350 -350
  102. package/lib/router/index.js +436 -436
  103. package/lib/sse/index.js +8 -8
  104. package/lib/sse/stream.js +349 -349
  105. package/lib/ws/connection.js +451 -451
  106. package/lib/ws/handshake.js +125 -125
  107. package/lib/ws/index.js +14 -14
  108. package/lib/ws/room.js +223 -223
  109. package/package.json +73 -73
  110. package/types/app.d.ts +223 -223
  111. package/types/auth.d.ts +520 -520
  112. package/types/cluster.d.ts +75 -75
  113. package/types/env.d.ts +80 -80
  114. package/types/errors.d.ts +316 -316
  115. package/types/fetch.d.ts +43 -43
  116. package/types/grpc.d.ts +432 -432
  117. package/types/index.d.ts +384 -384
  118. package/types/lifecycle.d.ts +60 -60
  119. package/types/middleware.d.ts +320 -320
  120. package/types/observe.d.ts +304 -304
  121. package/types/orm.d.ts +1887 -1887
  122. package/types/request.d.ts +109 -109
  123. package/types/response.d.ts +157 -157
  124. package/types/router.d.ts +78 -78
  125. package/types/sse.d.ts +78 -78
  126. package/types/websocket.d.ts +126 -126
@@ -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;