@setu-ts/exceptions 0.0.0-stage → 0.10.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 (49) hide show
  1. package/AGENT_CARD.md +50 -0
  2. package/LICENSE +21 -0
  3. package/README.md +188 -2
  4. package/dist/errors/exceptions.d.ts +146 -0
  5. package/dist/errors/exceptions.d.ts.map +1 -0
  6. package/dist/errors/exceptions.js +189 -0
  7. package/dist/errors/exceptions.js.map +1 -0
  8. package/dist/errors/http-error.d.ts +111 -0
  9. package/dist/errors/http-error.d.ts.map +1 -0
  10. package/dist/errors/http-error.js +81 -0
  11. package/dist/errors/http-error.js.map +1 -0
  12. package/dist/formatters/error-formatter.d.ts +81 -0
  13. package/dist/formatters/error-formatter.d.ts.map +1 -0
  14. package/dist/formatters/error-formatter.js +58 -0
  15. package/dist/formatters/error-formatter.js.map +1 -0
  16. package/dist/formatters/problem-details.d.ts +118 -0
  17. package/dist/formatters/problem-details.d.ts.map +1 -0
  18. package/dist/formatters/problem-details.js +108 -0
  19. package/dist/formatters/problem-details.js.map +1 -0
  20. package/dist/formatters/rfc7807-formatter.d.ts +20 -0
  21. package/dist/formatters/rfc7807-formatter.d.ts.map +1 -0
  22. package/dist/formatters/rfc7807-formatter.js +27 -0
  23. package/dist/formatters/rfc7807-formatter.js.map +1 -0
  24. package/dist/formatters/rfc9457-formatter.d.ts +29 -0
  25. package/dist/formatters/rfc9457-formatter.d.ts.map +1 -0
  26. package/dist/formatters/rfc9457-formatter.js +43 -0
  27. package/dist/formatters/rfc9457-formatter.js.map +1 -0
  28. package/dist/index.d.ts +33 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +33 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/middleware/error-handler.d.ts +111 -0
  33. package/dist/middleware/error-handler.d.ts.map +1 -0
  34. package/dist/middleware/error-handler.js +221 -0
  35. package/dist/middleware/error-handler.js.map +1 -0
  36. package/dist/middleware/error-responder-impl.d.ts +57 -0
  37. package/dist/middleware/error-responder-impl.d.ts.map +1 -0
  38. package/dist/middleware/error-responder-impl.js +158 -0
  39. package/dist/middleware/error-responder-impl.js.map +1 -0
  40. package/package.json +53 -3
  41. package/src/errors/exceptions.ts +209 -0
  42. package/src/errors/http-error.ts +132 -0
  43. package/src/formatters/error-formatter.ts +127 -0
  44. package/src/formatters/problem-details.ts +177 -0
  45. package/src/formatters/rfc7807-formatter.ts +48 -0
  46. package/src/formatters/rfc9457-formatter.ts +69 -0
  47. package/src/index.ts +57 -0
  48. package/src/middleware/error-handler.ts +357 -0
  49. package/src/middleware/error-responder-impl.ts +192 -0
