@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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Path-based compose utility for Ergo middleware pipelines (v2).
|
|
3
|
+
*
|
|
4
|
+
* Extends `utils/compose` with a two-accumulator model: a **domain accumulator** for
|
|
5
|
+
* inter-middleware data and a **response accumulator** for HTTP response construction.
|
|
6
|
+
*
|
|
7
|
+
* Each operation can be expressed as:
|
|
8
|
+
* - A plain function: `fn` (receives `(...args, domainAcc)`)
|
|
9
|
+
* - A tuple: `[fn, setPath]` where:
|
|
10
|
+
* - `fn` — the middleware function
|
|
11
|
+
* - `setPath` — the domain accumulator key to store the return value under
|
|
12
|
+
*
|
|
13
|
+
* Middleware returns are interpreted as `{value?, response?}`:
|
|
14
|
+
* - `value` is merged into the domain accumulator (at `setPath` for tuples, or
|
|
15
|
+
* `Object.assign` for plain functions)
|
|
16
|
+
* - `response` is merged into the response accumulator (headers append, scalars set)
|
|
17
|
+
* - When `responseAcc.statusCode` is set, serial iteration breaks immediately
|
|
18
|
+
* - `undefined` / `null` returns skip all merges
|
|
19
|
+
*
|
|
20
|
+
* Plain return values (without `value` or `response` keys) are treated as `{value: ret}`
|
|
21
|
+
* for compatibility with simple middleware that only produce domain data.
|
|
22
|
+
*
|
|
23
|
+
* @module utils/compose-with
|
|
24
|
+
* @version 0.2.0
|
|
25
|
+
* @since 0.1.0
|
|
26
|
+
* @requires ./compose.js
|
|
27
|
+
* @requires ./set.js
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* import compose, {createResponseAcc} from 'ergo/utils/compose-with';
|
|
31
|
+
*
|
|
32
|
+
* const responseAcc = createResponseAcc();
|
|
33
|
+
* const pipeline = compose(
|
|
34
|
+
* [logger(), 'log'],
|
|
35
|
+
* [authorization({...}), 'auth'],
|
|
36
|
+
* [body(), 'body'],
|
|
37
|
+
* (req, res, acc) => ({response: {body: process(acc.body), statusCode: 200}})
|
|
38
|
+
* );
|
|
39
|
+
*
|
|
40
|
+
* await pipeline(req, res, responseAcc, domainAcc);
|
|
41
|
+
* // responseAcc.statusCode, responseAcc.headers, etc.
|
|
42
|
+
*/
|
|
43
|
+
import {accumulator} from './compose.js';
|
|
44
|
+
import set from './set.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates a null-prototype response accumulator.
|
|
48
|
+
*
|
|
49
|
+
* The returned object has a non-enumerable `isResponseAcc` flag used by the pipeline
|
|
50
|
+
* to distinguish it from the domain accumulator in the argument list.
|
|
51
|
+
*
|
|
52
|
+
* @returns {object} - Null-prototype object with `isResponseAcc: true`
|
|
53
|
+
*/
|
|
54
|
+
export function createResponseAcc() {
|
|
55
|
+
const acc = Object.create(null);
|
|
56
|
+
Object.defineProperty(acc, 'isResponseAcc', {value: true});
|
|
57
|
+
return acc;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Merges a response patch into the response accumulator.
|
|
62
|
+
*
|
|
63
|
+
* - `headers` arrays are **appended** (additive across middleware)
|
|
64
|
+
* - All other properties are **assigned** (last writer wins)
|
|
65
|
+
*
|
|
66
|
+
* @param {object} responseAcc - Mutable response accumulator
|
|
67
|
+
* @param {object} patch - Response contribution from middleware
|
|
68
|
+
*/
|
|
69
|
+
export function mergeResponse(responseAcc, patch) {
|
|
70
|
+
if (patch.headers) {
|
|
71
|
+
responseAcc.headers ??= [];
|
|
72
|
+
responseAcc.headers.push(...patch.headers);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const key of Object.keys(patch)) {
|
|
76
|
+
if (key !== 'headers') responseAcc[key] = patch[key];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extracts `{value, response}` from a middleware return value.
|
|
82
|
+
*
|
|
83
|
+
* If the return is an object with a `value` or `response` key, those are used directly.
|
|
84
|
+
* Otherwise the entire return is treated as `{value: ret}` for backward compatibility
|
|
85
|
+
* with middleware that return plain domain data.
|
|
86
|
+
*
|
|
87
|
+
* @param {*} resolved - Resolved return value from middleware
|
|
88
|
+
* @returns {{value: *, response: *}} - Extracted value and response
|
|
89
|
+
*/
|
|
90
|
+
function extractReturn(resolved) {
|
|
91
|
+
if (
|
|
92
|
+
resolved !== null &&
|
|
93
|
+
typeof resolved === 'object' &&
|
|
94
|
+
!Array.isArray(resolved) &&
|
|
95
|
+
('value' in resolved || 'response' in resolved)
|
|
96
|
+
) {
|
|
97
|
+
return resolved;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {value: resolved};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Converts an operation spec into a normalized descriptor.
|
|
105
|
+
*
|
|
106
|
+
* @param {function|Array} op - A plain function or `[fn, setPath]` tuple
|
|
107
|
+
* @returns {{fn: function, setPath: string|undefined}} - Normalized descriptor
|
|
108
|
+
*/
|
|
109
|
+
function normalizeOp(op) {
|
|
110
|
+
if (!Array.isArray(op)) return {fn: op, setPath: undefined};
|
|
111
|
+
return {fn: op[0], setPath: op[1]};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Runs middleware descriptors sequentially with two-accumulator support.
|
|
116
|
+
*
|
|
117
|
+
* Uses a sync fast-path: when a middleware returns a non-thenable value, merging
|
|
118
|
+
* happens immediately without a microtask hop.
|
|
119
|
+
*
|
|
120
|
+
* @param {Array<{fn: function, setPath: string|undefined}>} descriptors - Normalized ops
|
|
121
|
+
* @param {*[]} args - Original request arguments (req, res, etc.)
|
|
122
|
+
* @param {object} domainAcc - Domain accumulator
|
|
123
|
+
* @param {object} responseAcc - Response accumulator
|
|
124
|
+
* @returns {object} - Final domain accumulator
|
|
125
|
+
*/
|
|
126
|
+
async function serial(descriptors, args, domainAcc, responseAcc) {
|
|
127
|
+
for (const {fn, setPath} of descriptors) {
|
|
128
|
+
const raw = fn(...args, domainAcc, responseAcc);
|
|
129
|
+
const resolved = typeof raw?.then === 'function' ? await raw : raw;
|
|
130
|
+
|
|
131
|
+
if (resolved == null) continue;
|
|
132
|
+
|
|
133
|
+
const {value, response} = extractReturn(resolved);
|
|
134
|
+
|
|
135
|
+
if (value !== undefined) {
|
|
136
|
+
if (setPath !== undefined) {
|
|
137
|
+
set(domainAcc, setPath, value);
|
|
138
|
+
} else if (typeof value === 'object') {
|
|
139
|
+
Object.assign(domainAcc, value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (response !== undefined) {
|
|
144
|
+
mergeResponse(responseAcc, response);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (responseAcc.statusCode !== undefined) break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return domainAcc;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Runs middleware descriptors concurrently with two-accumulator support.
|
|
155
|
+
*
|
|
156
|
+
* Each branch receives an isolated domain accumulator copy. After all branches
|
|
157
|
+
* complete, values and responses are merged in declaration order.
|
|
158
|
+
*
|
|
159
|
+
* @param {Array<{fn: function, setPath: string|undefined}>} descriptors - Normalized ops
|
|
160
|
+
* @param {*[]} args - Original request arguments
|
|
161
|
+
* @param {object} domainAcc - Domain accumulator
|
|
162
|
+
* @param {object} responseAcc - Response accumulator
|
|
163
|
+
* @returns {object} - Final domain accumulator
|
|
164
|
+
*/
|
|
165
|
+
async function concurrent(descriptors, args, domainAcc, responseAcc) {
|
|
166
|
+
const copies = descriptors.map(() => Object.assign(accumulator(), domainAcc));
|
|
167
|
+
const rets = descriptors.map(({fn}, i) => fn(...args, copies[i], responseAcc));
|
|
168
|
+
const hasAsync = rets.some(r => typeof r?.then === 'function');
|
|
169
|
+
const results = hasAsync ? await Promise.all(rets) : rets;
|
|
170
|
+
|
|
171
|
+
for (let i = 0; i < descriptors.length; i++) {
|
|
172
|
+
const resolved = results[i];
|
|
173
|
+
if (resolved == null) continue;
|
|
174
|
+
|
|
175
|
+
const {setPath} = descriptors[i];
|
|
176
|
+
const {value, response} = extractReturn(resolved);
|
|
177
|
+
|
|
178
|
+
if (value !== undefined) {
|
|
179
|
+
if (setPath !== undefined) {
|
|
180
|
+
set(domainAcc, setPath, value);
|
|
181
|
+
} else if (typeof value === 'object') {
|
|
182
|
+
Object.assign(domainAcc, value);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (response !== undefined) {
|
|
187
|
+
mergeResponse(responseAcc, response);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return domainAcc;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Composes middleware with path-based result storage and two-accumulator support.
|
|
196
|
+
*
|
|
197
|
+
* The composed function pops the domain accumulator (last arg with `isAccumulator`)
|
|
198
|
+
* and response accumulator (last arg with `isResponseAcc`) from its arguments.
|
|
199
|
+
* If either is absent, a fresh one is created.
|
|
200
|
+
*
|
|
201
|
+
* @param {...(function|Array)} ops - Operation specs; each is a function or `[fn, setPath]`
|
|
202
|
+
* @returns {function} - Async composed pipeline
|
|
203
|
+
*/
|
|
204
|
+
const composeWith = (...ops) => {
|
|
205
|
+
const descriptors = ops.map(normalizeOp);
|
|
206
|
+
|
|
207
|
+
return async (...args) => {
|
|
208
|
+
const domainAcc = args.at(-1)?.isAccumulator ? args.pop() : accumulator();
|
|
209
|
+
const responseAcc = args.at(-1)?.isResponseAcc ? args.pop() : createResponseAcc();
|
|
210
|
+
|
|
211
|
+
return await serial(descriptors, args, domainAcc, responseAcc);
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Concurrent variant of composeWith.
|
|
217
|
+
*
|
|
218
|
+
* @param {...(function|Array)} ops - Operation specs
|
|
219
|
+
* @returns {function} - Async composed pipeline
|
|
220
|
+
*/
|
|
221
|
+
composeWith.all = (...ops) => {
|
|
222
|
+
const descriptors = ops.map(normalizeOp);
|
|
223
|
+
|
|
224
|
+
return async (...args) => {
|
|
225
|
+
const domainAcc = args.at(-1)?.isAccumulator ? args.pop() : accumulator();
|
|
226
|
+
const responseAcc = args.at(-1)?.isResponseAcc ? args.pop() : createResponseAcc();
|
|
227
|
+
|
|
228
|
+
return await concurrent(descriptors, args, domainAcc, responseAcc);
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export default composeWith;
|
package/utils/compose.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Async pipeline composition utility.
|
|
3
|
+
*
|
|
4
|
+
* Creates an async pipeline from a list of middleware functions. Each function receives
|
|
5
|
+
* the original arguments plus the accumulated state object from prior middleware.
|
|
6
|
+
*
|
|
7
|
+
* - `compose(...fns)` — runs functions sequentially (`serial`)
|
|
8
|
+
* - `compose.all(...fns)` — runs functions concurrently and merges all results
|
|
9
|
+
* - `compose.withOptions(options, ...fns)` — sequential with options (e.g. `breakWhen`)
|
|
10
|
+
* - `compose.all.withOptions(options, ...fns)` — concurrent with options
|
|
11
|
+
*
|
|
12
|
+
* State is accumulated into a null-prototype accumulator object with an
|
|
13
|
+
* `isAccumulator: true` flag and a `size` getter. If the last argument passed to the
|
|
14
|
+
* composed pipeline is already an accumulator object, it is reused for accumulation.
|
|
15
|
+
*
|
|
16
|
+
* Serial composition uses a sync fast-path: when a middleware returns a non-thenable
|
|
17
|
+
* value, the result is merged immediately without scheduling a microtask. This
|
|
18
|
+
* eliminates unnecessary `await` overhead for synchronous middleware (the majority
|
|
19
|
+
* of ergo's built-in middleware).
|
|
20
|
+
*
|
|
21
|
+
* @module utils/compose
|
|
22
|
+
* @version 0.2.0
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* import compose from 'ergo/utils/compose';
|
|
27
|
+
*
|
|
28
|
+
* const pipeline = compose(
|
|
29
|
+
* async (req, res) => ({user: await getUser(req)}),
|
|
30
|
+
* async (req, res, {user}) => ({role: await getRole(user)})
|
|
31
|
+
* );
|
|
32
|
+
*
|
|
33
|
+
* const result = await pipeline(req, res);
|
|
34
|
+
* // result.user, result.role
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* // Early termination with breakWhen
|
|
38
|
+
* const pipeline = compose.withOptions(
|
|
39
|
+
* {breakWhen: (acc) => acc.done},
|
|
40
|
+
* () => ({done: true, a: 1}),
|
|
41
|
+
* () => ({b: 2}) // never reached
|
|
42
|
+
* );
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Composes middleware functions into an async pipeline with result accumulation.
|
|
46
|
+
*
|
|
47
|
+
* @param {...function} fns - Middleware functions to compose
|
|
48
|
+
* @returns {function} - Async composed pipeline
|
|
49
|
+
*/
|
|
50
|
+
const compose = (...fns) => setup(serial, fns);
|
|
51
|
+
compose.all = (...fns) => setup(concurrent, fns);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates a sequential pipeline with configuration options.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} options - Pipeline options
|
|
57
|
+
* @param {function} [options.breakWhen] - Predicate `(acc) => boolean`; when truthy,
|
|
58
|
+
* serial iteration stops after the current step's result is merged
|
|
59
|
+
* @param {...function} fns - Middleware functions to compose
|
|
60
|
+
* @returns {function} - Async composed pipeline
|
|
61
|
+
*/
|
|
62
|
+
compose.withOptions = (options, ...fns) => setup(serial, fns, options);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Creates a concurrent pipeline with configuration options.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} options - Pipeline options
|
|
68
|
+
* @param {...function} fns - Middleware functions to compose
|
|
69
|
+
* @returns {function} - Async composed pipeline
|
|
70
|
+
*/
|
|
71
|
+
compose.all.withOptions = (options, ...fns) => setup(concurrent, fns, options);
|
|
72
|
+
|
|
73
|
+
export default compose;
|
|
74
|
+
export {accumulator};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Runs middleware functions sequentially, accumulating results.
|
|
78
|
+
*
|
|
79
|
+
* Uses a sync fast-path: if a function returns a non-thenable value the result
|
|
80
|
+
* is merged immediately, avoiding a microtask hop for synchronous middleware.
|
|
81
|
+
* The accumulator is passed directly (no defensive copy) because serial steps
|
|
82
|
+
* cannot observe concurrent mutations.
|
|
83
|
+
*
|
|
84
|
+
* When `breakWhen` is provided, the predicate is evaluated after each step's
|
|
85
|
+
* result is merged. If it returns a truthy value, iteration stops immediately.
|
|
86
|
+
*
|
|
87
|
+
* @param {function[]} fns - Ordered middleware functions
|
|
88
|
+
* @param {*[]} args - Original request arguments (req, res, etc.)
|
|
89
|
+
* @param {object} acc - Accumulator
|
|
90
|
+
* @param {object} [options] - Pipeline options
|
|
91
|
+
* @param {function} [options.breakWhen] - Predicate `(acc) => boolean`
|
|
92
|
+
* @returns {object} - Final accumulated state
|
|
93
|
+
*/
|
|
94
|
+
async function serial(fns, args, acc, {breakWhen} = {}) {
|
|
95
|
+
for (const f of fns) {
|
|
96
|
+
const ret = f(...args, acc);
|
|
97
|
+
Object.assign(acc, typeof ret?.then === 'function' ? await ret : ret);
|
|
98
|
+
|
|
99
|
+
if (breakWhen?.(acc)) break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return acc;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Runs middleware functions concurrently, merging all results.
|
|
107
|
+
*
|
|
108
|
+
* Each branch receives an isolated accumulator copy so concurrent functions
|
|
109
|
+
* cannot observe each other's mutations. When every branch returns a
|
|
110
|
+
* non-thenable value, `Promise.all` is skipped entirely.
|
|
111
|
+
*
|
|
112
|
+
* @param {function[]} fns - Middleware functions to run concurrently
|
|
113
|
+
* @param {*[]} args - Original request arguments
|
|
114
|
+
* @param {object} acc - Accumulator
|
|
115
|
+
* @param {object} [_options] - Pipeline options (unused for concurrent; accepted for API symmetry)
|
|
116
|
+
* @returns {object} - Merged accumulated state
|
|
117
|
+
*/
|
|
118
|
+
async function concurrent(fns, args, acc, _options) {
|
|
119
|
+
const copies = fns.map(() => accumulator(acc));
|
|
120
|
+
const rets = fns.map((f, i) => f(...args, copies[i]));
|
|
121
|
+
const hasAsync = rets.some(r => typeof r?.then === 'function');
|
|
122
|
+
|
|
123
|
+
if (hasAsync) {
|
|
124
|
+
return Object.assign(acc, ...(await Promise.all(rets)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return Object.assign(acc, ...rets);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Creates a composed async function with an initialized accumulator.
|
|
132
|
+
*
|
|
133
|
+
* @param {function} processor - `serial` or `concurrent`
|
|
134
|
+
* @param {function[]} fns - Middleware functions
|
|
135
|
+
* @param {object} [options] - Pipeline options forwarded to the processor
|
|
136
|
+
* @returns {function} - Async composed function `(...args) => Accumulator`
|
|
137
|
+
*/
|
|
138
|
+
function setup(processor, fns, options = {}) {
|
|
139
|
+
return async (...args) => {
|
|
140
|
+
const acc = args.length && args.at(-1)?.isAccumulator ? args.pop() : accumulator();
|
|
141
|
+
|
|
142
|
+
return await processor(fns, args, acc, options);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Creates a null-prototype accumulator object.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} [defaults={}] - Initial properties to copy into the accumulator
|
|
150
|
+
* @returns {object} - Null-prototype accumulator with `isAccumulator: true` and `size` getter
|
|
151
|
+
*/
|
|
152
|
+
function accumulator(defaults = {}) {
|
|
153
|
+
const acc = Object.create(null);
|
|
154
|
+
|
|
155
|
+
Object.defineProperties(acc, {
|
|
156
|
+
isAccumulator: {value: true},
|
|
157
|
+
size: {
|
|
158
|
+
get() {
|
|
159
|
+
return Object.keys(this).length;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return Object.assign(acc, defaults);
|
|
165
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Variadic flat-array utility.
|
|
3
|
+
*
|
|
4
|
+
* Flattens all arguments into a single array using `Array.prototype.flat()`.
|
|
5
|
+
* Equivalent to `[...args].flat()` — accepts any mix of scalars and nested arrays.
|
|
6
|
+
*
|
|
7
|
+
* @module utils/flat-array
|
|
8
|
+
* @version 0.1.0
|
|
9
|
+
* @since 0.1.0
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* import flatArray from 'ergo/utils/flat-array';
|
|
13
|
+
*
|
|
14
|
+
* flatArray('a', ['b', 'c'], 'd') // => ['a', 'b', 'c', 'd']
|
|
15
|
+
* flatArray(1, [2, 3], 4) // => [1, 2, 3, 4]
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {...*} items - Values or nested arrays to flatten
|
|
20
|
+
* @returns {Array} - Single-level flattened array
|
|
21
|
+
*/
|
|
22
|
+
export default (...items) => {
|
|
23
|
+
return items.flat();
|
|
24
|
+
};
|
package/utils/get.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Deep property getter with dot-notation path support.
|
|
3
|
+
*
|
|
4
|
+
* Traverses a nested object using a dot-delimited path string. Optionally:
|
|
5
|
+
* - Optionally invokes function values at each step (`invoke: false`, default)
|
|
6
|
+
* - Returns `undefined` gracefully when a step is missing (`safe: true`)
|
|
7
|
+
*
|
|
8
|
+
* @module utils/get
|
|
9
|
+
* @version 0.1.0
|
|
10
|
+
* @since 0.1.0
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* import get from 'ergo/utils/get';
|
|
14
|
+
*
|
|
15
|
+
* get({a: {b: {c: 42}}}, 'a.b.c') // => 42
|
|
16
|
+
* get({a: null}, 'a.b.c', {safe: true}) // => undefined (no throw)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} [obj={}] - Source object
|
|
21
|
+
* @param {string} [path=''] - Dot-delimited property path
|
|
22
|
+
* @param {object} [options] - Traversal options
|
|
23
|
+
* @param {boolean} [options.safe=false] - Return undefined instead of throwing on missing paths
|
|
24
|
+
* @param {boolean} [options.invoke=false] - Call function values at each path step
|
|
25
|
+
* @returns {*} - Value at the path, or undefined in safe mode
|
|
26
|
+
*/
|
|
27
|
+
export default (obj = {}, path = '', {safe = false, invoke = false} = {}) => {
|
|
28
|
+
let val = obj;
|
|
29
|
+
|
|
30
|
+
for (const subPath of path.split('.')) {
|
|
31
|
+
if (val == null && safe) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
val = typeof val[subPath] === 'function' && invoke ? val[subPath]() : val[subPath];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return val;
|
|
39
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview HTTP error factory producing RFC 9457 Problem Details errors.
|
|
3
|
+
*
|
|
4
|
+
* Creates `Error`-prototype objects with RFC 9457 (Problem Details for HTTP APIs)
|
|
5
|
+
* properties: `type`, `title`, `status`, `detail`, plus internal properties `headers`
|
|
6
|
+
* and `originalError`. Uses `Object.create(Error.prototype)` instead of `new Error()`
|
|
7
|
+
* to avoid V8's automatic stack trace capture (~10 µs overhead per call). This is
|
|
8
|
+
* critical in hot paths like rate-limit rejections where errors are expected control
|
|
9
|
+
* flow, not exceptional conditions. The objects pass `instanceof Error` checks.
|
|
10
|
+
*
|
|
11
|
+
* The `toJSON()` method serializes to RFC 9457 format automatically when
|
|
12
|
+
* `JSON.stringify()` is called (e.g. by `send()`).
|
|
13
|
+
*
|
|
14
|
+
* All Ergo middleware throws these errors directly. The `handler()` middleware
|
|
15
|
+
* catches them and forwards to `send()` for serialization.
|
|
16
|
+
*
|
|
17
|
+
* @module utils/http-errors
|
|
18
|
+
* @version 0.1.0
|
|
19
|
+
* @since 0.1.0
|
|
20
|
+
* @requires node:http
|
|
21
|
+
*
|
|
22
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc9457 RFC 9457 - Problem Details for HTTP APIs}
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* import httpErrors from 'ergo/utils/http-errors';
|
|
26
|
+
*
|
|
27
|
+
* throw httpErrors(404);
|
|
28
|
+
* // Error { name: 'NotFound', status: 404, type: 'https://...', title: 'Not Found', detail: 'Not Found' }
|
|
29
|
+
* // JSON.stringify → {"type":"https://...","title":"Not Found","status":404,"detail":"Not Found"}
|
|
30
|
+
*
|
|
31
|
+
* throw httpErrors(422, {detail: 'Invalid email', details: errors});
|
|
32
|
+
* // Error { name: 'UnprocessableEntity', status: 422, detail: 'Invalid email', details: [...] }
|
|
33
|
+
*/
|
|
34
|
+
import {STATUS_CODES} from 'node:http';
|
|
35
|
+
|
|
36
|
+
const baseUrl = 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {number} [statusCode=500] - HTTP status code
|
|
40
|
+
* @param {object} [options] - RFC 9457 Problem Details fields
|
|
41
|
+
* @param {string} [options.type] - RFC 9457 `type` URI (defaults to MDN docs link)
|
|
42
|
+
* @param {string} [options.detail] - RFC 9457 `detail` (human-readable explanation)
|
|
43
|
+
* @param {string} [options.message] - Alias for `detail` (backward compat)
|
|
44
|
+
* @param {Array<[string, string]>} [options.headers] - Response headers to attach
|
|
45
|
+
* @param {string} [options.instance] - RFC 9457 `instance` URI identifying the specific occurrence
|
|
46
|
+
* @param {number|string} [options.retryAfter] - Retry-After value (seconds or HTTP-date).
|
|
47
|
+
* Auto-appended to `headers` as `['Retry-After', String(value)]` and included in toJSON().
|
|
48
|
+
* @param {Error} [options.originalError] - Underlying error
|
|
49
|
+
* @returns {Error} - Error with RFC 9457 properties and `toJSON()` method
|
|
50
|
+
*/
|
|
51
|
+
export default function httpErrors(
|
|
52
|
+
statusCode = 500,
|
|
53
|
+
{
|
|
54
|
+
type = STATUS_CODES[statusCode] ? `${baseUrl}${statusCode}` : undefined,
|
|
55
|
+
message,
|
|
56
|
+
detail: rawDetail = message,
|
|
57
|
+
headers,
|
|
58
|
+
instance,
|
|
59
|
+
retryAfter,
|
|
60
|
+
originalError,
|
|
61
|
+
...extra
|
|
62
|
+
} = {}
|
|
63
|
+
) {
|
|
64
|
+
const title = STATUS_CODES[statusCode] ?? STATUS_CODES[500];
|
|
65
|
+
const detail = rawDetail ?? title;
|
|
66
|
+
const name = title.replace(/\s/g, '');
|
|
67
|
+
|
|
68
|
+
const effectiveHeaders =
|
|
69
|
+
retryAfter != null ? [...(headers ?? []), ['Retry-After', String(retryAfter)]] : headers;
|
|
70
|
+
|
|
71
|
+
if (retryAfter != null) {
|
|
72
|
+
extra.retryAfter = retryAfter;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const err = Object.create(Error.prototype);
|
|
76
|
+
err.message = detail;
|
|
77
|
+
err.name = name;
|
|
78
|
+
err.statusCode = statusCode;
|
|
79
|
+
err.status = statusCode;
|
|
80
|
+
err.type = type;
|
|
81
|
+
err.title = title;
|
|
82
|
+
err.detail = detail;
|
|
83
|
+
err.instance = instance;
|
|
84
|
+
err.headers = effectiveHeaders;
|
|
85
|
+
err.originalError = originalError;
|
|
86
|
+
if (extra) Object.assign(err, extra);
|
|
87
|
+
err.message = detail;
|
|
88
|
+
err.name = name;
|
|
89
|
+
err.statusCode = statusCode;
|
|
90
|
+
err.status = statusCode;
|
|
91
|
+
err.type = type;
|
|
92
|
+
err.title = title;
|
|
93
|
+
err.detail = detail;
|
|
94
|
+
|
|
95
|
+
const extraKeys = Object.keys(extra);
|
|
96
|
+
|
|
97
|
+
Object.defineProperty(err, 'toJSON', {
|
|
98
|
+
value() {
|
|
99
|
+
const json = {};
|
|
100
|
+
for (const key of extraKeys) {
|
|
101
|
+
json[key] = this[key];
|
|
102
|
+
}
|
|
103
|
+
json.type = this.type;
|
|
104
|
+
json.title = this.title;
|
|
105
|
+
json.status = this.status;
|
|
106
|
+
json.detail = this.detail;
|
|
107
|
+
if (this.instance !== undefined) json.instance = this.instance;
|
|
108
|
+
return json;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return err;
|
|
113
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Streaming Buffer split generator for async iterable pipelines.
|
|
3
|
+
*
|
|
4
|
+
* Creates a generator function that consumes an iterable of Buffer chunks and yields
|
|
5
|
+
* `[index, buffer]` pairs split by the given separator. Handles partial matches across
|
|
6
|
+
* chunk boundaries by carrying over unmatched bytes to the next iteration.
|
|
7
|
+
*
|
|
8
|
+
* Used by `lib/body/multiparse.js` for multipart boundary splitting.
|
|
9
|
+
*
|
|
10
|
+
* @module utils/iterables/buffer-split
|
|
11
|
+
* @version 0.1.0
|
|
12
|
+
* @since 0.1.0
|
|
13
|
+
* @requires ../buffers/split.js
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import {chain, bufferSplit, reduce} from 'ergo/utils/iterables';
|
|
17
|
+
*
|
|
18
|
+
* const chunks = [Buffer.from('a--b--c')];
|
|
19
|
+
* const result = chain(chunks, bufferSplit('--'), reduce((acc, [, b]) => [...acc, b.toString()], []));
|
|
20
|
+
* // => ['a', 'b', 'c']
|
|
21
|
+
*/
|
|
22
|
+
import split from '../buffers/split.js';
|
|
23
|
+
/**
|
|
24
|
+
* Creates a Buffer-splitting generator.
|
|
25
|
+
*
|
|
26
|
+
* @param {import('node:buffer').Buffer|string} [separator=Buffer.from('')] - Byte pattern to split on
|
|
27
|
+
* @param {number} [limit=Infinity] - Maximum number of parts to yield
|
|
28
|
+
* @returns {function} - Generator function `(iterable) => Generator<[number, import('node:buffer').Buffer]>`
|
|
29
|
+
*/
|
|
30
|
+
export default (separator = Buffer.from(''), limit = Infinity) => {
|
|
31
|
+
const sepBuffer = Buffer.isBuffer(separator) ? separator : Buffer.from(separator);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {Iterable} iterable - Source iterable of Buffers to split
|
|
35
|
+
*/
|
|
36
|
+
return function* (iterable) {
|
|
37
|
+
let partialCarryover = Buffer.from('');
|
|
38
|
+
let partialCarryoverIndexInc = false;
|
|
39
|
+
let index = 0;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
let buffers;
|
|
43
|
+
let lookup;
|
|
44
|
+
let partial;
|
|
45
|
+
|
|
46
|
+
for (let chunk of iterable) {
|
|
47
|
+
if (index >= limit) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Append the previous iteration's carryover from a partial match
|
|
52
|
+
// for the separator.
|
|
53
|
+
const pCoLen = partialCarryover.length;
|
|
54
|
+
|
|
55
|
+
if (pCoLen > 0) {
|
|
56
|
+
chunk = Buffer.concat([partialCarryover, chunk], pCoLen + chunk.length);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const incStart = partialCarryoverIndexInc && partial ? -1 : 0;
|
|
60
|
+
|
|
61
|
+
({buffers, lookup, partial} = split(chunk, sepBuffer, {limit, lookup, partial}));
|
|
62
|
+
/*
|
|
63
|
+
buffers:
|
|
64
|
+
A: [n] no matches
|
|
65
|
+
B: [0, 0] one match, entire chunk
|
|
66
|
+
C: [0, x[, ...]] one match at beginning and at least one more match after
|
|
67
|
+
D: [x[, ...], 0] one match at end and at least one more match before
|
|
68
|
+
E: [x[, ...], y] at least one match in the middle
|
|
69
|
+
|
|
70
|
+
index increment:
|
|
71
|
+
A: none
|
|
72
|
+
B: on second element
|
|
73
|
+
C: on second element and beyond
|
|
74
|
+
D: on second element and beyond
|
|
75
|
+
E: on second element and beyond
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
partialCarryoverIndexInc = buffers.length > 1;
|
|
79
|
+
|
|
80
|
+
if (partial > 0) {
|
|
81
|
+
if (partial === buffers.at(-1).length) {
|
|
82
|
+
partialCarryover = buffers.pop();
|
|
83
|
+
} else {
|
|
84
|
+
const last = buffers.at(-1);
|
|
85
|
+
partialCarryover = last.slice(-partial);
|
|
86
|
+
buffers[buffers.length - 1] = last.slice(0, -partial);
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
partialCarryover = Buffer.from('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < buffers.length; i++) {
|
|
93
|
+
if (i > incStart) {
|
|
94
|
+
index++;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (index >= limit) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const buffer = buffers[i];
|
|
102
|
+
|
|
103
|
+
yield [index, buffer];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
if (partialCarryoverIndexInc) {
|
|
108
|
+
index++;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Push the carryover if a buffer ends with a partial match
|
|
112
|
+
if (partialCarryover.length && index < limit) {
|
|
113
|
+
yield [index, partialCarryover];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
};
|