@ultimat3/http 2.0.0 → 3.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 +17 -1
- package/package.json +5 -5
- package/src/error-map.ts +22 -20
- package/src/finalize.ts +8 -3
- package/src/index.ts +0 -1
package/CLAUDE.md
CHANGED
|
@@ -156,7 +156,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
156
156
|
- Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
|
|
157
157
|
table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
|
|
158
158
|
`registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
|
|
159
|
-
every app code was 500 and `pipeline.ts` paged the on-call for a wrong password.
|
|
159
|
+
every app code was 500 and `pipeline.ts` paged the on-call for a wrong password. There is
|
|
160
|
+
deliberately **no projection of the app's half**: `appErrorStatus()` was exported for "`x errors
|
|
161
|
+
list` and the manifest" and neither ever called it (deleted 2026-08). It could not have worked —
|
|
162
|
+
`APP_ERROR_STATUS` is process-global runtime state filled by the app's own imports, while both
|
|
163
|
+
named surfaces are build artefacts derived from source, so in a CLI process it answers `{}`.
|
|
164
|
+
Wiring one means deriving it from source, not re-exporting the map.
|
|
160
165
|
- **The context carries the inbound headers, never the `Request`.** `ctx.requestHeaders` is set
|
|
161
166
|
once at construction; `useRequestHeader` / `useRequestCookie` are what app code reads, and
|
|
162
167
|
`UltimateRequest.cookie()` is what `hooks.authenticate` reads. A `Request` on the context is a
|
|
@@ -187,6 +192,17 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
187
192
|
hit: the stage that renders a throw has nothing left to render its own. Every degraded answer goes
|
|
188
193
|
*through* the recover stage, never around it — reporting, logging and the overlay each keep one
|
|
189
194
|
call site.
|
|
195
|
+
- **Both guards in that tail are TOTAL against a throwable that fights being read** (`As of
|
|
196
|
+
2026-08`). `recoverWith`'s catch built its log line with `String(failure)`, which is itself a
|
|
197
|
+
`TypeError` on a null-prototype object — thrown out of the one guard documented "never throws, by
|
|
198
|
+
construction", from the frame with nothing above it. It is a log FIELD now, the same rule the
|
|
199
|
+
`error-map` stage already follows, and `logger.emit` degrades a hostile field per key. The second
|
|
200
|
+
half is `factsOf`: it read `record['code']` directly, and that read is a getter call or a
|
|
201
|
+
`Proxy`'s `get` trap on a value the framework did not build — so a handler throwing one took the
|
|
202
|
+
recover stage AND the `problem()` the guard degrades to, and `handle()` rejected. Every field
|
|
203
|
+
comes off the throwable through core's `stringField`. Never spell either read inline again:
|
|
204
|
+
`String(x)`, `${x}` and a bare property read on a caught value are all the same defect, and
|
|
205
|
+
`error-render.ts` names seven prior instances.
|
|
190
206
|
- **The memory rate-limit store is bounded, and the eviction order is part of the guarantee.**
|
|
191
207
|
The key falls back to the connection address (`rateLimitKey`), so a scan rotating through an
|
|
192
208
|
IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.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": "3.0.0",
|
|
35
|
+
"@ultimat3/i18n": "3.0.0",
|
|
36
|
+
"@ultimat3/schema": "3.0.0",
|
|
37
|
+
"@ultimat3/time": "3.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 } from '@ultimat3/core';
|
|
4
|
+
import { renderCauseValue, stringField } from '@ultimat3/core';
|
|
5
5
|
import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -31,6 +31,10 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
31
31
|
// and declaring a status the framework already owns. 500 is the honest answer to either.
|
|
32
32
|
X_NO_REQUEST: 500,
|
|
33
33
|
X_ERROR_STATUS_INVALID: 500,
|
|
34
|
+
// A `hive()` whose `split()` returned no members. The caller cannot fix it by sending
|
|
35
|
+
// different input — the guard belongs in the app, either by returning at least one member
|
|
36
|
+
// or by skipping the hive when the source is empty — so it is the server's bug, not theirs.
|
|
37
|
+
X_HIVE_EMPTY: 500,
|
|
34
38
|
// Thrown while `app.config.ts` resolves, so no request is ever answered with it — the row exists
|
|
35
39
|
// because a code with no status is a 500 anyway and this table is the closed one.
|
|
36
40
|
X_CORS_CONFIG_INVALID: 500,
|
|
@@ -228,10 +232,6 @@ export const registerErrorStatus = (statuses: Readonly<Record<string, number>>):
|
|
|
228
232
|
/** Test seam. Production registers once at boot and never unregisters. */
|
|
229
233
|
export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
|
|
230
234
|
|
|
231
|
-
/** Every status the app declared, for `x errors list` and the manifest. */
|
|
232
|
-
export const appErrorStatus = (): Readonly<Record<string, number>> =>
|
|
233
|
-
Object.fromEntries([...APP_ERROR_STATUS].sort(([a], [b]) => a.localeCompare(b)));
|
|
234
|
-
|
|
235
235
|
// Framework table first: `registerErrorStatus` already refuses those codes, so the order is
|
|
236
236
|
// belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
|
|
237
237
|
// even if a future caller reaches the map some other way.
|
|
@@ -250,35 +250,38 @@ export interface ErrorFacts {
|
|
|
250
250
|
readonly stack: string | undefined;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
/**
|
|
254
|
+
* One string field off the throwable, through core's `stringField`. The read is a getter call —
|
|
255
|
+
* or a `Proxy`'s `get` trap — on a value the framework did not build, and it throws in the one
|
|
256
|
+
* place with nothing left to answer with: `factsOf` is called by the RECOVER stage, and again by
|
|
257
|
+
* the `problem()` that `recoverWith` degrades to, so a value that refuses to be read took both
|
|
258
|
+
* renderings and `handle()` rejected against its own contract.
|
|
259
|
+
*/
|
|
260
|
+
const str = (source: unknown, key: string): string | undefined => {
|
|
261
|
+
const value = stringField(source, key);
|
|
262
|
+
return value !== undefined && value.length > 0 ? value : undefined;
|
|
256
263
|
};
|
|
257
264
|
|
|
258
|
-
const asRecord = (value: unknown): Record<string, unknown> =>
|
|
259
|
-
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
|
260
|
-
|
|
261
265
|
/**
|
|
262
266
|
* Normalises any throwable into the framework's error contract. Non-Ultimate
|
|
263
267
|
* throwables still get a code and a fix, because "errors are instructions" has to
|
|
264
268
|
* hold for the accidental `TypeError` too.
|
|
265
269
|
*/
|
|
266
270
|
export const factsOf = (error: unknown): ErrorFacts => {
|
|
267
|
-
const
|
|
268
|
-
const code = str(record, 'code') ?? 'X_INTERNAL';
|
|
271
|
+
const code = str(error, 'code') ?? 'X_INTERNAL';
|
|
269
272
|
// The error's own title first: every `UltimateError` resolves one from the code registry at
|
|
270
273
|
// construction, so this renders the OWNING package's title — including the codes http only
|
|
271
274
|
// borrows (`X_FORBIDDEN` is policy's, `X_UNAUTHENTICATED` is auth's) and so cannot title itself.
|
|
272
275
|
// Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
|
|
273
276
|
const title =
|
|
274
|
-
str(
|
|
277
|
+
str(error, 'title') ??
|
|
275
278
|
HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES] ??
|
|
276
|
-
str(
|
|
279
|
+
str(error, 'message') ??
|
|
277
280
|
'unhandled server error';
|
|
278
281
|
// The last fallback is the only one that touches the throwable whole, and every throwable a
|
|
279
282
|
// request produces reaches it. `String()` runs the value's own `toString`, so the value that
|
|
280
283
|
// took the request down took the 500 renderer with it and the server had nothing left to send.
|
|
281
|
-
const cause = str(
|
|
284
|
+
const cause = str(error, 'cause') ?? str(error, 'message') ?? renderCauseValue(error);
|
|
282
285
|
return {
|
|
283
286
|
code,
|
|
284
287
|
title,
|
|
@@ -286,11 +289,10 @@ export const factsOf = (error: unknown): ErrorFacts => {
|
|
|
286
289
|
// `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
|
|
287
290
|
// command that throws is axiom 4 inverted: the one instruction the reader is given fails.
|
|
288
291
|
// `x errors explain` ships, and it is the command that answers "what is this code".
|
|
289
|
-
fix:
|
|
290
|
-
|
|
291
|
-
docs: str(record, 'docs') ?? `https://ultimate.dev/errors/${code}`,
|
|
292
|
+
fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
|
|
293
|
+
docs: str(error, 'docs') ?? `https://ultimate.dev/errors/${code}`,
|
|
292
294
|
status: statusFor(code),
|
|
293
|
-
stack: str(
|
|
295
|
+
stack: str(error, 'stack'),
|
|
294
296
|
};
|
|
295
297
|
};
|
|
296
298
|
|
package/src/finalize.ts
CHANGED
|
@@ -26,9 +26,14 @@ export const recoverWith =
|
|
|
26
26
|
const rendered = await stage?.run(request, ctx);
|
|
27
27
|
if (rendered !== undefined) return rendered;
|
|
28
28
|
} catch (failure) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
)
|
|
29
|
+
// FIELDS, never interpolation — and this is the file whose one promise is that it cannot
|
|
30
|
+
// itself throw. `String(failure)` runs the value's own coercion, so a null-prototype object
|
|
31
|
+
// (`Cannot convert object to primitive value`) or a `Proxy` threw a SECOND time out of the
|
|
32
|
+
// guard, and `handle`'s "always resolves to a Response" died in the one frame with nothing
|
|
33
|
+
// above it. `logger.emit` degrades a hostile field per key and never rethrows, which is
|
|
34
|
+
// exactly what a value nobody here built needs; it is also the shape `error-map` already
|
|
35
|
+
// uses, so the value stays redactable by key.
|
|
36
|
+
logger.error('pipeline.recover_failed', { requestId: ctx.requestId, error: failure });
|
|
32
37
|
}
|
|
33
38
|
return problem(ctx.error, { instance: ctx.url.pathname, requestId: ctx.requestId });
|
|
34
39
|
};
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,6 @@ export type { Deadline } from './deadline';
|
|
|
25
25
|
export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
|
|
26
26
|
export type { ErrorFacts, ProblemDocument } from './error-map';
|
|
27
27
|
export {
|
|
28
|
-
appErrorStatus,
|
|
29
28
|
DEFAULT_STATUS,
|
|
30
29
|
ERROR_STATUS,
|
|
31
30
|
factsOf,
|