@zudojs/middleware 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +163 -16
- package/dist/middlewareCore/index.d.ts +1 -1
- package/dist/middlewareCore/index.js +1 -1
- package/dist/middlewareCore/middlewareCore.compose.d.ts +36 -4
- package/dist/middlewareCore/middlewareCore.compose.js +53 -14
- package/dist/middlewareErrors/index.d.ts +1 -1
- package/dist/middlewareErrors/index.js +1 -1
- package/dist/middlewareErrors/middlewareError.base.d.ts +30 -0
- package/dist/middlewareErrors/middlewareError.base.js +43 -0
- package/dist/middlewarePipeline/middlewarePipeline.core.d.ts +3 -1
- package/dist/middlewarePipeline/middlewarePipeline.core.js +99 -21
- package/dist/middlewareTypes/index.d.ts +1 -1
- package/dist/middlewareTypes/middlewareContext.type.d.ts +81 -12
- package/dist/middlewareUtils/index.d.ts +1 -1
- package/dist/middlewareUtils/index.js +1 -1
- package/dist/middlewareUtils/middlewareUtils.builtins.d.ts +87 -8
- package/dist/middlewareUtils/middlewareUtils.builtins.js +179 -28
- package/package.json +20 -13
- package/dist/.tsbuildinfo +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/middlewareCore/index.d.ts.map +0 -1
- package/dist/middlewareCore/index.js.map +0 -1
- package/dist/middlewareCore/middlewareCore.compose.d.ts.map +0 -1
- package/dist/middlewareCore/middlewareCore.compose.js.map +0 -1
- package/dist/middlewareErrors/index.d.ts.map +0 -1
- package/dist/middlewareErrors/index.js.map +0 -1
- package/dist/middlewareErrors/middlewareError.base.d.ts.map +0 -1
- package/dist/middlewareErrors/middlewareError.base.js.map +0 -1
- package/dist/middlewarePipeline/index.d.ts.map +0 -1
- package/dist/middlewarePipeline/index.js.map +0 -1
- package/dist/middlewarePipeline/middlewarePipeline.core.d.ts.map +0 -1
- package/dist/middlewarePipeline/middlewarePipeline.core.js.map +0 -1
- package/dist/middlewareTypes/index.d.ts.map +0 -1
- package/dist/middlewareTypes/index.js.map +0 -1
- package/dist/middlewareTypes/middlewareContext.type.d.ts.map +0 -1
- package/dist/middlewareTypes/middlewareContext.type.js.map +0 -1
- package/dist/middlewareTypes/middlewareDefinition.type.d.ts.map +0 -1
- package/dist/middlewareTypes/middlewareDefinition.type.js.map +0 -1
- package/dist/middlewareUtils/index.d.ts.map +0 -1
- package/dist/middlewareUtils/index.js.map +0 -1
- package/dist/middlewareUtils/middlewareUtils.builtins.d.ts.map +0 -1
- package/dist/middlewareUtils/middlewareUtils.builtins.js.map +0 -1
|
@@ -3,26 +3,62 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module middlewareUtils/middlewareUtils
|
|
5
5
|
*/
|
|
6
|
+
import { MiddlewareError, MiddlewareRateLimitError, MiddlewareTimeoutError, } from "../middlewareErrors/middlewareError.base.js";
|
|
7
|
+
/** Longest field value written into a log line before truncation. */
|
|
8
|
+
const MAX_LOG_FIELD_LENGTH = 256;
|
|
9
|
+
/** Control characters, which are what let a value break out of its log line. */
|
|
10
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/g;
|
|
11
|
+
/**
|
|
12
|
+
* Make a caller-supplied value safe to write into a single-line log.
|
|
13
|
+
*
|
|
14
|
+
* CR and LF above all are what let a request path forge extra log lines, so
|
|
15
|
+
* control characters are escaped rather than dropped — the original stays
|
|
16
|
+
* visible without being able to break out of its line.
|
|
17
|
+
*/
|
|
18
|
+
export function sanitizeLogValue(value, maxLength = MAX_LOG_FIELD_LENGTH) {
|
|
19
|
+
const text = typeof value === "string" ? value : String(value);
|
|
20
|
+
const escaped = text.replace(CONTROL_CHARACTERS, (char) => {
|
|
21
|
+
if (char === "\n")
|
|
22
|
+
return "\\n";
|
|
23
|
+
if (char === "\r")
|
|
24
|
+
return "\\r";
|
|
25
|
+
if (char === "\t")
|
|
26
|
+
return "\\t";
|
|
27
|
+
return `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`;
|
|
28
|
+
});
|
|
29
|
+
return escaped.length > maxLength
|
|
30
|
+
? `${escaped.slice(0, maxLength)}…`
|
|
31
|
+
: escaped;
|
|
32
|
+
}
|
|
6
33
|
/**
|
|
7
34
|
* Create a logging middleware.
|
|
8
35
|
*
|
|
9
|
-
* Logs request start, completion, and errors.
|
|
36
|
+
* Logs request start, completion, and errors. Every interpolated field is
|
|
37
|
+
* escaped, so a path containing newlines cannot forge log lines.
|
|
10
38
|
*/
|
|
11
|
-
export function loggingMiddleware(logger) {
|
|
12
|
-
const log = logger ?? console.log;
|
|
39
|
+
export function loggingMiddleware(logger, options) {
|
|
40
|
+
const log = logger ?? ((msg) => console.log(msg));
|
|
41
|
+
const maxFieldLength = options?.maxFieldLength ?? MAX_LOG_FIELD_LENGTH;
|
|
42
|
+
const includeErrorMessage = options?.includeErrorMessage ?? false;
|
|
13
43
|
return {
|
|
14
44
|
name: "logging",
|
|
15
45
|
handler: async (ctx, next) => {
|
|
16
46
|
const start = performance.now();
|
|
17
|
-
|
|
47
|
+
const method = sanitizeLogValue(ctx.method ?? "UNKNOWN", maxFieldLength);
|
|
48
|
+
const path = sanitizeLogValue(ctx.path ?? "/", maxFieldLength);
|
|
49
|
+
log(`[middleware] → ${method} ${path}`);
|
|
18
50
|
try {
|
|
19
|
-
await next();
|
|
51
|
+
const result = await next();
|
|
20
52
|
const ms = (performance.now() - start).toFixed(1);
|
|
21
53
|
log(`[middleware] ✓ completed in ${ms}ms`);
|
|
54
|
+
return result;
|
|
22
55
|
}
|
|
23
56
|
catch (error) {
|
|
24
57
|
const ms = (performance.now() - start).toFixed(1);
|
|
25
|
-
|
|
58
|
+
const detail = includeErrorMessage
|
|
59
|
+
? `: ${sanitizeLogValue(error instanceof Error ? error.message : error, maxFieldLength)}`
|
|
60
|
+
: "";
|
|
61
|
+
log(`[middleware] ✗ failed in ${ms}ms${detail}`);
|
|
26
62
|
throw error;
|
|
27
63
|
}
|
|
28
64
|
},
|
|
@@ -31,9 +67,11 @@ export function loggingMiddleware(logger) {
|
|
|
31
67
|
/**
|
|
32
68
|
* Create an error-handling middleware.
|
|
33
69
|
*
|
|
34
|
-
*
|
|
70
|
+
* Reports errors through `onError` and rethrows them. A reporter that throws
|
|
71
|
+
* cannot replace the error it was reporting — its own failure goes to
|
|
72
|
+
* `onReporterError` instead.
|
|
35
73
|
*/
|
|
36
|
-
export function errorMiddleware(onError) {
|
|
74
|
+
export function errorMiddleware(onError, onReporterError) {
|
|
37
75
|
return {
|
|
38
76
|
name: "error-handler",
|
|
39
77
|
priority: 0,
|
|
@@ -42,7 +80,12 @@ export function errorMiddleware(onError) {
|
|
|
42
80
|
return await next();
|
|
43
81
|
}
|
|
44
82
|
catch (error) {
|
|
45
|
-
|
|
83
|
+
try {
|
|
84
|
+
onError?.(error, ctx);
|
|
85
|
+
}
|
|
86
|
+
catch (reporterError) {
|
|
87
|
+
onReporterError?.(reporterError);
|
|
88
|
+
}
|
|
46
89
|
throw error;
|
|
47
90
|
}
|
|
48
91
|
},
|
|
@@ -51,40 +94,148 @@ export function errorMiddleware(onError) {
|
|
|
51
94
|
/**
|
|
52
95
|
* Create a timeout middleware.
|
|
53
96
|
*
|
|
54
|
-
* Rejects if the
|
|
97
|
+
* Rejects with a {@link MiddlewareTimeoutError} if the rest of the pipeline
|
|
98
|
+
* takes too long. The timer is always cleared, so a fast request leaves
|
|
99
|
+
* nothing pending on the event loop, and the losing promise stays handled so
|
|
100
|
+
* a late rejection cannot surface as an unhandled rejection.
|
|
101
|
+
*
|
|
102
|
+
* The downstream work is not cancelled — nothing in the middleware contract
|
|
103
|
+
* can cancel it. Use the pipeline's `signal` option, or carry an
|
|
104
|
+
* `AbortSignal` on your own context, when the work itself needs to stop.
|
|
55
105
|
*/
|
|
56
|
-
export function timeoutMiddleware(timeoutMs) {
|
|
106
|
+
export function timeoutMiddleware(timeoutMs, options) {
|
|
107
|
+
const name = options?.name ?? "timeout";
|
|
108
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
109
|
+
throw new MiddlewareError(`Timeout must be a positive, finite number of milliseconds; received ${timeoutMs}`, { middlewareName: name });
|
|
110
|
+
}
|
|
57
111
|
return {
|
|
58
|
-
name:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
112
|
+
// The configured name, not a constant: two timeouts in one pipeline were
|
|
113
|
+
// both reported as "timeout" in `executedMiddleware` and in error
|
|
114
|
+
// attribution, so `options.name` reached the error message and nothing
|
|
115
|
+
// else.
|
|
116
|
+
name,
|
|
117
|
+
handler: async (_ctx, next) => {
|
|
118
|
+
let timer;
|
|
119
|
+
const pending = next();
|
|
120
|
+
// Keep the loser of the race handled: if the timeout wins and the
|
|
121
|
+
// downstream work rejects later, that rejection must not escape.
|
|
122
|
+
void pending.catch(() => { });
|
|
123
|
+
try {
|
|
124
|
+
return await Promise.race([
|
|
125
|
+
pending,
|
|
126
|
+
new Promise((_resolve, reject) => {
|
|
127
|
+
timer = setTimeout(() => {
|
|
128
|
+
reject(new MiddlewareTimeoutError(name, timeoutMs));
|
|
129
|
+
}, timeoutMs);
|
|
130
|
+
if (typeof timer === "object" && "unref" in timer) {
|
|
131
|
+
timer.unref();
|
|
132
|
+
}
|
|
133
|
+
}),
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
if (timer !== undefined)
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
64
140
|
},
|
|
65
141
|
};
|
|
66
142
|
}
|
|
67
143
|
/**
|
|
68
144
|
* Create a rate-limiting middleware.
|
|
69
145
|
*
|
|
70
|
-
*
|
|
146
|
+
* Implements a true sliding window: each key keeps the timestamps of its
|
|
147
|
+
* requests within `windowMs`, so a client cannot burst `2 × maxRequests`
|
|
148
|
+
* across a window boundary the way a fixed-window counter allows.
|
|
149
|
+
*
|
|
150
|
+
* Rejections throw {@link MiddlewareRateLimitError}, which carries
|
|
151
|
+
* `retryAfterMs` for an HTTP adapter to turn into a 429 with `Retry-After`.
|
|
152
|
+
*
|
|
153
|
+
* State is per-instance and in-process. Behind more than one instance of a
|
|
154
|
+
* service, each process enforces its own limit; use a shared store for a
|
|
155
|
+
* cluster-wide one.
|
|
71
156
|
*/
|
|
72
|
-
export function rateLimitMiddleware(maxRequests, windowMs) {
|
|
73
|
-
|
|
157
|
+
export function rateLimitMiddleware(maxRequests, windowMs, options) {
|
|
158
|
+
if (!Number.isInteger(maxRequests) || maxRequests <= 0) {
|
|
159
|
+
throw new MiddlewareError(`maxRequests must be a positive integer; received ${maxRequests}`, { middlewareName: "rate-limit" });
|
|
160
|
+
}
|
|
161
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0) {
|
|
162
|
+
throw new MiddlewareError(`windowMs must be a positive, finite number; received ${windowMs}`, { middlewareName: "rate-limit" });
|
|
163
|
+
}
|
|
164
|
+
const maxKeys = options?.maxKeys ?? 10_000;
|
|
165
|
+
const sweepIntervalMs = options?.sweepIntervalMs ?? 60_000;
|
|
166
|
+
const rejectUnkeyed = options?.rejectUnkeyed ?? false;
|
|
167
|
+
/** key → request timestamps inside the current window, oldest first. */
|
|
168
|
+
const hits = new Map();
|
|
169
|
+
let lastSweep = Date.now();
|
|
170
|
+
function prune(timestamps, now) {
|
|
171
|
+
const cutoff = now - windowMs;
|
|
172
|
+
let firstLive = 0;
|
|
173
|
+
while (firstLive < timestamps.length && timestamps[firstLive] <= cutoff) {
|
|
174
|
+
firstLive++;
|
|
175
|
+
}
|
|
176
|
+
return timestamps.slice(firstLive);
|
|
177
|
+
}
|
|
178
|
+
function sweep(now) {
|
|
179
|
+
for (const [key, timestamps] of hits) {
|
|
180
|
+
const live = prune(timestamps, now);
|
|
181
|
+
if (live.length === 0)
|
|
182
|
+
hits.delete(key);
|
|
183
|
+
else
|
|
184
|
+
hits.set(key, live);
|
|
185
|
+
}
|
|
186
|
+
lastSweep = now;
|
|
187
|
+
}
|
|
188
|
+
function enforceKeyBudget(now) {
|
|
189
|
+
if (hits.size <= maxKeys)
|
|
190
|
+
return;
|
|
191
|
+
sweep(now);
|
|
192
|
+
// Map preserves insertion order and every touch re-inserts, so the head
|
|
193
|
+
// of the iteration order is the least recently seen key.
|
|
194
|
+
while (hits.size > maxKeys) {
|
|
195
|
+
const oldest = hits.keys().next();
|
|
196
|
+
if (oldest.done === true)
|
|
197
|
+
break;
|
|
198
|
+
hits.delete(oldest.value);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
74
201
|
return {
|
|
75
202
|
name: "rate-limit",
|
|
203
|
+
inspect(key) {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const live = prune(hits.get(key) ?? [], now);
|
|
206
|
+
if (live.length === 0)
|
|
207
|
+
return undefined;
|
|
208
|
+
return {
|
|
209
|
+
count: live.length,
|
|
210
|
+
retryAfterMs: Math.max(0, live[0] + windowMs - now),
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
size() {
|
|
214
|
+
return hits.size;
|
|
215
|
+
},
|
|
216
|
+
reset() {
|
|
217
|
+
hits.clear();
|
|
218
|
+
},
|
|
76
219
|
handler: async (ctx, next) => {
|
|
77
|
-
const key = ctx.key ?? "global";
|
|
78
220
|
const now = Date.now();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
counts.set(key, { count: 1, resetAt: now + windowMs });
|
|
82
|
-
return next();
|
|
221
|
+
if (ctx.key === undefined && rejectUnkeyed) {
|
|
222
|
+
throw new MiddlewareError("Rate-limited request carried no key and unkeyed requests are rejected", { middlewareName: "rate-limit" });
|
|
83
223
|
}
|
|
84
|
-
|
|
85
|
-
|
|
224
|
+
const key = ctx.key ?? "global";
|
|
225
|
+
if (now - lastSweep >= sweepIntervalMs)
|
|
226
|
+
sweep(now);
|
|
227
|
+
const live = prune(hits.get(key) ?? [], now);
|
|
228
|
+
if (live.length >= maxRequests) {
|
|
229
|
+
hits.set(key, live);
|
|
230
|
+
const retryAfterMs = Math.max(1, live[0] + windowMs - now);
|
|
231
|
+
throw new MiddlewareRateLimitError(maxRequests, windowMs, retryAfterMs);
|
|
86
232
|
}
|
|
87
|
-
|
|
233
|
+
live.push(now);
|
|
234
|
+
// Delete first so the re-insert moves the key to the end of the
|
|
235
|
+
// iteration order, which is what makes eviction least-recently-seen.
|
|
236
|
+
hits.delete(key);
|
|
237
|
+
hits.set(key, live);
|
|
238
|
+
enforceKeyBudget(now);
|
|
88
239
|
return next();
|
|
89
240
|
},
|
|
90
241
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/middleware",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Composable middleware pipeline with composition, timing, error handling, and context propagation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,23 +15,19 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
|
-
"dist"
|
|
18
|
+
"dist",
|
|
19
|
+
"!dist/**/*.map",
|
|
20
|
+
"!dist/**/*.tsbuildinfo",
|
|
21
|
+
"!dist/.tsbuildinfo"
|
|
19
22
|
],
|
|
20
|
-
"scripts": {
|
|
21
|
-
"build": "tsc -p tsconfig.json",
|
|
22
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
23
|
-
"clean": "rm -rf dist",
|
|
24
|
-
"test": "vitest run",
|
|
25
|
-
"test:watch": "vitest"
|
|
26
|
-
},
|
|
27
23
|
"dependencies": {
|
|
28
|
-
"@zudojs/errors": "
|
|
24
|
+
"@zudojs/errors": "1.0.0"
|
|
29
25
|
},
|
|
30
26
|
"engines": {
|
|
31
27
|
"node": ">=24.0.0"
|
|
32
28
|
},
|
|
33
29
|
"devDependencies": {
|
|
34
|
-
"typescript": "
|
|
30
|
+
"typescript": "7.0.2",
|
|
35
31
|
"vitest": "^4.1.11"
|
|
36
32
|
},
|
|
37
33
|
"publishConfig": {
|
|
@@ -44,8 +40,19 @@
|
|
|
44
40
|
"composition"
|
|
45
41
|
],
|
|
46
42
|
"homepage": "https://github.com/oyinlola-tech/zudo#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
45
|
+
},
|
|
47
46
|
"repository": {
|
|
48
47
|
"type": "git",
|
|
49
|
-
"url": "https://github.com/oyinlola-tech/zudo"
|
|
48
|
+
"url": "https://github.com/oyinlola-tech/zudo",
|
|
49
|
+
"directory": "packages/middleware"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc -p tsconfig.json",
|
|
53
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
|
|
54
|
+
"clean": "rm -rf dist *.tsbuildinfo",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"test:watch": "vitest"
|
|
50
57
|
}
|
|
51
|
-
}
|
|
58
|
+
}
|