@ultimat3/http 3.0.0 → 4.1.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 +53 -2
- package/README.md +8 -0
- package/package.json +5 -5
- package/src/deadline.ts +19 -2
- package/src/error-map.ts +68 -8
- package/src/errors.ts +14 -1
- package/src/finalize.ts +41 -1
- package/src/pipeline.ts +4 -0
- package/src/rate-limit.ts +13 -2
- package/src/request.ts +24 -4
package/CLAUDE.md
CHANGED
|
@@ -55,8 +55,16 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
55
55
|
a caller may SHORTEN it with `x-request-timeout-ms`, never lengthen it. Two halves, both needed:
|
|
56
56
|
the abort is what cooperative code unwinds on, and the race in `execute` is what answers the
|
|
57
57
|
socket when a handler never looked at the signal. `X_TIMEOUT` is borrowed (core's concept) and
|
|
58
|
-
already mapped to 504.
|
|
59
|
-
|
|
58
|
+
already mapped to 504. **`ctx.signal` is the deadline OR the caller going away**, `As of
|
|
59
|
+
2026-08`: `pipeline.ts` hands `startDeadline` the inbound `Request.signal` and the two are joined
|
|
60
|
+
with `AbortSignal.any`, which is what `context.ts` had documented and nothing wired — a closed
|
|
61
|
+
tab held its handler, its pool slot and its vendor connection for the whole 30s. `expired` stays
|
|
62
|
+
the timer's alone: it answers the SOCKET, and a caller that hung up has no socket to answer.
|
|
63
|
+
With `requestTimeoutMs: 0` the caller's signal is handed through as-is rather than the shared
|
|
64
|
+
never-aborted singleton, which every such request used to share — one `abort` listener per
|
|
65
|
+
request, accumulating for the life of the process. Always `deadline.clear()` in the `finally` —
|
|
66
|
+
a live timer keeps the event loop from going idle, so a process that answered everything still
|
|
67
|
+
refuses to exit.
|
|
60
68
|
- **`admit` is the second stage, and it refuses before ANY work.** `isDraining()` had no reader in
|
|
61
69
|
this package while this file claimed the layer answered 503 on it; past `config.maxInflight`
|
|
62
70
|
(1000, `0` disables) a request is shed `X_OVERLOADED` with `retry-after`. Both set the header on
|
|
@@ -82,6 +90,17 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
82
90
|
and not reported and therefore kept for the full retention. The message is the CODE alone. The
|
|
83
91
|
other half is `@ultimat3/schema`'s `describeValue` (shape, never content) and it is the
|
|
84
92
|
load-bearing one; this half is what makes the value redactable at all.
|
|
93
|
+
- **A rejected BODY is not a log field either — `bodyInvalid`'s `issues` may name only what the
|
|
94
|
+
framework chose** (`As of 2026-08-19`). `request.ts` built `could not parse ${type}: ${String(error)}`,
|
|
95
|
+
and the runtime's `SyntaxError` quotes the token it choked on: a `POST` of
|
|
96
|
+
`{"password": hunter2SuperSecret}` answered `422` with that identifier in `cause`, which goes to
|
|
97
|
+
the CALLER through `toProblem` and to the log store as the unredactable field `cause`. Two rules,
|
|
98
|
+
both needed. The caller-facing `issues` are a fixed vocabulary — `could not parse the body as
|
|
99
|
+
JSON`, and the LIST of accepted content-types rather than the one that was sent — and everything
|
|
100
|
+
the caller supplied rides in `bodyInvalid`'s third argument, `meta`, which `toProblem` never
|
|
101
|
+
renders. The parser's own message goes through core's `renderThrowable`, never `String(error)`:
|
|
102
|
+
`bun run error-render` cannot see this class of defect, because a `catch` binding is not a
|
|
103
|
+
parameter, so it is a review rule here and a blind spot there.
|
|
85
104
|
- **A browser that fails `auth: 'required'` is redirected; an agent gets the problem document.**
|
|
86
105
|
One condition, two audiences, decided once in `auth-redirect.ts` and applied in the `error-map`
|
|
87
106
|
stage before the overlay. `config.signInPath` is `null` until an app names its page, because a
|
|
@@ -153,6 +172,13 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
153
172
|
can never import each other, so the only conversion between `{ limit, windowMs }` and a `Bucket`
|
|
154
173
|
sitting in one of them is why a `query` could not declare a rate limit at all. It is beside
|
|
155
174
|
`Bucket` and the maths it validates, and it throws http's own `X_RATE_LIMIT_INVALID`.
|
|
175
|
+
- **`ERROR_STATUS`'s keys are LITERAL, not an index signature** (`As of 2026-08-19`). The
|
|
176
|
+
annotation was `Readonly<Record<string, number>>`, which made `ERROR_STATUS.X_QUERY_NOT_PAGABLE`
|
|
177
|
+
a legal read answering `undefined` — a mistyped row in the one table the framework's whole error
|
|
178
|
+
contract rests on. It is now an object literal `satisfies Readonly<Record<string, number>>`, so a
|
|
179
|
+
typo is a compile error; `error-map.test.ts` pins that with a `@ts-expect-error`. Read it by a
|
|
180
|
+
code the framework did not mint through `statusFor()`, which goes via the file-local `BY_CODE`
|
|
181
|
+
view and keeps `Object.hasOwn`.
|
|
156
182
|
- Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
|
|
157
183
|
table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
|
|
158
184
|
`registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
|
|
@@ -203,6 +229,23 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
203
229
|
comes off the throwable through core's `stringField`. Never spell either read inline again:
|
|
204
230
|
`String(x)`, `${x}` and a bare property read on a caught value are all the same defect, and
|
|
205
231
|
`error-render.ts` names seven prior instances.
|
|
232
|
+
- **A table keyed by a `code` is read with `Object.hasOwn`, never `[code] !== undefined`** (`As of
|
|
233
|
+
2026-08`). `code` is a string off a throwable this package did not build, so `ERROR_STATUS` and
|
|
234
|
+
`HTTP_ERROR_TITLES` — object literals, and therefore holders of every name on
|
|
235
|
+
`Object.prototype` — answered `'toString'`, `'constructor'`, `'valueOf'` and `'hasOwnProperty'`
|
|
236
|
+
with a FUNCTION. `statusFor` handed that to `new Response(body, { status })`, a `RangeError`
|
|
237
|
+
raised inside `recoverWith`'s fallback, and `handle()` rejected: the same defect class as the
|
|
238
|
+
reads above, arriving through a lookup instead of a property. `registerErrorStatus` had the third
|
|
239
|
+
copy, refusing an app code named `toString` with a cause reading `the framework already maps it
|
|
240
|
+
to function toString() { [native code] }`. `scripts/error-map.ts` reads this table correctly and
|
|
241
|
+
always has. `APP_ERROR_STATUS` is a `Map`, which is why it never had the bug — prefer one for
|
|
242
|
+
anything keyed by a value a caller chose.
|
|
243
|
+
- **`recoverWith`'s fallback is INSIDE its `try`.** `return problem(ctx.error, …)` sat beside the
|
|
244
|
+
guard, so the file whose one promise is "never throws, by construction" rested on every reader
|
|
245
|
+
below that line being total. The degraded answer is a literal `problem+json` document naming
|
|
246
|
+
`X_INTERNAL`, built with no call that could fail in turn, and the renderer's own failure goes to
|
|
247
|
+
the log as `pipeline.problem_failed` — a last resort sharing a code path with what just broke is
|
|
248
|
+
not one.
|
|
206
249
|
- **The memory rate-limit store is bounded, and the eviction order is part of the guarantee.**
|
|
207
250
|
The key falls back to the connection address (`rateLimitKey`), so a scan rotating through an
|
|
208
251
|
IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every
|
|
@@ -211,6 +254,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
211
254
|
and it evicts the entries **closest to full** first: throwing away a spent bucket is a free
|
|
212
255
|
reset for whoever spent it, so the most-throttled key is the last one to go. Never swap that
|
|
213
256
|
comparator for insertion order or an LRU — recency is not the same as worthlessness here.
|
|
257
|
+
- **The limiter takes a `Clock`; `Date.now()` is not read here** (`As of 2026-08-19`). `rate-limit.ts`
|
|
258
|
+
read it inline while BOTH production call sites (`server.ts`, `pipeline.ts`) built their limiter
|
|
259
|
+
with no override, so the bucket maths that decides whether a caller is throttled could not be
|
|
260
|
+
frozen by any test — while `@ultimat3/auth`'s credential limiter has taken an injected `Clock`
|
|
261
|
+
since it shipped. `createRateLimiter({ clock })` defaults to `systemClock`, the same shape as
|
|
262
|
+
`createRequestContext`'s `init.clock`. Deliberately NOT a `clock` on `PipelineDeps`:
|
|
263
|
+
`deps.limiter` is already the one seam for handing the pipeline a limiter you built, and a second
|
|
264
|
+
entry point for one number is axiom 1.
|
|
214
265
|
- **Where the limiter's counters live is DECLARED by the app, never inferred, and refused at
|
|
215
266
|
boot — and there is no default.** `DEFAULT_RATE_LIMIT` carries no `scope`, so
|
|
216
267
|
`resolveRateLimitConfig` refuses `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig` when a limiter
|
package/README.md
CHANGED
|
@@ -59,6 +59,7 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
59
59
|
| `trustProxy: true` with no `trustedProxyHops` | `X_TRUST_PROXY_UNSET` at `defineHttpConfig`. **Breaking, `As of 2026-08`**: `trustProxy` now defaults to `false`, and `x-forwarded-for` is read at `entries.length - hops` — never at `[0]`, which is whatever the client typed |
|
|
60
60
|
| a credentialed unsafe method that cannot be shown to be same-origin | `X_CSRF_BLOCKED` (403). `sec-fetch-site: same-origin`, `Origin` equal to this app, or an `Origin` in `cors.origins` — anything else is refused before the body is read |
|
|
61
61
|
| a request past `requestTimeoutMs` (30s) | `ctx.signal` aborts and the socket is answered `X_TIMEOUT` (504); a caller may shorten the deadline with `x-request-timeout-ms`, never lengthen it |
|
|
62
|
+
| the caller going away mid-request | `ctx.signal` aborts on the inbound `Request.signal` too, so a closed tab unwinds cooperative work instead of holding its pool slot for the rest of the budget. Both halves are one signal (`AbortSignal.any`), and `requestTimeoutMs: 0` still delivers the caller's |
|
|
62
63
|
| a request while the process is draining | `X_DRAINING` (503) + `retry-after`, which is what `isDraining()` was always documented to do here and had no reader for |
|
|
63
64
|
| a request past `maxInflight` (1000) | `X_OVERLOADED` (503) + `retry-after`, shed in the `admit` stage before any work |
|
|
64
65
|
|
|
@@ -99,6 +100,13 @@ so nothing can be wrong.
|
|
|
99
100
|
maths stays in `createRateLimiter`, so every driver agrees on the numbers. **No shared store ships
|
|
100
101
|
yet, `As of 2026-08`** — `memoryRateLimitStore()` is the only implementation in the framework.
|
|
101
102
|
|
|
103
|
+
The maths reads an injected `Clock`, defaulting to `systemClock`: `createRateLimiter({ config,
|
|
104
|
+
clock })`. **Breaking, `As of 2026-08-19`** — it took `now?: () => number` before and read
|
|
105
|
+
`Date.now()` when nothing passed one, which both production call sites did, so the limiter that
|
|
106
|
+
actually throttles a request could not be frozen. Replace `now: () => t` with
|
|
107
|
+
`clock: frozenClock(t)`; a limiter you build yourself still reaches the pipeline through
|
|
108
|
+
`PipelineDeps.limiter`, which stays the only seam for one.
|
|
109
|
+
|
|
102
110
|
### A route may bring its own bucket
|
|
103
111
|
|
|
104
112
|
`meta.rateLimit` names a bucket; `meta.rateLimitBucket` is the numbers that bucket must hold.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/i18n": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "4.1.0",
|
|
35
|
+
"@ultimat3/i18n": "4.1.0",
|
|
36
|
+
"@ultimat3/schema": "4.1.0",
|
|
37
|
+
"@ultimat3/time": "4.1.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/deadline.ts
CHANGED
|
@@ -52,9 +52,21 @@ export const startDeadline = (input: {
|
|
|
52
52
|
readonly config: HttpConfig;
|
|
53
53
|
readonly method: string;
|
|
54
54
|
readonly pathname: string;
|
|
55
|
+
/**
|
|
56
|
+
* The INBOUND `Request.signal` — the caller-went-away half of `ctx.signal`. Optional because a
|
|
57
|
+
* context can exist without a request (a job, a test), never because a server may skip it.
|
|
58
|
+
*/
|
|
59
|
+
readonly clientSignal?: AbortSignal | undefined;
|
|
55
60
|
}): Deadline => {
|
|
56
61
|
const timeoutMs = resolveTimeoutMs(input.headers, input.config);
|
|
57
|
-
|
|
62
|
+
const client = input.clientSignal;
|
|
63
|
+
// The caller's own signal, not the shared never-aborted one: with no deadline configured every
|
|
64
|
+
// request used to share a module-level singleton, so a handler that added an `abort` listener
|
|
65
|
+
// accumulated one per request for the life of the process — and no request could learn its
|
|
66
|
+
// caller had gone. `NO_DEADLINE` is left for the callers that genuinely have no client.
|
|
67
|
+
if (timeoutMs <= 0) {
|
|
68
|
+
return client === undefined ? NO_DEADLINE : { ...NO_DEADLINE, signal: client };
|
|
69
|
+
}
|
|
58
70
|
|
|
59
71
|
const controller = new AbortController();
|
|
60
72
|
let fire: (() => void) | undefined;
|
|
@@ -71,7 +83,12 @@ export const startDeadline = (input: {
|
|
|
71
83
|
}, timeoutMs);
|
|
72
84
|
|
|
73
85
|
return {
|
|
74
|
-
signal:
|
|
86
|
+
// Both halves, or the doc on `ctx.signal` is half true — which it was: nothing in this package
|
|
87
|
+
// read the inbound signal, so a browser closing the tab left the request holding its pool slot
|
|
88
|
+
// and its vendor connection for the whole budget, for a caller that is gone. `expired` stays
|
|
89
|
+
// the TIMER's alone: it is what answers the socket, and a socket the caller already closed has
|
|
90
|
+
// nothing to answer.
|
|
91
|
+
signal: client === undefined ? controller.signal : AbortSignal.any([client, controller.signal]),
|
|
75
92
|
expired,
|
|
76
93
|
timeoutMs,
|
|
77
94
|
clear: () => clearTimeout(timer),
|
package/src/error-map.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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 { renderCauseValue, stringField } from '@ultimat3/core';
|
|
4
|
+
import { renderCauseValue, singleLine, stringField } from '@ultimat3/core';
|
|
5
5
|
import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -9,7 +9,7 @@ import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
|
|
|
9
9
|
* is the only layer that knows what a status means, so no other package should
|
|
10
10
|
* ever hardcode one.
|
|
11
11
|
*/
|
|
12
|
-
export const ERROR_STATUS
|
|
12
|
+
export const ERROR_STATUS = {
|
|
13
13
|
// @ultimat3/http
|
|
14
14
|
X_ROUTE_NOT_FOUND: 404,
|
|
15
15
|
X_METHOD_NOT_ALLOWED: 405,
|
|
@@ -101,6 +101,12 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
101
101
|
// the same class as `X_BODY_INVALID` and `X_INVARIANT_VIOLATED` above. Unmapped, a visitor
|
|
102
102
|
// choosing "password" at a signup form was reported to the on-call monitor as a server fault.
|
|
103
103
|
X_PASSWORD_WEAK: 422,
|
|
104
|
+
// 422 for the same reason as the row above, and deliberately not 500: `enrolTotp` throws it for a
|
|
105
|
+
// secret the CALLER supplied — an import from another MFA system, or a value off a form — and
|
|
106
|
+
// what failed is that value's content, not the server. `verifyTotp` never throws it (an
|
|
107
|
+
// unreadable stored secret is a non-verdict there, the rule `verifyAgainst` follows for a hash
|
|
108
|
+
// Bun cannot read), so a login checking a broken row cannot reach this status at all.
|
|
109
|
+
X_MFA_SECRET_INVALID: 422,
|
|
104
110
|
// @ultimat3/entity
|
|
105
111
|
X_NOT_FOUND: 404,
|
|
106
112
|
X_ENTITY_DUPLICATE: 409,
|
|
@@ -126,6 +132,18 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
126
132
|
// at it), which 422 describes only for the insert.
|
|
127
133
|
X_DB_UNIQUE_VIOLATION: 409,
|
|
128
134
|
X_DB_FOREIGN_KEY_VIOLATION: 409,
|
|
135
|
+
// @ultimat3/jobs — the ONE jobs code with a row here, and the reason the rest are pinned in
|
|
136
|
+
// `scripts/error-map-backlog.ts` does not cover it. That pin says "a job runs with no socket
|
|
137
|
+
// attached; `ROLE=worker` opens no HTTP port at all" — true of `X_JOB_TIMEOUT` and every other
|
|
138
|
+
// worker-runtime code, and NOT true of a decode failure: `toJobRecord` runs wherever a row is
|
|
139
|
+
// READ, which includes the admin dashboard's job panel and `x jobs show` served over HTTP.
|
|
140
|
+
// 500, and it should page: a queue holding rows this build cannot read is an operator's
|
|
141
|
+
// problem, and nothing the caller sent is wrong.
|
|
142
|
+
X_JOB_ROW_STATUS_UNKNOWN: 500,
|
|
143
|
+
// Thrown by `registerJobs()` while the app's modules load, so no request is ever answered with
|
|
144
|
+
// it either — the row exists for the reason `X_CORS_CONFIG_INVALID`'s does: this table is the
|
|
145
|
+
// closed one, and a code with no row is a 500 anyway.
|
|
146
|
+
X_ACTION_JOB_UNBRIDGED: 500,
|
|
129
147
|
// @ultimat3/policy
|
|
130
148
|
X_POLICY_MISSING: 500,
|
|
131
149
|
X_PERMISSION_UNKNOWN: 500,
|
|
@@ -159,6 +177,11 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
159
177
|
X_STORAGE_CHECKSUM_MISMATCH: 422,
|
|
160
178
|
X_STORAGE_URL_INVALID: 403,
|
|
161
179
|
X_STORAGE_URL_EXPIRED: 410,
|
|
180
|
+
// 500, not 403, and the distinction is the whole reason this code exists rather than
|
|
181
|
+
// reusing X_STORAGE_URL_INVALID: nothing is wrong with the caller's URL. The disk was
|
|
182
|
+
// built with no way to check a signature, which is the operator's misconfiguration and
|
|
183
|
+
// not an attacker — reporting it as 403 sends the on-call hunting somebody who is not there.
|
|
184
|
+
X_STORAGE_URL_UNVERIFIABLE: 500,
|
|
162
185
|
// 409, not the 500 it fell through to: the object exists and the request is well formed — the
|
|
163
186
|
// STATE is wrong. A validated upload lands under the quarantine segment and `promoteAttachment`
|
|
164
187
|
// refuses it until the app's own scanner calls `releaseQuarantine`, which is a thing the caller
|
|
@@ -189,10 +212,29 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
189
212
|
X_TIMEOUT: 504,
|
|
190
213
|
X_ABORTED: 499,
|
|
191
214
|
X_INTERNAL: 500,
|
|
192
|
-
|
|
215
|
+
// The keys are LITERAL — deliberately not `Readonly<Record<string, number>>`, which is what the
|
|
216
|
+
// annotation used to say. This table is the closed one, so `ERROR_STATUS.X_QUERY_NOT_PAGABLE`
|
|
217
|
+
// has to be a compile error rather than an `undefined` a test then asserts `toBeNumber()` on.
|
|
218
|
+
// Read it by a code the framework did not mint through `statusFor`, never by index.
|
|
219
|
+
} satisfies Readonly<Record<string, number>>;
|
|
193
220
|
|
|
194
221
|
export const DEFAULT_STATUS = 500;
|
|
195
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The framework's row for a code, or `undefined` — through `Object.hasOwn`, never `[code]`.
|
|
225
|
+
*
|
|
226
|
+
* `code` is a STRING read off a throwable this package did not build, and `ERROR_STATUS` is an
|
|
227
|
+
* object literal, so it holds every name on `Object.prototype`: an app throwing
|
|
228
|
+
* `{ code: 'toString' }` read a FUNCTION out of this table. `statusFor` handed it to
|
|
229
|
+
* `new Response(body, { status })` — a `RangeError` raised inside `recoverWith`'s fallback, the
|
|
230
|
+
* one frame with nothing above it, so `Pipeline.handle` REJECTED against its own contract.
|
|
231
|
+
* `scripts/error-map.ts` reads the same table this way already.
|
|
232
|
+
*/
|
|
233
|
+
const BY_CODE: Readonly<Record<string, number>> = ERROR_STATUS;
|
|
234
|
+
|
|
235
|
+
const frameworkStatus = (code: string): number | undefined =>
|
|
236
|
+
Object.hasOwn(BY_CODE, code) ? BY_CODE[code] : undefined;
|
|
237
|
+
|
|
196
238
|
/**
|
|
197
239
|
* Statuses for codes the APP owns. The table above is closed — it has to be, it is the
|
|
198
240
|
* framework's own contract — and every code outside it fell to 500, so a wrong password was an
|
|
@@ -218,8 +260,12 @@ export const registerErrorStatus = (statuses: Readonly<Record<string, number>>):
|
|
|
218
260
|
}
|
|
219
261
|
// The framework's own codes are not negotiable: an app that could map `X_UNAUTHENTICATED`
|
|
220
262
|
// to 200 would be an app whose 401 contract every client already depends on, changed.
|
|
221
|
-
|
|
222
|
-
|
|
263
|
+
// Through `frameworkStatus`, so this refusal cannot answer for a code the framework does not
|
|
264
|
+
// own: `registerErrorStatus({ toString: 401 })` was rejected with a cause reading `the
|
|
265
|
+
// framework already maps it to function toString() { [native code] }`.
|
|
266
|
+
const framework = frameworkStatus(code);
|
|
267
|
+
if (framework !== undefined) {
|
|
268
|
+
throw errorStatusInvalid(code, `the framework already maps it to ${framework}`);
|
|
223
269
|
}
|
|
224
270
|
const existing = APP_ERROR_STATUS.get(code);
|
|
225
271
|
if (existing !== undefined && existing !== status) {
|
|
@@ -235,8 +281,10 @@ export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
|
|
|
235
281
|
// Framework table first: `registerErrorStatus` already refuses those codes, so the order is
|
|
236
282
|
// belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
|
|
237
283
|
// even if a future caller reaches the map some other way.
|
|
284
|
+
// `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
|
|
285
|
+
// prefer one for anything keyed by a value a caller chose.
|
|
238
286
|
export const statusFor = (code: string): number =>
|
|
239
|
-
|
|
287
|
+
frameworkStatus(code) ?? APP_ERROR_STATUS.get(code) ?? DEFAULT_STATUS;
|
|
240
288
|
|
|
241
289
|
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
|
|
242
290
|
export interface ErrorFacts {
|
|
@@ -275,7 +323,12 @@ export const factsOf = (error: unknown): ErrorFacts => {
|
|
|
275
323
|
// Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
|
|
276
324
|
const title =
|
|
277
325
|
str(error, 'title') ??
|
|
278
|
-
|
|
326
|
+
// `Object.hasOwn` for `statusFor`'s reason, one table over: `code: 'toString'` read the
|
|
327
|
+
// function off the prototype and put it in `title`, which is rendered into the problem
|
|
328
|
+
// document and the terminal.
|
|
329
|
+
(Object.hasOwn(HTTP_ERROR_TITLES, code)
|
|
330
|
+
? HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES]
|
|
331
|
+
: undefined) ??
|
|
279
332
|
str(error, 'message') ??
|
|
280
333
|
'unhandled server error';
|
|
281
334
|
// The last fallback is the only one that touches the throwable whole, and every throwable a
|
|
@@ -332,5 +385,12 @@ export const toProblem = (
|
|
|
332
385
|
/** The exact three lines the terminal prints, reused by the overlay and `--json`. */
|
|
333
386
|
export const renderErrorLines = (error: unknown): string => {
|
|
334
387
|
const facts = factsOf(error);
|
|
335
|
-
|
|
388
|
+
// The newlines here are the format's own. Every interpolated field goes through `singleLine`
|
|
389
|
+
// so a caller-controlled value cannot add a third one — this string is rendered into the dev
|
|
390
|
+
// overlay's `<pre>`, where HTML escaping does not help because a newline is not markup.
|
|
391
|
+
return [
|
|
392
|
+
`${singleLine(facts.code)}: ${singleLine(facts.title)}`,
|
|
393
|
+
` cause: ${singleLine(facts.cause)}`,
|
|
394
|
+
` fix: ${singleLine(facts.fix)}`,
|
|
395
|
+
].join('\n');
|
|
336
396
|
};
|
package/src/errors.ts
CHANGED
|
@@ -160,10 +160,23 @@ export const pathInvalid = (pathname: string, segment: string): HttpError =>
|
|
|
160
160
|
fix: 'send the segment percent-encoded — encodeURIComponent(value); a literal % is %25',
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
/**
|
|
164
|
+
* `issues` is the CALLER-facing half and must name only facts the framework itself chose — a
|
|
165
|
+
* schema rule, a byte count, a content-type the router supports. Anything the caller sent goes in
|
|
166
|
+
* `meta`, which `toProblem` never renders and `stages.ts` never writes into the log message: a
|
|
167
|
+
* `cause` reaches the log store as an unredactable field AND the problem document, so a value
|
|
168
|
+
* baked into it has no key left to redact. The runtime's `SyntaxError` quotes the token it choked
|
|
169
|
+
* on, which is how a fragment of `{"password": …}` used to travel in both directions at once.
|
|
170
|
+
*/
|
|
171
|
+
export const bodyInvalid = (
|
|
172
|
+
pathname: string,
|
|
173
|
+
issues: readonly string[],
|
|
174
|
+
meta?: Readonly<Record<string, unknown>>,
|
|
175
|
+
): HttpError =>
|
|
164
176
|
new HttpError({
|
|
165
177
|
code: 'X_BODY_INVALID',
|
|
166
178
|
cause: `${pathname} body rejected: ${issues.join('; ')}`,
|
|
179
|
+
...(meta === undefined ? {} : { meta }),
|
|
167
180
|
// `x schema show` is not a command — not in the registry and not in `PLANNED_COMMANDS`, so it
|
|
168
181
|
// exits `X_CLI_UNKNOWN_COMMAND`. The same axiom-4 inversion `x logs tail` had in `error-map`:
|
|
169
182
|
// the one instruction the reader is given fails when they run it. `x routes` ships, and
|
package/src/finalize.ts
CHANGED
|
@@ -12,6 +12,32 @@ import type { Stage } from './stages';
|
|
|
12
12
|
/** Renders whatever sits on `ctx.error` into a Response. Never throws, by construction. */
|
|
13
13
|
export type Recover = (request: UltimateRequest, ctx: RequestContext) => Promise<Response>;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The answer when even rendering the problem document failed. Literals and one `new Response`,
|
|
17
|
+
* calling nothing that could fail in turn — a last resort that shares a code path with the thing
|
|
18
|
+
* that just broke is not one. Restating the RFC-9457 shape here is the cost of that: the client
|
|
19
|
+
* still gets a document its parser understands, with a code it can look up.
|
|
20
|
+
*/
|
|
21
|
+
const lastResort = (): Response =>
|
|
22
|
+
new Response(
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
type: 'https://ultimate.dev/errors/X_INTERNAL',
|
|
25
|
+
title: 'unhandled server error',
|
|
26
|
+
status: 500,
|
|
27
|
+
detail: 'the error renderer itself failed, so nothing of the original error survives here',
|
|
28
|
+
code: 'X_INTERNAL',
|
|
29
|
+
cause: 'the error renderer itself failed, so nothing of the original error survives here',
|
|
30
|
+
fix: 'grep the logs for pipeline.problem_failed — it carries the renderer failure this reply could not',
|
|
31
|
+
}),
|
|
32
|
+
{
|
|
33
|
+
status: 500,
|
|
34
|
+
headers: {
|
|
35
|
+
'content-type': 'application/problem+json; charset=utf-8',
|
|
36
|
+
'cache-control': 'no-store',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
15
41
|
/**
|
|
16
42
|
* The recover stage is the single place a throw becomes a status — so a throw INSIDE it (an app's
|
|
17
43
|
* `onError` sink, a `devNotices` producer) has nothing left to render it. Rethrowing would break
|
|
@@ -35,7 +61,21 @@ export const recoverWith =
|
|
|
35
61
|
// uses, so the value stays redactable by key.
|
|
36
62
|
logger.error('pipeline.recover_failed', { requestId: ctx.requestId, error: failure });
|
|
37
63
|
}
|
|
38
|
-
|
|
64
|
+
// INSIDE a guard, not beside it — and that is this file's whole promise. The line renders a
|
|
65
|
+
// value nobody here built, so "never throws" rested on every reader below it being total, and
|
|
66
|
+
// one was not: `statusFor` read `ERROR_STATUS['toString']` off the prototype chain and handed
|
|
67
|
+
// `new Response(body, { status })` a function, so an app throwing `{ code: 'toString' }` made
|
|
68
|
+
// `handle()` REJECT from the one frame with nothing above it. That read is fixed in
|
|
69
|
+
// `error-map.ts`; this guard is what keeps the contract from depending on the next one.
|
|
70
|
+
try {
|
|
71
|
+
return problem(ctx.error, { instance: ctx.url.pathname, requestId: ctx.requestId });
|
|
72
|
+
} catch (failure) {
|
|
73
|
+
// No `ctx` read in this branch, deliberately: it is reached because reading `ctx` or the
|
|
74
|
+
// value on it threw, so reaching for one more field is how a guard throws out of the guard.
|
|
75
|
+
// `logger.emit` degrades a hostile field per key and never rethrows.
|
|
76
|
+
logger.error('pipeline.problem_failed', { error: failure });
|
|
77
|
+
return lastResort();
|
|
78
|
+
}
|
|
39
79
|
};
|
|
40
80
|
|
|
41
81
|
/**
|
package/src/pipeline.ts
CHANGED
|
@@ -210,6 +210,10 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
210
210
|
config,
|
|
211
211
|
method: raw.method.toUpperCase(),
|
|
212
212
|
pathname: url.pathname,
|
|
213
|
+
// The caller-went-away half of `ctx.signal`, which `context.ts` documented and nothing
|
|
214
|
+
// wired: without it a closed tab kept its handler, its pool slot and its vendor
|
|
215
|
+
// connection alive for the whole `requestTimeoutMs`.
|
|
216
|
+
clientSignal: raw.signal,
|
|
213
217
|
});
|
|
214
218
|
const forwarded = {
|
|
215
219
|
headers: raw.headers,
|
package/src/rate-limit.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// dev/tests and against a shared tier in a multi-replica deployment — installed through
|
|
3
3
|
// `createServer({ rateLimitStore })`, and refused at boot when its scope cannot keep the app's
|
|
4
4
|
// declaration; the bucket maths lives here so every driver agrees on the numbers.
|
|
5
|
+
import { type Clock, systemClock } from '@ultimat3/core';
|
|
5
6
|
import { rateLimited, rateLimitInvalid, rateLimitNotShared, rateLimitScopeUnset } from './errors';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -289,10 +290,20 @@ export interface RateLimiter {
|
|
|
289
290
|
export const createRateLimiter = (options: {
|
|
290
291
|
config: RateLimitConfig;
|
|
291
292
|
store?: RateLimitStore;
|
|
292
|
-
|
|
293
|
+
/**
|
|
294
|
+
* The one source of "now" for the bucket maths. `Date.now()` used to be read inline here, and
|
|
295
|
+
* BOTH production call sites (`server.ts`, `pipeline.ts`) build their limiter without an
|
|
296
|
+
* override — so the limiter that actually throttles requests could not be frozen by any test,
|
|
297
|
+
* while `@ultimat3/auth`'s credential limiter has taken an injected `Clock` since it shipped.
|
|
298
|
+
* Defaulted rather than required, the same shape as `createRequestContext`'s `init.clock`;
|
|
299
|
+
* `PipelineDeps.limiter` stays the one seam for handing the pipeline a limiter of your own,
|
|
300
|
+
* because a second `clock` beside it would be a second way to set one number.
|
|
301
|
+
*/
|
|
302
|
+
clock?: Clock;
|
|
293
303
|
}): RateLimiter => {
|
|
294
304
|
const store = options.store ?? memoryRateLimitStore();
|
|
295
|
-
const
|
|
305
|
+
const clock = options.clock ?? systemClock;
|
|
306
|
+
const now = (): number => clock.now().getTime();
|
|
296
307
|
const bucketFor = (name: string): Bucket =>
|
|
297
308
|
options.config.buckets[name] ??
|
|
298
309
|
options.config.buckets[options.config.defaultBucket] ??
|
package/src/request.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// context so they cannot drift from what the pipeline resolved.
|
|
4
4
|
|
|
5
5
|
import type { Actor } from '@ultimat3/core';
|
|
6
|
-
import { readWithinLimit } from '@ultimat3/core';
|
|
6
|
+
import { readWithinLimit, renderThrowable } from '@ultimat3/core';
|
|
7
7
|
import type { RequestContext } from './context';
|
|
8
8
|
import { bodyInvalid, buildSkew } from './errors';
|
|
9
9
|
import { readCookie } from './locale';
|
|
@@ -186,7 +186,12 @@ export class UltimateRequest {
|
|
|
186
186
|
}).formData();
|
|
187
187
|
return Object.fromEntries(form);
|
|
188
188
|
} catch (error) {
|
|
189
|
-
|
|
189
|
+
// The parser's own message is a diagnostic, not an instruction, and it quotes the bytes
|
|
190
|
+
// it choked on — so it rides in `meta`, rendered by core rather than by `String(error)`,
|
|
191
|
+
// which is itself a `TypeError` on a null-prototype throwable.
|
|
192
|
+
throw bodyInvalid(this.pathname, ['could not parse multipart/form-data'], {
|
|
193
|
+
parseError: renderThrowable(error),
|
|
194
|
+
});
|
|
190
195
|
}
|
|
191
196
|
}
|
|
192
197
|
|
|
@@ -198,8 +203,23 @@ export class UltimateRequest {
|
|
|
198
203
|
}
|
|
199
204
|
if (type.startsWith('text/')) return body;
|
|
200
205
|
} catch (error) {
|
|
201
|
-
|
|
206
|
+
// `JSON.parse` is the only thing above that throws — `new URLSearchParams(s)` and the
|
|
207
|
+
// `text/` branch accept any string — so the caller-facing half can name the format without
|
|
208
|
+
// echoing the `content-type` header the caller chose.
|
|
209
|
+
throw bodyInvalid(this.pathname, ['could not parse the body as JSON'], {
|
|
210
|
+
parseError: renderThrowable(error),
|
|
211
|
+
contentType: type,
|
|
212
|
+
});
|
|
202
213
|
}
|
|
203
|
-
|
|
214
|
+
// The list of what IS accepted, which is the actionable half; the value the caller sent is
|
|
215
|
+
// theirs already and is a log field here rather than a string in the message.
|
|
216
|
+
throw bodyInvalid(
|
|
217
|
+
this.pathname,
|
|
218
|
+
[
|
|
219
|
+
'content-type is not one of application/json, application/x-www-form-urlencoded, ' +
|
|
220
|
+
'multipart/form-data, text/*',
|
|
221
|
+
],
|
|
222
|
+
{ contentType: type },
|
|
223
|
+
);
|
|
204
224
|
}
|
|
205
225
|
}
|