@ultimat3/http 19.1.2 → 19.2.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 +16 -1
- package/README.md +24 -0
- package/package.json +5 -5
- package/src/error-facts.ts +29 -0
- package/src/error-map.ts +9 -0
- package/src/errors.ts +19 -1
- package/src/index.ts +2 -0
- package/src/problem-meta.ts +197 -0
- package/src/stages.ts +5 -0
package/CLAUDE.md
CHANGED
|
@@ -250,9 +250,24 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
250
250
|
both needed. The caller-facing `issues` are a fixed vocabulary — `could not parse the body as
|
|
251
251
|
JSON`, and the LIST of accepted content-types rather than the one that was sent — and everything
|
|
252
252
|
the caller supplied rides in `bodyInvalid`'s third argument, `meta`, which `toProblem` never
|
|
253
|
-
renders. The parser's own message goes through core's `renderThrowable`, never `String(error)`:
|
|
253
|
+
renders for a framework code (`registerProblemMeta` refuses to declare one). The parser's own message goes through core's `renderThrowable`, never `String(error)`:
|
|
254
254
|
`bun run error-render` cannot see this class of defect, because a `catch` binding is not a
|
|
255
255
|
parameter, so it is a review rule here and a blind spot there.
|
|
256
|
+
- **`meta` reaches the document only by declaration — per code, per key, app codes only**
|
|
257
|
+
(`As of 2026-09-05`). `meta` is the operator-only bag: `bodyInvalid` keeps the body excerpt
|
|
258
|
+
there, the limiter its internal key, core's `assert` the rejected value, `env-example.ts` file
|
|
259
|
+
paths, and each of those relies on `toProblem` never rendering it. So "carry `meta`" was never
|
|
260
|
+
an option, and an app whose `X_SESSION_CHECKOUT_BUSY` put `{ sessionId, title, state }` there
|
|
261
|
+
had its island recover the id by running a UUID regex over `cause`. `registerProblemMeta({
|
|
262
|
+
X_SESSION_CHECKOUT_BUSY: ['sessionId', 'title', 'state'] })` (`problem-meta.ts`) is the seam,
|
|
263
|
+
in `registerErrorStatus`'s shape and refusing what it refuses — a framework-owned code —
|
|
264
|
+
plus `issues` (one home, one bound) and `__proto__`. `wireMeta` copies the declared keys that
|
|
265
|
+
are set, member by member through `Object.defineProperty`, all-or-nothing for `issuesOf`'s
|
|
266
|
+
reason and bounded at `MAX_PROBLEM_META_BYTES`; `toProblem` drops it under exactly the
|
|
267
|
+
`opaque` condition that blanks `cause`, which is also the belt on "both registrations are
|
|
268
|
+
needed": a code with keys and no status is unclassified. The typed client
|
|
269
|
+
(`@ultimat3/action`'s `metaFromWire`) puts the member back on `RemoteActionError.meta`, under
|
|
270
|
+
the four members that class owns.
|
|
256
271
|
- **A browser that fails `auth: 'required'` is redirected; an agent gets the problem document.**
|
|
257
272
|
One condition, two audiences, decided once in `auth-redirect.ts` and applied in the `error-map`
|
|
258
273
|
stage before the overlay. `config.signInPath` is `null` until an app names its page, because a
|
package/README.md
CHANGED
|
@@ -342,6 +342,30 @@ identifier a client switches on, per code, with no host to resolve or rot. `docs
|
|
|
342
342
|
in a table row and a table row has no anchor. Assert against `problemTypeFor` and
|
|
343
343
|
`ERROR_DOCS_URL`, never against a copy of either string.
|
|
344
344
|
|
|
345
|
+
An error's `meta` is **operator-only by default** and the document never carries it: that bag is
|
|
346
|
+
where `bodyInvalid` keeps the excerpt the parser choked on, where the limiter keeps the internal
|
|
347
|
+
key it promoted an anonymous caller to, and where core's `assert` keeps the rejected value. An app
|
|
348
|
+
that wants a key on the wire declares it, per code, beside the status — and both declarations
|
|
349
|
+
are needed, because a code with no status is an unclassified 5xx whose `meta` is blanked with
|
|
350
|
+
its `cause`:
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
import { registerErrorStatus, registerProblemMeta } from '@ultimat3/http';
|
|
354
|
+
|
|
355
|
+
registerErrorStatus({ X_SESSION_CHECKOUT_BUSY: 409 });
|
|
356
|
+
registerProblemMeta({ X_SESSION_CHECKOUT_BUSY: ['sessionId', 'title', 'state'] });
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
The document then carries `meta: { sessionId, title, state }` — the declared keys that are set,
|
|
360
|
+
copied member by member (`__proto__` skipped at every depth), and nothing else off the error. It
|
|
361
|
+
is all-or-nothing, as `issues` is: one value JSON cannot carry (a `Date`, a `bigint`, a class
|
|
362
|
+
instance, a cycle) or a serialisation past `MAX_PROBLEM_META_BYTES` (4096) drops the whole member,
|
|
363
|
+
because a document missing one declared key is a claim the server never made. Absent when no
|
|
364
|
+
declared key is set — never `{}`. A framework-owned code is refused (`X_PROBLEM_META_INVALID`),
|
|
365
|
+
as is `issues`, which has its own top-level home. `@ultimat3/action`'s typed client puts the
|
|
366
|
+
member back on the rebuilt error's `meta`, so an island reads `error.meta.sessionId` where it
|
|
367
|
+
used to run a regex over `cause`.
|
|
368
|
+
|
|
345
369
|
## Boundaries
|
|
346
370
|
|
|
347
371
|
Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n` and `@ultimat3/time` —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "19.
|
|
3
|
+
"version": "19.2.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": "19.
|
|
35
|
-
"@ultimat3/i18n": "19.
|
|
36
|
-
"@ultimat3/schema": "19.
|
|
37
|
-
"@ultimat3/time": "19.
|
|
34
|
+
"@ultimat3/core": "19.2.0",
|
|
35
|
+
"@ultimat3/i18n": "19.2.0",
|
|
36
|
+
"@ultimat3/schema": "19.2.0",
|
|
37
|
+
"@ultimat3/time": "19.2.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/error-facts.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ulti
|
|
|
6
6
|
import type { ValidationIssue } from '@ultimat3/schema';
|
|
7
7
|
import { declaredStatusFor, statusFor } from './error-map';
|
|
8
8
|
import { HTTP_ERROR_TITLES } from './errors';
|
|
9
|
+
import { type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
|
|
9
10
|
|
|
10
11
|
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
|
|
11
12
|
export interface ErrorFacts {
|
|
@@ -179,6 +180,22 @@ function issuesOf(error: unknown): readonly ValidationIssue[] | undefined {
|
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The declared `meta` keys of a throwable, carried, or `undefined`. The declaration is read by
|
|
185
|
+
* the CODE — never by the class, which the framework cannot see across a bundle — and the walk
|
|
186
|
+
* itself is `wireMeta`'s. Total, for `retryAfterOf`'s reason: `meta` is a property read on a
|
|
187
|
+
* value this package did not build, in the frame that decides what the caller sees.
|
|
188
|
+
*/
|
|
189
|
+
function metaOf(error: unknown, code: string): ProblemMeta | undefined {
|
|
190
|
+
const keys = problemMetaKeysFor(code);
|
|
191
|
+
if (keys === undefined || typeof error !== 'object' || error === null) return undefined;
|
|
192
|
+
try {
|
|
193
|
+
return wireMeta((error as Record<string, unknown>)['meta'], keys);
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
182
199
|
/**
|
|
183
200
|
* RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
|
|
184
201
|
* identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
|
|
@@ -214,6 +231,14 @@ export interface ProblemDocument {
|
|
|
214
231
|
* `[]` says "validated clean", which is a different and false claim.
|
|
215
232
|
*/
|
|
216
233
|
readonly issues?: readonly ValidationIssue[] | undefined;
|
|
234
|
+
/**
|
|
235
|
+
* The keys of the error's `meta` that `registerProblemMeta` declared for its code, JSON-safe
|
|
236
|
+
* and bounded — and nothing else off `meta`, which is the operator-only bag the rest of this
|
|
237
|
+
* package keeps OUT of the document (`HttpError`'s doc comment says why). ABSENT when the code
|
|
238
|
+
* declares none, when none of the declared keys is set, and when one of them cannot be carried
|
|
239
|
+
* — all-or-nothing, like `issues`, and for its reason.
|
|
240
|
+
*/
|
|
241
|
+
readonly meta?: ProblemMeta | undefined;
|
|
217
242
|
}
|
|
218
243
|
|
|
219
244
|
/** The title a caller gets for a failure the framework cannot name. */
|
|
@@ -257,6 +282,9 @@ export const toProblem = (
|
|
|
257
282
|
// withhold — it names the fields and the expectations of something the caller was never meant to
|
|
258
283
|
// see the inside of. `X_INPUT_INVALID` is a declared 4xx, so it is never opaque.
|
|
259
284
|
const issues = opaque ? undefined : issuesOf(error);
|
|
285
|
+
// The same condition, for the same reason: a code nobody classified cannot have declared any
|
|
286
|
+
// key either, so this is the belt on `registerProblemMeta`'s "both registrations are needed".
|
|
287
|
+
const carried = opaque ? undefined : metaOf(error, facts.code);
|
|
260
288
|
return {
|
|
261
289
|
type: problemTypeFor(facts.code),
|
|
262
290
|
title: opaque ? INTERNAL_TITLE : facts.title,
|
|
@@ -269,6 +297,7 @@ export const toProblem = (
|
|
|
269
297
|
docs: facts.docs,
|
|
270
298
|
requestId: meta.requestId,
|
|
271
299
|
...(issues === undefined ? {} : { issues }),
|
|
300
|
+
...(carried === undefined ? {} : { meta: carried }),
|
|
272
301
|
};
|
|
273
302
|
};
|
|
274
303
|
|
package/src/error-map.ts
CHANGED
|
@@ -31,6 +31,7 @@ export const ERROR_STATUS = {
|
|
|
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
|
+
X_PROBLEM_META_INVALID: 500,
|
|
34
35
|
// A `hive()` whose `split()` returned no members. The caller cannot fix it by sending
|
|
35
36
|
// different input — the guard belongs in the app, either by returning at least one member
|
|
36
37
|
// or by skipping the hive when the source is empty — so it is the server's bug, not theirs.
|
|
@@ -338,6 +339,14 @@ export const ERROR_STATUS = {
|
|
|
338
339
|
// runtime and makes that answer a reviewed one instead of an accident, which is the whole reason
|
|
339
340
|
// this table is closed.
|
|
340
341
|
X_UI_FORM_PATH_INVALID: 500,
|
|
342
|
+
// @ultimat3/render — an island handed props it cannot carry: an undeclared key, a value that is
|
|
343
|
+
// not JSON, or a bag over `ISLAND_PROPS_MAX_BYTES`. The author's fault and never the caller's,
|
|
344
|
+
// so 500 is the honest class — and it HAS to be a declared 500. Without a row the code was an
|
|
345
|
+
// unclassified failure, and `toProblem` blanks the cause of one of those outside dev
|
|
346
|
+
// (`isUnclassifiedFailure`): a 34-row catalog over the cap took a page down with a problem
|
|
347
|
+
// document that said "the details are in this process's logs" about an error whose whole
|
|
348
|
+
// value is the sentence naming the prop and its bytes. Measured on ai-maxxing, 2026-09-05.
|
|
349
|
+
X_ISLAND_PROPS_INVALID: 500,
|
|
341
350
|
// @ultimat3/mail
|
|
342
351
|
// The deployment configured no transport. It reaches a caller only through an inline
|
|
343
352
|
// `send(…, { sync: true })` inside a request; the queued path dead-letters instead. A server-side
|
package/src/errors.ts
CHANGED
|
@@ -22,6 +22,7 @@ export const HTTP_OWNED_ERROR_CODES = [
|
|
|
22
22
|
'X_PIPELINE_FINALIZE_FAILED',
|
|
23
23
|
'X_NO_REQUEST',
|
|
24
24
|
'X_ERROR_STATUS_INVALID',
|
|
25
|
+
'X_PROBLEM_META_INVALID',
|
|
25
26
|
'X_CORS_CONFIG_INVALID',
|
|
26
27
|
'X_CSP_DIRECTIVE_INVALID',
|
|
27
28
|
'X_RATE_LIMIT_NOT_SHARED',
|
|
@@ -84,6 +85,7 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
|
|
|
84
85
|
X_PIPELINE_FINALIZE_FAILED: 'a finalize stage threw instead of finishing the response',
|
|
85
86
|
X_NO_REQUEST: 'the inbound request is not in scope here',
|
|
86
87
|
X_ERROR_STATUS_INVALID: 'an error code cannot be mapped to that status',
|
|
88
|
+
X_PROBLEM_META_INVALID: 'a problem document cannot carry that meta declaration',
|
|
87
89
|
X_CORS_CONFIG_INVALID: 'the cors config can never produce a working response',
|
|
88
90
|
X_CSP_DIRECTIVE_INVALID: 'a csp extension would emit something other than the directive it names',
|
|
89
91
|
X_RATE_LIMIT_NOT_SHARED: 'the rate limit is declared fleet-wide and the store is per-process',
|
|
@@ -240,7 +242,11 @@ export const buildSkew = (clientBuildId: string, serverBuildId: string): HttpErr
|
|
|
240
242
|
new HttpError({
|
|
241
243
|
code: 'X_BUILD_SKEW',
|
|
242
244
|
cause: `client sent build ${clientBuildId}, server is running ${serverBuildId}`,
|
|
243
|
-
|
|
245
|
+
// Not "reload": a reload re-enters the same service worker, which stamps the same stale
|
|
246
|
+
// id and earns this same refusal. The worker heals itself on this response (it needs the
|
|
247
|
+
// `x-ultimate-build` header the `context` stage stamps beside this throw); a client with
|
|
248
|
+
// no worker only ever sees this once, on the request that carried a stale id by hand.
|
|
249
|
+
fix: 'the service worker recovers on this response; a stale tab that cannot, clears its registration',
|
|
244
250
|
});
|
|
245
251
|
|
|
246
252
|
export const serverNotStarted = (member: string): HttpError =>
|
|
@@ -292,6 +298,18 @@ export const errorStatusInvalid = (code: string, reason: string): HttpError =>
|
|
|
292
298
|
fix: `x errors list --json # then registerErrorStatus({ ${code}: 422 }) with a status the framework does not already own`,
|
|
293
299
|
});
|
|
294
300
|
|
|
301
|
+
/**
|
|
302
|
+
* At boot, beside `errorStatusInvalid` and for the same class of mistake: a declaration the
|
|
303
|
+
* document could never honour — a framework-owned code, whose `meta` is operator-only; a key that
|
|
304
|
+
* is not one (`issues` rides at the top level already); or a second, different list for one code.
|
|
305
|
+
*/
|
|
306
|
+
export const problemMetaInvalid = (code: string, reason: string): HttpError =>
|
|
307
|
+
new HttpError({
|
|
308
|
+
code: 'X_PROBLEM_META_INVALID',
|
|
309
|
+
cause: `${code} cannot carry that meta: ${reason}`,
|
|
310
|
+
fix: `registerProblemMeta({ ${code}: ['<key>'] }) with the keys this app's error puts in meta, beside its registerErrorStatus() call`,
|
|
311
|
+
});
|
|
312
|
+
|
|
295
313
|
/**
|
|
296
314
|
* At `defineHttpConfig`, never on the request. A CORS pair a browser can never accept resolves to
|
|
297
315
|
* "emit no CORS headers at all", which is unreadable from the console: every cross-origin call
|
package/src/index.ts
CHANGED
|
@@ -115,6 +115,8 @@ export type { PeerIdentity } from './peer-identity';
|
|
|
115
115
|
export { peerIdentity } from './peer-identity';
|
|
116
116
|
export type { HandleInit, Pipeline, PipelineDeps } from './pipeline';
|
|
117
117
|
export { createPipeline, PIPELINE_STAGES } from './pipeline';
|
|
118
|
+
export type { ProblemMeta, ProblemMetaValue } from './problem-meta';
|
|
119
|
+
export { MAX_PROBLEM_META_BYTES, registerProblemMeta, resetProblemMeta } from './problem-meta';
|
|
118
120
|
export type {
|
|
119
121
|
Bucket,
|
|
120
122
|
MemoryRateLimitStore,
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Which of an error's `meta` keys a problem document may carry, per code — and the copy of them
|
|
2
|
+
// that leaves the process. Split from `error-map.ts` (457 lines against the 500-line ceiling) and
|
|
3
|
+
// kept beside it in shape: `registerErrorStatus` is the app's declaration of the HTTP half of a
|
|
4
|
+
// code, and this is the second half of the same declaration, written in the same file of the app.
|
|
5
|
+
//
|
|
6
|
+
// OPT-IN, PER KEY, BY CODE — never "carry `meta`". `meta` is the framework's OPERATOR-ONLY bag
|
|
7
|
+
// and four packages depend on it never reaching a caller: `bodyInvalid` puts the fragment of the
|
|
8
|
+
// body the parser choked on there (a `{"password": …}` excerpt, once), the rate limiter puts the
|
|
9
|
+
// internal key it promoted an anonymous caller to, core's `assert` puts the rejected VALUE, and
|
|
10
|
+
// `env-example.ts` puts file paths. A blanket carry would have shipped every one of them to the
|
|
11
|
+
// network tab. So an app names the keys, for the codes it owns, and everything else stays where
|
|
12
|
+
// it was. Measured need: an app's `X_SESSION_CHECKOUT_BUSY` carried `{ sessionId, title, state }`
|
|
13
|
+
// in `meta` and its island recovered the id by running a UUID regex over `cause`.
|
|
14
|
+
|
|
15
|
+
import { ERROR_STATUS } from './error-map';
|
|
16
|
+
import { problemMetaInvalid } from './errors';
|
|
17
|
+
|
|
18
|
+
/** What a problem document's `meta` member holds — JSON, and nothing JSON cannot carry. */
|
|
19
|
+
export type ProblemMetaValue =
|
|
20
|
+
| string
|
|
21
|
+
| number
|
|
22
|
+
| boolean
|
|
23
|
+
| null
|
|
24
|
+
| readonly ProblemMetaValue[]
|
|
25
|
+
| { readonly [key: string]: ProblemMetaValue };
|
|
26
|
+
|
|
27
|
+
export type ProblemMeta = Readonly<Record<string, ProblemMetaValue>>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The serialised ceiling, in bytes, of what a document carries under `meta`. A problem body is a
|
|
31
|
+
* refusal, not a payload: `MAX_PROBLEM_ISSUES` bounds the other extension for the same reason.
|
|
32
|
+
* Over the cap the member is dropped WHOLE, never cut — a subset of keys is a claim the server
|
|
33
|
+
* never made, and a client reading `meta.sessionId` off a document that dropped `sessionId` to
|
|
34
|
+
* make room has no way to tell "absent" from "cut".
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_PROBLEM_META_BYTES = 4096;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A key is spelled the way a code's `meta` key is spelled in source. `issues` is refused by name
|
|
40
|
+
* because it already has a top-level home (`ProblemDocument.issues`, parsed and bounded on its
|
|
41
|
+
* own terms), and a second copy under `meta.issues` is two places for a client to read one fact.
|
|
42
|
+
* `__proto__` is refused because `JSON.parse` on the receiving side mints it as a real own key.
|
|
43
|
+
*/
|
|
44
|
+
const KEY = /^[A-Za-z_$][\w$]*$/;
|
|
45
|
+
const RESERVED_KEYS: ReadonlySet<string> = new Set(['issues', '__proto__']);
|
|
46
|
+
|
|
47
|
+
/** Per app-owned code, the `meta` keys its documents carry. A `Map`, for `APP_ERROR_STATUS`'s reason. */
|
|
48
|
+
const DECLARED = new Map<string, readonly string[]>();
|
|
49
|
+
|
|
50
|
+
const sameKeys = (left: readonly string[], right: readonly string[]): boolean =>
|
|
51
|
+
left.length === right.length && left.every((key, at) => key === right[at]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Declare, per code, which `meta` keys the problem document carries. Call it once at boot, in
|
|
55
|
+
* the module that declares the codes — beside `registerErrorStatus`, and BOTH are needed: a code
|
|
56
|
+
* with no declared status is an unclassified 5xx, and `toProblem` blanks everything but the code
|
|
57
|
+
* and the request id on one of those, `meta` included.
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* registerErrorStatus({ X_SESSION_CHECKOUT_BUSY: 409 });
|
|
61
|
+
* registerProblemMeta({ X_SESSION_CHECKOUT_BUSY: ['sessionId', 'title', 'state'] });
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* Framework-owned codes are refused, exactly as `registerErrorStatus` refuses them: their `meta`
|
|
65
|
+
* is where the framework keeps what a caller must not be handed, and an app that could declare
|
|
66
|
+
* `X_RATE_LIMITED: ['key']` would publish the limiter's internal key on every 429.
|
|
67
|
+
*/
|
|
68
|
+
export const registerProblemMeta = (
|
|
69
|
+
declarations: Readonly<Record<string, readonly string[]>>,
|
|
70
|
+
): void => {
|
|
71
|
+
for (const [code, keys] of Object.entries(declarations)) {
|
|
72
|
+
if (frameworkOwns(code)) {
|
|
73
|
+
throw problemMetaInvalid(code, 'the framework owns that code, and its meta is operator-only');
|
|
74
|
+
}
|
|
75
|
+
if (keys.length === 0) {
|
|
76
|
+
throw problemMetaInvalid(code, 'the key list is empty — omit the code instead');
|
|
77
|
+
}
|
|
78
|
+
for (const key of keys) {
|
|
79
|
+
if (typeof key !== 'string' || !KEY.test(key)) {
|
|
80
|
+
throw problemMetaInvalid(code, `${JSON.stringify(key)} is not a meta key`);
|
|
81
|
+
}
|
|
82
|
+
if (RESERVED_KEYS.has(key)) {
|
|
83
|
+
throw problemMetaInvalid(
|
|
84
|
+
code,
|
|
85
|
+
key === 'issues'
|
|
86
|
+
? '`issues` already rides at the top level of the document'
|
|
87
|
+
: `\`${key}\` is not a key a document may carry`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const existing = DECLARED.get(code);
|
|
92
|
+
if (existing !== undefined && !sameKeys(existing, keys)) {
|
|
93
|
+
throw problemMetaInvalid(code, `already declared as [${existing.join(', ')}] by this app`);
|
|
94
|
+
}
|
|
95
|
+
DECLARED.set(code, [...keys]);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Test seam. Production registers once at boot and never unregisters. */
|
|
100
|
+
export const resetProblemMeta = (): void => DECLARED.clear();
|
|
101
|
+
|
|
102
|
+
/** The keys declared for a code, or `undefined` when nothing was — which is every framework code. */
|
|
103
|
+
export const problemMetaKeysFor = (code: string): readonly string[] | undefined =>
|
|
104
|
+
DECLARED.get(code);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A code this package's table maps is the framework's. A `Set` of the table's own keys, for the
|
|
108
|
+
* reason `frameworkStatus` reads that table through `Object.hasOwn`: it is an object literal,
|
|
109
|
+
* and `registerProblemMeta({ toString: [...] })` must not find a function in it.
|
|
110
|
+
*/
|
|
111
|
+
const FRAMEWORK_CODES: ReadonlySet<string> = new Set(Object.keys(ERROR_STATUS));
|
|
112
|
+
const frameworkOwns = (code: string): boolean => FRAMEWORK_CODES.has(code);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The declared keys of `meta`, copied member by member into a fresh document, or `undefined`.
|
|
116
|
+
*
|
|
117
|
+
* TOTAL — `meta` is a property read on a value this package did not build, in the frame that
|
|
118
|
+
* decides what the caller sees (`retryAfterOf`'s reason). ALL-OR-NOTHING, for `issuesOf`'s
|
|
119
|
+
* reason: a document carrying `sessionId` and silently missing `state` is a claim the server
|
|
120
|
+
* never made. So one value JSON cannot carry — a function, a `bigint`, a `Date`, a class
|
|
121
|
+
* instance, a non-finite number, a cycle — drops the WHOLE member, and so does a serialisation
|
|
122
|
+
* past `MAX_PROBLEM_META_BYTES`. A structural walk and never a `JSON.stringify` round trip: the
|
|
123
|
+
* round trip drops a function and an `undefined` SILENTLY, which is the footgun rather than the
|
|
124
|
+
* check (`@ultimat3/render`'s `island-props.ts` walks for the same reason).
|
|
125
|
+
*
|
|
126
|
+
* ABSENT when nothing survives — never `{}`. `{}` says "the server declared meta and had none",
|
|
127
|
+
* which a client cannot tell from "the server carries no meta for this code".
|
|
128
|
+
*/
|
|
129
|
+
export function wireMeta(source: unknown, keys: readonly string[]): ProblemMeta | undefined {
|
|
130
|
+
try {
|
|
131
|
+
if (typeof source !== 'object' || source === null) return undefined;
|
|
132
|
+
const meta = source as Record<string, unknown>;
|
|
133
|
+
const out: Record<string, ProblemMetaValue> = {};
|
|
134
|
+
let carried = 0;
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
if (!Object.hasOwn(meta, key)) continue;
|
|
137
|
+
const value = jsonSafe(meta[key], new Set());
|
|
138
|
+
if (value === MISSING) return undefined;
|
|
139
|
+
define(out, key, value);
|
|
140
|
+
carried += 1;
|
|
141
|
+
}
|
|
142
|
+
if (carried === 0) return undefined;
|
|
143
|
+
const bytes = new TextEncoder().encode(JSON.stringify(out)).byteLength;
|
|
144
|
+
return bytes > MAX_PROBLEM_META_BYTES ? undefined : out;
|
|
145
|
+
} catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The one value the walk cannot answer with, distinct from the `null` JSON can carry. */
|
|
151
|
+
const MISSING: unique symbol = Symbol('problem-meta.missing');
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A plain own data property, whatever the name: `out[key] = value` for the one name `__proto__`
|
|
155
|
+
* runs `Object.prototype`'s setter instead of adding a key. `registerProblemMeta` refuses that
|
|
156
|
+
* name at the top level; a NESTED object off the error is walked here with the same care, and
|
|
157
|
+
* the key is skipped outright — the receiving `JSON.parse` would mint it as an own key again.
|
|
158
|
+
*/
|
|
159
|
+
function define(out: Record<string, ProblemMetaValue>, key: string, value: ProblemMetaValue): void {
|
|
160
|
+
Object.defineProperty(out, key, { value, writable: true, enumerable: true, configurable: true });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function jsonSafe(value: unknown, seen: Set<object>): ProblemMetaValue | typeof MISSING {
|
|
164
|
+
if (value === null) return null;
|
|
165
|
+
if (typeof value === 'string' || typeof value === 'boolean') return value;
|
|
166
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : MISSING;
|
|
167
|
+
if (typeof value !== 'object') return MISSING;
|
|
168
|
+
if (seen.has(value)) return MISSING;
|
|
169
|
+
seen.add(value);
|
|
170
|
+
let out: ProblemMetaValue | typeof MISSING = MISSING;
|
|
171
|
+
if (Array.isArray(value)) {
|
|
172
|
+
const items: ProblemMetaValue[] = [];
|
|
173
|
+
for (const item of value as readonly unknown[]) {
|
|
174
|
+
const safe = jsonSafe(item, seen);
|
|
175
|
+
if (safe === MISSING) return MISSING;
|
|
176
|
+
items.push(safe);
|
|
177
|
+
}
|
|
178
|
+
out = items;
|
|
179
|
+
} else if (isPlainObject(value)) {
|
|
180
|
+
const record: Record<string, ProblemMetaValue> = {};
|
|
181
|
+
for (const [key, item] of Object.entries(value)) {
|
|
182
|
+
if (key === '__proto__') continue;
|
|
183
|
+
const safe = jsonSafe(item, seen);
|
|
184
|
+
if (safe === MISSING) return MISSING;
|
|
185
|
+
define(record, key, safe);
|
|
186
|
+
}
|
|
187
|
+
out = record;
|
|
188
|
+
}
|
|
189
|
+
seen.delete(value);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A `Date`, a `Map`, an error, a class instance: each serialises to something other than itself. */
|
|
194
|
+
function isPlainObject(value: object): value is Record<string, unknown> {
|
|
195
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
196
|
+
return proto === Object.prototype || proto === null;
|
|
197
|
+
}
|
package/src/stages.ts
CHANGED
|
@@ -160,6 +160,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
160
160
|
if (answered !== undefined) return answered;
|
|
161
161
|
|
|
162
162
|
ctx.clientBuildId = request.header(config.buildIdHeader);
|
|
163
|
+
// Stamped BEFORE the assertion, so the refusal carries it too. A skew answer that
|
|
164
|
+
// withholds the server's own id tells a client it is stale without telling it what to
|
|
165
|
+
// become — and the one caller that can act on that, the service worker holding the
|
|
166
|
+
// stale id, then has no way to tell this 409 from any other.
|
|
167
|
+
if (config.buildId !== null) ctx.headers.set(config.buildIdHeader, config.buildId);
|
|
163
168
|
request.assertBuild();
|
|
164
169
|
|
|
165
170
|
const pathname = stripBasePath(ctx.url.pathname, config.basePath);
|