@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,8 +3,18 @@
3
3
  *
4
4
  * @module middlewarePipeline/middlewarePipeline
5
5
  */
6
- import { resolveMiddleware } from "../middlewareCore/middlewareCore.compose.js";
6
+ import { resolveNamedMiddleware } from "../middlewareCore/middlewareCore.compose.js";
7
+ import { MiddlewareAbortedError, MiddlewareLimitExceededError, MiddlewareNextCalledMultipleTimesError, } from "../middlewareErrors/middlewareError.base.js";
7
8
  const DEFAULT_MAX = 50;
9
+ /** Name recorded for a failure thrown by the final handler. */
10
+ const HANDLER_NAME = "handler";
11
+ function resolveErrorMode(options) {
12
+ if (options?.errorMode)
13
+ return options.errorMode;
14
+ if (options?.stopOnError === false)
15
+ return "throw";
16
+ return "capture";
17
+ }
8
18
  /**
9
19
  * Create a middleware pipeline that tracks execution.
10
20
  *
@@ -12,33 +22,93 @@ const DEFAULT_MAX = 50;
12
22
  * @param handler - Final handler function
13
23
  * @param options - Pipeline configuration
14
24
  * @returns Pipeline execution function
25
+ * @throws MiddlewareLimitExceededError if more middleware are enabled than
26
+ * `maxMiddleware` allows
15
27
  */