@@ -0,0 +1,158 @@
1
+ import { httpStatusHintOf, resolveResponseStatus } from '@setu-ts/common';
2
+ import { HttpError } from "../errors/http-error.js";
3
+ import { RESPONDER_DETAIL } from "../formatters/problem-details.js";
4
+ /**
5
+ * The `application/problem+json` content type for Problem Details responses.
6
+ *
7
+ * Mirrors the constant `errorHandler` uses to decide the media type: a
8
+ * formatter answers Problem Details (and so derives `instance` from the
9
+ * request path) exactly when its resolved content type is this one.
10
+ */
11
+ const PROBLEM_JSON = 'application/problem+json';
12
+ /** The pure thrown-value rule, shared by serving and request-log prediction. */
13
+ export function thrownErrorStatus(error) {
14
+ const requested = error instanceof HttpError ? error.statusCode : (httpStatusHintOf(error)?.status ?? 500);
15
+ // Fetch refuses informational responses and bodies on 204, 205 and 304.
16
+ const status = Number.isInteger(requested) &&
17
+ requested >= 200 &&
18
+ requested <= 599 &&
19
+ requested !== 204 &&
20
+ requested !== 205 &&
21
+ requested !== 304
22
+ ? requested
23
+ : 500;
24
+ return { requested, status };
25
+ }
26
+ /**
27
+ * Narrows an {@linkcode ErrorResponderTarget} to a full {@linkcode
28
+ * IRequestContext} when it genuinely is one.
29
+ *
30
+ * The responder is reached two ways. In-pipeline sites — the kernel's terminals
31
+ * and every short-circuiting middleware — pass the live `IRequestContext`,
32
+ * which carries `id`, `services`, and `startTime`. The kernel's PRE-pipeline
33
+ * sites — the shutdown-drain `503` and the malformed-request `400` — pass a
34
+ * bare target of just `state`, `response`, and an optional safe `request`
35
+ * path, because no request context exists yet. The three members checked here
36
+ * are present on every real context and absent from every bare target, so the
37
+ * test distinguishes the two without a lie.
38
+ *
39
+ * @param target - The responder target to test
40
+ * @returns `true` when the target is a full request context
41
+ */
42
+ function isFullRequestContext(target) {
43
+ const ctx = target;
44
+ return (typeof ctx.id === 'string' &&
45
+ typeof ctx.services === 'object' &&
46
+ ctx.services !== null &&
47
+ typeof ctx.startTime === 'number');
48
+ }
49
+ /**
50
+ * Builds the {@linkcode HttpError} that carries one error response's status,
51
+ * title and disclosure.
52
+ *
53
+ * This is the single owner of that mapping, and it has **two** callers: the
54
+ * responder below, and `errorHandler`'s status-hint path (M89b), which answers
55
+ * a thrown error branded with an `HttpStatusHint` — a type that IS an
56
+ * `ErrorResponseInit`. Sharing it is what makes the two agree: a hinted throw
57
+ * and a `respondWithError` call carrying the same values produce byte-identical
58
+ * bodies under `default`, `rfc9457`, `rfc7807` and any custom formatter. A
59
+ * second hand-rolled construction would silently diverge — the `default`
60
+ * formatter reads `details.detail` while the Problem Details formatters read
61
+ * the {@linkcode RESPONDER_DETAIL} symbol, so getting either half wrong loses
62
+ * the disclosure in exactly one format.
63
+ *
64
+ * @param init - The status, title, disclosure and optional structured details
65
+ * @returns An `HttpError` every formatter renders with the disclosure intact
66
+ */
67
+ export function buildErrorFromInit(init) {
68
+ // The title is the framework-default `error` member — the `message` of the
69
+ // `default` format and the `detail` fallback of the Problem Details
70
+ // formatters — so it stays `error.message`. The disclosure (`init.detail`)
71
+ // is kept verbatim by every format (the `ErrorResponseInit` contract in
72
+ // `@setu-ts/common`): it is carried in the error's structured `details`
73
+ // under a `detail` key, so the `default` format emits it as `details.detail`
74
+ // beside `message`, and `buildProblemDetails` promotes it to the Problem
75
+ // Details `detail` member (falling back to the title when a site supplies no
76
+ // disclosure). `init.details` (e.g. a validation `errors` array) is merged
77
+ // through unchanged. Carrying it this way — rather than in `error.message` —
78
+ // keeps the title verbatim, which a site's (non-)disclosure decision depends
79
+ // on.
80
+ const details = init.detail !== undefined ? { ...init.details, detail: init.detail } : init.details;
81
+ const error = new HttpError(init.status, init.title, details);
82
+ // Mark the error as responder-built and carry the disclosure on a
83
+ // module-private symbol, which is what `buildProblemDetails` promotes to the
84
+ // Problem Details `detail` member. The `details.detail` key above is what the
85
+ // `default` formatter emits (it serializes `details` verbatim); the Problem
86
+ // Details formatters read the symbol instead, so a THROWN error that happens
87
+ // to carry a `details.detail` key of its own keeps `detail: <message>` (M70f
88
+ // code review, finding 4). Non-enumerable so it never reaches a serialized
89
+ // body.
90
+ if (init.detail !== undefined) {
91
+ Object.defineProperty(error, RESPONDER_DETAIL, {
92
+ value: init.detail,
93
+ enumerable: false,
94
+ writable: false,
95
+ configurable: true,
96
+ });
97
+ }
98
+ return error;
99
+ }
100
+ /**
101
+ * Builds an {@linkcode IErrorResponder} from the formatter and content type
102
+ * that `errorHandler` has **already** resolved at factory time.
103
+ *
104
+ * The responder keeps the `HttpError` construction inside the only package that
105
+ * owns it: it builds an `HttpError` from the init (so `buildProblemDetails` sees
106
+ * a real `statusCode` and the validation `errors` extension, and
107
+ * `maskInternalErrors` would never mask a deliberately 4xx), runs the resolved
108
+ * formatter over it, and writes status, `content-type` and the serialized body
109
+ * — the same three-step tail `errorHandler`'s catch path performs.
110
+ *
111
+ * @param formatter - The resolved error formatter
112
+ * @param contentType - The resolved response content type
113
+ * @returns A responder publishing the application's error format
114
+ * @since 0.1.0
115
+ */
116
+ export function createErrorResponder(formatter, contentType) {
117
+ return {
118
+ statusFor: (error) => thrownErrorStatus(error).status,
119
+ respond(target, init) {
120
+ // Sanitized before the error is built, not just before the status is
121
+ // written, so the formatted body's `status` member agrees with the status
122
+ // actually written. Idempotent, so the common path through
123
+ // `respondWithError` — which already sanitized — reports nothing twice.
124
+ const status = resolveResponseStatus(init.status, target);
125
+ const error = buildErrorFromInit(status === init.status ? init : { ...init, status });
126
+ // The formatter's `ctx` parameter is a FULL `IRequestContext` by its
127
+ // contract (M70f re-review round 2, finding 2). The target is a full
128
+ // context only at the in-pipeline sites; the kernel's pre-pipeline sites
129
+ // hand over a bare target whose `services`, `id`, `params`, `query`,
130
+ // `signal` and `startTime` do not exist. Passing that partial object
131
+ // would let a custom formatter read a documented member and throw
132
+ // `TypeError: Cannot read properties of undefined` — replacing the
133
+ // configured 503/400 with an unhandled exception. So the formatter
134
+ // receives the context only when it genuinely is one, and `undefined`
135
+ // otherwise — the value its optional `ctx` parameter already declares.
136
+ const fullContext = isFullRequestContext(target) ? target : undefined;
137
+ let body = formatter(error, fullContext);
138
+ // The Problem Details `instance` member comes from the request path. At
139
+ // the in-pipeline sites the formatter read it off the full context; at
140
+ // the pre-pipeline sites there is no context, so the responder supplies
141
+ // the safe path the kernel captured separately — a separate internal
142
+ // mechanism, since the formatter's optional `ctx` parameter is the only
143
+ // public channel and it must stay honest (M70f re-review round 2,
144
+ // finding 2). A malformed request carries no path, in which case the
145
+ // member is simply absent. A fresh object is composed rather than
146
+ // mutating the formatter's return value, which a custom formatter may
147
+ // share or freeze.
148
+ if (fullContext === undefined &&
149
+ contentType === PROBLEM_JSON &&
150
+ target.request !== undefined) {
151
+ body = { ...body, instance: target.request.path };
152
+ }
153
+ const bytes = new TextEncoder().encode(JSON.stringify(body));
154
+ target.response.status(status).header('content-type', contentType).send(bytes);
155
+ },
156
+ };
157
+ }
158
+ //# sourceMappingURL=error-responder-impl.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-responder-impl.js","sourceRoot":"","sources":["../../src/middleware/error-responder-impl.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@setu-ts/exceptions",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.10.0",
4
+ "description": "Exception hierarchy, RFC 9457 Problem Details, and the error-handler middleware",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/setu-ts/setu-ts.git",
8
+ "directory": "packages/exceptions"
9
+ },
10
+ "homepage": "https://github.com/setu-ts/setu-ts/tree/main/packages/exceptions",
11
+ "bugs": {
12
+ "url": "https://github.com/setu-ts/setu-ts/issues"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "sideEffects": false,
18
+ "type": "module",
19
+ "license": "MIT",
20
+ "engines": {
21
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src",
26
+ "README.md",
27
+ "LICENSE",
28
+ "AGENT_CARD.md"
29
+ ],
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "dependencies": {
37
+ "@setu-ts/common": "^0.10.0"
38
+ },
39
+ "devDependencies": {
40
+ "@setu-ts/auth-plugin": "^0.10.0",
41
+ "@setu-ts/feature-flags-plugin": "^0.10.0",
42
+ "@setu-ts/http-security-plugin": "^0.10.0",
43
+ "@setu-ts/logger-plugin": "^0.10.0",
44
+ "@setu-ts/multi-tenancy-plugin": "^0.10.0",
45
+ "@setu-ts/runtime": "^0.10.0",
46
+ "@setu-ts/session-plugin": "^0.10.0",
47
+ "@setu-ts/storage-plugin": "^0.10.0",
48
+ "@setu-ts/testing": "^0.10.0",
49
+ "@types/node": "^24",
50
+ "typescript": "6.0.3",
51
+ "vitest": "5.0.3"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -b"
55
+ }
6
56
  }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Exception factory functions.
