@setu-ts/exceptions 0.0.0-stage → 0.9.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -2
  3. package/dist/errors/exceptions.d.ts +146 -0
  4. package/dist/errors/exceptions.d.ts.map +1 -0
  5. package/dist/errors/exceptions.js +189 -0
  6. package/dist/errors/exceptions.js.map +1 -0
  7. package/dist/errors/http-error.d.ts +111 -0
  8. package/dist/errors/http-error.d.ts.map +1 -0
  9. package/dist/errors/http-error.js +81 -0
  10. package/dist/errors/http-error.js.map +1 -0
  11. package/dist/formatters/error-formatter.d.ts +81 -0
  12. package/dist/formatters/error-formatter.d.ts.map +1 -0
  13. package/dist/formatters/error-formatter.js +58 -0
  14. package/dist/formatters/error-formatter.js.map +1 -0
  15. package/dist/formatters/problem-details.d.ts +118 -0
  16. package/dist/formatters/problem-details.d.ts.map +1 -0
  17. package/dist/formatters/problem-details.js +108 -0
  18. package/dist/formatters/problem-details.js.map +1 -0
  19. package/dist/formatters/rfc7807-formatter.d.ts +20 -0
  20. package/dist/formatters/rfc7807-formatter.d.ts.map +1 -0
  21. package/dist/formatters/rfc7807-formatter.js +27 -0
  22. package/dist/formatters/rfc7807-formatter.js.map +1 -0
  23. package/dist/formatters/rfc9457-formatter.d.ts +29 -0
  24. package/dist/formatters/rfc9457-formatter.d.ts.map +1 -0
  25. package/dist/formatters/rfc9457-formatter.js +43 -0
  26. package/dist/formatters/rfc9457-formatter.js.map +1 -0
  27. package/dist/index.d.ts +33 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +33 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/middleware/error-handler.d.ts +112 -0
  32. package/dist/middleware/error-handler.d.ts.map +1 -0
  33. package/dist/middleware/error-handler.js +210 -0
  34. package/dist/middleware/error-handler.js.map +1 -0
  35. package/dist/middleware/error-responder-impl.d.ts +52 -0
  36. package/dist/middleware/error-responder-impl.d.ts.map +1 -0
  37. package/dist/middleware/error-responder-impl.js +148 -0
  38. package/dist/middleware/error-responder-impl.js.map +1 -0
  39. package/package.json +52 -3
  40. package/src/errors/exceptions.ts +212 -0
  41. package/src/errors/http-error.ts +132 -0
  42. package/src/formatters/error-formatter.ts +127 -0
  43. package/src/formatters/problem-details.ts +176 -0
  44. package/src/formatters/rfc7807-formatter.ts +48 -0
  45. package/src/formatters/rfc9457-formatter.ts +69 -0
  46. package/src/index.ts +57 -0
  47. package/src/middleware/error-handler.ts +337 -0
  48. package/src/middleware/error-responder-impl.ts +178 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * RFC 9457 Problem Details error formatter.