16
28
  export function createPipeline(middlewareList, handler, options) {
17
- const resolved = resolveMiddleware(middlewareList);
29
+ const resolved = resolveNamedMiddleware(middlewareList);
18
30
  const maxMiddleware = options?.maxMiddleware ?? DEFAULT_MAX;
19
- const stopOnError = options?.stopOnError ?? true;
31
+ const errorMode = resolveErrorMode(options);
32
+ const signal = options?.signal;
20
33
  if (resolved.length > maxMiddleware) {
21
- throw new Error(`Pipeline has ${resolved.length} middleware, exceeding maximum of ${maxMiddleware}`);
34
+ throw new MiddlewareLimitExceededError(resolved.length, maxMiddleware);
22
35
  }
23
- const enabledNames = middlewareList
24
- .filter((mw) => mw.enabled !== false)
25
- .sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100))
26
- .map((mw) => mw.name);
27
36
  return async (context) => {
28
37
  const startTime = performance.now();
29
38
  const executed = [];
39
+ const errors = [];
30
40
  let index = -1;
41
+ /**
42
+ * The error the final handler threw, if it did. Middleware still sees the
43
+ * error itself — wrapping it would break `catch (e) { if (e instanceof
44
+ * HttpError) … }` in user middleware — so identity is what marks it as
45
+ * the handler's, and that is what stops `errorMode: "continue"` from
46
+ * treating it as a middleware failure it can step past.
47
+ */
48
+ let handlerError;
49
+ function throwIfAborted() {
50
+ if (signal?.aborted) {
51
+ throw new MiddlewareAbortedError(signal.reason);
52
+ }
53
+ }
54
+ /** Errors that `"continue"` must never swallow. */
55
+ function isFatal(error) {
56
+ return ((handlerError !== undefined && error === handlerError.error) ||
57
+ error instanceof MiddlewareAbortedError ||
58
+ error instanceof MiddlewareNextCalledMultipleTimesError);
59
+ }
31
60
  async function dispatch(i) {
32
61
  if (i <= index) {
33
- throw new Error("next() called multiple times");
62
+ // dispatch(i) is invoked by the middleware at i - 1, so that is the
63
+ // one that called next() again.
64
+ const name = resolved[i - 1]?.name ?? `middleware[${i - 1}]`;
65
+ throw new MiddlewareNextCalledMultipleTimesError(name);
34
66
  }
35
67
  index = i;
36
- if (i < resolved.length) {
37
- executed.push(enabledNames[i] ?? `middleware-${i}`);
38
- const mw = resolved[i];
39
- return mw(context, () => dispatch(i + 1));
68
+ throwIfAborted();
69
+ if (i >= resolved.length) {
70
+ try {
71
+ return await handler(context);
72
+ }
73
+ catch (error) {
74
+ handlerError = { error };
75
+ throw error;
76
+ }
77
+ }
78
+ const mw = resolved[i];
79
+ executed.push(mw.name);
80
+ if (errorMode !== "continue") {
81
+ return mw.handler(context, () => dispatch(i + 1));
82
+ }
83
+ let advanced = false;
84
+ // Boxed rather than a bare `TResult | undefined`, so "the downstream
85
+ // chain produced a value" is distinguishable from "it produced
86
+ // `undefined`" without an assertion.
87
+ let downstream;
88
+ try {
89
+ return await mw.handler(context, async () => {
90
+ advanced = true;
91
+ const value = await dispatch(i + 1);
92
+ downstream = { value };
93
+ return value;
94
+ });
95
+ }
96
+ catch (error) {
97
+ if (isFatal(error))
98
+ throw error;
99
+ if (advanced && downstream === undefined) {
100
+ // next() was entered but never produced a result — the middleware
101
+ // caught the downstream failure and threw something of its own.
102
+ // There is no downstream result to keep and no step left to
103
+ // continue to, so returning `undefined` as if it were a `TResult`
104
+ // would report a successful run that never happened.
105
+ throw error;
106
+ }
107
+ errors.push({ name: mw.name, error });
108
+ // The chain already ran past this middleware, so its downstream
109
+ // result stands; otherwise pick up at the next middleware.
110
+ return downstream ? downstream.value : dispatch(i + 1);
40
111
  }
41
- return handler(context);
42
112
  }
43
113
  try {
44
114
  const result = await dispatch(0);
@@ -47,18 +117,26 @@ export function createPipeline(middlewareList, handler, options) {
47
117
  result,
48
118
  durationMs: performance.now() - startTime,
49
119
  executedMiddleware: executed,
120
+ errors,
50
121
  };
51
122
  }
52
123
  catch (error) {
53
- if (stopOnError) {
54
- return {
55
- success: false,
124
+ const fromHandler = handlerError !== undefined && error === handlerError.error;
125
+ if (!errors.some((failure) => failure.error === error)) {
126
+ errors.push({
127
+ name: fromHandler ? HANDLER_NAME : (executed.at(-1) ?? HANDLER_NAME),
56
128
  error,
57
- durationMs: performance.now() - startTime,
58
- executedMiddleware: executed,
59
- };
129
+ });
60
130
  }
61
- throw error;
131
+ if (errorMode === "throw")
132
+ throw error;
133
+ return {
134
+ success: false,
135
+ error,
136
+ durationMs: performance.now() - startTime,
137
+ executedMiddleware: executed,
138
+ errors,
139
+ };
62
140
  }
63
141
  };
64
142
  }
@@ -4,5 +4,5 @@
4
4
  * @module middlewareTypes
5
5
  */
6
6
  export { type Middleware, type NamedMiddleware, type MiddlewareFactory, } from "./middlewareDefinition.type.js";
7
- export { type PipelineResult, type PipelineOptions, } from "./middlewareContext.type.js";
7
+ export { type PipelineResult, type PipelineSuccess, type PipelineFailure, type PipelineMiddlewareFailure, type PipelineErrorMode, type PipelineOptions, } from "./middlewareContext.type.js";
8
8
  //# sourceMappingURL=index.d.ts.map
@@ -4,27 +4,96 @@
4
4
  * @module middlewareTypes/middlewareContext
5
5
  */
6
6
  /**
7
- * Result of executing a middleware pipeline.
7
+ * How a pipeline reacts to a middleware or handler that throws.
8
+ *
9
+ * - `"capture"` — stop the chain and report the failure as a
10
+ * {@link PipelineFailure}. The default.
11
+ * - `"throw"` — stop the chain and let the error propagate to the caller.
12
+ * - `"continue"` — record the failure, skip the rest of the failing
13
+ * middleware, and continue with the next one. The handler still runs.
14
+ * A handler that throws is always captured or thrown; there is nothing
15
+ * left to continue to.
8
16
  */
9
- export interface PipelineResult<TResult> {
10
- /** Whether the pipeline completed successfully */
11
- readonly success: boolean;
12
- /** The result value (if successful) */
13
- readonly result?: TResult;
14
- /** Error that occurred (if failed) */
15
- readonly error?: unknown;
16
- /** Execution time in milliseconds */
17
+ export type PipelineErrorMode = "capture" | "throw" | "continue";
18
+ /**
19
+ * A failure recorded while the pipeline was running.
20
+ */
21
+ export interface PipelineMiddlewareFailure {
22
+ /** Name of the middleware that threw, or `"handler"` for the final handler. */
23
+ readonly name: string;
24
+ /** The thrown value. */
25
+ readonly error: unknown;
26
+ }
27
+ /**
28
+ * A pipeline run that reached the handler and completed.
29
+ */
30
+ export interface PipelineSuccess<TResult> {
31
+ readonly success: true;
32
+ /** The value returned by the handler. */
33
+ readonly result: TResult;
34
+ /** Always absent on a successful run. Present in the type so that
35
+ * `result.error` is legal to read before narrowing. */
36
+ readonly error?: undefined;
37
+ /** Execution time in milliseconds. */
17
38
  readonly durationMs: number;
18
- /** Names of middleware that executed */
39
+ /** Names of middleware that started executing, in execution order. */
19
40
  readonly executedMiddleware: readonly string[];
41
+ /** Failures swallowed under `errorMode: "continue"`. Empty otherwise. */
42
+ readonly errors: readonly PipelineMiddlewareFailure[];
20
43
  }
44
+ /**
45
+ * A pipeline run that ended in a captured failure.
46
+ */
47
+ export interface PipelineFailure {
48
+ readonly success: false;
49
+ /** Always absent on a failed run. */
50
+ readonly result?: undefined;
51
+ /** The error that ended the run. */
52
+ readonly error: unknown;
53
+ /** Execution time in milliseconds. */
54
+ readonly durationMs: number;
55
+ /** Names of middleware that started executing, in execution order. */
56
+ readonly executedMiddleware: readonly string[];
57
+ /** Every failure recorded during the run, including {@link PipelineFailure.error}. */
58
+ readonly errors: readonly PipelineMiddlewareFailure[];
59
+ }
60
+ /**
61
+ * Result of executing a middleware pipeline.
62
+ *
63
+ * Discriminated on `success`, so narrowing gives a non-optional `result`:
64
+ *
65
+ * ```ts
66
+ * const outcome = await pipeline(context);
67
+ * if (outcome.success) {
68
+ * use(outcome.result); // TResult, not TResult | undefined
69
+ * }
70
+ * ```
71
+ */
72
+ export type PipelineResult<TResult> = PipelineSuccess<TResult> | PipelineFailure;
21
73
  /**
22
74
  * Options for creating a middleware pipeline.
23
75
  */
24
76
  export interface PipelineOptions {
25
- /** Maximum number of middleware allowed */
77
+ /** Maximum number of enabled middleware allowed. Default: 50. */
26
78
  readonly maxMiddleware?: number;
27
- /** Whether to stop on first error (default: true) */
79
+ /**
80
+ * How the pipeline reacts to an error. Default: `"capture"`.
81
+ *
82
+ * @see PipelineErrorMode
83
+ */
84
+ readonly errorMode?: PipelineErrorMode;
85
+ /**
86
+ * Legacy alias for {@link PipelineOptions.errorMode}: `true` maps to
87
+ * `"capture"` and `false` to `"throw"`. Ignored when `errorMode` is set.
88
+ *
89
+ * @deprecated Use `errorMode` — the name describes the wrong axis, since
90
+ * both settings stop the chain.
91
+ */
28
92
  readonly stopOnError?: boolean;
93
+ /**
94
+ * Aborts the run. Checked before each middleware and before the handler;
95
+ * an aborted pipeline fails with a `MiddlewareAbortedError`.
96
+ */
97
+ readonly signal?: AbortSignal;
29
98
  }
30
99
  //# sourceMappingURL=middlewareContext.type.d.ts.map
@@ -3,5 +3,5 @@
3
3
  *
4
4
  * @module middlewareUtils
5
5
  */
6
- export { loggingMiddleware, errorMiddleware, timeoutMiddleware, rateLimitMiddleware, } from "./middlewareUtils.builtins.js";
6
+ export { loggingMiddleware, errorMiddleware, timeoutMiddleware, rateLimitMiddleware, sanitizeLogValue, type LoggingContext, type LoggingOptions, type TimeoutOptions, type RateLimitOptions, type RateLimitState, type RateLimitMiddleware, } from "./middlewareUtils.builtins.js";
7
7
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,5 @@
3
3
  *
4
4
  * @module middlewareUtils
5
5
  */
6
- export { loggingMiddleware, errorMiddleware, timeoutMiddleware, rateLimitMiddleware, } from "./middlewareUtils.builtins.js";
6
+ export { loggingMiddleware, errorMiddleware, timeoutMiddleware, rateLimitMiddleware, sanitizeLogValue, } from "./middlewareUtils.builtins.js";
7
7
  //# sourceMappingURL=index.js.map
@@ -9,30 +9,109 @@ export interface LoggingContext {
9
9
  readonly path?: string;
10
10
  readonly method?: string;
11
11
  }
12
+ /**
13
+ * Make a caller-supplied value safe to write into a single-line log.
14
+ *
15
+ * CR and LF above all are what let a request path forge extra log lines, so
16
+ * control characters are escaped rather than dropped — the original stays
17
+ * visible without being able to break out of its line.
18
+ */
19
+ export declare function sanitizeLogValue(value: unknown, maxLength?: number): string;
20
+ /** Options for {@link loggingMiddleware}. */
21
+ export interface LoggingOptions {
22
+ /**
23
+ * Include the failing error's message in the completion line.
24
+ * Defaults to `false`: error messages routinely carry connection strings,
25
+ * tokens and user data, and the pipeline already surfaces the error object
26
+ * to the caller.
27
+ */
28
+ readonly includeErrorMessage?: boolean;
29
+ /** Longest field value written before truncation. Default: 256. */
30
+ readonly maxFieldLength?: number;
31
+ }
12
32
  /**
13
33
  * Create a logging middleware.
14
34
  *
15
- * Logs request start, completion, and errors.
35
+ * Logs request start, completion, and errors. Every interpolated field is
36
+ * escaped, so a path containing newlines cannot forge log lines.
16
37
  */
17
- export declare function loggingMiddleware(logger?: (msg: string) => void): NamedMiddleware<LoggingContext>;
38
+ export declare function loggingMiddleware<TResult = void>(logger?: (msg: string) => void, options?: LoggingOptions): NamedMiddleware<LoggingContext, TResult>;
18
39
  /**
19
40
  * Create an error-handling middleware.
20
41
  *
21
- * Catches errors and wraps them with context.
42
+ * Reports errors through `onError` and rethrows them. A reporter that throws
43
+ * cannot replace the error it was reporting — its own failure goes to
44
+ * `onReporterError` instead.
22
45
  */
23
- export declare function errorMiddleware(onError?: (error: unknown, ctx: unknown) => void): NamedMiddleware<unknown>;
46
+ export declare function errorMiddleware<TResult = void>(onError?: (error: unknown, ctx: unknown) => void, onReporterError?: (error: unknown) => void): NamedMiddleware<unknown, TResult>;
47
+ /** Options for {@link timeoutMiddleware}. */
48
+ export interface TimeoutOptions {
49
+ /** Name reported in the timeout error. Default: `"timeout"`. */
50
+ readonly name?: string;
51
+ }
24
52
  /**
25
53
  * Create a timeout middleware.
26
54
  *
27
- * Rejects if the pipeline takes too long.
55
+ * Rejects with a {@link MiddlewareTimeoutError} if the rest of the pipeline
56
+ * takes too long. The timer is always cleared, so a fast request leaves
57
+ * nothing pending on the event loop, and the losing promise stays handled so
58
+ * a late rejection cannot surface as an unhandled rejection.
59
+ *
60
+ * The downstream work is not cancelled — nothing in the middleware contract
61
+ * can cancel it. Use the pipeline's `signal` option, or carry an
62
+ * `AbortSignal` on your own context, when the work itself needs to stop.
28
63
  */
29
- export declare function timeoutMiddleware<TContext>(timeoutMs: number): NamedMiddleware<TContext>;
64
+ export declare function timeoutMiddleware<TContext, TResult = void>(timeoutMs: number, options?: TimeoutOptions): NamedMiddleware<TContext, TResult>;
65
+ /** Options for {@link rateLimitMiddleware}. */
66
+ export interface RateLimitOptions {
67
+ /**
68
+ * Maximum number of distinct keys tracked at once. When the limit is
69
+ * reached, expired keys are swept and — if that is not enough — the
70
+ * least-recently-seen keys are evicted. Default: 10,000.
71
+ *
72
+ * Keys are usually caller-controlled (an IP, an API key, a tenant), so an
73
+ * unbounded map is a memory sink an attacker can drive.
74
+ */
75
+ readonly maxKeys?: number;
76
+ /** How often expired keys are swept, in milliseconds. Default: 60,000. */
77
+ readonly sweepIntervalMs?: number;
78
+ /**
79
+ * Reject requests that arrive with no key instead of pooling them into a
80
+ * single shared bucket. Default: `false`, preserving the shared bucket.
81
+ */
82
+ readonly rejectUnkeyed?: boolean;
83
+ }
84
+ /** A rate limiter's live view of one key. */
85
+ export interface RateLimitState {
86
+ /** Requests counted in the current window. */
87
+ readonly count: number;
88
+ /** Milliseconds until the oldest request leaves the window. */
89
+ readonly retryAfterMs: number;
90
+ }
91
+ /** The middleware returned by {@link rateLimitMiddleware}. */
92
+ export interface RateLimitMiddleware<TContext, TResult = void> extends NamedMiddleware<TContext, TResult> {
93
+ /** Inspect a key's current window. Exposed for metrics and tests. */
94
+ readonly inspect: (key: string) => RateLimitState | undefined;
95
+ /** Number of keys currently tracked. */
96
+ readonly size: () => number;
97
+ /** Drop all tracked keys. */
98
+ readonly reset: () => void;
99
+ }
30
100
  /**
31
101
  * Create a rate-limiting middleware.
32
102
  *
33
- * Uses a sliding window counter per key.
103
+ * Implements a true sliding window: each key keeps the timestamps of its
104
+ * requests within `windowMs`, so a client cannot burst `2 × maxRequests`
105
+ * across a window boundary the way a fixed-window counter allows.
106
+ *
107
+ * Rejections throw {@link MiddlewareRateLimitError}, which carries
108
+ * `retryAfterMs` for an HTTP adapter to turn into a 429 with `Retry-After`.
109
+ *
110
+ * State is per-instance and in-process. Behind more than one instance of a
111
+ * service, each process enforces its own limit; use a shared store for a
112
+ * cluster-wide one.
34
113
  */
35
114
  export declare function rateLimitMiddleware<TContext extends {
36
115
  readonly key?: string;
37
- }>(maxRequests: number, windowMs: number): NamedMiddleware<TContext>;
116
+ }, TResult = void>(maxRequests: number, windowMs: number, options?: RateLimitOptions): RateLimitMiddleware<TContext, TResult>;
38
117
  //# sourceMappingURL=middlewareUtils.builtins.d.ts.map