3
+ *
4
+ * Each factory returns a {@linkcode HttpError} with a fixed `statusCode`,
5
+ * embodying **composition over inheritance** (AI_GUIDELINES §1.4,
6
+ * ARCHITECTURE.md §13). Throw one in a route handler or service and the
7
+ * error-handler middleware will format and send it with the right status.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { notFound, badRequest } from '@setu-ts/exceptions';
12
+ *
13
+ * function getUser(id: string) {
14
+ * if (!id) throw badRequest('id is required');
15
+ * const user = db.find(id);
16
+ * if (!user) throw notFound(`User ${id} not found`);
17
+ * return user;
18
+ * }
19
+ * ```
20
+ *
21
+ * @module
22
+ */
23
+ import { HttpError } from './http-error.ts';
24
+ import type { ValidationError } from './http-error.ts';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Status-code → human title (single source of truth, shared with formatters)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * A human-readable title for a given HTTP status code. This is the single
32
+ * source of truth used by both the factory functions and the Problem Details
33
+ * formatters so the `title` field never drifts from the produced `statusCode`.
34
+ *
35
+ * Keys are every status the framework itself produces — the ones the factories
36
+ * below return AND the ones first-party code brands onto an error with
37
+ * `withHttpStatusHint` from `@setu-ts/common`, which reaches this table through
38
+ * the responder seam without passing a factory (`413` from the request-size and
39
+ * body-cap refusals, `415` from the form accessor, `501`/`503`/`504` from the
40
+ * database, secrets and resilience refusals). A branded status with no row here
41
+ * is served the generic `'Error'` title, so `test/status-title-coverage.test.ts`
42
+ * enumerates the branded statuses from source and fails when one is missing —
43
+ * `415` reached a client as `"title": "Error"` under `'rfc9457'` while the same
44
+ * error read `"message": "Unsupported Media Type"` under `'default'`, because
45
+ * every test asserted the brand on the error object and none read the served
46
+ * body.
47
+ *
48
+ * @since 0.1.0
49
+ */
50
+ export const STATUS_TITLES: Readonly<Record<number, string>> = {
51
+ 400: 'Bad Request',
52
+ 401: 'Unauthorized',
53
+ 403: 'Forbidden',
54
+ 404: 'Not Found',
55
+ 409: 'Conflict',
56
+ 413: 'Payload Too Large',
57
+ 415: 'Unsupported Media Type',
58
+ 422: 'Unprocessable Entity',
59
+ 429: 'Too Many Requests',
60
+ 500: 'Internal Server Error',
61
+ 501: 'Not Implemented',
62
+ 502: 'Bad Gateway',
63
+ 503: 'Service Unavailable',
64
+ 504: 'Gateway Timeout',
65
+ };
66
+
67
+ /**
68
+ * Resolves the human-readable title for a status code, falling back to a
69
+ * generic title for codes outside the well-known set.
70
+ *
71
+ * @param statusCode - The HTTP status code
72
+ * @returns A human-readable title
73
+ * @since 0.1.0
74
+ */
75
+ export function statusTitle(statusCode: number): string {
76
+ return STATUS_TITLES[statusCode] ?? 'Error';
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Factories
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Creates a `400 Bad Request` error.
85
+ *
86
+ * @param message - Human-readable error message
87
+ * @param details - Optional structured details appended to the error body
88
+ * @returns A `400` {@linkcode HttpError}
89
+ * @since 0.1.0
90
+ */
91
+ export function badRequest(
92
+ message: string,
93
+ details?: Readonly<Record<string, unknown>>,
94
+ ): HttpError {
95
+ return new HttpError(400, message, details);
96
+ }
97
+
98
+ /**
99
+ * Creates a `401 Unauthorized` error.
100
+ *
101
+ * @param message - Human-readable error message
102
+ * @returns A `401` {@linkcode HttpError}
103
+ * @since 0.1.0
104
+ */
105
+ export function unauthorized(message: string): HttpError {
106
+ return new HttpError(401, message);
107
+ }
108
+
109
+ /**
110
+ * Creates a `403 Forbidden` error.
111
+ *
112
+ * @param message - Human-readable error message
113
+ * @returns A `403` {@linkcode HttpError}
114
+ * @since 0.1.0
115
+ */
116
+ export function forbidden(message: string): HttpError {
117
+ return new HttpError(403, message);
118
+ }
119
+
120
+ /**
121
+ * Creates a `404 Not Found` error.
122
+ *
123
+ * @param message - Human-readable error message
124
+ * @returns A `404` {@linkcode HttpError}
125
+ * @since 0.1.0
126
+ */
127
+ export function notFound(message: string): HttpError {
128
+ return new HttpError(404, message);
129
+ }
130
+
131
+ /**
132
+ * Creates a `409 Conflict` error.
133
+ *
134
+ * @param message - Human-readable error message
135
+ * @returns A `409` {@linkcode HttpError}
136
+ * @since 0.1.0
137
+ */
138
+ export function conflict(message: string): HttpError {
139
+ return new HttpError(409, message);
140
+ }
141
+
142
+ /**
143
+ * Creates a `422 Unprocessable Entity` error wrapping a list of validation
144
+ * failures.
145
+ *
146
+ * The `errors` array is stored in the error's `details.errors` so it survives
147
+ * serialization into both the default and RFC 7807 error bodies.
148
+ *
149
+ * @param errors - The validation failures that caused this error
150
+ * @param message - Optional custom message (defaults to a count-based summary)
151
+ * @returns A `422` {@linkcode HttpError}
152
+ * @since 0.1.0
153
+ */
154
+ export function validationError(errors: readonly ValidationError[], message?: string): HttpError {
155
+ const summary = message ?? `Validation failed with ${errors.length} error(s).`;
156
+ return new HttpError(422, summary, { errors });
157
+ }
158
+
159
+ /**
160
+ * Creates a `429 Too Many Requests` error.
161
+ *
162
+ * @param message - Human-readable error message
163
+ * @param details - Optional structured details (e.g. `retryAfter`)
164
+ * @returns A `429` {@linkcode HttpError}
165
+ * @since 0.1.0
166
+ */
167
+ export function tooManyRequests(
168
+ message: string,
169
+ details?: Readonly<Record<string, unknown>>,
170
+ ): HttpError {
171
+ return new HttpError(429, message, details);
172
+ }
173
+
174
+ /**
175
+ * Creates a `500 Internal Server Error` error.
176
+ *
177
+ * Pass the original error as `cause` so the root cause is preserved in the
178
+ * ES2022 error chain for logging and debugging.
179
+ *
180
+ * @param message - Human-readable error message
181
+ * @param cause - Optional underlying error forwarded to the cause chain
182
+ * @returns A `500` {@linkcode HttpError}
183
+ * @since 0.1.0
184
+ */
185
+ export function internalServerError(message: string, cause?: Error): HttpError {
186
+ return new HttpError(500, message, undefined, cause);
187
+ }
188
+
189
+ /**
190
+ * Creates a `501 Not Implemented` error.
191
+ *
192
+ * @param message - Human-readable error message
193
+ * @returns A `501` {@linkcode HttpError}
194
+ * @since 0.1.0
195
+ */
196
+ export function notImplemented(message: string): HttpError {
197
+ return new HttpError(501, message);
198
+ }
199
+
200
+ /**
201
+ * Creates a `503 Service Unavailable` error.
202
+ *
203
+ * @param message - Human-readable error message
204
+ * @returns A `503` {@linkcode HttpError}
205
+ * @since 0.1.0
206
+ */
207
+ export function serviceUnavailable(message: string): HttpError {
208
+ return new HttpError(503, message);
209
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `HttpError` — the framework's single concrete HTTP error type.
3
+ *
4
+ * Rather than a deep class hierarchy (`BadRequestError extends HttpError`,
5
+ * `NotFoundError extends HttpError`, …), the framework uses **composition via
6
+ * factory functions** (AI_GUIDELINES §1.4, ARCHITECTURE.md §13). Every HTTP
7
+ * error is an instance of this one class with a `statusCode` set at
8
+ * construction time by a factory. This keeps the hierarchy flat, makes errors
9
+ * trivially serializable, and allows runtime composition of properties.
10
+ *
11
+ * `cause` is forwarded to the ES2022 `Error` cause chain — pass the original
12
+ * error so callers can inspect the root cause without losing the stack.
13
+ *
14
+ * @module
15
+ */
16
+
17
+ /**
18
+ * A single validation failure carried by a `422` error.
19
+ *
20
+ * Mirrors the shape used by `@setu-ts/validation-plugin` so error
21
+ * payloads are consistent across the validation and exceptions packages.
22
+ *
23
+ * @since 0.1.0
24
+ */
25
+ export interface ValidationError {
26
+ /** Dot-path of the offending field (e.g. `"address.zip"`). */
27
+ readonly field: string;
28
+ /** Human-readable description of the failure. */
29
+ readonly message: string;
30
+ /** Optional machine-readable failure code. */
31
+ readonly code?: string;
32
+ }
33
+
34
+ /**
35
+ * Options accepted by the {@linkcode HttpError} constructor.
36
+ *
37
+ * Extracted as a type so factory functions can accept a consistent object.
38
+ *
39
+ * @since 0.1.0
40
+ */
41
+ export interface HttpErrorInit {
42
+ /** HTTP status code (e.g. `404`). */
43
+ readonly statusCode: number;
44
+ /** Human-readable error message. */
45
+ readonly message: string;
46
+ /** Optional structured details attached to the error body. */
47
+ readonly details?: Readonly<Record<string, unknown>>;
48
+ /** Optional underlying cause (forwarded to the ES2022 `Error` cause chain). */
49
+ readonly cause?: Error;
50
+ }
51
+
52
+ /**
53
+ * The framework's HTTP error type.
54
+ *
55
+ * Throw an instance directly, or — preferably — construct one via a factory
56
+ * function from {@linkcode ./exceptions.ts} so the status code is correct by
57
+ * construction:
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * import { notFound } from '@setu-ts/exceptions';
62
+ *
63
+ * throw notFound(`User ${id} not found`);
64
+ * ```
65
+ *
66
+ * The error handler middleware inspects the `statusCode` to set the response
67
+ * status, and serializes `message` and `details` into the error body.
68
+ *
69
+ * @since 0.1.0
70
+ */
71
+ export class HttpError extends Error {
72
+ /** The HTTP status code this error maps to. */
73
+ readonly statusCode: number;
74
+ /**
75
+ * Structured details appended to the error body. Omitted entirely when not
76
+ * supplied (never `undefined`) so serialization stays clean.
77
+ *
78
+ * `declare` suppresses the ES2022 class-field initialization that would
79
+ * otherwise set this to `undefined` on every instance — it only exists on
80
+ * the object when actually assigned by the constructor.
81
+ */
82
+ declare readonly details?: Readonly<Record<string, unknown>>;
83
+
84
+ /**
85
+ * Creates a new `HttpError`.
86
+ *
87
+ * Prefer the factory functions in `exceptions.ts` over calling this
88
+ * constructor directly — they guarantee a correct status code.
89
+ *
90
+ * @param statusCode - HTTP status code (e.g. `404`)
91
+ * @param message - Human-readable error message
92
+ * @param details - Optional structured details; omitted when absent
93
+ * @param cause - Optional underlying error; forwarded to the ES2022 cause chain
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * const err = new HttpError(404, 'User not found');
98
+ * console.log(err.statusCode); // 404
99
+ * ```
100
+ * @since 0.1.0
101
+ */
102
+ constructor(
103
+ statusCode: number,
104
+ message: string,
105
+ details?: Readonly<Record<string, unknown>>,
106
+ cause?: Error,
107
+ ) {
108
+ // Forward `cause` to the ES2022 Error cause chain when present. Under
109
+ // `exactOptionalPropertyTypes`, passing `{ cause: undefined }` would
110
+ // violate the option, so the options object is built conditionally.
111
+ super(message, cause !== undefined ? { cause } : undefined);
112
+ this.name = 'HttpError';
113
+ this.statusCode = statusCode;
114
+ if (details !== undefined) {
115
+ this.details = details;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Creates an `HttpError` from an {@linkcode HttpErrorInit} object.
121
+ *
122
+ * Convenience overload used by factory functions so they can branch on
123
+ * optional `details`/`cause` once.
124
+ *
125
+ * @param init - The error options
126
+ * @returns A new `HttpError`
127
+ * @since 0.1.0
128
+ */
129
+ static from(init: HttpErrorInit): HttpError {
130
+ return new HttpError(init.statusCode, init.message, init.details, init.cause);
131
+ }
132
+ }