@centralping/ergo 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +139 -0
  4. package/http/accepts.js +69 -0
  5. package/http/authorization.js +65 -0
  6. package/http/body.js +311 -0
  7. package/http/cache-control.js +123 -0
  8. package/http/compress.js +157 -0
  9. package/http/cookie.js +39 -0
  10. package/http/cors.js +79 -0
  11. package/http/csrf.js +76 -0
  12. package/http/handler.js +74 -0
  13. package/http/index.js +13 -0
  14. package/http/json-api-query.js +53 -0
  15. package/http/logger.js +167 -0
  16. package/http/main.js +140 -0
  17. package/http/precondition.js +53 -0
  18. package/http/prefer.js +36 -0
  19. package/http/rate-limit.js +66 -0
  20. package/http/security-headers.js +62 -0
  21. package/http/send.js +399 -0
  22. package/http/timeout.js +47 -0
  23. package/http/url.js +47 -0
  24. package/http/validate.js +84 -0
  25. package/lib/accepts.js +49 -0
  26. package/lib/attach-instance.js +23 -0
  27. package/lib/authorization.js +187 -0
  28. package/lib/body/multiparse.js +173 -0
  29. package/lib/body/multipart/headers.js +69 -0
  30. package/lib/body/writer.js +73 -0
  31. package/lib/cookie/cookie.js +192 -0
  32. package/lib/cookie/index.js +14 -0
  33. package/lib/cookie/jar.js +106 -0
  34. package/lib/cookie/parse.js +101 -0
  35. package/lib/cors.js +191 -0
  36. package/lib/csrf.js +96 -0
  37. package/lib/from-connect.js +69 -0
  38. package/lib/json-api-query/index.js +25 -0
  39. package/lib/json-api-query/schema.json +105 -0
  40. package/lib/json-api-query/validate.js +56 -0
  41. package/lib/link.js +96 -0
  42. package/lib/prefer.js +52 -0
  43. package/lib/query.js +113 -0
  44. package/lib/rate-limit.js +115 -0
  45. package/lib/sanitize-quoted-string.js +28 -0
  46. package/lib/security-headers.js +125 -0
  47. package/lib/validate.js +80 -0
  48. package/lib/vary.js +40 -0
  49. package/package.json +158 -0
  50. package/types/http/accepts.d.ts +8 -0
  51. package/types/http/authorization.d.ts +8 -0
  52. package/types/http/body.d.ts +20 -0
  53. package/types/http/cache-control.d.ts +16 -0
  54. package/types/http/compress.d.ts +5 -0
  55. package/types/http/cookie.d.ts +2 -0
  56. package/types/http/cors.d.ts +9 -0
  57. package/types/http/csrf.d.ts +9 -0
  58. package/types/http/handler.d.ts +2 -0
  59. package/types/http/index.d.ts +1 -0
  60. package/types/http/json-api-query.d.ts +2 -0
  61. package/types/http/logger.d.ts +9 -0
  62. package/types/http/main.d.ts +142 -0
  63. package/types/http/precondition.d.ts +44 -0
  64. package/types/http/prefer.d.ts +2 -0
  65. package/types/http/rate-limit.d.ts +17 -0
  66. package/types/http/security-headers.d.ts +10 -0
  67. package/types/http/send.d.ts +8 -0
  68. package/types/http/timeout.d.ts +5 -0
  69. package/types/http/url.d.ts +2 -0
  70. package/types/http/validate.d.ts +6 -0
  71. package/types/lib/accepts.d.ts +7 -0
  72. package/types/lib/attach-instance.d.ts +19 -0
  73. package/types/lib/authorization.d.ts +6 -0
  74. package/types/lib/body/multiparse.d.ts +9 -0
  75. package/types/lib/body/multipart/headers.d.ts +2 -0
  76. package/types/lib/body/writer.d.ts +2 -0
  77. package/types/lib/cookie/cookie.d.ts +32 -0
  78. package/types/lib/cookie/index.d.ts +2 -0
  79. package/types/lib/cookie/jar.d.ts +8 -0
  80. package/types/lib/cookie/parse.d.ts +19 -0
  81. package/types/lib/cors.d.ts +9 -0
  82. package/types/lib/csrf.d.ts +32 -0
  83. package/types/lib/from-connect.d.ts +47 -0
  84. package/types/lib/json-api-query/index.d.ts +123 -0
  85. package/types/lib/json-api-query/validate.d.ts +5 -0
  86. package/types/lib/link.d.ts +37 -0
  87. package/types/lib/prefer.d.ts +36 -0
  88. package/types/lib/query.d.ts +6 -0
  89. package/types/lib/rate-limit.d.ts +76 -0
  90. package/types/lib/sanitize-quoted-string.d.ts +19 -0
  91. package/types/lib/security-headers.d.ts +24 -0
  92. package/types/lib/validate.d.ts +16 -0
  93. package/types/lib/vary.d.ts +17 -0
  94. package/types/utils/attempt.d.ts +2 -0
  95. package/types/utils/buffers/index.d.ts +2 -0
  96. package/types/utils/buffers/match.d.ts +10 -0
  97. package/types/utils/buffers/split.d.ts +10 -0
  98. package/types/utils/compose-with.d.ts +40 -0
  99. package/types/utils/compose.d.ts +83 -0
  100. package/types/utils/flat-array.d.ts +2 -0
  101. package/types/utils/get.d.ts +5 -0
  102. package/types/utils/http-errors.d.ts +22 -0
  103. package/types/utils/iterables/buffer-split.d.ts +2 -0
  104. package/types/utils/iterables/chain.d.ts +2 -0
  105. package/types/utils/iterables/exec-all.d.ts +2 -0
  106. package/types/utils/iterables/filter.d.ts +2 -0
  107. package/types/utils/iterables/for-each.d.ts +2 -0
  108. package/types/utils/iterables/from-stream.d.ts +2 -0
  109. package/types/utils/iterables/index.d.ts +10 -0
  110. package/types/utils/iterables/map.d.ts +2 -0
  111. package/types/utils/iterables/range.d.ts +24 -0
  112. package/types/utils/iterables/reduce.d.ts +2 -0
  113. package/types/utils/iterables/take.d.ts +2 -0
  114. package/types/utils/observables/buffer-split.d.ts +2 -0
  115. package/types/utils/observables/chain.d.ts +2 -0
  116. package/types/utils/observables/index.d.ts +4 -0
  117. package/types/utils/observables/map.d.ts +2 -0
  118. package/types/utils/observables/take.d.ts +2 -0
  119. package/types/utils/pick.d.ts +2 -0
  120. package/types/utils/set.d.ts +2 -0
  121. package/types/utils/streams/index.d.ts +2 -0
  122. package/types/utils/streams/meter.d.ts +5 -0
  123. package/types/utils/streams/tee.d.ts +2 -0
  124. package/types/utils/type.d.ts +2 -0
  125. package/utils/attempt.js +37 -0
  126. package/utils/buffers/index.js +13 -0
  127. package/utils/buffers/match.js +96 -0
  128. package/utils/buffers/split.js +55 -0
  129. package/utils/compose-with.js +232 -0
  130. package/utils/compose.js +165 -0
  131. package/utils/flat-array.js +24 -0
  132. package/utils/get.js +39 -0
  133. package/utils/http-errors.js +113 -0
  134. package/utils/iterables/buffer-split.js +117 -0
  135. package/utils/iterables/chain.js +32 -0
  136. package/utils/iterables/exec-all.js +42 -0
  137. package/utils/iterables/filter.js +35 -0
  138. package/utils/iterables/for-each.js +33 -0
  139. package/utils/iterables/from-stream.js +29 -0
  140. package/utils/iterables/index.js +21 -0
  141. package/utils/iterables/map.js +47 -0
  142. package/utils/iterables/range.js +34 -0
  143. package/utils/iterables/reduce.js +43 -0
  144. package/utils/iterables/take.js +36 -0
  145. package/utils/observables/buffer-split.js +109 -0
  146. package/utils/observables/chain.js +33 -0
  147. package/utils/observables/index.js +19 -0
  148. package/utils/observables/map.js +34 -0
  149. package/utils/observables/take.js +40 -0
  150. package/utils/pick.js +41 -0
  151. package/utils/set.js +38 -0
  152. package/utils/streams/index.js +11 -0
  153. package/utils/streams/meter.js +98 -0
  154. package/utils/streams/tee.js +84 -0
  155. package/utils/type.js +47 -0
