@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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -16
  3. package/dist/middlewareCore/index.d.ts +1 -1
  4. package/dist/middlewareCore/index.js +1 -1
  5. package/dist/middlewareCore/middlewareCore.compose.d.ts +36 -4
  6. package/dist/middlewareCore/middlewareCore.compose.js +53 -14
  7. package/dist/middlewareErrors/index.d.ts +1 -1
  8. package/dist/middlewareErrors/index.js +1 -1
  9. package/dist/middlewareErrors/middlewareError.base.d.ts +30 -0
  10. package/dist/middlewareErrors/middlewareError.base.js +43 -0
  11. package/dist/middlewarePipeline/middlewarePipeline.core.d.ts +3 -1
  12. package/dist/middlewarePipeline/middlewarePipeline.core.js +99 -21
  13. package/dist/middlewareTypes/index.d.ts +1 -1
  14. package/dist/middlewareTypes/middlewareContext.type.d.ts +81 -12
  15. package/dist/middlewareUtils/index.d.ts +1 -1
  16. package/dist/middlewareUtils/index.js +1 -1
  17. package/dist/middlewareUtils/middlewareUtils.builtins.d.ts +87 -8
  18. package/dist/middlewareUtils/middlewareUtils.builtins.js +179 -28
  19. package/package.json +20 -13
  20. package/dist/.tsbuildinfo +0 -1
  21. package/dist/index.d.ts.map +0 -1
  22. package/dist/index.js.map +0 -1
  23. package/dist/middlewareCore/index.d.ts.map +0 -1
  24. package/dist/middlewareCore/index.js.map +0 -1
  25. package/dist/middlewareCore/middlewareCore.compose.d.ts.map +0 -1
  26. package/dist/middlewareCore/middlewareCore.compose.js.map +0 -1
  27. package/dist/middlewareErrors/index.d.ts.map +0 -1
  28. package/dist/middlewareErrors/index.js.map +0 -1
  29. package/dist/middlewareErrors/middlewareError.base.d.ts.map +0 -1
  30. package/dist/middlewareErrors/middlewareError.base.js.map +0 -1
  31. package/dist/middlewarePipeline/index.d.ts.map +0 -1
  32. package/dist/middlewarePipeline/index.js.map +0 -1
  33. package/dist/middlewarePipeline/middlewarePipeline.core.d.ts.map +0 -1
  34. package/dist/middlewarePipeline/middlewarePipeline.core.js.map +0 -1
  35. package/dist/middlewareTypes/index.d.ts.map +0 -1
  36. package/dist/middlewareTypes/index.js.map +0 -1
  37. package/dist/middlewareTypes/middlewareContext.type.d.ts.map +0 -1
  38. package/dist/middlewareTypes/middlewareContext.type.js.map +0 -1
  39. package/dist/middlewareTypes/middlewareDefinition.type.d.ts.map +0 -1
  40. package/dist/middlewareTypes/middlewareDefinition.type.js.map +0 -1
  41. package/dist/middlewareUtils/index.d.ts.map +0 -1
  42. package/dist/middlewareUtils/index.js.map +0 -1
  43. package/dist/middlewareUtils/middlewareUtils.builtins.d.ts.map +0 -1
  44. 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
- log(`[middleware] → ${ctx.method ?? "UNKNOWN"} ${ctx.path ?? "/"}`);
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
- log(`[middleware] ✗ failed in ${ms}ms: ${error}`);
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
- * Catches errors and wraps them with context.
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
- onError?.(error, ctx);
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 pipeline takes too long.
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: "timeout",
59
- handler: async (ctx, next) => {
60
- return Promise.race([
61
- next(),
62
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Middleware timeout after ${timeoutMs}ms`)), timeoutMs)),
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
- * Uses a sliding window counter per key.
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
- const counts = new Map();
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
- const entry = counts.get(key);
80
- if (!entry || now > entry.resetAt) {
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
- if (entry.count >= maxRequests) {
85
- throw new Error(`Rate limit exceeded: ${maxRequests} requests per ${windowMs}ms`);
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
- entry.count++;
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": "0.1.0",
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": "0.1.0"
24
+ "@zudojs/errors": "1.0.0"
29
25
  },
30
26
  "engines": {
31
27
  "node": ">=24.0.0"
32
28
  },
33
29
  "devDependencies": {
34
- "typescript": "^7.0.2",
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
+ }