3
+ *
4
+ * Produces a JSON body conforming to
5
+ * [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) Problem Details for
6
+ * HTTP APIs, which obsoletes RFC 7807. The body carries `type`, `title`,
7
+ * `status`, and `detail`, with `instance` derived from the request path and an
8
+ * optional `errors` extension for validation failures.
9
+ *
10
+ * @module
11
+ */
12
+ import type { IRequestContext } from '@setu-ts/common';
13
+
14
+ import type { ErrorHandlerFormatter } from './error-formatter.ts';
15
+ import {
16
+ ABOUT_BLANK,
17
+ buildProblemDetails,
18
+ type ProblemDetails,
19
+ VALIDATION_TYPE,
20
+ } from './problem-details.ts';
21
+
22
+ export { ERROR_TYPE_BASE } from './problem-details.ts';
23
+ export type { ProblemDetails } from './problem-details.ts';
24
+
25
+ /**
26
+ * Resolves the RFC 9457 `type` member.
27
+ *
28
+ * An {@linkcode HttpError} carries no problem-type identity beyond its status
29
+ * code, so a URI minted from that status would identify nothing the `status`
30
+ * member does not already convey — which is exactly the case RFC 9457 §4.2
31
+ * registers `about:blank` for. The one exception is the body produced by
32
+ * `validationError()`, which defines an `errors` extension member and is
33
+ * therefore a distinct problem type deserving its own URI.
34
+ *
35
+ * @param _statusCode - The HTTP status code (not used; the status member carries it)
36
+ * @param hasErrors - Whether the error carries a validation `errors` extension
37
+ * @returns `about:blank`, or the validation problem type URI
38
+ */
39
+ const resolveRfc9457Type = (_statusCode: number, hasErrors: boolean): string =>
40
+ hasErrors ? VALIDATION_TYPE : ABOUT_BLANK;
41
+
42
+ /**
43
+ * Format an error as RFC 9457 Problem Details.
44
+ *
45
+ * `type` is `about:blank` for problems whose only semantics are their status
46
+ * code, and the framework validation problem type when the error carries an
47
+ * `errors` extension. Every other member is assembled identically to the
48
+ * deprecated RFC 7807 formatter.
49
+ *
50
+ * @param error - The thrown error to format
51
+ * @param ctx - Optional request context (used for `instance`)
52
+ * @returns An RFC 9457 Problem Details body
53
+ * @example
54
+ * ```typescript
55
+ * rfc9457Formatter(notFound('User 42 does not exist'), ctx);
56
+ * // {
57
+ * // type: 'about:blank',
58
+ * // title: 'Not Found',
59
+ * // status: 404,
60
+ * // detail: 'User 42 does not exist',
61
+ * // instance: '/users/42',
62
+ * // }
63
+ * ```
64
+ * @since 0.1.0
65
+ */
66
+ export const rfc9457Formatter: ErrorHandlerFormatter = (
67
+ error: Error,
68
+ ctx?: IRequestContext,
69
+ ): ProblemDetails => buildProblemDetails(error, ctx, resolveRfc9457Type);
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @module
3
+ *
4
+ * Exception factory functions, `HttpError`, error formatters, and the global
5
+ * error handler middleware.
6
+ *
7
+ * This is a **plain package** (not a plugin) — it depends on
8
+ * `@setu-ts/common` only and exposes types, factories, and a
9
+ * middleware factory. Register the middleware via the application's pipeline:
10
+ *
11
+ * ```typescript
12
+ * import { errorHandler } from '@setu-ts/exceptions';
13
+ *
14
+ * app.middleware.add(errorHandler({ format: 'rfc9457' }), {
15
+ * priority: 0,
16
+ * name: 'error-handler',
17
+ * });
18
+ * ```
19
+ *
20
+ * Every export here is public API and documented in PUBLIC_API.md
21
+ * (AI_GUIDELINES §10).
22
+ */
23
+
24
+ // Error type
25
+ export { HttpError } from './errors/http-error.ts';
26
+ export type { HttpErrorInit, ValidationError } from './errors/http-error.ts';
27
+
28
+ // Exception factories
29
+ export {
30
+ badRequest,
31
+ conflict,
32
+ forbidden,
33
+ internalServerError,
34
+ notFound,
35
+ notImplemented,
36
+ serviceUnavailable,
37
+ STATUS_TITLES,
38
+ statusTitle,
39
+ tooManyRequests,
40
+ unauthorized,
41
+ validationError,
42
+ } from './errors/exceptions.ts';
43
+
44
+ // Formatters
45
+ export { defaultFormatter, selectFormatter } from './formatters/error-formatter.ts';
46
+ export type {
47
+ DefaultErrorBody,
48
+ ErrorFormat,
49
+ ErrorHandlerFormatter,
50
+ } from './formatters/error-formatter.ts';
51
+ export { ERROR_TYPE_BASE, rfc9457Formatter } from './formatters/rfc9457-formatter.ts';
52
+ export type { ProblemDetails } from './formatters/rfc9457-formatter.ts';
53
+ export { rfc7807Formatter } from './formatters/rfc7807-formatter.ts';
54
+
55
+ // Middleware
56
+ export { errorHandler } from './middleware/error-handler.ts';
57
+ export type { ErrorHandlerOptions } from './middleware/error-handler.ts';
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Global error-handler middleware factory.
3
+ *
4
+ * Returns a {@linkcode MiddlewareFunction} that wraps `next()` in a try/catch,
5
+ * so any error thrown by downstream middleware or the route handler is caught,
6
+ * logged (when a logger is registered), formatted, and sent as a JSON error
7
+ * response. Register it as the **outermost** middleware (lowest priority number)
8
+ * so it wraps the entire pipeline.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * import { errorHandler } from '@setu-ts/exceptions';
13
+ *
14
+ * app.middleware.add(errorHandler({
15
+ * format: 'rfc9457',
16
+ * includeStackTrace: config.get('NODE_ENV') === 'development',
17
+ * logErrors: true,
18
+ * }), { priority: 0, name: 'error-handler' });
19
+ * ```
20
+ *
21
+ * @module
22
+ */
23
+ import type { HandlerResult, ILogger, IRequestContext, MiddlewareFunction } from '@setu-ts/common';
24
+ import {
25
+ brandErrorResponder,
26
+ CAPABILITIES,
27
+ ERROR_RESPONDER_STATE_KEY,
28
+ httpStatusHintOf,
29
+ resolveResponseStatus,
30
+ serializeError,
31
+ } from '@setu-ts/common';
32
+
33
+ import { HttpError } from '../errors/http-error.ts';
34
+ import { internalServerError, statusTitle } from '../errors/exceptions.ts';
35
+ import { buildErrorFromInit, createErrorResponder } from './error-responder-impl.ts';
36
+ import {
37
+ type ErrorFormat,
38
+ type ErrorHandlerFormatter,
39
+ selectFormatter,
40
+ } from '../formatters/error-formatter.ts';
41
+ import { rfc9457Formatter } from '../formatters/rfc9457-formatter.ts';
42
+ import { rfc7807Formatter } from '../formatters/rfc7807-formatter.ts';
43
+
44
+ /**
45
+ * Options for the {@linkcode errorHandler} middleware factory.
46
+ *
47
+ * @since 0.1.0
48
+ */
49
+ export interface ErrorHandlerOptions {
50
+ /**
51
+ * The error body format: `'default'`, `'rfc9457'`, the deprecated
52
+ * `'rfc7807'`, or a custom formatter function. Defaults to `'default'`.
53
+ */
54
+ readonly format?: ErrorFormat | ErrorHandlerFormatter;
55
+ /**
56
+ * When `true`, the error `stack` trace is included in the response body.
57
+ * **Never** enable this in production — pass a config-derived boolean (e.g.
58
+ * `config.get('NODE_ENV') === 'development'`), never read `process.env`
59
+ * directly (AI_GUIDELINES §4.1). Defaults to `false`.
60
+ *
61
+ * Note the stack is the _secondary_ disclosure: the primary one is the
62
+ * error **message**, which for a failed query carries the SQL and its bound
63
+ * parameter values. Masking that is `maskInternalErrors`' job.
64
+ *
65
+ * The two compose safely, and masking wins: an error masked by
66
+ * {@linkcode ErrorHandlerOptions.maskInternalErrors} carries **no** stack in
67
+ * the body even when this option is `true`, because a stack begins with the
68
+ * very message that was masked. Set `maskInternalErrors: false` to see the
69
+ * stack of an internal error.
70
+ */
71
+ readonly includeStackTrace?: boolean;
72
+ /**
73
+ * When `true` (the default), a caught value that was **not** an
74
+ * {@linkcode HttpError} and resolves to a status `>= 500` is masked in the
75
+ * response: its `detail`/`message` becomes the status title
76
+ * (`'Internal Server Error'`) and the raw message — which for a failed
77
+ * query carries the SQL and its bound parameter values — is dropped from the
78
+ * body. The log is unaffected: `logErrors` still records the unmasked error
79
+ * and its cause chain, so an operator loses nothing unless `logErrors` is
80
+ * _also_ `false`, the configuration that already logs nothing.
81
+ *
82
+ * A deliberately thrown `HttpError` is never masked — `instanceof HttpError`
83
+ * is the line between "the developer wrote this for a caller" and "this
84
+ * escaped from a driver". `false` restores the previous behaviour verbatim.
85
+ */
86
+ readonly maskInternalErrors?: boolean;
87
+ /**
88
+ * When `true` (the default), caught errors are logged at `error` level via
89
+ * the `ILogger` resolved from `ctx.services` — but only if a logger is
90
+ * registered. When no logger is present, logging is silently skipped.
91
+ */
92
+ readonly logErrors?: boolean;
93
+ /**
94
+ * Lets the application write its own response for a caught error. The hook
95
+ * receives the normalized error after status hints, internal-error masking,
96
+ * and status resolution, so `error.statusCode` is safe to serve. Return a
97
+ * {@linkcode HandlerResult} produced by `ctx.response` to use that response;
98
+ * it may resolve that result asynchronously. Return or resolve to `undefined`
99
+ * to fall through to the configured formatter unchanged.
100
+ *
101
+ * The hook owns its returned response's status, headers, and body. It is
102
+ * called only for errors caught by this middleware, not responder-based
103
+ * terminals such as an unmatched-path `404`.
104
+ */
105
+ readonly respond?: (
106
+ error: HttpError,
107
+ ctx: IRequestContext,
108
+ ) => HandlerResult | undefined | Promise<HandlerResult | undefined>;
109
+ }
110
+
111
+ /** The `application/problem+json` content type for Problem Details responses. */
112
+ const PROBLEM_JSON = 'application/problem+json';
113
+ /** The default JSON content type. */
114
+ const JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
115
+
116
+ /**
117
+ * The formatters whose bodies are Problem Details and therefore require the
118
+ * `application/problem+json` media type (RFC 9457 §3).
119
+ *
120
+ * Membership is keyed on the RESOLVED formatter rather than on the format
121
+ * string, because both formatters are exported: `format: rfc9457Formatter`
122
+ * passes a reference and must carry the same media type as `format: 'rfc9457'`.
123
+ * A body served as `application/json` is ignored by generic problem-details
124
+ * clients, so a formatter missing from this set is a silent interoperability
125
+ * defect rather than a visible failure.
126
+ */
127
+ const PROBLEM_DETAILS_FORMATTERS: ReadonlySet<ErrorHandlerFormatter> = new Set([
128
+ rfc9457Formatter,
129
+ rfc7807Formatter,
130
+ ]);
131
+
132
+ /**
133
+ * Creates a global error-handler middleware.
134
+ *
135
+ * Behavior:
136
+ * 1. Calls `next()` inside a try/catch.
137
+ * 2. If `next()` throws an `HttpError`, its `statusCode` is used as-is.
138
+ * 3. If `next()` throws any other `Error`, it is wrapped in a `500`
139
+ * `internalServerError` carrying the original as `cause`.
140
+ * 4. When configured, lets the application write its own response. A returned
141
+ * or resolved `HandlerResult` short-circuits formatting; `undefined` falls
142
+ * through.
143
+ * 5. When `logErrors` is on and a logger is registered, the unmasked error is
144
+ * logged with the status the selected response serves.
145
+ * 6. Otherwise, formats the error via {@linkcode selectFormatter}, optionally
146
+ * enriches it with a `stack` trace, and sends it with the right status and
147
+ * content type. The middleware **returns a `HandlerResult`** (short-circuit)
148
+ * and never re-invokes `next()`.
149
+ *
150
+ * @param options - Error handler configuration
151
+ * @returns A middleware function
152
+ * @since 0.1.0
153
+ */
154
+ export function errorHandler(options?: ErrorHandlerOptions): MiddlewareFunction {
155
+ const format = options?.format ?? 'default';
156
+ const includeStackTrace = options?.includeStackTrace ?? false;
157
+ const maskInternalErrors = options?.maskInternalErrors ?? true;
158
+ const logErrors = options?.logErrors ?? true;
159
+ const respond = options?.respond;
160
+ const formatter = selectFormatter(format);
161
+ const contentType = PROBLEM_DETAILS_FORMATTERS.has(formatter) ? PROBLEM_JSON : JSON_CONTENT_TYPE;
162
+ // The responder is built ONCE at factory time from the formatter and content
163
+ // type already resolved above, and published per request below: one
164
+ // `Map.set` of a pre-built object, not a re-resolution (AI_GUIDELINES §14).
165
+ const responder = createErrorResponder(formatter, contentType);
166
+
167
+ const handleError = async (
168
+ ctx: IRequestContext,
169
+ next: () => Promise<void>,
170
+ ): Promise<void | HandlerResult> => {
171
+ // Publish the responder BEFORE `next()` so every site inside the pipeline
172
+ // — the kernel's own terminals and every short-circuiting middleware —
173
+ // answers in this application's configured format (M70f).
174
+ ctx.state.set(ERROR_RESPONDER_STATE_KEY, responder);
175
+ try {
176
+ await next();
177
+ return;
178
+ } catch (rawError) {
179
+ // Normalize: HttpError passes through, anything else becomes a 500.
180
+ const isHttpError = rawError instanceof HttpError;
181
+ const error: HttpError = isHttpError ? rawError : internalServerError(
182
+ rawError instanceof Error ? rawError.message : 'Internal Server Error',
183
+ rawError instanceof Error ? rawError : undefined,
184
+ );
185
+
186
+ // A thrower that cannot import this package states the status its own
187
+ // error means by branding it with an `HttpStatusHint` (M89b) — the seam
188
+ // `@setu-ts/database-plugin` uses for its query-shape refusals, which
189
+ // are caller errors and were answered as masked 500s. The hint carries
190
+ // its own caller-safe `detail`, so the response is built from THAT and
191
+ // never from the error's message, and `buildErrorFromInit` is the same
192
+ // constructor `respondWithError` reaches: one mapping, one body shape.
193
+ //
194
+ // Read only for a non-`HttpError`. A deliberately thrown `HttpError`
195
+ // already states its own status, and letting a brand override it would
196
+ // give one error two answers.
197
+ const hint = isHttpError ? undefined : httpStatusHintOf(rawError);
198
+
199
+ // Mask a driver-shaped 500 for the response: the raw message becomes the
200
+ // status title so the body carries neither the statement nor the values.
201
+ // A deliberately thrown HttpError is never masked, and a 4xx is left
202
+ // alone — only a non-HttpError with status >= 500 is rewritten.
203
+ //
204
+ // A hinted error is exempt, and the exemption is narrow by construction
205
+ // rather than by trust: what it serves is the hint's own `detail`, a
206
+ // fixed sentence the brand site wrote, never the `Error`'s message. So
207
+ // there is no driver diagnostic in the body for masking to remove. It is
208
+ // checked FIRST because a hinted 501 satisfies all three masking clauses
209
+ // and would otherwise be rewritten into the exact symptom this closes.
210
+ let responseError = error;
211
+ if (hint !== undefined) {
212
+ responseError = buildErrorFromInit(hint);
213
+ } else if (maskInternalErrors && !isHttpError && error.statusCode >= 500) {
214
+ // Preserve the log's cause chain without leaking the message. `cause`
215
+ // is `unknown` on `Error`; only an `Error` value is a valid
216
+ // `HttpError` cause, so narrow rather than cast.
217
+ const cause = error.cause instanceof Error ? error.cause : undefined;
218
+ responseError = new HttpError(
219
+ error.statusCode,
220
+ statusTitle(error.statusCode),
221
+ undefined,
222
+ cause,
223
+ );
224
+ }
225
+
226
+ // An `HttpError` carries whatever status its thrower passed: the
227
+ // constructor validates nothing, and its own JSDoc says the factory
228
+ // functions are what "guarantee a correct status code". So
229
+ // `throw new HttpError(4004, ...)` — a typo for `404` — reached
230
+ // `response.status()` and threw `RangeError` out of the one `catch` that
231
+ // exists to contain throws. The status is clamped BEFORE the log and the
232
+ // body are built, so the logged status, the body's own status member and
233
+ // the written status are one number. Rebuilding rather than clamping at
234
+ // the write also makes `responseError === error` false, which suppresses
235
+ // the stack — correct by that check's own stated invariant, since the
236
+ // error being SERVED is no longer the error that was LOGGED.
237
+ const servedStatus = resolveResponseStatus(responseError.statusCode, ctx);
238
+ if (servedStatus !== responseError.statusCode) {
239
+ responseError = new HttpError(
240
+ servedStatus,
241
+ responseError.message,
242
+ responseError.details,
243
+ responseError.cause instanceof Error ? responseError.cause : undefined,
244
+ );
245
+ }
246
+
247
+ // The hook sees the same safe error and served status that the formatter
248
+ // would receive. It can perform asynchronous view rendering; only
249
+ // `undefined` means "use the framework formatter". A hook failure stays
250
+ // an application failure, but records the original caught diagnostic
251
+ // before it propagates to the surrounding pipeline.
252
+ let result: HandlerResult | undefined;
253
+ try {
254
+ result = await respond?.(responseError, ctx);
255
+ } catch (hookError) {
256
+ if (logErrors) {
257
+ logError(ctx, error, responseError.statusCode);
258
+ }
259
+ throw hookError;
260
+ }
261
+ if (result !== undefined) {
262
+ // The application-owned result can choose a status different from the
263
+ // normalized error (for example a 418 HTML page for a caught 500), so
264
+ // inspect the completed writer rather than reporting the fallback.
265
+ if (logErrors) {
266
+ logError(ctx, error, ctx.response.snapshot().status);
267
+ }
268
+ return result;
269
+ }
270
+
271
+ // Log the UNMASKED error, so the log keeps the SQL, the bound parameters
272
+ // and the cause chain regardless of masking — and report the status the
273
+ // formatter below will actually serve. A hint may change a normalized
274
+ // `500` to `501`, so logging the pre-hint status would mislead operators.
275
+ if (logErrors) {
276
+ logError(ctx, error, responseError.statusCode);
277
+ }
278
+
279
+ const body = formatter(responseError, ctx);
280
+ // Masking wins over `includeStackTrace`. A stack's first line is
281
+ // `<name>: <message>`, so attaching the unmasked error's stack would put
282
+ // the SQL and its bound parameter values straight back into the body that
283
+ // was just masked — defeating the mask through the one option documented
284
+ // as unsafe in production. A masked error therefore carries no stack at
285
+ // all; the unmasked one is in the log, where `logErrors` already sent it.
286
+ //
287
+ // The condition is `responseError === error` rather than `!masked`
288
+ // because a hinted response is the second case with the same property:
289
+ // its body deliberately carries the brand site's sentence and not the
290
+ // error's own text, and a stack's first line is `<name>: <message>`. So
291
+ // the stack is attached exactly when the error being SERVED is the error
292
+ // that was LOGGED, which is the invariant both exclusions want.
293
+ if (includeStackTrace && responseError === error && error.stack !== undefined) {
294
+ body.stack = error.stack;
295
+ }
296
+
297
+ // Serialize and send via `.send(bytes)` rather than `.json(body)` so
298
+ // the content-type header we set is not overwritten by json()'s own
299
+ // `application/json` default — RFC 9457 requires `application/problem+json`.
300
+ const bytes = new TextEncoder().encode(JSON.stringify(body));
301
+ return ctx.response
302
+ .status(responseError.statusCode)
303
+ .header('content-type', contentType)
304
+ .send(bytes);
305
+ }
306
+ };
307
+
308
+ // Brand the middleware with the SAME responder it publishes into `ctx.state`
309
+ // below, so the kernel can seed it into the state of the pre-pipeline sites
310
+ // — the drain `503`, the malformed-request `400`, and the request-lifecycle
311
+ // hooks — which run before this middleware and would otherwise always take
312
+ // the fallback shape (M70f re-review, findings 1 & 2).
313
+ brandErrorResponder(handleError, responder);
314
+ return handleError;
315
+ }
316
+
317
+ /**
318
+ * Logs an error via the registered `ILogger`, if one is available. Silently
319
+ * does nothing when no logger capability is registered.
320
+ *
321
+ * @param ctx - The request context
322
+ * @param error - The error to log, carrying the unmasked message and cause
323
+ * @param statusCode - The status actually served, which a hint may have changed
324
+ */
325
+ function logError(ctx: IRequestContext, error: HttpError, statusCode: number): void {
326
+ if (!ctx.services.has(CAPABILITIES.LOGGER)) {
327
+ return;
328
+ }
329
+ const logger = ctx.services.get<ILogger>(CAPABILITIES.LOGGER);
330
+ // Serialize the cause (X2-5): a raw `Error` in log metadata renders as `{}`
331
+ // under `JSON.stringify` because `message`/`stack` are non-enumerable.
332
+ logger.error(error.message, {
333
+ statusCode,
334
+ requestId: ctx.id,
335
+ ...(error.cause !== undefined && { cause: serializeError(error.cause) }),
336
+ });
337
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The `exceptions` implementation of the request-scoped error responder seam
3
+ * (M70f).
4
+ *
5
+ * This module is **internal**: it is not exported from `src/index.ts`. The
6
+ * interface it satisfies (`IErrorResponder`), the state key, the init type, and
7
+ * the `respondWithError` free function all live in `@setu-ts/common`; exporting
8
+ * the implementation as well would give one concept two public names. A
9
+ * `barrel-exports.test.ts` assertion pins that it stays out of the barrel.
10
+ *
11
+ * @module
12
+ */
13
+ import type {
14
+ ErrorResponderTarget,
15
+ ErrorResponseInit,
16
+ IErrorResponder,
17
+ IRequestContext,
18
+ } from '@setu-ts/common';
19
+ import { resolveResponseStatus } from '@setu-ts/common';
20
+
21
+ import { HttpError } from '../errors/http-error.ts';
22
+ import { RESPONDER_DETAIL } from '../formatters/problem-details.ts';
23
+ import type { ErrorHandlerFormatter } from '../formatters/error-formatter.ts';
24
+
25
+ /**
26
+ * The `application/problem+json` content type for Problem Details responses.
27
+ *
28
+ * Mirrors the constant `errorHandler` uses to decide the media type: a
29
+ * formatter answers Problem Details (and so derives `instance` from the
30
+ * request path) exactly when its resolved content type is this one.
31
+ */
32
+ const PROBLEM_JSON = 'application/problem+json';
33
+
34
+ /**
35
+ * Narrows an {@linkcode ErrorResponderTarget} to a full {@linkcode
36
+ * IRequestContext} when it genuinely is one.
37
+ *
38
+ * The responder is reached two ways. In-pipeline sites — the kernel's terminals
39
+ * and every short-circuiting middleware — pass the live `IRequestContext`,
40
+ * which carries `id`, `services`, and `startTime`. The kernel's PRE-pipeline
41
+ * sites — the shutdown-drain `503` and the malformed-request `400` — pass a
42
+ * bare target of just `state`, `response`, and an optional safe `request`
43
+ * path, because no request context exists yet. The three members checked here
44
+ * are present on every real context and absent from every bare target, so the
45
+ * test distinguishes the two without a lie.
46
+ *
47
+ * @param target - The responder target to test
48
+ * @returns `true` when the target is a full request context
49
+ */
50
+ function isFullRequestContext(target: ErrorResponderTarget): target is IRequestContext {
51
+ const ctx = target as IRequestContext;
52
+ return (
53
+ typeof ctx.id === 'string' &&
54
+ typeof ctx.services === 'object' &&
55
+ ctx.services !== null &&
56
+ typeof ctx.startTime === 'number'
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Builds the {@linkcode HttpError} that carries one error response's status,
62
+ * title and disclosure.
63
+ *
64
+ * This is the single owner of that mapping, and it has **two** callers: the
65
+ * responder below, and `errorHandler`'s status-hint path (M89b), which answers
66
+ * a thrown error branded with an `HttpStatusHint` — a type that IS an
67
+ * `ErrorResponseInit`. Sharing it is what makes the two agree: a hinted throw
68
+ * and a `respondWithError` call carrying the same values produce byte-identical
69
+ * bodies under `default`, `rfc9457`, `rfc7807` and any custom formatter. A
70
+ * second hand-rolled construction would silently diverge — the `default`
71
+ * formatter reads `details.detail` while the Problem Details formatters read
72
+ * the {@linkcode RESPONDER_DETAIL} symbol, so getting either half wrong loses
73
+ * the disclosure in exactly one format.
74
+ *
75
+ * @param init - The status, title, disclosure and optional structured details
76
+ * @returns An `HttpError` every formatter renders with the disclosure intact
77
+ */
78
+ export function buildErrorFromInit(init: ErrorResponseInit): HttpError {
79
+ // The title is the framework-default `error` member — the `message` of the
80
+ // `default` format and the `detail` fallback of the Problem Details
81
+ // formatters — so it stays `error.message`. The disclosure (`init.detail`)
82
+ // is kept verbatim by every format (the `ErrorResponseInit` contract in
83
+ // `@setu-ts/common`): it is carried in the error's structured `details`
84
+ // under a `detail` key, so the `default` format emits it as `details.detail`
85
+ // beside `message`, and `buildProblemDetails` promotes it to the Problem
86
+ // Details `detail` member (falling back to the title when a site supplies no
87
+ // disclosure). `init.details` (e.g. a validation `errors` array) is merged
88
+ // through unchanged. Carrying it this way — rather than in `error.message` —
89
+ // keeps the title verbatim, which a site's (non-)disclosure decision depends
90
+ // on.
91
+ const details = init.detail !== undefined
92
+ ? { ...init.details, detail: init.detail }
93
+ : init.details;
94
+ const error = new HttpError(init.status, init.title, details);
95
+ // Mark the error as responder-built and carry the disclosure on a
96
+ // module-private symbol, which is what `buildProblemDetails` promotes to the
97
+ // Problem Details `detail` member. The `details.detail` key above is what the
98
+ // `default` formatter emits (it serializes `details` verbatim); the Problem
99
+ // Details formatters read the symbol instead, so a THROWN error that happens
100
+ // to carry a `details.detail` key of its own keeps `detail: <message>` (M70f
101
+ // code review, finding 4). Non-enumerable so it never reaches a serialized
102
+ // body.
103
+ if (init.detail !== undefined) {
104
+ Object.defineProperty(error, RESPONDER_DETAIL, {
105
+ value: init.detail,
106
+ enumerable: false,
107
+ writable: false,
108
+ configurable: true,
109
+ });
110
+ }
111
+ return error;
112
+ }
113
+
114
+ /**
115
+ * Builds an {@linkcode IErrorResponder} from the formatter and content type
116
+ * that `errorHandler` has **already** resolved at factory time.
117
+ *
118
+ * The responder keeps the `HttpError` construction inside the only package that
119
+ * owns it: it builds an `HttpError` from the init (so `buildProblemDetails` sees
120
+ * a real `statusCode` and the validation `errors` extension, and
121
+ * `maskInternalErrors` would never mask a deliberately 4xx), runs the resolved
122
+ * formatter over it, and writes status, `content-type` and the serialized body
123
+ * — the same three-step tail `errorHandler`'s catch path performs.
124
+ *
125
+ * @param formatter - The resolved error formatter
126
+ * @param contentType - The resolved response content type
127
+ * @returns A responder publishing the application's error format
128
+ * @since 0.1.0
129
+ */
130
+ export function createErrorResponder(
131
+ formatter: ErrorHandlerFormatter,
132
+ contentType: string,
133
+ ): IErrorResponder {
134
+ return {
135
+ respond(target: ErrorResponderTarget, init: ErrorResponseInit): void {
136
+ // Sanitized before the error is built, not just before the status is
137
+ // written, so the formatted body's `status` member agrees with the status
138
+ // actually written. Idempotent, so the common path through
139
+ // `respondWithError` — which already sanitized — reports nothing twice.
140
+ const status = resolveResponseStatus(init.status, target);
141
+ const error = buildErrorFromInit(status === init.status ? init : { ...init, status });
142
+ // The formatter's `ctx` parameter is a FULL `IRequestContext` by its
143
+ // contract (M70f re-review round 2, finding 2). The target is a full
144
+ // context only at the in-pipeline sites; the kernel's pre-pipeline sites
145
+ // hand over a bare target whose `services`, `id`, `params`, `query`,
146
+ // `signal` and `startTime` do not exist. Passing that partial object
147
+ // would let a custom formatter read a documented member and throw
148
+ // `TypeError: Cannot read properties of undefined` — replacing the
149
+ // configured 503/400 with an unhandled exception. So the formatter
150
+ // receives the context only when it genuinely is one, and `undefined`
151
+ // otherwise — the value its optional `ctx` parameter already declares.
152
+ const fullContext = isFullRequestContext(target) ? target : undefined;
153
+ let body = formatter(error, fullContext);
154
+ // The Problem Details `instance` member comes from the request path. At
155
+ // the in-pipeline sites the formatter read it off the full context; at
156
+ // the pre-pipeline sites there is no context, so the responder supplies
157
+ // the safe path the kernel captured separately — a separate internal
158
+ // mechanism, since the formatter's optional `ctx` parameter is the only
159
+ // public channel and it must stay honest (M70f re-review round 2,
160
+ // finding 2). A malformed request carries no path, in which case the
161
+ // member is simply absent. A fresh object is composed rather than
162
+ // mutating the formatter's return value, which a custom formatter may
163
+ // share or freeze.
164
+ if (
165
+ fullContext === undefined &&
166
+ contentType === PROBLEM_JSON &&
167
+ target.request !== undefined
168
+ ) {
169
+ body = { ...body, instance: target.request.path };
170
+ }
171
+ const bytes = new TextEncoder().encode(JSON.stringify(body));
172
+ target.response
173
+ .status(status)
174
+ .header('content-type', contentType)
175
+ .send(bytes);
176
+ },
177
+ };
178
+ }