package/http/send.js ADDED
@@ -0,0 +1,399 @@
1
+ /**
2
+ * @fileoverview HTTP response serialization (v2: two-accumulator model).
3
+ *
4
+ * Called directly by auto-wrap / handler after the pipeline completes.
5
+ * Reads from two accumulators:
6
+ * - **responseAcc**: `statusCode`, `body`, `headers`, `detail`, `retryAfter`,
7
+ * `instance`, `location`, `lastModified`, `type` (explicit body type override)
8
+ * - **domainAcc**: `cookies` (cookie jar), `prefer` (parsed Prefer header)
9
+ *
10
+ * For error responses (`statusCode >= 400`), builds an RFC 9457 Problem Details
11
+ * body automatically from `statusCode`, `detail`, `retryAfter`, `instance`, and
12
+ * any extension members on the response accumulator.
13
+ *
14
+ * For success responses (`statusCode < 400`), handles multiple body types:
15
+ * - `null`/`undefined` — default text from STATUS_CODES (enforced empty for 204/304)
16
+ * - `string` — written as-is; detects HTML vs plain text content type
17
+ * - `Uint8Array` (non-Buffer) — written as `application/octet-stream`
18
+ * - `Stream` (readable) — piped to the response
19
+ * - `Object` — JSON-serialized; respects `prettify` option
20
+ *
21
+ * Also handles:
22
+ * - ETag generation and conditional request evaluation (`If-None-Match`, `If-Match`)
23
+ * - `Last-Modified` and date-based conditionals (`If-Modified-Since`, `If-Unmodified-Since`)
24
+ * - `Location` header for 201 Created and 3xx redirect responses
25
+ * - `Vary` header injection
26
+ * - `Retry-After` header from `retryAfter` on the response accumulator
27
+ * - `Set-Cookie` from `domainAcc.cookies.toHeader()`
28
+ * - RFC 7240 Prefer handling (`return=minimal`, `return=representation`)
29
+ * - Optional response envelope for 2xx Object bodies
30
+ *
31
+ * @module http/send
32
+ * @version 0.2.0
33
+ * @since 0.1.0
34
+ * @requires node:stream
35
+ * @requires node:http
36
+ * @requires etag
37
+ * @requires ../utils/http-errors.js
38
+ * @requires ../lib/vary.js
39
+ *
40
+ * @example
41
+ * import {send, createResponseAcc} from 'ergo';
42
+ *
43
+ * const writer = send({etag: true});
44
+ * // After pipeline completes:
45
+ * writer(req, res, responseAcc, domainAcc);
46
+ *
47
+ * @see {@link https://www.rfc-editor.org/rfc/rfc9110 RFC 9110 - HTTP Semantics}
48
+ * @see {@link https://www.rfc-editor.org/rfc/rfc9457 RFC 9457 - Problem Details for HTTP APIs}
49
+ */
50
+ import {Stream, pipeline} from 'node:stream';
51
+ import {STATUS_CODES} from 'node:http';
52
+ import generateETag from 'etag';
53
+ import httpErrors from '../utils/http-errors.js';
54
+ import appendVary from '../lib/vary.js';
55
+
56
+ const isHTML = /<[a-z][^>]*>/i;
57
+
58
+ const NO_BODY_STATUSES = new Set([204, 304]);
59
+ const ETAG_UNSAFE_METHODS = new Set(['PUT', 'PATCH', 'DELETE']);
60
+
61
+ const SEND_RESERVED = new Set([
62
+ 'statusCode',
63
+ 'body',
64
+ 'headers',
65
+ 'detail',
66
+ 'retryAfter',
67
+ 'instance',
68
+ 'type',
69
+ 'lastModified',
70
+ 'location'
71
+ ]);
72
+
73
+ /**
74
+ * Built-in response envelope format.
75
+ *
76
+ * @param {object} body - Original response body
77
+ * @param {{requestId: string, statusCode: number}} ctx - Request context
78
+ * @returns {object} - Enveloped body
79
+ */
80
+ function defaultEnvelope(body, {requestId, statusCode}) {
81
+ const result = {id: requestId, status: statusCode, data: body};
82
+ if (Array.isArray(body)) result.count = body.length;
83
+ return result;
84
+ }
85
+
86
+ /**
87
+ * Inline body type detection.
88
+ *
89
+ * @param {*} body - Response body value to classify
90
+ * @returns {string} - Type label: 'Null', 'String', 'Uint8Array', 'Stream', or 'Object'
91
+ */
92
+ function bodyType(body) {
93
+ if (body === null || body === undefined) return 'Null';
94
+ if (typeof body === 'string') return 'String';
95
+ if (body instanceof Uint8Array && !Buffer.isBuffer(body)) return 'Uint8Array';
96
+ if (body instanceof Stream) return 'Stream';
97
+ return 'Object';
98
+ }
99
+
100
+ /**
101
+ * Creates a response serialization function.
102
+ *
103
+ * @param {object} [options] - Send configuration
104
+ * @param {boolean} [options.prettify=false] - Pretty-print JSON output
105
+ * @param {string[]} [options.vary=['Accept']] - Vary header values to append
106
+ * @param {boolean} [options.etag=true] - Generate and evaluate ETags for conditional responses
107
+ * @param {boolean} [options.prefer=false] - When true, read `domainAcc.prefer` for RFC 7240
108
+ * handling. Adds `Prefer` to the Vary header.
109
+ * @param {boolean|function} [options.envelope=false] - Wrap 2xx Object bodies in a response
110
+ * envelope. `false` (default) — no envelope. `true` — built-in format `{id, status, data,
111
+ * count?}`. `function(body, ctx)` — custom envelope.
112
+ * @returns {function} - `(req, res, responseAcc, domainAcc) => void`
113
+ */
114
+ export default ({
115
+ prettify = false,
116
+ vary = ['Accept'],
117
+ etag = true,
118
+ prefer = false,
119
+ envelope = false
120
+ } = {}) => {
121
+ const effectiveVary = prefer ? [...(vary || []), 'Prefer'] : vary;
122
+ const varyValue = effectiveVary?.length ? effectiveVary.join(', ') : undefined;
123
+
124
+ return (req, res, responseAcc, domainAcc = {}) => {
125
+ if (res.writableEnded || !res.writable) return;
126
+
127
+ let {
128
+ statusCode = res.statusCode,
129
+ body,
130
+ type: explicitType,
131
+ headers = [],
132
+ lastModified,
133
+ location,
134
+ detail,
135
+ retryAfter,
136
+ instance
137
+ } = responseAcc;
138
+
139
+ // RFC 9457: build error body for 4xx/5xx
140
+ if (statusCode >= 400) {
141
+ const opts = {};
142
+ if (detail) opts.message = detail;
143
+ if (retryAfter != null) opts.retryAfter = retryAfter;
144
+ if (instance) opts.instance = instance;
145
+
146
+ for (const key of Object.keys(responseAcc)) {
147
+ if (!SEND_RESERVED.has(key) && opts[key] === undefined) {
148
+ opts[key] = responseAcc[key];
149
+ }
150
+ }
151
+
152
+ body = httpErrors(statusCode, opts);
153
+ } else if (body === undefined) {
154
+ body = STATUS_CODES[statusCode] ?? String(statusCode);
155
+ }
156
+
157
+ if (
158
+ envelope &&
159
+ statusCode >= 200 &&
160
+ statusCode < 300 &&
161
+ bodyType(body) === 'Object' &&
162
+ !(body instanceof Error)
163
+ ) {
164
+ const requestId = res.getHeader('x-request-id');
165
+ body =
166
+ typeof envelope === 'function'
167
+ ? envelope(body, {requestId, statusCode, method: req.method})
168
+ : defaultEnvelope(body, {requestId, statusCode});
169
+ }
170
+
171
+ const resolvedType = explicitType ?? bodyType(body);
172
+
173
+ res.statusCode = statusCode;
174
+
175
+ // Middleware-contributed headers (from responseAcc.headers)
176
+ for (const [header, value] of headers) {
177
+ if (header !== undefined) {
178
+ if (value === undefined) {
179
+ res.clearHeader(header);
180
+ } else {
181
+ res.setHeader(header, value);
182
+ }
183
+ }
184
+ }
185
+
186
+ // Cookies from domain accumulator
187
+ if (domainAcc.cookies) {
188
+ res.setHeader('Set-Cookie', domainAcc.cookies.toHeader());
189
+ }
190
+
191
+ if (retryAfter != null) {
192
+ res.setHeader('Retry-After', String(retryAfter));
193
+ }
194
+
195
+ if (varyValue) {
196
+ appendVary(res, varyValue);
197
+ }
198
+
199
+ // RFC 9110 §10.2.2: Location header for 201 Created and 3xx redirects
200
+ if (location && statusCode >= 200 && statusCode < 400) {
201
+ res.setHeader('Location', location);
202
+ }
203
+
204
+ // RFC 7240: Prefer header handling
205
+ if (prefer) {
206
+ const preferData = domainAcc.prefer;
207
+ if (preferData?.return === 'minimal' && statusCode >= 200 && statusCode < 300) {
208
+ res.setHeader('Preference-Applied', 'return=minimal');
209
+ if (statusCode === 200) res.statusCode = 204;
210
+ endNoBody(res);
211
+ return;
212
+ }
213
+ if (preferData?.return === 'representation' && statusCode >= 200 && statusCode < 300) {
214
+ res.setHeader('Preference-Applied', 'return=representation');
215
+ }
216
+ }
217
+
218
+ // RFC 7231: 204 No Content and 304 Not Modified MUST NOT contain a body
219
+ if (NO_BODY_STATUSES.has(statusCode)) {
220
+ endNoBody(res);
221
+ return;
222
+ }
223
+
224
+ if (res.getHeader('Content-Type') === undefined) {
225
+ if (resolvedType === 'Stream') {
226
+ res.setHeader('Content-Type', 'application/octet-stream');
227
+ pipeline(body, res, err => {
228
+ if (err && res.listenerCount('error') > 0) {
229
+ res.emit('error', err);
230
+ }
231
+ });
232
+ return;
233
+ }
234
+
235
+ switch (resolvedType) {
236
+ case 'String':
237
+ res.setHeader(
238
+ 'Content-Type',
239
+ isHTML.test(body) ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8'
240
+ );
241
+ break;
242
+ case 'Uint8Array':
243
+ res.setHeader('Content-Type', 'application/octet-stream');
244
+ break;
245
+ default:
246
+ res.setHeader(
247
+ 'Content-Type',
248
+ body instanceof Error
249
+ ? 'application/problem+json; charset=utf-8'
250
+ : 'application/json; charset=utf-8'
251
+ );
252
+ break;
253
+ }
254
+ }
255
+
256
+ const contentType = res.getHeader('Content-Type');
257
+ if (contentType.includes('/json') || contentType.includes('+json')) {
258
+ body = JSON.stringify(body, null, prettify ? 2 : 0);
259
+ }
260
+
261
+ const len = typeof body === 'string' ? Buffer.byteLength(body) : body.length;
262
+
263
+ if (len !== undefined) {
264
+ res.setHeader('Content-Length', len);
265
+ }
266
+
267
+ // Last-Modified header (set before conditionals so it's present in 304/412 responses)
268
+ let lastModifiedDate;
269
+ if (lastModified != null && statusCode >= 200 && statusCode < 300) {
270
+ lastModifiedDate = lastModified instanceof Date ? lastModified : new Date(lastModified);
271
+ if (!Number.isNaN(lastModifiedDate.getTime())) {
272
+ res.setHeader('Last-Modified', lastModifiedDate.toUTCString());
273
+ } else {
274
+ lastModifiedDate = undefined;
275
+ }
276
+ }
277
+
278
+ const ifNoneMatch = req.headers?.['if-none-match'];
279
+ const ifMatch = req.headers?.['if-match'];
280
+
281
+ // ETag: conditional responses
282
+ if (etag && statusCode >= 200 && statusCode < 300) {
283
+ const entityBody =
284
+ typeof body === 'string' ? body : Buffer.isBuffer(body) ? body : Buffer.from(String(body));
285
+ const tag = generateETag(entityBody);
286
+ res.setHeader('ETag', tag);
287
+
288
+ // If-None-Match -> 304 (weak comparison per RFC 9110 §8.8.3.2)
289
+ if (ifNoneMatch && weakMatchesETag(ifNoneMatch, tag)) {
290
+ res.statusCode = 304;
291
+ endNoBody(res);
292
+ return;
293
+ }
294
+
295
+ // If-Match -> 412 (strong comparison per RFC 9110 §8.8.3.2)
296
+ if (ifMatch && ETAG_UNSAFE_METHODS.has(req.method)) {
297
+ if (!strongMatchesETag(ifMatch, tag)) {
298
+ endWithProblem(res, 412);
299
+ return;
300
+ }
301
+ }
302
+ }
303
+
304
+ // Date-based conditional responses (RFC 9110 §8.8.2)
305
+ if (lastModifiedDate && statusCode >= 200 && statusCode < 300) {
306
+ // If-Modified-Since → 304 (skip when If-None-Match is present per RFC 9110 §13.1.3)
307
+ if (!ifNoneMatch) {
308
+ const ifModifiedSince = req.headers?.['if-modified-since'];
309
+ if (ifModifiedSince && !isModifiedSince(lastModifiedDate, ifModifiedSince)) {
310
+ res.statusCode = 304;
311
+ endNoBody(res);
312
+ return;
313
+ }
314
+ }
315
+
316
+ // If-Unmodified-Since → 412 (skip when If-Match is present per RFC 9110 §13.1.4)
317
+ if (!ifMatch && ETAG_UNSAFE_METHODS.has(req.method)) {
318
+ const ifUnmodifiedSince = req.headers?.['if-unmodified-since'];
319
+ if (ifUnmodifiedSince && isModifiedSince(lastModifiedDate, ifUnmodifiedSince)) {
320
+ endWithProblem(res, 412);
321
+ return;
322
+ }
323
+ }
324
+ }
325
+
326
+ res.end(body);
327
+ };
328
+ };
329
+
330
+ /**
331
+ * Weak comparison: two ETags match if their opaque-tags are identical after
332
+ * stripping any `W/` prefix (RFC 9110 §8.8.3.2).
333
+ *
334
+ * @param {string} header - The header value (e.g., '"abc", W/"def"')
335
+ * @param {string} tag - The generated ETag
336
+ * @returns {boolean} - True if any ETag in the header weakly matches the tag
337
+ */
338
+ function weakMatchesETag(header, tag) {
339
+ if (header === '*') return true;
340
+ const normalized = tag.replace(/^W\//, '');
341
+ return header.split(',').some(t => t.trim().replace(/^W\//, '') === normalized);
342
+ }
343
+
344
+ /**
345
+ * Strong comparison: two ETags match only if both are strong (no `W/` prefix)
346
+ * and their opaque-tags are identical (RFC 9110 §8.8.3.2).
347
+ *
348
+ * @param {string} header - The header value (e.g., '"abc", "def"')
349
+ * @param {string} tag - The generated ETag
350
+ * @returns {boolean} - True if any strong ETag in the header matches the tag
351
+ */
352
+ function strongMatchesETag(header, tag) {
353
+ if (header === '*') return true;
354
+ if (tag.startsWith('W/')) return false;
355
+ return header.split(',').some(t => {
356
+ const trimmed = t.trim();
357
+ return !trimmed.startsWith('W/') && trimmed === tag;
358
+ });
359
+ }
360
+
361
+ /**
362
+ * Determines whether a resource has been modified after the given date.
363
+ *
364
+ * @param {Date} lastModified - The resource's last modification date
365
+ * @param {string} headerValue - The If-Modified-Since or If-Unmodified-Since header value
366
+ * @returns {boolean} - True if the resource was modified after the header date
367
+ */
368
+ function isModifiedSince(lastModified, headerValue) {
369
+ const since = new Date(headerValue);
370
+ if (Number.isNaN(since.getTime())) return true;
371
+ return Math.floor(lastModified.getTime() / 1000) > Math.floor(since.getTime() / 1000);
372
+ }
373
+
374
+ /**
375
+ * End the response with no body for 304 Not Modified (or 204 No Content).
376
+ *
377
+ * @param {import('node:http').ServerResponse} res - HTTP response object
378
+ */
379
+ function endNoBody(res) {
380
+ res.removeHeader('Content-Type');
381
+ res.removeHeader('Content-Length');
382
+ res.removeHeader('Transfer-Encoding');
383
+ res.end();
384
+ }
385
+
386
+ /**
387
+ * End the response with an RFC 9457 Problem Details body for conditional
388
+ * request failures (412 Precondition Failed).
389
+ *
390
+ * @param {import('node:http').ServerResponse} res - HTTP response object
391
+ * @param {number} statusCode - HTTP status code for the error
392
+ */
393
+ function endWithProblem(res, statusCode) {
394
+ res.statusCode = statusCode;
395
+ res.setHeader('Content-Type', 'application/problem+json; charset=utf-8');
396
+ const body = JSON.stringify(httpErrors(statusCode));
397
+ res.setHeader('Content-Length', Buffer.byteLength(body));
398
+ res.end(body);
399
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @fileoverview HTTP middleware factory for request timeouts (v2).
3
+ *
4
+ * Races the downstream pipeline against a configurable deadline.
5
+ * When the deadline fires, sets `responseAcc.statusCode` and `responseAcc.detail`
6
+ * via closure access, then destroys the request (without an error argument).
7
+ * The pipeline's catch block detects the pre-set statusCode and skips error
8
+ * formatting.
9
+ *
10
+ * Uses a cancellable setTimeout + res 'close' listener. When the response
11
+ * completes normally, the timer is cleared immediately so the req/res closure
12
+ * can be GC'd.
13
+ *
14
+ * @module http/timeout
15
+ * @version 0.2.0
16
+ * @since 0.1.0
17
+ *
18
+ * @example
19
+ * import {compose, timeout} from 'ergo';
20
+ *
21
+ * const pipeline = compose(
22
+ * [timeout({ms: 10000, statusCode: 504}), 'timeout'],
23
+ * (req, res, acc) => ({response: {body: await slowCall(), statusCode: 200}})
24
+ * );
25
+ */
26
+
27
+ /**
28
+ * Creates a request timeout middleware.
29
+ *
30
+ * @param {object} [options] - Timeout configuration
31
+ * @param {number} [options.ms=30000] - Timeout in milliseconds
32
+ * @param {number} [options.statusCode=408] - HTTP status code on timeout (408 or 504)
33
+ * @returns {function} - Ergo middleware `(req, res, domainAcc, responseAcc) => void`
34
+ */
35
+ export default ({ms = 30000, statusCode = 408} = {}) => {
36
+ return (req, res, domainAcc, responseAcc) => {
37
+ const timer = setTimeout(() => {
38
+ if (!req.destroyed) {
39
+ responseAcc.statusCode = statusCode;
40
+ responseAcc.detail = `Request timed out after ${ms}ms`;
41
+ req.destroy();
42
+ }
43
+ }, ms);
44
+
45
+ res.on('close', () => clearTimeout(timer));
46
+ };
47
+ };
package/http/url.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @fileoverview HTTP middleware factory for URL parsing.
3
+ *
4
+ * Parses the request URL into pathname, query parameters, and raw search string
5
+ * using a fast single-pass parser (no `URL` construction overhead).
6
+ * Multi-value parameters are returned as arrays.
7
+ *
8
+ * Returns `{query, pathname, search}` where:
9
+ * - `query` is the parsed key-value object (multi-value keys become arrays)
10
+ * - `pathname` is the URL path component (before `?`)
11
+ * - `search` is the raw query string including the `?` prefix, or `undefined`
12
+ *
13
+ * @module http/url
14
+ * @version 0.1.0
15
+ * @since 0.1.0
16
+ * @requires ../lib/query.js
17
+ *
18
+ * @example
19
+ * import {compose, url} from 'ergo';
20
+ *
21
+ * const pipeline = compose(
22
+ * [url(), 'url'],
23
+ * // acc.url => {query: {page: '1', filter: ['a','b']}, pathname: '/users', search: '?page=1&filter=a&filter=b'}
24
+ * );
25
+ */
26
+ import queryParse from '../lib/query.js';
27
+
28
+ /**
29
+ * Creates a URL parsing middleware.
30
+ *
31
+ * @returns {function} - Ergo middleware `({url}) => {query, pathname, search}`
32
+ */
33
+ export default () =>
34
+ ({url} = {}) => {
35
+ const raw = url ?? '/';
36
+ const qIdx = raw.indexOf('?');
37
+
38
+ if (qIdx === -1) {
39
+ return {query: Object.create(null), pathname: raw || undefined, search: undefined};
40
+ }
41
+
42
+ return {
43
+ query: queryParse(raw.slice(qIdx + 1)),
44
+ pathname: raw.slice(0, qIdx) || undefined,
45
+ search: raw.slice(qIdx)
46
+ };
47
+ };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @fileoverview HTTP middleware factory for JSON Schema validation.
3
+ *
4
+ * Validates properties from the accumulator (body, url, params) against provided JSON
5
+ * Schemas using AJV. Schemas are compiled once at middleware creation time for performance.
6
+ *
7
+ * Returns `{response: {statusCode: 422, detail: ...}}` with structured error details on validation failure.
8
+ * Must be placed after `body()` and/or `url()` in the pipeline so accumulator values
9
+ * are populated before validation runs.
10
+ *
11
+ * @module http/validate
12
+ * @version 0.1.0
13
+ * @since 0.1.0
14
+ * @requires ../lib/validate.js
15
+ *
16
+ * @example
17
+ * import {compose, body, url, validate} from 'ergo';
18
+ *
19
+ * const pipeline = compose(
20
+ * [body(), 'body'],
21
+ * [url(), 'url'],
22
+ * [validate({
23
+ * body: {
24
+ * type: 'object',
25
+ * properties: {name: {type: 'string'}},
26
+ * required: ['name']
27
+ * },
28
+ * query: {
29
+ * type: 'object',
30
+ * properties: {page: {type: 'string', pattern: '^[0-9]+$'}}
31
+ * }
32
+ * }), 'validation'],
33
+ * );
34
+ */
35
+ import createValidator from '../lib/validate.js';
36
+
37
+ /**
38
+ * Creates a JSON Schema validation middleware.
39
+ *
40
+ * @param {object} [schemas] - Schema map; each key corresponds to an accumulator property
41
+ * @param {object} [schemas.body] - JSON Schema for the parsed request body
42
+ * @param {object} [schemas.query] - JSON Schema for parsed query parameters
43
+ * @param {object} [schemas.params] - JSON Schema for route path parameters
44
+ * @param {object} [options] - AJV options forwarded to each compiled validator
45
+ * @returns {function} - Ergo middleware `(req, res, acc) => void` that returns `{response: {statusCode: 422}}`
46
+ * (with `detail` and `details` from AJV) on validation failure
47
+ */
48
+ export default (schemas = {}, options = {}) => {
49
+ const validators = {};
50
+
51
+ if (schemas.body) {
52
+ validators.body = createValidator(schemas.body, options);
53
+ }
54
+ if (schemas.query) {
55
+ validators.query = createValidator(schemas.query, options);
56
+ }
57
+ if (schemas.params) {
58
+ validators.params = createValidator(schemas.params, options);
59
+ }
60
+
61
+ return (req, res, acc) => {
62
+ try {
63
+ if (validators.body && acc.body && acc.body.parsed !== undefined) {
64
+ validators.body(acc.body.parsed);
65
+ }
66
+
67
+ if (validators.query && acc.url?.query) {
68
+ validators.query(acc.url.query);
69
+ }
70
+
71
+ if (validators.params && acc.params) {
72
+ validators.params(acc.params);
73
+ }
74
+ } catch (err) {
75
+ return {
76
+ response: {
77
+ statusCode: err.statusCode ?? 422,
78
+ detail: err.message,
79
+ details: err.details
80
+ }
81
+ };
82
+ }
83
+ };
84
+ };
package/lib/accepts.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @fileoverview Core content negotiation logic using the `negotiator` library.
3
+ *
4
+ * Provides a configurable negotiation factory that matches request `Accept*` headers
5
+ * against allowed types, languages, charsets, and encodings. Used by `http/accepts.js`
6
+ * as the pure-logic backing implementation.
7
+ *
8
+ * @module lib/accepts
9
+ * @version 0.1.0
10
+ * @since 0.1.0
11
+ * @requires negotiator
12
+ * @requires ../utils/flat-array.js
13
+ *
14
+ * @example
15
+ * import accepts from 'ergo/lib/accepts';
16
+ *
17
+ * const negotiate = accepts({types: ['application/json', 'text/html']});
18
+ * const result = negotiate({'accept': 'text/html,application/json;q=0.9'});
19
+ * // result.type => 'text/html'
20
+ *
21
+ * @see {@link https://www.rfc-editor.org/rfc/rfc9110#section-12.5 RFC 9110 Section 12.5 - Content Negotiation}
22
+ */
23
+ import Negotiator from 'negotiator';
24
+
25
+ import flatArray from '../utils/flat-array.js';
26
+
27
+ /**
28
+ * Creates a content negotiation function for the given preference lists.
29
+ *
30
+ * @param {object} [options] - Negotiation preferences
31
+ * @param {string|string[]} [options.types] - Acceptable media types
32
+ * @param {string|string[]} [options.languages] - Acceptable languages
33
+ * @param {string|string[]} [options.charsets] - Acceptable charsets
34
+ * @param {string|string[]} [options.encodings] - Acceptable encodings
35
+ * @returns {function} - `(headers) => {type, language, charset, encoding}`
36
+ */
37
+ export default ({types, languages, charsets, encodings} = {}) =>
38
+ (headers = {}) => {
39
+ const negotiator = new Negotiator({headers: {...headers}});
40
+
41
+ const toArr = v => (v ? flatArray(v) : undefined);
42
+
43
+ return {
44
+ type: negotiator.mediaType(toArr(types)),
45
+ language: negotiator.language(toArr(languages)),
46
+ charset: negotiator.charset(toArr(charsets)),
47
+ encoding: negotiator.encoding(toArr(encodings))
48
+ };
49
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @fileoverview Shared RFC 9457 instance injection helper.
3
+ *
4
+ * Auto-populates the `instance` property on an error from the response's
5
+ * `x-request-id` header, formatted as a `urn:uuid:` URI.
6
+ *
7
+ * @module lib/attach-instance
8
+ * @version 0.1.0
9
+ * @since 0.1.0
10
+ */
11
+
12
+ /**
13
+ * Set `err.instance` from the response's `x-request-id` header if not already set.
14
+ *
15
+ * @param {Error & {instance?: string}} err - Error to annotate
16
+ * @param {import('node:http').ServerResponse} res - HTTP response
17
+ */
18
+ export default function attachInstance(err, res) {
19
+ if (err.instance === undefined) {
20
+ const requestId = res.getHeader?.('x-request-id');
21
+ if (requestId) err.instance = `urn:uuid:${requestId}`;
22
+ }
23
+ }