@ultimat3/http 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +285 -0
- package/README.md +99 -2
- package/package.json +6 -3
- package/src/auth-redirect.ts +81 -0
- package/src/cache-policy.ts +24 -0
- package/src/config.ts +67 -7
- package/src/context.ts +187 -36
- package/src/correlation.ts +44 -0
- package/src/cors.ts +33 -5
- package/src/csrf.ts +85 -0
- package/src/deadline.ts +79 -0
- package/src/error-map.ts +204 -4
- package/src/errors.ts +334 -5
- package/src/finalize.ts +70 -0
- package/src/forwarded.ts +94 -0
- package/src/hooks.ts +39 -4
- package/src/index.ts +60 -23
- package/src/locale.ts +34 -82
- package/src/overlay-style.ts +43 -0
- package/src/overlay.ts +59 -41
- package/src/peer-identity.ts +107 -0
- package/src/pipeline.ts +175 -294
- package/src/rate-limit-buckets.ts +86 -0
- package/src/rate-limit.ts +197 -9
- package/src/redirect.ts +29 -0
- package/src/request.ts +47 -13
- package/src/response.ts +34 -8
- package/src/router.ts +63 -13
- package/src/security-headers.ts +34 -13
- package/src/server.ts +44 -7
- package/src/stages.ts +381 -0
- package/src/type-pins.ts +48 -0
- package/src/validate.ts +13 -2
package/src/deadline.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The per-request deadline: the one thing that makes `ctx.signal` real. Nothing in this package
|
|
2
|
+
// created an `AbortController` before, so `throwIfAborted()` and `fetch(url, { signal })` were
|
|
3
|
+
// documented seams wired to nothing — a hung vendor call held its connection and its DB pool slot
|
|
4
|
+
// until the process died, and SIGTERM then waited out the whole drain budget for work that would
|
|
5
|
+
// never finish.
|
|
6
|
+
|
|
7
|
+
import type { HttpConfig } from './config';
|
|
8
|
+
import { requestTimedOut } from './errors';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A caller may SHORTEN this request's deadline, never lengthen it. Honoured without trusting the
|
|
12
|
+
* proxy, because the only thing it can buy an attacker is a faster 504 for their own request.
|
|
13
|
+
*/
|
|
14
|
+
export const REQUEST_TIMEOUT_HEADER = 'x-request-timeout-ms';
|
|
15
|
+
|
|
16
|
+
export interface Deadline {
|
|
17
|
+
/** Aborted when the deadline passes. Handed to the context as `ctx.signal`. */
|
|
18
|
+
readonly signal: AbortSignal;
|
|
19
|
+
/** Rejects with `X_TIMEOUT` at the deadline; `undefined` when there is no deadline. */
|
|
20
|
+
readonly expired: Promise<never> | undefined;
|
|
21
|
+
readonly timeoutMs: number;
|
|
22
|
+
/** Always call it — a live timer keeps the event loop from going idle. */
|
|
23
|
+
clear(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const NEVER_ABORTED: AbortSignal = new AbortController().signal;
|
|
27
|
+
|
|
28
|
+
const NO_DEADLINE: Deadline = {
|
|
29
|
+
signal: NEVER_ABORTED,
|
|
30
|
+
expired: undefined,
|
|
31
|
+
timeoutMs: 0,
|
|
32
|
+
clear: () => undefined,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The configured budget, or the caller's if theirs is shorter. `0` means "no deadline". */
|
|
36
|
+
export const resolveTimeoutMs = (headers: Headers, config: HttpConfig): number => {
|
|
37
|
+
const configured = config.requestTimeoutMs;
|
|
38
|
+
const raw = headers.get(REQUEST_TIMEOUT_HEADER);
|
|
39
|
+
if (raw === null) return configured;
|
|
40
|
+
const asked = Number.parseInt(raw, 10);
|
|
41
|
+
if (!Number.isFinite(asked) || asked < 1) return configured;
|
|
42
|
+
return configured > 0 ? Math.min(configured, asked) : asked;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One timer and one controller per request. The abort is the cooperative half — app code that
|
|
47
|
+
* passed `ctx.signal` unwinds on its own — and `expired` is the half that answers the socket
|
|
48
|
+
* either way, because a handler ignoring the signal must still not hold the connection forever.
|
|
49
|
+
*/
|
|
50
|
+
export const startDeadline = (input: {
|
|
51
|
+
readonly headers: Headers;
|
|
52
|
+
readonly config: HttpConfig;
|
|
53
|
+
readonly method: string;
|
|
54
|
+
readonly pathname: string;
|
|
55
|
+
}): Deadline => {
|
|
56
|
+
const timeoutMs = resolveTimeoutMs(input.headers, input.config);
|
|
57
|
+
if (timeoutMs <= 0) return NO_DEADLINE;
|
|
58
|
+
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
let fire: (() => void) | undefined;
|
|
61
|
+
const expired = new Promise<never>((_resolve, reject) => {
|
|
62
|
+
fire = () => reject(requestTimedOut(input.method, input.pathname, timeoutMs));
|
|
63
|
+
});
|
|
64
|
+
// A rejection nothing is awaiting is an unhandled rejection, and a process that dies on the
|
|
65
|
+
// first slow request is worse than the slow request. `Promise.race` attaches its own handler;
|
|
66
|
+
// this one is for the caller that reads `signal` and never the promise.
|
|
67
|
+
void expired.catch(() => undefined);
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
controller.abort(requestTimedOut(input.method, input.pathname, timeoutMs));
|
|
70
|
+
fire?.();
|
|
71
|
+
}, timeoutMs);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
signal: controller.signal,
|
|
75
|
+
expired,
|
|
76
|
+
timeoutMs,
|
|
77
|
+
clear: () => clearTimeout(timer),
|
|
78
|
+
};
|
|
79
|
+
};
|
package/src/error-map.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The one place a framework error code becomes an HTTP status. A table, not a
|
|
2
2
|
// switch chain: adding a code elsewhere in the framework means adding a row here,
|
|
3
3
|
// and a missing row is a loud 500 rather than a silently wrong 200.
|
|
4
|
-
import {
|
|
4
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
5
|
+
import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* code -> status. Codes owned by other packages are listed here on purpose: HTTP
|
|
@@ -12,6 +13,9 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
12
13
|
// @ultimat3/http
|
|
13
14
|
X_ROUTE_NOT_FOUND: 404,
|
|
14
15
|
X_METHOD_NOT_ALLOWED: 405,
|
|
16
|
+
// The request line itself is unreadable, so there is nothing to route: 400, and never a 500 —
|
|
17
|
+
// a malformed escape is the caller's typo, not this server's defect.
|
|
18
|
+
X_PATH_INVALID: 400,
|
|
15
19
|
X_BODY_INVALID: 422,
|
|
16
20
|
X_UNAUTHENTICATED: 401,
|
|
17
21
|
X_FORBIDDEN: 403,
|
|
@@ -20,21 +24,163 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
20
24
|
X_ROUTE_CONFLICT: 500,
|
|
21
25
|
X_SERVER_NOT_STARTED: 500,
|
|
22
26
|
X_PIPELINE_NO_RESPONSE: 500,
|
|
27
|
+
// The request was answered and the answer could not be finished: the caller gets nothing usable
|
|
28
|
+
// either way, so this is the server's failure, never the caller's.
|
|
29
|
+
X_PIPELINE_FINALIZE_FAILED: 500,
|
|
30
|
+
// Both are wiring bugs, never a caller's mistake: reading a cookie where no request exists,
|
|
31
|
+
// and declaring a status the framework already owns. 500 is the honest answer to either.
|
|
32
|
+
X_NO_REQUEST: 500,
|
|
33
|
+
X_ERROR_STATUS_INVALID: 500,
|
|
34
|
+
// Thrown while `app.config.ts` resolves, so no request is ever answered with it — the row exists
|
|
35
|
+
// because a code with no status is a 500 anyway and this table is the closed one.
|
|
36
|
+
X_CORS_CONFIG_INVALID: 500,
|
|
37
|
+
// Thrown while the server is being constructed, so no request is ever answered with it either.
|
|
38
|
+
// The row exists because this table is the closed one: a code missing from it is a 500 anyway,
|
|
39
|
+
// and a code the framework owns must never fall through to the app's table.
|
|
40
|
+
X_RATE_LIMIT_NOT_SHARED: 500,
|
|
41
|
+
// Same construction-time class as the row above: a route and the config declare one bucket
|
|
42
|
+
// differently, and the process refuses to start rather than pick.
|
|
43
|
+
X_RATE_LIMIT_BUCKET_CONFLICT: 500,
|
|
44
|
+
// Construction time as well: the limiter installed cannot enforce a bucket a route declares.
|
|
45
|
+
X_RATE_LIMIT_BUCKET_UNBOUND: 500,
|
|
46
|
+
// `defineHttpConfig` time, both of them: a declaration the deployment owes and did not make.
|
|
47
|
+
X_RATE_LIMIT_SCOPE_UNSET: 500,
|
|
48
|
+
X_TRUST_PROXY_UNSET: 500,
|
|
49
|
+
// Raised by `toBucket` while a route or an action is being projected, never on the request.
|
|
50
|
+
X_RATE_LIMIT_INVALID: 500,
|
|
51
|
+
// The two the `admit` stage answers with, and the only 503s the pipeline produces. Both carry
|
|
52
|
+
// `retry-after`: a shed request that does not say when to come back is a request that comes
|
|
53
|
+
// back immediately, which is the load it was shed to avoid.
|
|
54
|
+
X_DRAINING: 503,
|
|
55
|
+
X_OVERLOADED: 503,
|
|
56
|
+
// 403 and never 401: the caller IS authenticated — that is what makes the forged write work —
|
|
57
|
+
// so a 401 would send a signed-in user to a sign-in page they are already past.
|
|
58
|
+
X_CSRF_BLOCKED: 403,
|
|
59
|
+
// @ultimat3/action — the code every primitive throws when the CALLER's input fails the schema
|
|
60
|
+
// the primitive declared. 400 because that is what the published OpenAPI operation promises for
|
|
61
|
+
// it, and because a missing row made a typo'd uuid a 500: the caller was told the server broke,
|
|
62
|
+
// and the `error-map` stage reported the caller's mistake to the on-call monitor.
|
|
63
|
+
X_INPUT_INVALID: 400,
|
|
64
|
+
// A retried `Idempotency-Key` naming a different payload, or one still in flight. 409 because
|
|
65
|
+
// that is what the action's own OpenAPI operation publishes for it — the runtime answered 500
|
|
66
|
+
// while the document promised 409, and a client written against the spec read the framework
|
|
67
|
+
// working exactly as designed as an outage.
|
|
68
|
+
X_IDEMPOTENCY_CONFLICT: 409,
|
|
69
|
+
// A blank or over-long `Idempotency-Key` HEADER, refused before the handler runs. 400 and not
|
|
70
|
+
// the 422 a body gets: what failed is a parameter the OpenAPI operation publishes a `maxLength`
|
|
71
|
+
// for, which is the same thing `X_INPUT_INVALID` is 400 for.
|
|
72
|
+
X_IDEMPOTENCY_KEY_INVALID: 400,
|
|
73
|
+
// 500, and deliberately not the 409 above. `IdempotencyReplayedFailureError` re-throws the FIRST
|
|
74
|
+
// attempt's own code whenever the store recorded one, so this literal code is reached only when
|
|
75
|
+
// that attempt failed carrying no code at all: an unclassified throw whose commit state nobody
|
|
76
|
+
// knows. That is the server's to explain, and it is worth reporting.
|
|
77
|
+
X_IDEMPOTENCY_REPLAYED_FAILURE: 500,
|
|
78
|
+
// @ultimat3/auth — every one of these is reachable from a request: the OAuth route descriptors
|
|
79
|
+
// are mounted by the app, and `authenticate` throws the session codes inside the pipeline. Without
|
|
80
|
+
// a row each fell to 500, so a user pressing Cancel on a consent screen paged the on-call and a
|
|
81
|
+
// provider this app never enabled read as an outage. `packages/auth/src/oauth-route.ts` answers
|
|
82
|
+
// from this table's values when its descriptors are driven OUTSIDE a pipeline; the pin that keeps
|
|
83
|
+
// the two identical is `scripts/oauth-route-status.test.ts`, since auth is this tier and cannot
|
|
84
|
+
// import this package.
|
|
85
|
+
X_SESSION_EXPIRED: 401,
|
|
86
|
+
X_MFA_REQUIRED: 401,
|
|
87
|
+
X_ACCOUNT_LOCKED: 429,
|
|
88
|
+
X_API_KEY_INVALID: 401,
|
|
89
|
+
X_OAUTH_STATE_INVALID: 400,
|
|
90
|
+
X_OAUTH_TOKEN_INVALID: 400,
|
|
91
|
+
X_OAUTH_PROVIDER_UNKNOWN: 404,
|
|
92
|
+
X_OAUTH_DENIED: 403,
|
|
93
|
+
// 502, not 500: the conversation that failed is with the provider's server, and the on-call
|
|
94
|
+
// question "is it us or them?" is the one a status is read for.
|
|
95
|
+
X_OAUTH_EXCHANGE_FAILED: 502,
|
|
96
|
+
// 422 and not 400: the body parsed, the field is a string, and a policy rejected its CONTENT —
|
|
97
|
+
// the same class as `X_BODY_INVALID` and `X_INVARIANT_VIOLATED` above. Unmapped, a visitor
|
|
98
|
+
// choosing "password" at a signup form was reported to the on-call monitor as a server fault.
|
|
99
|
+
X_PASSWORD_WEAK: 422,
|
|
23
100
|
// @ultimat3/entity
|
|
24
101
|
X_NOT_FOUND: 404,
|
|
25
102
|
X_ENTITY_DUPLICATE: 409,
|
|
26
103
|
X_INVARIANT_VIOLATED: 422,
|
|
27
104
|
X_TENANCY_UNSCOPED: 500,
|
|
28
105
|
X_DB_DRIFT: 500,
|
|
106
|
+
// The three tenancy refusals, all 403, and all deliberately NOT the 404 `X_STORAGE_ORG_MISMATCH`
|
|
107
|
+
// takes: that one answers 404 because a 403 on a KEY the caller supplied confirms the key exists.
|
|
108
|
+
// These three name no resource and read no row — the comparison is the actor against an argument
|
|
109
|
+
// (`X_TENANCY_ACTOR_MISMATCH`), the actor against nothing at all (`X_TENANCY_ACTOR_ORG_REQUIRED`),
|
|
110
|
+
// or the actor's scopes at `crossTenant()` (`X_TENANCY_CROSS_DENIED`) — so the answer is the same
|
|
111
|
+
// whether or not the other tenant's row exists, and a 404 would buy no secrecy for the lie.
|
|
112
|
+
X_TENANCY_ACTOR_MISMATCH: 403,
|
|
113
|
+
// 403 and never 401, for the reason `X_CSRF_BLOCKED` is one: the actor may be fully
|
|
114
|
+
// authenticated and merely carry no org — a service actor minted without one — so a 401 sends a
|
|
115
|
+
// signed-in caller to a sign-in page that cannot give them a tenant.
|
|
116
|
+
X_TENANCY_ACTOR_ORG_REQUIRED: 403,
|
|
117
|
+
X_TENANCY_CROSS_DENIED: 403,
|
|
118
|
+
// @ultimat3/db — the constraints a request trips, both 409. db's own `fix:` for the unique
|
|
119
|
+
// violation says "answer 409, which is what a raced signup is", and `X_ENTITY_DUPLICATE` — the
|
|
120
|
+
// same event one layer up — is 409 above; a foreign key rides with it because both halves of it
|
|
121
|
+
// are a conflict with the state that is there (the parent is missing, or the child still points
|
|
122
|
+
// at it), which 422 describes only for the insert.
|
|
123
|
+
X_DB_UNIQUE_VIOLATION: 409,
|
|
124
|
+
X_DB_FOREIGN_KEY_VIOLATION: 409,
|
|
29
125
|
// @ultimat3/policy
|
|
30
126
|
X_POLICY_MISSING: 500,
|
|
31
127
|
X_PERMISSION_UNKNOWN: 500,
|
|
128
|
+
// @ultimat3/query — the read declares no id, so no cursor can name a position in it. The one
|
|
129
|
+
// paging failure that is NOT the caller's: the fix is an edit to the read's own select, nothing
|
|
130
|
+
// the client sends changes the answer, and the report to the on-call monitor is the point.
|
|
131
|
+
X_QUERY_NOT_PAGEABLE: 500,
|
|
132
|
+
// @ultimat3/i18n — a well-formed tag outside the set this app ships, asserted on a value the
|
|
133
|
+
// caller supplied (`assertSupportedLocale`). 400 rather than 406: the http `locale` stage
|
|
134
|
+
// negotiates `Accept-Language` and never throws, so the tag that reaches here came from a path,
|
|
135
|
+
// query or body the caller wrote — the same place `X_IMAGE_QUERY_INVALID` comes from.
|
|
136
|
+
X_LOCALE_UNSUPPORTED: 400,
|
|
137
|
+
// @ultimat3/money — a well-formed code this process carries no row for. The currency table is
|
|
138
|
+
// OPEN (`registerCurrency`), and every surface between the wire and the throw accepts any
|
|
139
|
+
// `^[A-Z]{3}$`: `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the OpenAPI `pattern` emitted from
|
|
140
|
+
// it, and `@ultimat3/entity`'s `char(3)` CHECK. So `{ minor: 100, currency: 'ZWL' }` parses,
|
|
141
|
+
// reaches `money()` -> `assertCurrency`, and with no row answered 500 — reporting a value the
|
|
142
|
+
// framework's own schema had just accepted to the error monitor. 400 rather than 422, beside
|
|
143
|
+
// `X_LOCALE_UNSUPPORTED`: the same shape of mistake, a well-formed value naming something
|
|
144
|
+
// outside the set this process carries, and money's `fix:` already instructs the caller.
|
|
145
|
+
X_CURRENCY_UNKNOWN: 400,
|
|
32
146
|
// @ultimat3/seo — a transform query the caller wrote, so the caller is the one who can fix it.
|
|
33
147
|
X_IMAGE_QUERY_INVALID: 400,
|
|
148
|
+
// @ultimat3/storage — every one of these is reachable from a route: `/media/*` already serves
|
|
149
|
+
// objects, and a mounted `/_storage` serves signed reads and takes signed writes. Without a row
|
|
150
|
+
// a missing image answers 500, which reads as an outage instead of a 404.
|
|
151
|
+
X_STORAGE_NOT_FOUND: 404,
|
|
152
|
+
X_STORAGE_PATH_UNSAFE: 400,
|
|
153
|
+
X_STORAGE_TOO_LARGE: 413,
|
|
154
|
+
X_STORAGE_TYPE_REJECTED: 415,
|
|
155
|
+
X_STORAGE_CHECKSUM_MISMATCH: 422,
|
|
156
|
+
X_STORAGE_URL_INVALID: 403,
|
|
157
|
+
X_STORAGE_URL_EXPIRED: 410,
|
|
158
|
+
// 409, not the 500 it fell through to: the object exists and the request is well formed — the
|
|
159
|
+
// STATE is wrong. A validated upload lands under the quarantine segment and `promoteAttachment`
|
|
160
|
+
// refuses it until the app's own scanner calls `releaseQuarantine`, which is a thing the caller
|
|
161
|
+
// can do. A 500 would have read as "the server broke" for a workflow working exactly as built.
|
|
162
|
+
X_STORAGE_QUARANTINED: 409,
|
|
163
|
+
// 404, deliberately NOT 403: the org check fires before anything is read, so answering
|
|
164
|
+
// "forbidden" would confirm that a key exists to the one caller who must not learn it.
|
|
165
|
+
X_STORAGE_ORG_MISMATCH: 404,
|
|
166
|
+
// @ultimat3/mail
|
|
167
|
+
// The deployment configured no transport. It reaches a caller only through an inline
|
|
168
|
+
// `send(…, { sync: true })` inside a request; the queued path dead-letters instead. A server-side
|
|
169
|
+
// configuration fault either way, so 500 and never a 4xx — nothing the caller sent is wrong, and
|
|
170
|
+
// this is exactly the condition somebody should be paged for.
|
|
171
|
+
X_MAIL_CREDENTIAL_MISSING: 500,
|
|
34
172
|
// @ultimat3/core
|
|
35
173
|
// The caller asked for a format the pipeline cannot produce (`?f=avif`): the request names an
|
|
36
174
|
// unsupported representation, which is 415 — not a 500, which would blame the server for it.
|
|
37
175
|
X_IMAGE_UNSUPPORTED: 415,
|
|
176
|
+
// A page token this server minted and the caller echoed back, and it did not verify — tampered,
|
|
177
|
+
// or replayed against another read. The caller's value and the caller's repair ("request the
|
|
178
|
+
// first page again"), so it belongs beside `X_IMAGE_QUERY_INVALID` at 400 and not at 500.
|
|
179
|
+
X_CURSOR_INVALID: 400,
|
|
180
|
+
// Raised by `markReady()` while a role STARTS, in a process whose lifecycle already drained — so
|
|
181
|
+
// no request is ever answered with it, and the row exists for the reason the construction-time
|
|
182
|
+
// rows above do: this table is the closed one, and a code with no row is a 500 anyway.
|
|
183
|
+
X_LIFECYCLE_DRAINED: 500,
|
|
38
184
|
X_NOT_IMPLEMENTED: 501,
|
|
39
185
|
X_TIMEOUT: 504,
|
|
40
186
|
X_ABORTED: 499,
|
|
@@ -43,7 +189,54 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
43
189
|
|
|
44
190
|
export const DEFAULT_STATUS = 500;
|
|
45
191
|
|
|
46
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Statuses for codes the APP owns. The table above is closed — it has to be, it is the
|
|
194
|
+
* framework's own contract — and every code outside it fell to 500, so a wrong password was an
|
|
195
|
+
* incident: `pipeline.ts` reports `status >= 500` to the error monitor, and a user's typo paged
|
|
196
|
+
* whoever was on call. This is the app's half of the same table, kept separate so a registration
|
|
197
|
+
* can never move `X_FORBIDDEN` off 403.
|
|
198
|
+
*/
|
|
199
|
+
const APP_ERROR_STATUS = new Map<string, number>();
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Declare the status for the codes this app throws. Call it once at boot, beside the module
|
|
203
|
+
* that declares the codes — importing that module IS the registration, the convention
|
|
204
|
+
* `registerActions` and `registerErrorCodes` already use.
|
|
205
|
+
*
|
|
206
|
+
* ```ts
|
|
207
|
+
* registerErrorStatus({ X_CREDENTIALS_INVALID: 401, X_SIGNUP_CLOSED: 403 });
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
export const registerErrorStatus = (statuses: Readonly<Record<string, number>>): void => {
|
|
211
|
+
for (const [code, status] of Object.entries(statuses)) {
|
|
212
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
213
|
+
throw errorStatusInvalid(code, `${String(status)} is not an HTTP status (100-599)`);
|
|
214
|
+
}
|
|
215
|
+
// The framework's own codes are not negotiable: an app that could map `X_UNAUTHENTICATED`
|
|
216
|
+
// to 200 would be an app whose 401 contract every client already depends on, changed.
|
|
217
|
+
if (ERROR_STATUS[code] !== undefined) {
|
|
218
|
+
throw errorStatusInvalid(code, `the framework already maps it to ${ERROR_STATUS[code]}`);
|
|
219
|
+
}
|
|
220
|
+
const existing = APP_ERROR_STATUS.get(code);
|
|
221
|
+
if (existing !== undefined && existing !== status) {
|
|
222
|
+
throw errorStatusInvalid(code, `already registered as ${existing} by this app`);
|
|
223
|
+
}
|
|
224
|
+
APP_ERROR_STATUS.set(code, status);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** Test seam. Production registers once at boot and never unregisters. */
|
|
229
|
+
export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
|
|
230
|
+
|
|
231
|
+
/** Every status the app declared, for `x errors list` and the manifest. */
|
|
232
|
+
export const appErrorStatus = (): Readonly<Record<string, number>> =>
|
|
233
|
+
Object.fromEntries([...APP_ERROR_STATUS].sort(([a], [b]) => a.localeCompare(b)));
|
|
234
|
+
|
|
235
|
+
// Framework table first: `registerErrorStatus` already refuses those codes, so the order is
|
|
236
|
+
// belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
|
|
237
|
+
// even if a future caller reaches the map some other way.
|
|
238
|
+
export const statusFor = (code: string): number =>
|
|
239
|
+
ERROR_STATUS[code] ?? APP_ERROR_STATUS.get(code) ?? DEFAULT_STATUS;
|
|
47
240
|
|
|
48
241
|
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
|
|
49
242
|
export interface ErrorFacts {
|
|
@@ -82,12 +275,19 @@ export const factsOf = (error: unknown): ErrorFacts => {
|
|
|
82
275
|
HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES] ??
|
|
83
276
|
str(record, 'message') ??
|
|
84
277
|
'unhandled server error';
|
|
85
|
-
|
|
278
|
+
// The last fallback is the only one that touches the throwable whole, and every throwable a
|
|
279
|
+
// request produces reaches it. `String()` runs the value's own `toString`, so the value that
|
|
280
|
+
// took the request down took the 500 renderer with it and the server had nothing left to send.
|
|
281
|
+
const cause = str(record, 'cause') ?? str(record, 'message') ?? renderCauseValue(error);
|
|
86
282
|
return {
|
|
87
283
|
code,
|
|
88
284
|
title,
|
|
89
285
|
cause,
|
|
90
|
-
|
|
286
|
+
// `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
|
|
287
|
+
// command that throws is axiom 4 inverted: the one instruction the reader is given fails.
|
|
288
|
+
// `x errors explain` ships, and it is the command that answers "what is this code".
|
|
289
|
+
fix:
|
|
290
|
+
str(record, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
|
|
91
291
|
docs: str(record, 'docs') ?? `https://ultimate.dev/errors/${code}`,
|
|
92
292
|
status: statusFor(code),
|
|
93
293
|
stack: str(record, 'stack'),
|