@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 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.1.2",
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.1.2",
35
- "@ultimat3/i18n": "19.1.2",
36
- "@ultimat3/schema": "19.1.2",
37
- "@ultimat3/time": "19.1.2"
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
  }
@@ -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
- fix: 'reload the page — the service worker will fetch the new build manifest',
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);