@nextrush/errors 3.0.6 → 4.0.0-beta.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/README.md +321 -522
- package/dist/index.d.ts +88 -38
- package/dist/index.js +185 -16
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/audit-fixes.test.ts +181 -0
- package/src/__tests__/base.test.ts +2 -2
- package/src/__tests__/factory.test.ts +9 -2
- package/src/__tests__/middleware.test.ts +38 -33
- package/src/__tests__/public-surface.test.ts +118 -0
- package/src/base.ts +148 -3
- package/src/codes.ts +77 -0
- package/src/factory.ts +58 -1
- package/src/http-errors.ts +6 -0
- package/src/index.ts +4 -8
- package/src/middleware.ts +9 -43
- package/src/validation.ts +2 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/errors - Public API surface test
|
|
3
|
+
*
|
|
4
|
+
* Locks the exported symbol set from `src/index.ts`. If this test fails, the
|
|
5
|
+
* public API has changed. Intentional changes require an explicit update to
|
|
6
|
+
* the expected list below, plus a changeset for a published package.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
9
|
+
import * as errorsApi from '../index';
|
|
10
|
+
import type { ErrorHandlerOptions } from '../index';
|
|
11
|
+
import type { HttpErrorOptions } from '../index';
|
|
12
|
+
import type { ValidationIssue } from '../index';
|
|
13
|
+
|
|
14
|
+
describe('Public API surface (runtime exports)', () => {
|
|
15
|
+
it('exports exactly the intended runtime symbols', () => {
|
|
16
|
+
const actualExports = Object.keys(errorsApi).sort();
|
|
17
|
+
|
|
18
|
+
// SEALED: intentional public runtime API surface.
|
|
19
|
+
const expectedRuntime = [
|
|
20
|
+
// Base classes + helper
|
|
21
|
+
'HttpError',
|
|
22
|
+
'NextRushError',
|
|
23
|
+
'getHttpStatusMessage',
|
|
24
|
+
|
|
25
|
+
// Central error-code registry
|
|
26
|
+
'ERROR_CODES',
|
|
27
|
+
'GENERIC_ERROR_CODE',
|
|
28
|
+
'VALIDATION_ERROR_CODE',
|
|
29
|
+
'codeForStatus',
|
|
30
|
+
|
|
31
|
+
// HTTP errors — 4xx
|
|
32
|
+
'BadRequestError',
|
|
33
|
+
'ConflictError',
|
|
34
|
+
'ExpectationFailedError',
|
|
35
|
+
'FailedDependencyError',
|
|
36
|
+
'ForbiddenError',
|
|
37
|
+
'GoneError',
|
|
38
|
+
'ImATeapotError',
|
|
39
|
+
'LengthRequiredError',
|
|
40
|
+
'LockedError',
|
|
41
|
+
'MethodNotAllowedError',
|
|
42
|
+
'NotAcceptableError',
|
|
43
|
+
'NotFoundError',
|
|
44
|
+
'PayloadTooLargeError',
|
|
45
|
+
'PaymentRequiredError',
|
|
46
|
+
'PreconditionFailedError',
|
|
47
|
+
'PreconditionRequiredError',
|
|
48
|
+
'ProxyAuthRequiredError',
|
|
49
|
+
'RangeNotSatisfiableError',
|
|
50
|
+
'RequestHeaderFieldsTooLargeError',
|
|
51
|
+
'RequestTimeoutError',
|
|
52
|
+
'TooEarlyError',
|
|
53
|
+
'TooManyRequestsError',
|
|
54
|
+
'UnauthorizedError',
|
|
55
|
+
'UnavailableForLegalReasonsError',
|
|
56
|
+
'UnprocessableEntityError',
|
|
57
|
+
'UnsupportedMediaTypeError',
|
|
58
|
+
'UpgradeRequiredError',
|
|
59
|
+
'UriTooLongError',
|
|
60
|
+
|
|
61
|
+
// HTTP errors — 5xx
|
|
62
|
+
'BadGatewayError',
|
|
63
|
+
'GatewayTimeoutError',
|
|
64
|
+
'HttpVersionNotSupportedError',
|
|
65
|
+
'InsufficientStorageError',
|
|
66
|
+
'InternalServerError',
|
|
67
|
+
'LoopDetectedError',
|
|
68
|
+
'NetworkAuthRequiredError',
|
|
69
|
+
'NotExtendedError',
|
|
70
|
+
'NotImplementedError',
|
|
71
|
+
'ServiceUnavailableError',
|
|
72
|
+
'VariantAlsoNegotiatesError',
|
|
73
|
+
|
|
74
|
+
// Validation errors
|
|
75
|
+
'InvalidEmailError',
|
|
76
|
+
'InvalidUrlError',
|
|
77
|
+
'LengthError',
|
|
78
|
+
'PatternError',
|
|
79
|
+
'RangeValidationError',
|
|
80
|
+
'RequiredFieldError',
|
|
81
|
+
'TypeMismatchError',
|
|
82
|
+
'ValidationError',
|
|
83
|
+
|
|
84
|
+
// Factory functions
|
|
85
|
+
'badGateway',
|
|
86
|
+
'badRequest',
|
|
87
|
+
'conflict',
|
|
88
|
+
'createError',
|
|
89
|
+
'forbidden',
|
|
90
|
+
'gatewayTimeout',
|
|
91
|
+
'getErrorStatus',
|
|
92
|
+
'getSafeErrorMessage',
|
|
93
|
+
'internalError',
|
|
94
|
+
'isHttpError',
|
|
95
|
+
'methodNotAllowed',
|
|
96
|
+
'notFound',
|
|
97
|
+
'serviceUnavailable',
|
|
98
|
+
'tooManyRequests',
|
|
99
|
+
'unauthorized',
|
|
100
|
+
'unprocessableEntity',
|
|
101
|
+
|
|
102
|
+
// Middleware
|
|
103
|
+
'errorHandler',
|
|
104
|
+
'notFoundHandler',
|
|
105
|
+
].sort();
|
|
106
|
+
|
|
107
|
+
expect(actualExports).toEqual(expectedRuntime);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('Public API surface (type-only exports)', () => {
|
|
112
|
+
it('the type-only surface stays importable from the barrel', () => {
|
|
113
|
+
// Compile-time only: removing/renaming any of these in src/index.ts fails
|
|
114
|
+
// this file to type-check.
|
|
115
|
+
type Surface = [HttpErrorOptions, ValidationIssue, ErrorHandlerOptions];
|
|
116
|
+
expectTypeOf<Surface>().not.toBeNever();
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/base.ts
CHANGED
|
@@ -6,10 +6,52 @@
|
|
|
6
6
|
* @packageDocumentation
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { codeForStatus } from './codes';
|
|
10
|
+
|
|
11
|
+
/** Options accepted by {@link NextRushError.hydrate} when reconstructing errors. */
|
|
12
|
+
interface HydrateOptions {
|
|
13
|
+
status?: number;
|
|
14
|
+
code?: string;
|
|
15
|
+
details?: Record<string, unknown>;
|
|
16
|
+
requestId?: string;
|
|
17
|
+
traceId?: string;
|
|
18
|
+
timestamp?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
const V8Error = Error as ErrorConstructor & {
|
|
10
22
|
captureStackTrace?: (targetObject: object, constructorOpt?: Function) => void;
|
|
11
23
|
};
|
|
12
24
|
|
|
25
|
+
/** Maximum depth walked when serializing a `cause` chain (audit E-2). */
|
|
26
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Serialize an error `cause` chain into a plain, JSON-safe object.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* Walks nested `cause` links up to {@link MAX_CAUSE_DEPTH}, guards against
|
|
33
|
+
* cyclic chains via a visited set, and only surfaces `name`/`message`/`code`
|
|
34
|
+
* so no unexpected enumerable properties (or secrets on ad-hoc error objects)
|
|
35
|
+
* leak. Returns `undefined` when there is nothing meaningful to serialize.
|
|
36
|
+
*/
|
|
37
|
+
function serializeCause(cause: unknown, seen: Set<unknown>, depth: number): unknown {
|
|
38
|
+
if (cause === undefined || cause === null || depth >= MAX_CAUSE_DEPTH) return undefined;
|
|
39
|
+
if (typeof cause !== 'object') return { message: String(cause) };
|
|
40
|
+
if (seen.has(cause)) return undefined; // cycle guard
|
|
41
|
+
seen.add(cause);
|
|
42
|
+
|
|
43
|
+
const err = cause as { name?: unknown; message?: unknown; code?: unknown; cause?: unknown };
|
|
44
|
+
const out: Record<string, unknown> = {};
|
|
45
|
+
if (typeof err.name === 'string') out.name = err.name;
|
|
46
|
+
if (typeof err.message === 'string') out.message = err.message;
|
|
47
|
+
if (typeof err.code === 'string') out.code = err.code;
|
|
48
|
+
|
|
49
|
+
const nested = serializeCause(err.cause, seen, depth + 1);
|
|
50
|
+
if (nested !== undefined) out.cause = nested;
|
|
51
|
+
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
13
55
|
/**
|
|
14
56
|
* Base error class for all NextRush errors
|
|
15
57
|
*/
|
|
@@ -29,6 +71,15 @@ export class NextRushError extends Error {
|
|
|
29
71
|
/** Original error that caused this error */
|
|
30
72
|
readonly cause?: unknown;
|
|
31
73
|
|
|
74
|
+
/** Correlation/request identifier for tracing across boundaries (audit E-5). */
|
|
75
|
+
readonly requestId?: string;
|
|
76
|
+
|
|
77
|
+
/** Distributed-trace identifier (audit E-5). */
|
|
78
|
+
readonly traceId?: string;
|
|
79
|
+
|
|
80
|
+
/** ISO-8601 timestamp of when the error was created, when supplied. */
|
|
81
|
+
readonly timestamp?: string;
|
|
82
|
+
|
|
32
83
|
constructor(
|
|
33
84
|
message: string,
|
|
34
85
|
options: {
|
|
@@ -37,15 +88,25 @@ export class NextRushError extends Error {
|
|
|
37
88
|
expose?: boolean;
|
|
38
89
|
details?: Record<string, unknown>;
|
|
39
90
|
cause?: unknown;
|
|
91
|
+
requestId?: string;
|
|
92
|
+
traceId?: string;
|
|
93
|
+
timestamp?: string;
|
|
40
94
|
} = {}
|
|
41
95
|
) {
|
|
42
|
-
|
|
96
|
+
// Pass cause to the native Error constructor so `util.inspect` / logging
|
|
97
|
+
// tooling walks the chain natively, in addition to our own `cause` field.
|
|
98
|
+
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
43
99
|
this.name = this.constructor.name;
|
|
44
100
|
this.status = options.status ?? 500;
|
|
45
101
|
this.code = options.code ?? 'INTERNAL_ERROR';
|
|
46
102
|
this.expose = options.expose ?? this.status < 500;
|
|
47
|
-
|
|
103
|
+
// Freeze a shallow snapshot so the error owns immutable details and the
|
|
104
|
+
// caller's object cannot be mutated through the error (audit E-6).
|
|
105
|
+
this.details = options.details ? Object.freeze({ ...options.details }) : undefined;
|
|
48
106
|
this.cause = options.cause;
|
|
107
|
+
this.requestId = options.requestId;
|
|
108
|
+
this.traceId = options.traceId;
|
|
109
|
+
this.timestamp = options.timestamp;
|
|
49
110
|
|
|
50
111
|
// Maintain proper stack trace for V8
|
|
51
112
|
// Skip for common client errors (4xx with expose=true) to reduce overhead.
|
|
@@ -71,9 +132,66 @@ export class NextRushError extends Error {
|
|
|
71
132
|
json.details = this.details;
|
|
72
133
|
}
|
|
73
134
|
|
|
135
|
+
// Surface the cause chain for diagnosability, but only on exposed errors —
|
|
136
|
+
// a non-exposed 5xx cause may contain internal detail (audit E-2). Server
|
|
137
|
+
// -side logging should read `error.cause` directly, which is never gated.
|
|
138
|
+
if (this.expose && this.cause !== undefined) {
|
|
139
|
+
const serialized = serializeCause(this.cause, new Set(), 0);
|
|
140
|
+
if (serialized !== undefined) {
|
|
141
|
+
json.cause = serialized;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Correlation identifiers are safe to surface and are the primary handle
|
|
146
|
+
// for cross-service debugging (audit E-5). Only emitted when set, so the
|
|
147
|
+
// default serialized shape is unchanged.
|
|
148
|
+
if (this.requestId !== undefined) json.requestId = this.requestId;
|
|
149
|
+
if (this.traceId !== undefined) json.traceId = this.traceId;
|
|
150
|
+
if (this.timestamp !== undefined) json.timestamp = this.timestamp;
|
|
151
|
+
|
|
74
152
|
return json;
|
|
75
153
|
}
|
|
76
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Reconstruct a {@link NextRushError} from a serialized {@link toJSON} payload
|
|
157
|
+
* (audit E-7).
|
|
158
|
+
*
|
|
159
|
+
* @remarks
|
|
160
|
+
* Enables cross-service transport: a downstream service can rebuild a typed
|
|
161
|
+
* error (with a working `instanceof NextRushError`) from the JSON it received,
|
|
162
|
+
* rather than being left with an opaque plain object. `message` falls back to
|
|
163
|
+
* a generic string when the source error was not exposed.
|
|
164
|
+
*
|
|
165
|
+
* @param json - A payload previously produced by {@link toJSON}.
|
|
166
|
+
* @returns A reconstructed {@link NextRushError}.
|
|
167
|
+
*/
|
|
168
|
+
static fromJSON(json: Record<string, unknown>): NextRushError {
|
|
169
|
+
return NextRushError.hydrate(json, (message, options) => new NextRushError(message, options));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Shared hydration logic for {@link fromJSON} across the hierarchy.
|
|
174
|
+
* @internal
|
|
175
|
+
*/
|
|
176
|
+
protected static hydrate<T extends NextRushError>(
|
|
177
|
+
json: Record<string, unknown>,
|
|
178
|
+
build: (message: string, options: HydrateOptions) => T
|
|
179
|
+
): T {
|
|
180
|
+
const status = typeof json.status === 'number' ? json.status : 500;
|
|
181
|
+
const message = typeof json.message === 'string' ? json.message : getHttpStatusMessage(status);
|
|
182
|
+
return build(message, {
|
|
183
|
+
status,
|
|
184
|
+
code: typeof json.code === 'string' ? json.code : undefined,
|
|
185
|
+
details:
|
|
186
|
+
json.details !== null && typeof json.details === 'object'
|
|
187
|
+
? (json.details as Record<string, unknown>)
|
|
188
|
+
: undefined,
|
|
189
|
+
requestId: typeof json.requestId === 'string' ? json.requestId : undefined,
|
|
190
|
+
traceId: typeof json.traceId === 'string' ? json.traceId : undefined,
|
|
191
|
+
timestamp: typeof json.timestamp === 'string' ? json.timestamp : undefined,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
77
195
|
/**
|
|
78
196
|
* Create a response-safe version of the error
|
|
79
197
|
*/
|
|
@@ -149,14 +267,41 @@ export class HttpError extends NextRushError {
|
|
|
149
267
|
expose?: boolean;
|
|
150
268
|
details?: Record<string, unknown>;
|
|
151
269
|
cause?: unknown;
|
|
270
|
+
requestId?: string;
|
|
271
|
+
traceId?: string;
|
|
272
|
+
timestamp?: string;
|
|
152
273
|
} = {}
|
|
153
274
|
) {
|
|
154
275
|
super(message ?? getHttpStatusMessage(status), {
|
|
155
276
|
status,
|
|
156
|
-
code: options.code ??
|
|
277
|
+
code: options.code ?? codeForStatus(status),
|
|
157
278
|
expose: options.expose ?? status < 500,
|
|
158
279
|
details: options.details,
|
|
159
280
|
cause: options.cause,
|
|
281
|
+
requestId: options.requestId,
|
|
282
|
+
traceId: options.traceId,
|
|
283
|
+
timestamp: options.timestamp,
|
|
160
284
|
});
|
|
161
285
|
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Reconstruct an {@link HttpError} from a serialized {@link NextRushError.toJSON}
|
|
289
|
+
* payload (audit E-7).
|
|
290
|
+
*
|
|
291
|
+
* @param json - A payload previously produced by `toJSON()`.
|
|
292
|
+
* @returns A reconstructed {@link HttpError}.
|
|
293
|
+
*/
|
|
294
|
+
static override fromJSON(json: Record<string, unknown>): HttpError {
|
|
295
|
+
return NextRushError.hydrate(
|
|
296
|
+
json,
|
|
297
|
+
(message, options) =>
|
|
298
|
+
new HttpError(options.status ?? 500, message, {
|
|
299
|
+
code: options.code,
|
|
300
|
+
details: options.details,
|
|
301
|
+
requestId: options.requestId,
|
|
302
|
+
traceId: options.traceId,
|
|
303
|
+
timestamp: options.timestamp,
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
}
|
|
162
307
|
}
|
package/src/codes.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/errors - Central Error Code Registry
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth mapping HTTP status codes to their canonical,
|
|
5
|
+
* machine-readable error codes (audit E-4). Both the typed error classes and
|
|
6
|
+
* the {@link createError} factory resolve codes through this registry, so a
|
|
7
|
+
* given status maps to exactly one code regardless of construction path.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Canonical error code for each supported HTTP status.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* Keep this in sync with the typed classes in `http-errors.ts`. The
|
|
17
|
+
* `audit-fixes` test suite asserts `createError(status).code === ERROR_CODES[status]`
|
|
18
|
+
* for every entry, so drift between a class and this registry fails CI.
|
|
19
|
+
*/
|
|
20
|
+
export const ERROR_CODES: Readonly<Record<number, string>> = Object.freeze({
|
|
21
|
+
400: 'BAD_REQUEST',
|
|
22
|
+
401: 'UNAUTHORIZED',
|
|
23
|
+
402: 'PAYMENT_REQUIRED',
|
|
24
|
+
403: 'FORBIDDEN',
|
|
25
|
+
404: 'NOT_FOUND',
|
|
26
|
+
405: 'METHOD_NOT_ALLOWED',
|
|
27
|
+
406: 'NOT_ACCEPTABLE',
|
|
28
|
+
407: 'PROXY_AUTH_REQUIRED',
|
|
29
|
+
408: 'REQUEST_TIMEOUT',
|
|
30
|
+
409: 'CONFLICT',
|
|
31
|
+
410: 'GONE',
|
|
32
|
+
411: 'LENGTH_REQUIRED',
|
|
33
|
+
412: 'PRECONDITION_FAILED',
|
|
34
|
+
413: 'PAYLOAD_TOO_LARGE',
|
|
35
|
+
414: 'URI_TOO_LONG',
|
|
36
|
+
415: 'UNSUPPORTED_MEDIA_TYPE',
|
|
37
|
+
416: 'RANGE_NOT_SATISFIABLE',
|
|
38
|
+
417: 'EXPECTATION_FAILED',
|
|
39
|
+
418: 'IM_A_TEAPOT',
|
|
40
|
+
422: 'UNPROCESSABLE_ENTITY',
|
|
41
|
+
423: 'LOCKED',
|
|
42
|
+
424: 'FAILED_DEPENDENCY',
|
|
43
|
+
425: 'TOO_EARLY',
|
|
44
|
+
426: 'UPGRADE_REQUIRED',
|
|
45
|
+
428: 'PRECONDITION_REQUIRED',
|
|
46
|
+
429: 'TOO_MANY_REQUESTS',
|
|
47
|
+
431: 'REQUEST_HEADER_FIELDS_TOO_LARGE',
|
|
48
|
+
451: 'UNAVAILABLE_FOR_LEGAL_REASONS',
|
|
49
|
+
500: 'INTERNAL_SERVER_ERROR',
|
|
50
|
+
501: 'NOT_IMPLEMENTED',
|
|
51
|
+
502: 'BAD_GATEWAY',
|
|
52
|
+
503: 'SERVICE_UNAVAILABLE',
|
|
53
|
+
504: 'GATEWAY_TIMEOUT',
|
|
54
|
+
505: 'HTTP_VERSION_NOT_SUPPORTED',
|
|
55
|
+
506: 'VARIANT_ALSO_NEGOTIATES',
|
|
56
|
+
507: 'INSUFFICIENT_STORAGE',
|
|
57
|
+
508: 'LOOP_DETECTED',
|
|
58
|
+
510: 'NOT_EXTENDED',
|
|
59
|
+
511: 'NETWORK_AUTH_REQUIRED',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the canonical error code for an HTTP status.
|
|
64
|
+
*
|
|
65
|
+
* @param status - HTTP status code.
|
|
66
|
+
* @returns The registered canonical code, or `HTTP_<status>` for statuses with
|
|
67
|
+
* no dedicated class.
|
|
68
|
+
*/
|
|
69
|
+
export function codeForStatus(status: number): string {
|
|
70
|
+
return ERROR_CODES[status] ?? `HTTP_${status}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Generic internal-error code used when no status-specific code applies. */
|
|
74
|
+
export const GENERIC_ERROR_CODE = 'INTERNAL_ERROR';
|
|
75
|
+
|
|
76
|
+
/** Canonical code for validation failures. */
|
|
77
|
+
export const VALIDATION_ERROR_CODE = 'VALIDATION_ERROR';
|
package/src/factory.ts
CHANGED
|
@@ -11,35 +11,92 @@ import {
|
|
|
11
11
|
BadGatewayError,
|
|
12
12
|
BadRequestError,
|
|
13
13
|
ConflictError,
|
|
14
|
+
ExpectationFailedError,
|
|
15
|
+
FailedDependencyError,
|
|
14
16
|
ForbiddenError,
|
|
15
17
|
GatewayTimeoutError,
|
|
18
|
+
GoneError,
|
|
19
|
+
HttpVersionNotSupportedError,
|
|
20
|
+
ImATeapotError,
|
|
21
|
+
InsufficientStorageError,
|
|
16
22
|
InternalServerError,
|
|
23
|
+
LengthRequiredError,
|
|
24
|
+
LockedError,
|
|
25
|
+
LoopDetectedError,
|
|
17
26
|
MethodNotAllowedError,
|
|
27
|
+
NetworkAuthRequiredError,
|
|
28
|
+
NotAcceptableError,
|
|
29
|
+
NotExtendedError,
|
|
18
30
|
NotFoundError,
|
|
19
31
|
NotImplementedError,
|
|
32
|
+
PayloadTooLargeError,
|
|
33
|
+
PaymentRequiredError,
|
|
34
|
+
PreconditionFailedError,
|
|
35
|
+
PreconditionRequiredError,
|
|
36
|
+
ProxyAuthRequiredError,
|
|
37
|
+
RangeNotSatisfiableError,
|
|
38
|
+
RequestHeaderFieldsTooLargeError,
|
|
39
|
+
RequestTimeoutError,
|
|
20
40
|
ServiceUnavailableError,
|
|
41
|
+
TooEarlyError,
|
|
21
42
|
TooManyRequestsError,
|
|
22
43
|
UnauthorizedError,
|
|
44
|
+
UnavailableForLegalReasonsError,
|
|
23
45
|
UnprocessableEntityError,
|
|
46
|
+
UnsupportedMediaTypeError,
|
|
47
|
+
UpgradeRequiredError,
|
|
48
|
+
UriTooLongError,
|
|
49
|
+
VariantAlsoNegotiatesError,
|
|
24
50
|
type HttpErrorOptions,
|
|
25
51
|
} from './http-errors';
|
|
26
52
|
|
|
27
53
|
/**
|
|
28
|
-
* HTTP status code to error class mapping
|
|
54
|
+
* HTTP status code to error class mapping.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* Covers every status that has a dedicated typed class so {@link createError}
|
|
58
|
+
* returns the correctly-coded instance (audit E-3). `405` is handled separately
|
|
59
|
+
* because {@link MethodNotAllowedError} has a divergent constructor signature.
|
|
29
60
|
*/
|
|
30
61
|
const ERROR_MAP: Record<number, new (message?: string, options?: HttpErrorOptions) => HttpError> = {
|
|
31
62
|
400: BadRequestError,
|
|
32
63
|
401: UnauthorizedError,
|
|
64
|
+
402: PaymentRequiredError,
|
|
33
65
|
403: ForbiddenError,
|
|
34
66
|
404: NotFoundError,
|
|
67
|
+
406: NotAcceptableError,
|
|
68
|
+
407: ProxyAuthRequiredError,
|
|
69
|
+
408: RequestTimeoutError,
|
|
35
70
|
409: ConflictError,
|
|
71
|
+
410: GoneError,
|
|
72
|
+
411: LengthRequiredError,
|
|
73
|
+
412: PreconditionFailedError,
|
|
74
|
+
413: PayloadTooLargeError,
|
|
75
|
+
414: UriTooLongError,
|
|
76
|
+
415: UnsupportedMediaTypeError,
|
|
77
|
+
416: RangeNotSatisfiableError,
|
|
78
|
+
417: ExpectationFailedError,
|
|
79
|
+
418: ImATeapotError,
|
|
36
80
|
422: UnprocessableEntityError,
|
|
81
|
+
423: LockedError,
|
|
82
|
+
424: FailedDependencyError,
|
|
83
|
+
425: TooEarlyError,
|
|
84
|
+
426: UpgradeRequiredError,
|
|
85
|
+
428: PreconditionRequiredError,
|
|
37
86
|
429: TooManyRequestsError,
|
|
87
|
+
431: RequestHeaderFieldsTooLargeError,
|
|
88
|
+
451: UnavailableForLegalReasonsError,
|
|
38
89
|
500: InternalServerError,
|
|
39
90
|
501: NotImplementedError,
|
|
40
91
|
502: BadGatewayError,
|
|
41
92
|
503: ServiceUnavailableError,
|
|
42
93
|
504: GatewayTimeoutError,
|
|
94
|
+
505: HttpVersionNotSupportedError,
|
|
95
|
+
506: VariantAlsoNegotiatesError,
|
|
96
|
+
507: InsufficientStorageError,
|
|
97
|
+
508: LoopDetectedError,
|
|
98
|
+
510: NotExtendedError,
|
|
99
|
+
511: NetworkAuthRequiredError,
|
|
43
100
|
};
|
|
44
101
|
|
|
45
102
|
/**
|
package/src/http-errors.ts
CHANGED
|
@@ -16,6 +16,12 @@ export interface HttpErrorOptions {
|
|
|
16
16
|
expose?: boolean;
|
|
17
17
|
details?: Record<string, unknown>;
|
|
18
18
|
cause?: unknown;
|
|
19
|
+
/** Correlation/request identifier (audit E-5). */
|
|
20
|
+
requestId?: string;
|
|
21
|
+
/** Distributed-trace identifier (audit E-5). */
|
|
22
|
+
traceId?: string;
|
|
23
|
+
/** ISO-8601 creation timestamp (audit E-5). */
|
|
24
|
+
timestamp?: string;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
// =============================================================================
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// Base classes
|
|
10
10
|
export { HttpError, NextRushError, getHttpStatusMessage } from './base';
|
|
11
11
|
|
|
12
|
+
// Central error-code registry
|
|
13
|
+
export { ERROR_CODES, GENERIC_ERROR_CODE, VALIDATION_ERROR_CODE, codeForStatus } from './codes';
|
|
14
|
+
|
|
12
15
|
// HTTP errors - 4xx
|
|
13
16
|
export {
|
|
14
17
|
BadRequestError,
|
|
@@ -91,11 +94,4 @@ export {
|
|
|
91
94
|
} from './factory';
|
|
92
95
|
|
|
93
96
|
// Middleware
|
|
94
|
-
export {
|
|
95
|
-
catchAsync,
|
|
96
|
-
errorHandler,
|
|
97
|
-
notFoundHandler,
|
|
98
|
-
type ErrorContext,
|
|
99
|
-
type ErrorHandlerOptions,
|
|
100
|
-
type ErrorMiddleware,
|
|
101
|
-
} from './middleware';
|
|
97
|
+
export { errorHandler, notFoundHandler, type ErrorHandlerOptions } from './middleware';
|
package/src/middleware.ts
CHANGED
|
@@ -9,27 +9,6 @@
|
|
|
9
9
|
import type { Context, Middleware, Next } from '@nextrush/types';
|
|
10
10
|
import { HttpError, NextRushError, getHttpStatusMessage } from './base';
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Minimal context interface for error handling.
|
|
14
|
-
*
|
|
15
|
-
* @deprecated Use `Context` from `@nextrush/types` instead.
|
|
16
|
-
* Kept for backward compatibility — will be removed in v4.
|
|
17
|
-
*/
|
|
18
|
-
export interface ErrorContext {
|
|
19
|
-
method: string;
|
|
20
|
-
path: string;
|
|
21
|
-
status: number;
|
|
22
|
-
json: (data: unknown) => void;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Error handler middleware function type.
|
|
27
|
-
*
|
|
28
|
-
* @deprecated Use `Middleware` from `@nextrush/types` instead.
|
|
29
|
-
* Kept for backward compatibility — will be removed in v4.
|
|
30
|
-
*/
|
|
31
|
-
export type ErrorMiddleware = Middleware;
|
|
32
|
-
|
|
33
12
|
/**
|
|
34
13
|
* Error handler options
|
|
35
14
|
*/
|
|
@@ -116,6 +95,12 @@ export function errorHandler(options: ErrorHandlerOptions = {}): Middleware {
|
|
|
116
95
|
|
|
117
96
|
if (transform) {
|
|
118
97
|
body = transform(err, ctx);
|
|
98
|
+
} else if (err instanceof NextRushError) {
|
|
99
|
+
// Delegate to the error's own toJSON() — this is the single source of
|
|
100
|
+
// truth for what a given error type serializes to (e.g. ValidationError
|
|
101
|
+
// adds `issues` while stripping `received`). Duplicating that shape here
|
|
102
|
+
// would silently drift out of sync with subclass overrides.
|
|
103
|
+
body = err.toJSON();
|
|
119
104
|
} else {
|
|
120
105
|
body = {
|
|
121
106
|
error: expose ? err.name : getHttpStatusMessage(status),
|
|
@@ -127,10 +112,10 @@ export function errorHandler(options: ErrorHandlerOptions = {}): Middleware {
|
|
|
127
112
|
if (expose && details) {
|
|
128
113
|
body.details = details;
|
|
129
114
|
}
|
|
115
|
+
}
|
|
130
116
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
117
|
+
if (includeStack && err.stack) {
|
|
118
|
+
body.stack = err.stack.split('\n').map((line) => line.trim());
|
|
134
119
|
}
|
|
135
120
|
|
|
136
121
|
ctx.json(body);
|
|
@@ -162,22 +147,3 @@ export function notFoundHandler(message = 'Not Found'): Middleware {
|
|
|
162
147
|
};
|
|
163
148
|
}
|
|
164
149
|
|
|
165
|
-
/**
|
|
166
|
-
* Catch async errors wrapper for route handlers
|
|
167
|
-
*
|
|
168
|
-
* @example
|
|
169
|
-
* ```typescript
|
|
170
|
-
* router.get('/users/:id', catchAsync(async (ctx) => {
|
|
171
|
-
* const user = await db.users.findById(ctx.params.id);
|
|
172
|
-
* if (!user) throw new NotFoundError('User not found');
|
|
173
|
-
* ctx.json(user);
|
|
174
|
-
* }));
|
|
175
|
-
* ```
|
|
176
|
-
*
|
|
177
|
-
* @deprecated This wrapper is redundant — async errors propagate naturally
|
|
178
|
-
* through the middleware chain and are caught by `errorHandler()`. Use the
|
|
179
|
-
* handler directly instead.
|
|
180
|
-
*/
|
|
181
|
-
export function catchAsync(handler: (ctx: Context, next: Next) => Promise<void>): Middleware {
|
|
182
|
-
return handler;
|
|
183
|
-
}
|
package/src/validation.ts
CHANGED
|
@@ -37,7 +37,8 @@ export class ValidationError extends NextRushError {
|
|
|
37
37
|
code: 'VALIDATION_ERROR',
|
|
38
38
|
expose: true,
|
|
39
39
|
});
|
|
40
|
-
|
|
40
|
+
// Freeze a snapshot so issues are immutable after construction (audit E-6).
|
|
41
|
+
this.issues = Object.freeze([...issues]) as ValidationIssue[];
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/**
|