@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.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/http/accepts.js +69 -0
- package/http/authorization.js +65 -0
- package/http/body.js +311 -0
- package/http/cache-control.js +123 -0
- package/http/compress.js +157 -0
- package/http/cookie.js +39 -0
- package/http/cors.js +79 -0
- package/http/csrf.js +76 -0
- package/http/handler.js +74 -0
- package/http/index.js +13 -0
- package/http/json-api-query.js +53 -0
- package/http/logger.js +167 -0
- package/http/main.js +140 -0
- package/http/precondition.js +53 -0
- package/http/prefer.js +36 -0
- package/http/rate-limit.js +66 -0
- package/http/security-headers.js +62 -0
- package/http/send.js +399 -0
- package/http/timeout.js +47 -0
- package/http/url.js +47 -0
- package/http/validate.js +84 -0
- package/lib/accepts.js +49 -0
- package/lib/attach-instance.js +23 -0
- package/lib/authorization.js +187 -0
- package/lib/body/multiparse.js +173 -0
- package/lib/body/multipart/headers.js +69 -0
- package/lib/body/writer.js +73 -0
- package/lib/cookie/cookie.js +192 -0
- package/lib/cookie/index.js +14 -0
- package/lib/cookie/jar.js +106 -0
- package/lib/cookie/parse.js +101 -0
- package/lib/cors.js +191 -0
- package/lib/csrf.js +96 -0
- package/lib/from-connect.js +69 -0
- package/lib/json-api-query/index.js +25 -0
- package/lib/json-api-query/schema.json +105 -0
- package/lib/json-api-query/validate.js +56 -0
- package/lib/link.js +96 -0
- package/lib/prefer.js +52 -0
- package/lib/query.js +113 -0
- package/lib/rate-limit.js +115 -0
- package/lib/sanitize-quoted-string.js +28 -0
- package/lib/security-headers.js +125 -0
- package/lib/validate.js +80 -0
- package/lib/vary.js +40 -0
- package/package.json +158 -0
- package/types/http/accepts.d.ts +8 -0
- package/types/http/authorization.d.ts +8 -0
- package/types/http/body.d.ts +20 -0
- package/types/http/cache-control.d.ts +16 -0
- package/types/http/compress.d.ts +5 -0
- package/types/http/cookie.d.ts +2 -0
- package/types/http/cors.d.ts +9 -0
- package/types/http/csrf.d.ts +9 -0
- package/types/http/handler.d.ts +2 -0
- package/types/http/index.d.ts +1 -0
- package/types/http/json-api-query.d.ts +2 -0
- package/types/http/logger.d.ts +9 -0
- package/types/http/main.d.ts +142 -0
- package/types/http/precondition.d.ts +44 -0
- package/types/http/prefer.d.ts +2 -0
- package/types/http/rate-limit.d.ts +17 -0
- package/types/http/security-headers.d.ts +10 -0
- package/types/http/send.d.ts +8 -0
- package/types/http/timeout.d.ts +5 -0
- package/types/http/url.d.ts +2 -0
- package/types/http/validate.d.ts +6 -0
- package/types/lib/accepts.d.ts +7 -0
- package/types/lib/attach-instance.d.ts +19 -0
- package/types/lib/authorization.d.ts +6 -0
- package/types/lib/body/multiparse.d.ts +9 -0
- package/types/lib/body/multipart/headers.d.ts +2 -0
- package/types/lib/body/writer.d.ts +2 -0
- package/types/lib/cookie/cookie.d.ts +32 -0
- package/types/lib/cookie/index.d.ts +2 -0
- package/types/lib/cookie/jar.d.ts +8 -0
- package/types/lib/cookie/parse.d.ts +19 -0
- package/types/lib/cors.d.ts +9 -0
- package/types/lib/csrf.d.ts +32 -0
- package/types/lib/from-connect.d.ts +47 -0
- package/types/lib/json-api-query/index.d.ts +123 -0
- package/types/lib/json-api-query/validate.d.ts +5 -0
- package/types/lib/link.d.ts +37 -0
- package/types/lib/prefer.d.ts +36 -0
- package/types/lib/query.d.ts +6 -0
- package/types/lib/rate-limit.d.ts +76 -0
- package/types/lib/sanitize-quoted-string.d.ts +19 -0
- package/types/lib/security-headers.d.ts +24 -0
- package/types/lib/validate.d.ts +16 -0
- package/types/lib/vary.d.ts +17 -0
- package/types/utils/attempt.d.ts +2 -0
- package/types/utils/buffers/index.d.ts +2 -0
- package/types/utils/buffers/match.d.ts +10 -0
- package/types/utils/buffers/split.d.ts +10 -0
- package/types/utils/compose-with.d.ts +40 -0
- package/types/utils/compose.d.ts +83 -0
- package/types/utils/flat-array.d.ts +2 -0
- package/types/utils/get.d.ts +5 -0
- package/types/utils/http-errors.d.ts +22 -0
- package/types/utils/iterables/buffer-split.d.ts +2 -0
- package/types/utils/iterables/chain.d.ts +2 -0
- package/types/utils/iterables/exec-all.d.ts +2 -0
- package/types/utils/iterables/filter.d.ts +2 -0
- package/types/utils/iterables/for-each.d.ts +2 -0
- package/types/utils/iterables/from-stream.d.ts +2 -0
- package/types/utils/iterables/index.d.ts +10 -0
- package/types/utils/iterables/map.d.ts +2 -0
- package/types/utils/iterables/range.d.ts +24 -0
- package/types/utils/iterables/reduce.d.ts +2 -0
- package/types/utils/iterables/take.d.ts +2 -0
- package/types/utils/observables/buffer-split.d.ts +2 -0
- package/types/utils/observables/chain.d.ts +2 -0
- package/types/utils/observables/index.d.ts +4 -0
- package/types/utils/observables/map.d.ts +2 -0
- package/types/utils/observables/take.d.ts +2 -0
- package/types/utils/pick.d.ts +2 -0
- package/types/utils/set.d.ts +2 -0
- package/types/utils/streams/index.d.ts +2 -0
- package/types/utils/streams/meter.d.ts +5 -0
- package/types/utils/streams/tee.d.ts +2 -0
- package/types/utils/type.d.ts +2 -0
- package/utils/attempt.js +37 -0
- package/utils/buffers/index.js +13 -0
- package/utils/buffers/match.js +96 -0
- package/utils/buffers/split.js +55 -0
- package/utils/compose-with.js +232 -0
- package/utils/compose.js +165 -0
- package/utils/flat-array.js +24 -0
- package/utils/get.js +39 -0
- package/utils/http-errors.js +113 -0
- package/utils/iterables/buffer-split.js +117 -0
- package/utils/iterables/chain.js +32 -0
- package/utils/iterables/exec-all.js +42 -0
- package/utils/iterables/filter.js +35 -0
- package/utils/iterables/for-each.js +33 -0
- package/utils/iterables/from-stream.js +29 -0
- package/utils/iterables/index.js +21 -0
- package/utils/iterables/map.js +47 -0
- package/utils/iterables/range.js +34 -0
- package/utils/iterables/reduce.js +43 -0
- package/utils/iterables/take.js +36 -0
- package/utils/observables/buffer-split.js +109 -0
- package/utils/observables/chain.js +33 -0
- package/utils/observables/index.js +19 -0
- package/utils/observables/map.js +34 -0
- package/utils/observables/take.js +40 -0
- package/utils/pick.js +41 -0
- package/utils/set.js +38 -0
- package/utils/streams/index.js +11 -0
- package/utils/streams/meter.js +98 -0
- package/utils/streams/tee.js +84 -0
- 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
|
+
}
|
package/http/timeout.js
ADDED
|
@@ -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
|
+
};
|
package/http/validate.js
ADDED
|
@@ -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
|
+
}
|