@ultimat3/http 9.0.0 → 10.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 +11 -0
- package/README.md +7 -0
- package/package.json +5 -5
- package/src/error-map.ts +56 -3
- package/src/errors.ts +6 -2
- package/src/finalize.ts +4 -1
- package/src/index.ts +2 -0
- package/src/rate-limit.ts +9 -2
- package/src/stages.ts +17 -5
package/CLAUDE.md
CHANGED
|
@@ -187,6 +187,17 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
187
187
|
typo is a compile error; `error-map.test.ts` pins that with a `@ts-expect-error`. Read it by a
|
|
188
188
|
code the framework did not mint through `statusFor()`, which goes via the file-local `BY_CODE`
|
|
189
189
|
view and keeps `Object.hasOwn`.
|
|
190
|
+
- **A problem document's `type` and its `docs` are two different questions, `As of 2026-08-23`.**
|
|
191
|
+
Both used to be `https://ultimate.dev/errors/<code>` — one string, twice, and a host that
|
|
192
|
+
answers **404** on every 4xx and 5xx this package has ever rendered. `docs` is now core's
|
|
193
|
+
`ERROR_DOCS_URL`, one wiki page for every code, and it is never spelled here: a construction site
|
|
194
|
+
omits `docs:` and `UltimateError` resolves it. `type` did NOT follow it there. It is RFC 9457's
|
|
195
|
+
primary identifier for the problem KIND — a client switches on it — so collapsing it onto one
|
|
196
|
+
page would have given a 422 and a 403 the same identifier. `problemTypeFor(code)` answers
|
|
197
|
+
`urn:ultimate:error:<CODE>`: per code, stable, and a URN has no host to rot. `finalize.ts`'s
|
|
198
|
+
`lastResort` spells its one `type` as a literal because that function calls nothing, and
|
|
199
|
+
`pipeline-finalize.test.ts` pins the literal against `problemTypeFor('X_INTERNAL')` so the two
|
|
200
|
+
cannot drift. Never assert either value as a copied string — import the constant.
|
|
190
201
|
- Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
|
|
191
202
|
table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
|
|
192
203
|
`registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
|
package/README.md
CHANGED
|
@@ -205,6 +205,13 @@ through to `fetch`. Method resolution stays ours so a 405 still carries problem+
|
|
|
205
205
|
One `factsOf()` feeds three renderings — terminal, `application/problem+json`, dev
|
|
206
206
|
overlay — so the `code`/`cause`/`fix` strings can never diverge.
|
|
207
207
|
|
|
208
|
+
A problem document's `type` and `docs` answer different questions and are two different
|
|
209
|
+
values. `type` is `problemTypeFor(code)` — `urn:ultimate:error:X_BODY_INVALID`, the RFC-9457
|
|
210
|
+
identifier a client switches on, per code, with no host to resolve or rot. `docs` is
|
|
211
|
+
`@ultimat3/core`'s `ERROR_DOCS_URL`, one wiki page for every code, because a code lives there
|
|
212
|
+
in a table row and a table row has no anchor. Assert against `problemTypeFor` and
|
|
213
|
+
`ERROR_DOCS_URL`, never against a copy of either string.
|
|
214
|
+
|
|
208
215
|
## Boundaries
|
|
209
216
|
|
|
210
217
|
Tier 2. Imports `@ultimat3/core` and `@ultimat3/schema` only. Authentication and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.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": "10.0.0",
|
|
35
|
+
"@ultimat3/i18n": "10.0.0",
|
|
36
|
+
"@ultimat3/schema": "10.0.0",
|
|
37
|
+
"@ultimat3/time": "10.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
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, singleLine, stringField } from '@ultimat3/core';
|
|
4
|
+
import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core';
|
|
5
5
|
import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -155,6 +155,13 @@ export const ERROR_STATUS = {
|
|
|
155
155
|
// @ultimat3/policy
|
|
156
156
|
X_POLICY_MISSING: 500,
|
|
157
157
|
X_PERMISSION_UNKNOWN: 500,
|
|
158
|
+
// 500, and the page IS the point — deliberately not a 4xx to keep this table quiet.
|
|
159
|
+
// `enforce()` was handed a surface no adapter answers to, which reaches a request only through a
|
|
160
|
+
// config-driven route table, a surface name off the wire or a JS host; none of those is a value
|
|
161
|
+
// the caller can correct, and a 400 would tell them to fix a request that is not the problem.
|
|
162
|
+
// It is the third authz-dispatch fault beside the two rows above and takes their status for the
|
|
163
|
+
// same reason: the declaration is wrong, not the call.
|
|
164
|
+
X_POLICY_SURFACE_UNKNOWN: 500,
|
|
158
165
|
// @ultimat3/query — the read declares no id, so no cursor can name a position in it. The one
|
|
159
166
|
// paging failure that is NOT the caller's: the fix is an edit to the read's own select, nothing
|
|
160
167
|
// the client sends changes the answer, and the report to the on-call monitor is the point.
|
|
@@ -351,12 +358,58 @@ export const factsOf = (error: unknown): ErrorFacts => {
|
|
|
351
358
|
// command that throws is axiom 4 inverted: the one instruction the reader is given fails.
|
|
352
359
|
// `x errors explain` ships, and it is the command that answers "what is this code".
|
|
353
360
|
fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
|
|
354
|
-
|
|
361
|
+
// Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
|
|
362
|
+
// and a code lives there in a table row, which has no anchor. An `UltimateError` already
|
|
363
|
+
// resolved this at construction, so the fallback only fires for a throwable the framework did
|
|
364
|
+
// not build — and it must not be the `https://ultimate.dev/errors/<code>` link that answered
|
|
365
|
+
// 404 on every problem document this package has ever rendered.
|
|
366
|
+
docs: str(error, 'docs') ?? ERROR_DOCS_URL,
|
|
355
367
|
status: statusFor(code),
|
|
356
368
|
stack: str(error, 'stack'),
|
|
357
369
|
};
|
|
358
370
|
};
|
|
359
371
|
|
|
372
|
+
/**
|
|
373
|
+
* `Retry-After`, in whole seconds, for a refusal that computed one — or `undefined`.
|
|
374
|
+
*
|
|
375
|
+
* The contract it reads is already written down by the packages BELOW this one: `@ultimat3/auth`'s
|
|
376
|
+
* `kdfOverloaded` says "`retryAfterSeconds` rides in `meta` because this package cannot reach an
|
|
377
|
+
* HTTP header; the host reads it onto `Retry-After`", and `rateLimited` in this package carries the
|
|
378
|
+
* same field. Nothing was the host. So a 503 shed by the KDF gate and a 429 from an account lockout
|
|
379
|
+
* both told the caller to come back and never said when — which is the shed-with-no-delay pattern
|
|
380
|
+
* the `admit` stage exists to avoid, one layer in.
|
|
381
|
+
*
|
|
382
|
+
* Total, for `str`'s reason one function up: `meta` is a property read on a value this package did
|
|
383
|
+
* not build, and it is read in the frame that decides what the caller sees.
|
|
384
|
+
*/
|
|
385
|
+
export function retryAfterOf(error: unknown): number | undefined {
|
|
386
|
+
if (typeof error !== 'object' || error === null) return undefined;
|
|
387
|
+
try {
|
|
388
|
+
const meta: unknown = (error as Record<string, unknown>)['meta'];
|
|
389
|
+
if (typeof meta !== 'object' || meta === null) return undefined;
|
|
390
|
+
const seconds: unknown = (meta as Record<string, unknown>)['retryAfterSeconds'];
|
|
391
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
|
|
392
|
+
// At least one second, exactly as `RateLimitDecision.retryAfterSeconds` is clamped: `0` reads
|
|
393
|
+
// as "retry now", which is the stampede a Retry-After exists to spread.
|
|
394
|
+
return Math.max(1, Math.ceil(seconds));
|
|
395
|
+
} catch {
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
|
|
402
|
+
* identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
|
|
403
|
+
* to read about it, and those stopped being the same string when `docs` became one wiki page for
|
|
404
|
+
* every code. Collapsing `type` onto that page too would have given a 422 body-invalid and a 403
|
|
405
|
+
* forbidden the same identifier, which is the one thing a `type` may not do.
|
|
406
|
+
*
|
|
407
|
+
* A URN has no host to resolve, so it cannot rot the way `https://ultimate.dev/errors/<code>` did
|
|
408
|
+
* — it was never dereferenceable and never claimed to be, which RFC 9457 §3.1.1 explicitly allows.
|
|
409
|
+
* `code` carries the same string as a plain member for a reader that would rather not parse a URI.
|
|
410
|
+
*/
|
|
411
|
+
export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`;
|
|
412
|
+
|
|
360
413
|
/** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */
|
|
361
414
|
export interface ProblemDocument {
|
|
362
415
|
readonly type: string;
|
|
@@ -377,7 +430,7 @@ export const toProblem = (
|
|
|
377
430
|
): ProblemDocument => {
|
|
378
431
|
const facts = factsOf(error);
|
|
379
432
|
return {
|
|
380
|
-
type: facts.
|
|
433
|
+
type: problemTypeFor(facts.code),
|
|
381
434
|
title: facts.title,
|
|
382
435
|
status: facts.status,
|
|
383
436
|
detail: facts.cause,
|
package/src/errors.ts
CHANGED
|
@@ -105,7 +105,12 @@ registerErrorRetry({
|
|
|
105
105
|
X_OVERLOADED: 'retry-after',
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
// No `docs:` below. `UltimateError` fills it from `describeErrorCode(code).docs`, which is
|
|
109
|
+
// `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
|
|
110
|
+
// `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE
|
|
111
|
+
// ROW, which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until
|
|
112
|
+
// 9.x answered 404, host included — and this package put them in `type` AND `docs` of every
|
|
113
|
+
// problem document, so the dead link was on every 4xx and 5xx an app has ever served.
|
|
109
114
|
|
|
110
115
|
/** Base class for every error this package throws. Never throw a bare `Error`. */
|
|
111
116
|
export class HttpError extends UltimateError {
|
|
@@ -126,7 +131,6 @@ export class HttpError extends UltimateError {
|
|
|
126
131
|
code: init.code,
|
|
127
132
|
cause: init.cause,
|
|
128
133
|
fix: init.fix,
|
|
129
|
-
docs: docsFor(init.code),
|
|
130
134
|
...(init.meta === undefined ? {} : { meta: init.meta }),
|
|
131
135
|
});
|
|
132
136
|
}
|
package/src/finalize.ts
CHANGED
|
@@ -21,7 +21,10 @@ export type Recover = (request: UltimateRequest, ctx: RequestContext) => Promise
|
|
|
21
21
|
const lastResort = (): Response =>
|
|
22
22
|
new Response(
|
|
23
23
|
JSON.stringify({
|
|
24
|
-
|
|
24
|
+
// Spelled out rather than built: this function calls nothing, which is what "last resort"
|
|
25
|
+
// means. `problemTypeFor('X_INTERNAL')` is the one it must equal, and
|
|
26
|
+
// `pipeline-finalize.test.ts` asserts that equality so the two cannot drift apart in silence.
|
|
27
|
+
type: 'urn:ultimate:error:X_INTERNAL',
|
|
25
28
|
title: 'unhandled server error',
|
|
26
29
|
status: 500,
|
|
27
30
|
detail: 'the error renderer itself failed, so nothing of the original error survives here',
|
package/src/index.ts
CHANGED
package/src/rate-limit.ts
CHANGED
|
@@ -326,9 +326,16 @@ export const createRateLimiter = (options: {
|
|
|
326
326
|
const store = options.store ?? memoryRateLimitStore();
|
|
327
327
|
const clock = options.clock ?? systemClock;
|
|
328
328
|
const now = (): number => clock.now().getTime();
|
|
329
|
+
// `Object.hasOwn`, never `buckets[name]` — the same read `error-map.ts`'s `statusFor` and
|
|
330
|
+
// `naming.ts` already take for a table of this shape. `buckets` is a plain object literal, so it
|
|
331
|
+
// holds every name on `Object.prototype`: `rateLimit: 'constructor'` read `Object` itself out of
|
|
332
|
+
// it, and a `Bucket` whose `capacity` is `undefined` is a limiter that decides nothing. Author-
|
|
333
|
+
// controlled, and still the one form — a table indexed by a name is indexed through `hasOwn`.
|
|
334
|
+
const declared = (name: string): Bucket | undefined =>
|
|
335
|
+
Object.hasOwn(options.config.buckets, name) ? options.config.buckets[name] : undefined;
|
|
329
336
|
const bucketFor = (name: string): Bucket =>
|
|
330
|
-
|
|
331
|
-
options.config.
|
|
337
|
+
declared(name) ??
|
|
338
|
+
declared(options.config.defaultBucket) ??
|
|
332
339
|
DEFAULT_RATE_LIMIT.buckets['default'] ?? { capacity: 60, refillPerSecond: 1 };
|
|
333
340
|
|
|
334
341
|
const check: RateLimiter['check'] = (key, bucketName, cost = 1) =>
|
package/src/stages.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { type HttpConfig, stripBasePath } from './config';
|
|
|
19
19
|
import { actorView, elapsedMs, type RequestContext } from './context';
|
|
20
20
|
import { corsHeaders, preflight } from './cors';
|
|
21
21
|
import { checkCsrf, selfOrigin } from './csrf';
|
|
22
|
-
import { factsOf } from './error-map';
|
|
22
|
+
import { factsOf, retryAfterOf } from './error-map';
|
|
23
23
|
import {
|
|
24
24
|
bodyInvalid,
|
|
25
25
|
csrfBlocked,
|
|
@@ -349,10 +349,22 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
349
349
|
...(notices.length === 0 ? {} : { notices }),
|
|
350
350
|
});
|
|
351
351
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
352
|
+
// The limiter's own decision first — it is the live one and it knows this request's bucket —
|
|
353
|
+
// then whatever the THROWABLE computed. Only the first half existed, so every other refusal
|
|
354
|
+
// that had a delay to give told the caller to come back without saying when:
|
|
355
|
+
// `X_ACCOUNT_LOCKED` is a 429 with no `Retry-After` at all, and `X_OVERLOADED` from
|
|
356
|
+
// `@ultimat3/auth`'s KDF gate carries the number in `meta` under a comment saying the host
|
|
357
|
+
// reads it. Nothing was the host.
|
|
358
|
+
// `> 0` and not merely `!== undefined`: `RateLimitDecision.retryAfterSeconds` is `0` on an
|
|
359
|
+
// ALLOWED request, so a handler raising `X_RATE_LIMITED` from a limiter of its own — an
|
|
360
|
+
// action's declared `rateLimit`, `@ultimat3/auth`'s credential limiter — was answered
|
|
361
|
+
// `retry-after: 0`, which is "retry now" and is the stampede the header exists to spread.
|
|
362
|
+
const decided =
|
|
363
|
+
facts.code === 'X_RATE_LIMITED' && (ctx.rateLimit?.retryAfterSeconds ?? 0) > 0
|
|
364
|
+
? ctx.rateLimit?.retryAfterSeconds
|
|
365
|
+
: undefined;
|
|
366
|
+
const seconds = decided ?? retryAfterOf(error);
|
|
367
|
+
const retryAfter = seconds === undefined ? {} : { 'retry-after': String(seconds) };
|
|
356
368
|
return problem(error, {
|
|
357
369
|
instance: ctx.url.pathname,
|
|
358
370
|
requestId: ctx.requestId,
|