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