@ultimat3/http 11.3.0 → 13.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 +159 -4
- package/README.md +109 -6
- package/package.json +5 -5
- package/src/app-config.ts +146 -0
- package/src/config.ts +5 -2
- package/src/context.ts +65 -33
- package/src/cors.ts +2 -2
- package/src/deadline.ts +18 -1
- package/src/error-facts.ts +286 -0
- package/src/error-map.ts +130 -193
- package/src/errors.ts +46 -6
- package/src/index.ts +32 -7
- package/src/overlay.ts +1 -1
- package/src/pipeline.ts +4 -0
- package/src/rate-limit-errors.ts +23 -7
- package/src/rate-limit.ts +61 -5
- package/src/response.ts +1 -1
- package/src/stages.ts +36 -14
- package/src/type-pins.ts +39 -0
- package/src/webhook-verify.ts +133 -0
package/src/context.ts
CHANGED
|
@@ -6,11 +6,11 @@ import {
|
|
|
6
6
|
anonymousActor,
|
|
7
7
|
type Clock,
|
|
8
8
|
type Ctx,
|
|
9
|
+
createContext,
|
|
9
10
|
isAnonymous,
|
|
10
11
|
type Logger,
|
|
11
12
|
traceId as newTraceId,
|
|
12
13
|
type Role,
|
|
13
|
-
logger as rootLogger,
|
|
14
14
|
type ServiceBag,
|
|
15
15
|
systemClock,
|
|
16
16
|
useContext,
|
|
@@ -28,15 +28,23 @@ import type { CacheHint, RedirectIntent } from './response';
|
|
|
28
28
|
import type { Route, RouteParams } from './router';
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* The per-request context, and — through `asCtx` — core's `Ctx` itself.
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* a member
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
31
|
+
* The per-request context, and — through `asCtx` — core's `Ctx` itself.
|
|
32
|
+
*
|
|
33
|
+
* `extends Ctx` again, `As of 2026-08-24`, and it is safe again for one reason: this file no
|
|
34
|
+
* longer BUILDS a `Ctx`, it composes one. `Ctx extends CtxServices`, an app augments
|
|
35
|
+
* `CtxServices` with `declare module` to declare `ctx.posts`, and every service it declared then
|
|
36
|
+
* became a required member of every context literal in the framework — this file failed to
|
|
37
|
+
* compile inside `examples/dummy` with `TS2739: missing posts, orgs` while the framework's own
|
|
38
|
+
* gate, which augments nothing, stayed green. `createRequestContext` now spreads
|
|
39
|
+
* `createContext()`'s result, so the members only an app's boot can supply arrive with it and the
|
|
40
|
+
* literal below is checked in full.
|
|
41
|
+
*
|
|
42
|
+
* The `extends` is therefore back to doing what it was always claimed to do: a member core adds
|
|
43
|
+
* to `Ctx` is set here — by `base` — or this file does not compile. `asCtx` is the identity
|
|
44
|
+
* function and never an assertion; `as unknown as Ctx` is what it used to be, over an object
|
|
45
|
+
* missing `clock`, `now`, `logger`, `signal` and `services`, and every reader threw at runtime.
|
|
46
|
+
* `CtxServices`' index signature is what an app augments; `noPropertyAccessFromIndexSignature`
|
|
47
|
+
* keeps `ctx.typo` a build error all the same.
|
|
40
48
|
*/
|
|
41
49
|
export interface RequestContext extends Ctx {
|
|
42
50
|
/** `performance.now()` at accept time; used for the server-timing header. */
|
|
@@ -80,6 +88,11 @@ export interface RequestContext extends Ctx {
|
|
|
80
88
|
readonly logger: Logger;
|
|
81
89
|
/** Aborted when the caller goes away or the request deadline passes. See `deadline.ts`. */
|
|
82
90
|
readonly signal: AbortSignal;
|
|
91
|
+
/**
|
|
92
|
+
* The instant `signal` will fire at, or `null` with `requestTimeoutMs: 0`. Core's field: a
|
|
93
|
+
* signal can only say "already over", and an outbound hop has to say how much is LEFT.
|
|
94
|
+
*/
|
|
95
|
+
readonly deadlineAt: number | null;
|
|
83
96
|
readonly services: ServiceBag;
|
|
84
97
|
|
|
85
98
|
// Mutable slots, each filled by exactly one pipeline stage. Kept mutable (and
|
|
@@ -135,15 +148,11 @@ export interface RequestContextInit {
|
|
|
135
148
|
readonly logger?: Logger;
|
|
136
149
|
/** The deadline/disconnect signal. Absent means a request nothing can cancel. */
|
|
137
150
|
readonly signal?: AbortSignal;
|
|
151
|
+
/** `Deadline.deadlineAt` — epoch ms. Absent means this request has no budget. */
|
|
152
|
+
readonly deadlineAt?: number | null;
|
|
138
153
|
readonly services?: ServiceBag;
|
|
139
154
|
}
|
|
140
155
|
|
|
141
|
-
/**
|
|
142
|
-
* One signal for every context built without one, so "no cancellation here" costs no allocation
|
|
143
|
-
* and `ctx.signal.aborted` is still a read rather than a `TypeError`. The same shape core uses.
|
|
144
|
-
*/
|
|
145
|
-
const NEVER_ABORTED: AbortSignal = new AbortController().signal;
|
|
146
|
-
|
|
147
156
|
export const createRequestContext = (init: RequestContextInit): RequestContext => {
|
|
148
157
|
const clock = init.clock ?? systemClock;
|
|
149
158
|
const requestId = init.requestId ?? uuid(clock);
|
|
@@ -151,39 +160,62 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
|
|
|
151
160
|
// collector rejects the span that carries one — while the log lines beside it, which quote the
|
|
152
161
|
// same field, look fine. Two ids for one request that cannot be joined.
|
|
153
162
|
const traceId = init.traceId ?? newTraceId();
|
|
163
|
+
// COMPOSED from core's constructor rather than built beside it, and that is what deletes the
|
|
164
|
+
// last cast in this file. `createContext` returns a `Ctx` that already carries the app's
|
|
165
|
+
// `CtxServices` augmentation, so spreading it hands this literal the members only the app's boot
|
|
166
|
+
// could supply — and the return below is checked in full, with nothing asserted anywhere.
|
|
167
|
+
//
|
|
168
|
+
// It is also one constructor for one shape instead of two. This file used to re-derive `clock`,
|
|
169
|
+
// `now`, the logger child, `signal`, `deadlineAt` and the service bag itself, so core could fix
|
|
170
|
+
// any of them and the HTTP surface would keep the old answer — which is exactly what happened to
|
|
171
|
+
// the bag: core has spread services ONTO the context since it shipped and this file never did,
|
|
172
|
+
// so an app declaring `ctx.posts` the documented way read `undefined` over HTTP while
|
|
173
|
+
// `ctx.services.posts` beside it was populated. Composing makes that class of drift unwritable.
|
|
174
|
+
//
|
|
175
|
+
// `defineService` factories now install on this surface too, for the same reason: they are
|
|
176
|
+
// `createContext`'s and this is `createContext`.
|
|
177
|
+
const base = createContext({
|
|
178
|
+
requestId,
|
|
179
|
+
traceId,
|
|
180
|
+
role: init.role,
|
|
181
|
+
// The build this PROCESS serves. The client's claim goes to `clientBuildId` below, where only
|
|
182
|
+
// `assertBuild()` reads it — the two shared this name until `asCtx` was checked.
|
|
183
|
+
buildId: init.config.buildId ?? 'dev',
|
|
184
|
+
// What the request gets before the `locale` stage runs, and what it keeps if the stage is
|
|
185
|
+
// never reached (a refusal in `admit`). The owners' configured fallbacks, never a third one.
|
|
186
|
+
locale: localeConfig().fallback,
|
|
187
|
+
tz: timeConfig().defaultZone,
|
|
188
|
+
clock,
|
|
189
|
+
...(init.logger === undefined ? {} : { logger: init.logger }),
|
|
190
|
+
// Absent means a request nothing can cancel, which is core's `neverAborted` — the same shape
|
|
191
|
+
// this file kept its own singleton for.
|
|
192
|
+
...(init.signal === undefined ? {} : { signal: init.signal }),
|
|
193
|
+
// `null` and "not set" are one fact to core, whose own field is `number | null`.
|
|
194
|
+
...(init.deadlineAt === undefined || init.deadlineAt === null
|
|
195
|
+
? {}
|
|
196
|
+
: { deadlineAt: init.deadlineAt }),
|
|
197
|
+
...(init.services === undefined ? {} : { services: init.services }),
|
|
198
|
+
});
|
|
154
199
|
return {
|
|
200
|
+
...base,
|
|
201
|
+
// Everything below is either this package's own or a core member the PIPELINE rewrites: the
|
|
202
|
+
// mutable slots are re-declared here so a stage can write them, and they must therefore be
|
|
203
|
+
// this object's own properties rather than the frozen base's.
|
|
155
204
|
requestId,
|
|
156
205
|
traceId,
|
|
157
206
|
parentSpanId: init.parentSpanId ?? null,
|
|
158
207
|
startedAt: performance.now(),
|
|
159
208
|
url: init.url,
|
|
160
209
|
method: init.method.toUpperCase(),
|
|
161
|
-
role: init.role,
|
|
162
210
|
config: init.config,
|
|
163
211
|
ip: init.ip ?? null,
|
|
164
212
|
https: init.https ?? init.url.protocol === 'https:',
|
|
165
213
|
peer: init.peer ?? null,
|
|
166
214
|
headers: new Headers(),
|
|
167
215
|
requestHeaders: new Headers(init.requestHeaders),
|
|
168
|
-
// The build this PROCESS serves, resolved the way core resolves it. The client's claim goes
|
|
169
|
-
// to `clientBuildId` below, where only `assertBuild()` reads it.
|
|
170
|
-
buildId: init.config.buildId ?? 'dev',
|
|
171
|
-
clock,
|
|
172
|
-
now: () => clock.now(),
|
|
173
|
-
// A child, so `ctx.logger` carries the ids even where core's ALS injector cannot see the
|
|
174
|
-
// context — a callback that outlived the request scope, a logger passed to a driver.
|
|
175
|
-
logger: (init.logger ?? rootLogger).child({ requestId, traceId }),
|
|
176
|
-
signal: init.signal ?? NEVER_ABORTED,
|
|
177
|
-
// Frozen and explicit. `defineService` factories are NOT installed here: core does not
|
|
178
|
-
// export the installer, so the honest answer for a service nothing passed is
|
|
179
|
-
// `X_SERVICE_MISSING` from `useService()` — which is what it exists to raise — rather than
|
|
180
|
-
// the `TypeError: undefined is not an object` a missing bag produced.
|
|
181
|
-
services: Object.freeze({ ...(init.services ?? {}) }),
|
|
182
216
|
params: {},
|
|
183
217
|
route: undefined,
|
|
184
218
|
actor: anonymousActor(),
|
|
185
|
-
// What the request gets before the `locale` stage runs, and what it keeps if the stage is
|
|
186
|
-
// never reached (a refusal in `admit`). The owners' configured fallbacks, never a third one.
|
|
187
219
|
locale: localeConfig().fallback,
|
|
188
220
|
tz: timeConfig().defaultZone,
|
|
189
221
|
clientBuildId: null,
|
package/src/cors.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// CORS with a locked default: same-origin only. Cross-origin access is a decision
|
|
2
|
-
//
|
|
1
|
+
// CORS with a locked default: same-origin only. Cross-origin access is a decision the app makes
|
|
2
|
+
// once, in `configureHttp({ cors })`, never something a route can quietly opt into.
|
|
3
3
|
|
|
4
4
|
import { corsConfigInvalid } from './errors';
|
|
5
5
|
|
package/src/deadline.ts
CHANGED
|
@@ -4,18 +4,31 @@
|
|
|
4
4
|
// until the process died, and SIGTERM then waited out the whole drain budget for work that would
|
|
5
5
|
// never finish.
|
|
6
6
|
|
|
7
|
+
import { REQUEST_TIMEOUT_HEADER, systemClock } from '@ultimat3/core';
|
|
7
8
|
import type { HttpConfig } from './config';
|
|
8
9
|
import { requestTimedOut } from './errors';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* A caller may SHORTEN this request's deadline, never lengthen it. Honoured without trusting the
|
|
12
13
|
* proxy, because the only thing it can buy an attacker is a faster 504 for their own request.
|
|
14
|
+
*
|
|
15
|
+
* The name is core's, re-exported rather than declared twice: this package READS the header and
|
|
16
|
+
* `@ultimat3/core`'s typed-client wire path WRITES it, and a second literal is a propagation that
|
|
17
|
+
* stops working the day one of the two strings is edited. Same shape as `logger.ts` re-exporting
|
|
18
|
+
* `REDACTED` — one definition, one public path.
|
|
13
19
|
*/
|
|
14
|
-
export
|
|
20
|
+
export { REQUEST_TIMEOUT_HEADER };
|
|
15
21
|
|
|
16
22
|
export interface Deadline {
|
|
17
23
|
/** Aborted when the deadline passes. Handed to the context as `ctx.signal`. */
|
|
18
24
|
readonly signal: AbortSignal;
|
|
25
|
+
/**
|
|
26
|
+
* Epoch ms the budget runs out at, or `null` when there is none — `ctx.deadlineAt`, and what an
|
|
27
|
+
* outbound hop subtracts `now` from. Real monotonic time (`systemClock`), never an injected
|
|
28
|
+
* clock, for the reason the drain budget is: the timer beside it runs on `setTimeout`, so a
|
|
29
|
+
* frozen clock would publish an instant the abort will not honour.
|
|
30
|
+
*/
|
|
31
|
+
readonly deadlineAt: number | null;
|
|
19
32
|
/** Rejects with `X_TIMEOUT` at the deadline; `undefined` when there is no deadline. */
|
|
20
33
|
readonly expired: Promise<never> | undefined;
|
|
21
34
|
readonly timeoutMs: number;
|
|
@@ -27,6 +40,7 @@ const NEVER_ABORTED: AbortSignal = new AbortController().signal;
|
|
|
27
40
|
|
|
28
41
|
const NO_DEADLINE: Deadline = {
|
|
29
42
|
signal: NEVER_ABORTED,
|
|
43
|
+
deadlineAt: null,
|
|
30
44
|
expired: undefined,
|
|
31
45
|
timeoutMs: 0,
|
|
32
46
|
clear: () => undefined,
|
|
@@ -69,6 +83,8 @@ export const startDeadline = (input: {
|
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
const controller = new AbortController();
|
|
86
|
+
// Read BEFORE the timer is armed, so the published instant is never later than the abort.
|
|
87
|
+
const deadlineAt = systemClock.now().getTime() + timeoutMs;
|
|
72
88
|
let fire: (() => void) | undefined;
|
|
73
89
|
const expired = new Promise<never>((_resolve, reject) => {
|
|
74
90
|
fire = () => reject(requestTimedOut(input.method, input.pathname, timeoutMs));
|
|
@@ -83,6 +99,7 @@ export const startDeadline = (input: {
|
|
|
83
99
|
}, timeoutMs);
|
|
84
100
|
|
|
85
101
|
return {
|
|
102
|
+
deadlineAt,
|
|
86
103
|
// Both halves, or the doc on `ctx.signal` is half true — which it was: nothing in this package
|
|
87
104
|
// read the inbound signal, so a browser closing the tab left the request holding its pool slot
|
|
88
105
|
// and its vendor connection for the whole budget, for a caller that is gone. `expired` stays
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Every RENDERING of a throwable the framework has: the normalised facts, the RFC-9457 problem
|
|
2
|
+
// document and the three lines the terminal and the overlay print. Split off `error-map.ts` at the
|
|
3
|
+
// 500-line ceiling — that file answers "what status is this code", one closed table, and this one
|
|
4
|
+
// answers "what does a reader see", which is three audiences and one opacity rule.
|
|
5
|
+
import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core';
|
|
6
|
+
import type { ValidationIssue } from '@ultimat3/schema';
|
|
7
|
+
import { declaredStatusFor, statusFor } from './error-map';
|
|
8
|
+
import { HTTP_ERROR_TITLES } from './errors';
|
|
9
|
+
|
|
10
|
+
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
|
|
11
|
+
export interface ErrorFacts {
|
|
12
|
+
readonly code: string;
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly cause: string;
|
|
15
|
+
readonly fix: string;
|
|
16
|
+
readonly docs: string;
|
|
17
|
+
readonly status: number;
|
|
18
|
+
/** Present only when the process is in dev mode; never sent to a client in prod. */
|
|
19
|
+
readonly stack: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One string field off the throwable, through core's `stringField`. The read is a getter call —
|
|
24
|
+
* or a `Proxy`'s `get` trap — on a value the framework did not build, and it throws in the one
|
|
25
|
+
* place with nothing left to answer with: `factsOf` is called by the RECOVER stage, and again by
|
|
26
|
+
* the `problem()` that `recoverWith` degrades to, so a value that refuses to be read took both
|
|
27
|
+
* renderings and `handle()` rejected against its own contract.
|
|
28
|
+
*/
|
|
29
|
+
const str = (source: unknown, key: string): string | undefined => {
|
|
30
|
+
const value = stringField(source, key);
|
|
31
|
+
return value !== undefined && value.length > 0 ? value : undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalises any throwable into the framework's error contract. Non-Ultimate
|
|
36
|
+
* throwables still get a code and a fix, because "errors are instructions" has to
|
|
37
|
+
* hold for the accidental `TypeError` too.
|
|
38
|
+
*/
|
|
39
|
+
export const factsOf = (error: unknown): ErrorFacts => {
|
|
40
|
+
const code = str(error, 'code') ?? 'X_INTERNAL';
|
|
41
|
+
// The error's own title first: every `UltimateError` resolves one from the code registry at
|
|
42
|
+
// construction, so this renders the OWNING package's title — including the codes http only
|
|
43
|
+
// borrows (`X_FORBIDDEN` is policy's, `X_UNAUTHENTICATED` is auth's) and so cannot title itself.
|
|
44
|
+
// Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
|
|
45
|
+
const title =
|
|
46
|
+
str(error, 'title') ??
|
|
47
|
+
// `Object.hasOwn` for `statusFor`'s reason, one table over: `code: 'toString'` read the
|
|
48
|
+
// function off the prototype and put it in `title`, which is rendered into the problem
|
|
49
|
+
// document and the terminal.
|
|
50
|
+
(Object.hasOwn(HTTP_ERROR_TITLES, code)
|
|
51
|
+
? HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES]
|
|
52
|
+
: undefined) ??
|
|
53
|
+
str(error, 'message') ??
|
|
54
|
+
'unhandled server error';
|
|
55
|
+
// The last fallback is the only one that touches the throwable whole, and every throwable a
|
|
56
|
+
// request produces reaches it. `String()` runs the value's own `toString`, so the value that
|
|
57
|
+
// took the request down took the 500 renderer with it and the server had nothing left to send.
|
|
58
|
+
const cause = str(error, 'cause') ?? str(error, 'message') ?? renderCauseValue(error);
|
|
59
|
+
return {
|
|
60
|
+
code,
|
|
61
|
+
title,
|
|
62
|
+
cause,
|
|
63
|
+
// `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
|
|
64
|
+
// command that throws is axiom 4 inverted: the one instruction the reader is given fails.
|
|
65
|
+
// `x errors explain` ships, and it is the command that answers "what is this code".
|
|
66
|
+
fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
|
|
67
|
+
// Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
|
|
68
|
+
// and a code lives there in a table row, which has no anchor. An `UltimateError` already
|
|
69
|
+
// resolved this at construction, so the fallback only fires for a throwable the framework did
|
|
70
|
+
// not build — and it must not be the `https://ultimate.dev/errors/<code>` link that answered
|
|
71
|
+
// 404 on every problem document this package has ever rendered.
|
|
72
|
+
docs: str(error, 'docs') ?? ERROR_DOCS_URL,
|
|
73
|
+
status: statusFor(code),
|
|
74
|
+
stack: str(error, 'stack'),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `Retry-After`, in whole seconds, for a refusal that computed one — or `undefined`.
|
|
80
|
+
*
|
|
81
|
+
* The contract it reads is already written down by the packages BELOW this one: `@ultimat3/auth`'s
|
|
82
|
+
* `kdfOverloaded` says "`retryAfterSeconds` rides in `meta` because this package cannot reach an
|
|
83
|
+
* HTTP header; the host reads it onto `Retry-After`", and `rateLimited` in this package carries the
|
|
84
|
+
* same field. Nothing was the host. So a 503 shed by the KDF gate and a 429 from an account lockout
|
|
85
|
+
* both told the caller to come back and never said when — which is the shed-with-no-delay pattern
|
|
86
|
+
* the `admit` stage exists to avoid, one layer in.
|
|
87
|
+
*
|
|
88
|
+
* Total, for `str`'s reason one function up: `meta` is a property read on a value this package did
|
|
89
|
+
* not build, and it is read in the frame that decides what the caller sees.
|
|
90
|
+
*/
|
|
91
|
+
export function retryAfterOf(error: unknown): number | undefined {
|
|
92
|
+
if (typeof error !== 'object' || error === null) return undefined;
|
|
93
|
+
try {
|
|
94
|
+
const meta: unknown = (error as Record<string, unknown>)['meta'];
|
|
95
|
+
if (typeof meta !== 'object' || meta === null) return undefined;
|
|
96
|
+
const seconds: unknown = (meta as Record<string, unknown>)['retryAfterSeconds'];
|
|
97
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
|
|
98
|
+
// At least one second, exactly as `RateLimitDecision.retryAfterSeconds` is clamped: `0` reads
|
|
99
|
+
// as "retry now", which is the stampede a Retry-After exists to spread.
|
|
100
|
+
return Math.max(1, Math.ceil(seconds));
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A list this long is not a form's worth of rejections; it is a body meant to be expensive. The
|
|
108
|
+
* same bound `@ultimat3/action`'s `issuesFromWire` applies on arrival, restated because that
|
|
109
|
+
* package is tier 3 and this one is tier 2 — `error-facts.test.ts` pins the number on this side.
|
|
110
|
+
*/
|
|
111
|
+
const MAX_PROBLEM_ISSUES = 100;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The rejections a validation failure carried, addressed by path — or `undefined`.
|
|
115
|
+
*
|
|
116
|
+
* `@ultimat3/action` attaches the list to `meta.issues` (`InputInvalidError`'s third parameter),
|
|
117
|
+
* and until this reader existed nothing carried it across the wire: a client rendering a form
|
|
118
|
+
* recovered per-field errors by splitting `cause` on `'; '`, which is guesswork the moment a
|
|
119
|
+
* message contains the separator.
|
|
120
|
+
*
|
|
121
|
+
* Total, for `retryAfterOf`'s reason directly above: `meta` is a property read on a value this
|
|
122
|
+
* package did not build, in the frame that decides what the caller sees.
|
|
123
|
+
*
|
|
124
|
+
* ALL-OR-NOTHING, and that is the load-bearing rule. A client that finds `issues` uses it INSTEAD
|
|
125
|
+
* of `cause`, so a partly-read list is a rejection the user never sees and a form that reports
|
|
126
|
+
* itself valid when it is not. One unreadable entry drops the whole list back to the prose line.
|
|
127
|
+
*
|
|
128
|
+
* Every entry is rebuilt MEMBER BY MEMBER and `received` is forced empty — never a spread. Not
|
|
129
|
+
* redundancy with `toValidationIssues`, which forces the same thing today: this is the boundary
|
|
130
|
+
* where the value leaves the process, and a future producer of `meta.issues` need not have gone
|
|
131
|
+
* through that helper. A conforming library's own issue object is first-class in this framework
|
|
132
|
+
* and routinely carries the rejected VALUE; `packages/schema/src/describe-value.ts` exists because
|
|
133
|
+
* a password-strength rule once wrote mistyped passwords into the log index.
|
|
134
|
+
*
|
|
135
|
+
* Module-private, unlike `retryAfterOf`: that one has a second caller (`stages.ts` writes it onto
|
|
136
|
+
* the header) and this one has exactly one, `toProblem`. An exported reader nobody outside calls
|
|
137
|
+
* is a public API that promises support it has never been asked for.
|
|
138
|
+
*/
|
|
139
|
+
function issuesOf(error: unknown): readonly ValidationIssue[] | undefined {
|
|
140
|
+
if (typeof error !== 'object' || error === null) return undefined;
|
|
141
|
+
try {
|
|
142
|
+
const meta: unknown = (error as Record<string, unknown>)['meta'];
|
|
143
|
+
if (typeof meta !== 'object' || meta === null) return undefined;
|
|
144
|
+
const raw: unknown = (meta as Record<string, unknown>)['issues'];
|
|
145
|
+
// EMPTY is `undefined`, never `[]`. `Array.isArray([])` is true and the loop below would
|
|
146
|
+
// simply not run, so an empty list reached the document as `issues: []` — which tells a client
|
|
147
|
+
// "we validated and found nothing wrong" about a request that was just refused.
|
|
148
|
+
//
|
|
149
|
+
// TOO LONG is `undefined` too, and dropped WHOLE rather than truncated: a subset is the
|
|
150
|
+
// silent-drop this reader refuses everywhere else. The typed client bounds the same list at
|
|
151
|
+
// `MAX_WIRE_ISSUES` and would refuse it on arrival anyway (`packages/action/src/wire-issues.ts`),
|
|
152
|
+
// so sending it is a body that costs the wire and answers nothing — and that package is tier 3,
|
|
153
|
+
// so the number is restated here rather than imported.
|
|
154
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_PROBLEM_ISSUES) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
const issues: ValidationIssue[] = [];
|
|
158
|
+
for (const entry of raw as readonly unknown[]) {
|
|
159
|
+
if (typeof entry !== 'object' || entry === null) return undefined;
|
|
160
|
+
const fields = entry as Record<string, unknown>;
|
|
161
|
+
const path: unknown = fields['path'];
|
|
162
|
+
const message: unknown = fields['message'];
|
|
163
|
+
const expected: unknown = fields['expected'];
|
|
164
|
+
// `path` and `message` are what a form binding addresses a control by and what it renders;
|
|
165
|
+
// an entry missing either is not usable, and a usable subset beside an unusable one is the
|
|
166
|
+
// silent-drop this list refuses.
|
|
167
|
+
if (typeof path !== 'string' || typeof message !== 'string') return undefined;
|
|
168
|
+
issues.push({
|
|
169
|
+
path,
|
|
170
|
+
expected: typeof expected === 'string' ? expected : message,
|
|
171
|
+
// Forced, never copied. See the paragraph above.
|
|
172
|
+
received: '',
|
|
173
|
+
message,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return issues;
|
|
177
|
+
} catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
|
|
184
|
+
* identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
|
|
185
|
+
* to read about it, and those stopped being the same string when `docs` became one wiki page for
|
|
186
|
+
* every code. Collapsing `type` onto that page too would have given a 422 body-invalid and a 403
|
|
187
|
+
* forbidden the same identifier, which is the one thing a `type` may not do.
|
|
188
|
+
*
|
|
189
|
+
* A URN has no host to resolve, so it cannot rot the way `https://ultimate.dev/errors/<code>` did
|
|
190
|
+
* — it was never dereferenceable and never claimed to be, which RFC 9457 §3.1.1 explicitly allows.
|
|
191
|
+
* `code` carries the same string as a plain member for a reader that would rather not parse a URI.
|
|
192
|
+
*/
|
|
193
|
+
export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`;
|
|
194
|
+
|
|
195
|
+
/** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */
|
|
196
|
+
export interface ProblemDocument {
|
|
197
|
+
readonly type: string;
|
|
198
|
+
readonly title: string;
|
|
199
|
+
readonly status: number;
|
|
200
|
+
readonly detail: string;
|
|
201
|
+
readonly instance: string | undefined;
|
|
202
|
+
readonly code: string;
|
|
203
|
+
readonly cause: string;
|
|
204
|
+
readonly fix: string;
|
|
205
|
+
readonly docs: string;
|
|
206
|
+
readonly requestId: string | undefined;
|
|
207
|
+
/**
|
|
208
|
+
* The rejections, addressed by path, for a failure that produced them. TOP-LEVEL and not an
|
|
209
|
+
* extension bag: RFC 9457 §3.2 puts extension members at the document root, and every Ultimate
|
|
210
|
+
* extension already is one (`code`, `cause`, `fix`, `docs`, `requestId`).
|
|
211
|
+
*
|
|
212
|
+
* ABSENT when there are none — never `undefined`, never `[]`. `JSON.stringify` drops an
|
|
213
|
+
* `undefined` member, but this interface is read directly by `error-page.ts` and by tests, and
|
|
214
|
+
* `[]` says "validated clean", which is a different and false claim.
|
|
215
|
+
*/
|
|
216
|
+
readonly issues?: readonly ValidationIssue[] | undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The title a caller gets for a failure the framework cannot name. */
|
|
220
|
+
const INTERNAL_TITLE = 'unhandled server error';
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The cause a caller gets for one. An unclassified 5xx has no `cause` of its own, so `factsOf`
|
|
224
|
+
* falls through to the throwable's `message` — a driver's DSN, the row Postgres rejected, an
|
|
225
|
+
* absolute path — and `toProblem` handed it to whoever asked. `error-page.ts` locked the BROWSER
|
|
226
|
+
* out of exactly this and said so in its header; the two audiences then disagreed about one
|
|
227
|
+
* condition. The real text is not lost: the `error-map` stage logs it as a redactable FIELD and
|
|
228
|
+
* reports every 5xx to the error monitor, both keyed by the request id below.
|
|
229
|
+
*/
|
|
230
|
+
const INTERNAL_CAUSE =
|
|
231
|
+
'the server failed while handling this request; the details are in this process\u2019s logs and ' +
|
|
232
|
+
'error reports, under this request id';
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A 5xx nobody declared a status for — not the framework's table, not the app's
|
|
236
|
+
* `registerErrorStatus` — or one whose code is `X_INTERNAL`. That is the discriminator, and not
|
|
237
|
+
* `status >= 500` alone: a declared code has an authored cause, and blanking `X_DRAINING`'s would
|
|
238
|
+
* take away the one instruction in it.
|
|
239
|
+
*
|
|
240
|
+
* `X_INTERNAL` is in the framework's table and still belongs here, because it is the framework's
|
|
241
|
+
* own word for "nobody classified this": `factsOf` mints it for a throwable carrying no code, and
|
|
242
|
+
* core's `toError()` wraps a caught value into an `InternalError` whose cause is
|
|
243
|
+
* `renderCauseValue(value)` — the driver's message, verbatim. Nothing in an `X_INTERNAL` is
|
|
244
|
+
* actionable by the caller; the code and the request id are.
|
|
245
|
+
*/
|
|
246
|
+
const isUnclassifiedFailure = (code: string, status: number): boolean =>
|
|
247
|
+
status >= 500 && (code === 'X_INTERNAL' || declaredStatusFor(code) === undefined);
|
|
248
|
+
|
|
249
|
+
export const toProblem = (
|
|
250
|
+
error: unknown,
|
|
251
|
+
meta: { instance?: string; requestId?: string; dev?: boolean } = {},
|
|
252
|
+
): ProblemDocument => {
|
|
253
|
+
const facts = factsOf(error);
|
|
254
|
+
const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
|
|
255
|
+
// Dropped under EXACTLY the condition that blanks `title`, `detail` and `cause`. An issue list
|
|
256
|
+
// on a failure nobody classified is precisely the internal detail `INTERNAL_CAUSE` exists to
|
|
257
|
+
// withhold — it names the fields and the expectations of something the caller was never meant to
|
|
258
|
+
// see the inside of. `X_INPUT_INVALID` is a declared 4xx, so it is never opaque.
|
|
259
|
+
const issues = opaque ? undefined : issuesOf(error);
|
|
260
|
+
return {
|
|
261
|
+
type: problemTypeFor(facts.code),
|
|
262
|
+
title: opaque ? INTERNAL_TITLE : facts.title,
|
|
263
|
+
status: facts.status,
|
|
264
|
+
detail: opaque ? INTERNAL_CAUSE : facts.cause,
|
|
265
|
+
instance: meta.instance,
|
|
266
|
+
code: facts.code,
|
|
267
|
+
cause: opaque ? INTERNAL_CAUSE : facts.cause,
|
|
268
|
+
fix: facts.fix,
|
|
269
|
+
docs: facts.docs,
|
|
270
|
+
requestId: meta.requestId,
|
|
271
|
+
...(issues === undefined ? {} : { issues }),
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** The exact three lines the terminal prints, reused by the overlay and `--json`. */
|
|
276
|
+
export const renderErrorLines = (error: unknown): string => {
|
|
277
|
+
const facts = factsOf(error);
|
|
278
|
+
// The newlines here are the format's own. Every interpolated field goes through `singleLine`
|
|
279
|
+
// so a caller-controlled value cannot add a third one — this string is rendered into the dev
|
|
280
|
+
// overlay's `<pre>`, where HTML escaping does not help because a newline is not markup.
|
|
281
|
+
return [
|
|
282
|
+
`${singleLine(facts.code)}: ${singleLine(facts.title)}`,
|
|
283
|
+
` cause: ${singleLine(facts.cause)}`,
|
|
284
|
+
` fix: ${singleLine(facts.fix)}`,
|
|
285
|
+
].join('\n');
|
|
286
|
+
};
|