@ultimat3/http
Advanced tools
+11
-0
@@ -190,2 +190,13 @@ # @ultimat3/http | ||
| view and keeps `Object.hasOwn`. | ||
| - **A problem document's `type` and its `docs` are two different questions, `As of 2026-08-23`.** | ||
| Both used to be `https://ultimate.dev/errors/<code>` — one string, twice, and a host that | ||
| answers **404** on every 4xx and 5xx this package has ever rendered. `docs` is now core's | ||
| `ERROR_DOCS_URL`, one wiki page for every code, and it is never spelled here: a construction site | ||
| omits `docs:` and `UltimateError` resolves it. `type` did NOT follow it there. It is RFC 9457's | ||
| primary identifier for the problem KIND — a client switches on it — so collapsing it onto one | ||
| page would have given a 422 and a 403 the same identifier. `problemTypeFor(code)` answers | ||
| `urn:ultimate:error:<CODE>`: per code, stable, and a URN has no host to rot. `finalize.ts`'s | ||
| `lastResort` spells its one `type` as a literal because that function calls nothing, and | ||
| `pipeline-finalize.test.ts` pins the literal against `problemTypeFor('X_INTERNAL')` so the two | ||
| cannot drift. Never assert either value as a copied string — import the constant. | ||
| - Statuses live in `error-map.ts` only. No other file writes a status number. The framework's | ||
@@ -192,0 +203,0 @@ table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with |
+5
-5
| { | ||
| "name": "@ultimat3/http", | ||
| "version": "9.0.0", | ||
| "version": "10.0.0", | ||
| "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors", | ||
@@ -34,7 +34,7 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "9.0.0", | ||
| "@ultimat3/i18n": "9.0.0", | ||
| "@ultimat3/schema": "9.0.0", | ||
| "@ultimat3/time": "9.0.0" | ||
| "@ultimat3/core": "10.0.0", | ||
| "@ultimat3/i18n": "10.0.0", | ||
| "@ultimat3/schema": "10.0.0", | ||
| "@ultimat3/time": "10.0.0" | ||
| } | ||
| } |
+7
-0
@@ -208,2 +208,9 @@ # @ultimat3/http 🌐 | ||
| A problem document's `type` and `docs` answer different questions and are two different | ||
| values. `type` is `problemTypeFor(code)` — `urn:ultimate:error:X_BODY_INVALID`, the RFC-9457 | ||
| identifier a client switches on, per code, with no host to resolve or rot. `docs` is | ||
| `@ultimat3/core`'s `ERROR_DOCS_URL`, one wiki page for every code, because a code lives there | ||
| in a table row and a table row has no anchor. Assert against `problemTypeFor` and | ||
| `ERROR_DOCS_URL`, never against a copy of either string. | ||
| ## Boundaries | ||
@@ -210,0 +217,0 @@ |
+56
-3
| // The one place a framework error code becomes an HTTP status. A table, not a | ||
| // switch chain: adding a code elsewhere in the framework means adding a row here, | ||
| // and a missing row is a loud 500 rather than a silently wrong 200. | ||
| import { renderCauseValue, singleLine, stringField } from '@ultimat3/core'; | ||
| import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core'; | ||
| import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors'; | ||
@@ -158,2 +158,9 @@ | ||
| X_PERMISSION_UNKNOWN: 500, | ||
| // 500, and the page IS the point — deliberately not a 4xx to keep this table quiet. | ||
| // `enforce()` was handed a surface no adapter answers to, which reaches a request only through a | ||
| // config-driven route table, a surface name off the wire or a JS host; none of those is a value | ||
| // the caller can correct, and a 400 would tell them to fix a request that is not the problem. | ||
| // It is the third authz-dispatch fault beside the two rows above and takes their status for the | ||
| // same reason: the declaration is wrong, not the call. | ||
| X_POLICY_SURFACE_UNKNOWN: 500, | ||
| // @ultimat3/query — the read declares no id, so no cursor can name a position in it. The one | ||
@@ -355,3 +362,8 @@ // paging failure that is NOT the caller's: the fix is an edit to the read's own select, nothing | ||
| fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`, | ||
| docs: str(error, 'docs') ?? `https://ultimate.dev/errors/${code}`, | ||
| // Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface | ||
| // and a code lives there in a table row, which has no anchor. An `UltimateError` already | ||
| // resolved this at construction, so the fallback only fires for a throwable the framework did | ||
| // not build — and it must not be the `https://ultimate.dev/errors/<code>` link that answered | ||
| // 404 on every problem document this package has ever rendered. | ||
| docs: str(error, 'docs') ?? ERROR_DOCS_URL, | ||
| status: statusFor(code), | ||
@@ -362,2 +374,43 @@ stack: str(error, 'stack'), | ||
| /** | ||
| * `Retry-After`, in whole seconds, for a refusal that computed one — or `undefined`. | ||
| * | ||
| * The contract it reads is already written down by the packages BELOW this one: `@ultimat3/auth`'s | ||
| * `kdfOverloaded` says "`retryAfterSeconds` rides in `meta` because this package cannot reach an | ||
| * HTTP header; the host reads it onto `Retry-After`", and `rateLimited` in this package carries the | ||
| * same field. Nothing was the host. So a 503 shed by the KDF gate and a 429 from an account lockout | ||
| * both told the caller to come back and never said when — which is the shed-with-no-delay pattern | ||
| * the `admit` stage exists to avoid, one layer in. | ||
| * | ||
| * Total, for `str`'s reason one function up: `meta` is a property read on a value this package did | ||
| * not build, and it is read in the frame that decides what the caller sees. | ||
| */ | ||
| export function retryAfterOf(error: unknown): number | undefined { | ||
| if (typeof error !== 'object' || error === null) return undefined; | ||
| try { | ||
| const meta: unknown = (error as Record<string, unknown>)['meta']; | ||
| if (typeof meta !== 'object' || meta === null) return undefined; | ||
| const seconds: unknown = (meta as Record<string, unknown>)['retryAfterSeconds']; | ||
| if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined; | ||
| // At least one second, exactly as `RateLimitDecision.retryAfterSeconds` is clamped: `0` reads | ||
| // as "retry now", which is the stampede a Retry-After exists to spread. | ||
| return Math.max(1, Math.ceil(seconds)); | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
| /** | ||
| * RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY | ||
| * identifier for the problem KIND — a client switches on it — while `docs` is where a human goes | ||
| * to read about it, and those stopped being the same string when `docs` became one wiki page for | ||
| * every code. Collapsing `type` onto that page too would have given a 422 body-invalid and a 403 | ||
| * forbidden the same identifier, which is the one thing a `type` may not do. | ||
| * | ||
| * A URN has no host to resolve, so it cannot rot the way `https://ultimate.dev/errors/<code>` did | ||
| * — it was never dereferenceable and never claimed to be, which RFC 9457 §3.1.1 explicitly allows. | ||
| * `code` carries the same string as a plain member for a reader that would rather not parse a URI. | ||
| */ | ||
| export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`; | ||
| /** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */ | ||
@@ -383,3 +436,3 @@ export interface ProblemDocument { | ||
| return { | ||
| type: facts.docs, | ||
| type: problemTypeFor(facts.code), | ||
| title: facts.title, | ||
@@ -386,0 +439,0 @@ status: facts.status, |
+6
-2
@@ -108,3 +108,8 @@ // The HTTP layer's stable error codes. Every throw in this package goes through a | ||
| const docsFor = (code: HttpErrorCode): string => `https://ultimate.dev/errors/${code}`; | ||
| // No `docs:` below. `UltimateError` fills it from `describeErrorCode(code).docs`, which is | ||
| // `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because | ||
| // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE | ||
| // ROW, which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until | ||
| // 9.x answered 404, host included — and this package put them in `type` AND `docs` of every | ||
| // problem document, so the dead link was on every 4xx and 5xx an app has ever served. | ||
@@ -130,3 +135,2 @@ /** Base class for every error this package throws. Never throw a bare `Error`. */ | ||
| fix: init.fix, | ||
| docs: docsFor(init.code), | ||
| ...(init.meta === undefined ? {} : { meta: init.meta }), | ||
@@ -133,0 +137,0 @@ }); |
+4
-1
@@ -24,3 +24,6 @@ // The tail of the lifecycle, guarded. `Pipeline.handle` promises a Response to every caller, and | ||
| JSON.stringify({ | ||
| type: 'https://ultimate.dev/errors/X_INTERNAL', | ||
| // Spelled out rather than built: this function calls nothing, which is what "last resort" | ||
| // means. `problemTypeFor('X_INTERNAL')` is the one it must equal, and | ||
| // `pipeline-finalize.test.ts` asserts that equality so the two cannot drift apart in silence. | ||
| type: 'urn:ultimate:error:X_INTERNAL', | ||
| title: 'unhandled server error', | ||
@@ -27,0 +30,0 @@ status: 500, |
+2
-0
@@ -32,5 +32,7 @@ // The public surface of @ultimat3/http. Explicit, never `export *`: what is not | ||
| factsOf, | ||
| problemTypeFor, | ||
| registerErrorStatus, | ||
| renderErrorLines, | ||
| resetErrorStatus, | ||
| retryAfterOf, | ||
| statusFor, | ||
@@ -37,0 +39,0 @@ toProblem, |
@@ -329,5 +329,12 @@ // Token-bucket rate limiting. The store is an interface so the same limiter runs in-memory in | ||
| const now = (): number => clock.now().getTime(); | ||
| // `Object.hasOwn`, never `buckets[name]` — the same read `error-map.ts`'s `statusFor` and | ||
| // `naming.ts` already take for a table of this shape. `buckets` is a plain object literal, so it | ||
| // holds every name on `Object.prototype`: `rateLimit: 'constructor'` read `Object` itself out of | ||
| // it, and a `Bucket` whose `capacity` is `undefined` is a limiter that decides nothing. Author- | ||
| // controlled, and still the one form — a table indexed by a name is indexed through `hasOwn`. | ||
| const declared = (name: string): Bucket | undefined => | ||
| Object.hasOwn(options.config.buckets, name) ? options.config.buckets[name] : undefined; | ||
| const bucketFor = (name: string): Bucket => | ||
| options.config.buckets[name] ?? | ||
| options.config.buckets[options.config.defaultBucket] ?? | ||
| declared(name) ?? | ||
| declared(options.config.defaultBucket) ?? | ||
| DEFAULT_RATE_LIMIT.buckets['default'] ?? { capacity: 60, refillPerSecond: 1 }; | ||
@@ -334,0 +341,0 @@ |
+17
-5
@@ -22,3 +22,3 @@ // One function per stage name: what each stage of the lifecycle DOES. The package's three-way | ||
| import { checkCsrf, selfOrigin } from './csrf'; | ||
| import { factsOf } from './error-map'; | ||
| import { factsOf, retryAfterOf } from './error-map'; | ||
| import { | ||
@@ -353,6 +353,18 @@ bodyInvalid, | ||
| } | ||
| const retryAfter = | ||
| facts.code === 'X_RATE_LIMITED' && ctx.rateLimit !== undefined | ||
| ? { 'retry-after': String(ctx.rateLimit.retryAfterSeconds) } | ||
| : {}; | ||
| // The limiter's own decision first — it is the live one and it knows this request's bucket — | ||
| // then whatever the THROWABLE computed. Only the first half existed, so every other refusal | ||
| // that had a delay to give told the caller to come back without saying when: | ||
| // `X_ACCOUNT_LOCKED` is a 429 with no `Retry-After` at all, and `X_OVERLOADED` from | ||
| // `@ultimat3/auth`'s KDF gate carries the number in `meta` under a comment saying the host | ||
| // reads it. Nothing was the host. | ||
| // `> 0` and not merely `!== undefined`: `RateLimitDecision.retryAfterSeconds` is `0` on an | ||
| // ALLOWED request, so a handler raising `X_RATE_LIMITED` from a limiter of its own — an | ||
| // action's declared `rateLimit`, `@ultimat3/auth`'s credential limiter — was answered | ||
| // `retry-after: 0`, which is "retry now" and is the stampede the header exists to spread. | ||
| const decided = | ||
| facts.code === 'X_RATE_LIMITED' && (ctx.rateLimit?.retryAfterSeconds ?? 0) > 0 | ||
| ? ctx.rateLimit?.retryAfterSeconds | ||
| : undefined; | ||
| const seconds = decided ?? retryAfterOf(error); | ||
| const retryAfter = seconds === undefined ? {} : { 'retry-after': String(seconds) }; | ||
| return problem(error, { | ||
@@ -359,0 +371,0 @@ instance: ctx.url.pathname, |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
287032
2.67%4816
1.67%221
3.27%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated