@ultimat3/http 24.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +37 -19
- package/README.md +59 -17
- package/package.json +9 -6
- package/src/app-config.ts +3 -1
- package/src/auth-redirect.ts +56 -17
- package/src/bearer-mount.ts +81 -26
- package/src/config.ts +27 -29
- package/src/context.ts +10 -10
- package/src/correlation.ts +2 -2
- package/src/cors.ts +3 -2
- package/src/deadline.ts +2 -5
- package/src/error-map-tier-0.ts +2 -0
- package/src/error-map-tier-1.ts +2 -0
- package/src/error-map-tier-2.ts +2 -0
- package/src/error-map-tier-3.ts +8 -0
- package/src/error-map-tier-4.ts +24 -0
- package/src/error-page.ts +2 -2
- package/src/error-titles.ts +93 -0
- package/src/errors.ts +44 -94
- package/src/forwarded.ts +20 -5
- package/src/health-disclosure.ts +0 -3
- package/src/hooks.ts +6 -5
- package/src/index.ts +31 -21
- package/src/navigation.ts +14 -2
- package/src/pipeline.ts +12 -8
- package/src/rate-limit-buckets.ts +26 -2
- package/src/rate-limit-errors.ts +36 -10
- package/src/rate-limit-headers.ts +40 -0
- package/src/rate-limit-installed.ts +79 -0
- package/src/rate-limit-postgres.ts +3 -3
- package/src/rate-limit-stage.ts +13 -4
- package/src/rate-limit.ts +22 -15
- package/src/request-facts.ts +44 -0
- package/src/request.ts +2 -2
- package/src/response.ts +2 -2
- package/src/route-cache.ts +1 -1
- package/src/router.ts +16 -1
- package/src/server.ts +74 -24
- package/src/set-cookie.ts +60 -0
- package/src/stages.ts +15 -9
- package/src/validate.ts +2 -2
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// A rate-limit decision as the three `RateLimit-*` response headers. One rendering, so the
|
|
2
|
+
// pipeline's limiter, a bearer mount's per-token allowance and a primitive's declared bucket can
|
|
3
|
+
// never answer a client in two shapes.
|
|
4
|
+
|
|
5
|
+
import type { RequestContext } from './context';
|
|
6
|
+
import type { RateLimitDecision } from './rate-limit';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lowercase names, as every writer sets them on `ctx.headers`. The reset is whole seconds from
|
|
10
|
+
* `nowMs`, floored at 0: a decision read after its own reset passed is a full bucket, not a
|
|
11
|
+
* negative wait.
|
|
12
|
+
*/
|
|
13
|
+
export const rateLimitHeaders = (
|
|
14
|
+
decision: RateLimitDecision,
|
|
15
|
+
nowMs: number,
|
|
16
|
+
): Record<string, string> => ({
|
|
17
|
+
'ratelimit-limit': String(decision.limit),
|
|
18
|
+
'ratelimit-remaining': String(decision.remaining),
|
|
19
|
+
'ratelimit-reset': String(Math.max(0, Math.ceil((decision.resetAtMs - nowMs) / 1000))),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A handler-spent bucket onto the answer (`RouteMeta.rateLimitedBy: 'handler'`), when it is the
|
|
24
|
+
* one closest to refusing — the rule the `rate-limit` stage follows across its own buckets:
|
|
25
|
+
* `remaining: 118` off the tenant's while the primitive's holds 1 tells a client to proceed and
|
|
26
|
+
* then refuses its next call. A refusal always wins. Set on the CONTEXT before the handler throws,
|
|
27
|
+
* so the `error-map` stage's 429 carries these headers and reads `Retry-After` off `ctx.rateLimit`.
|
|
28
|
+
*/
|
|
29
|
+
export const publishRateLimit = (
|
|
30
|
+
ctx: RequestContext,
|
|
31
|
+
decision: RateLimitDecision,
|
|
32
|
+
nowMs: number,
|
|
33
|
+
): void => {
|
|
34
|
+
const prior = ctx.rateLimit;
|
|
35
|
+
if (decision.allowed && prior !== undefined && prior.remaining < decision.remaining) return;
|
|
36
|
+
ctx.rateLimit = decision;
|
|
37
|
+
for (const [name, value] of Object.entries(rateLimitHeaders(decision, nowMs))) {
|
|
38
|
+
ctx.headers.set(name, value);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The store a PRIMITIVE's declared `rateLimit:` is counted in — `@ultimat3/action`'s `invoke` and
|
|
2
|
+
// `@ultimat3/query`'s read path both spend from it, on every surface. One slot, here, so the two
|
|
3
|
+
// tier-3 packages (which cannot import each other) count in one place, and the boot fills it with
|
|
4
|
+
// the same instance it hands `httpServer({ rateLimitStore })`.
|
|
5
|
+
|
|
6
|
+
import type { RateLimitConfig, RateLimitStore } from './rate-limit';
|
|
7
|
+
import { memoryRateLimitStore } from './rate-limit';
|
|
8
|
+
import { rateLimitNotShared } from './rate-limit-errors';
|
|
9
|
+
|
|
10
|
+
/** What a process falls back to when nothing chose: one process' memory. */
|
|
11
|
+
let base: RateLimitStore = memoryRateLimitStore();
|
|
12
|
+
let baseChosen = false;
|
|
13
|
+
/**
|
|
14
|
+
* Adopted stores, newest last. A STACK of frames, each popped only by its own adopter: two
|
|
15
|
+
* servers started A-then-B and stopped A-then-B used to have A's stop restore what A replaced —
|
|
16
|
+
* reverting the slot under B while B still served. A frame object is its own identity, so the
|
|
17
|
+
* same store adopted twice is two frames.
|
|
18
|
+
*/
|
|
19
|
+
const frames: { readonly store: RateLimitStore }[] = [];
|
|
20
|
+
|
|
21
|
+
const current = (): RateLimitStore => frames.at(-1)?.store ?? base;
|
|
22
|
+
const isChosen = (): boolean => baseChosen || frames.length > 0;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The embedder's one-line install, for a process that builds its own boot: the base everything
|
|
26
|
+
* else stacks over. Left at the default it is one process' memory — right for dev and tests, and
|
|
27
|
+
* N × every declared limit behind N replicas, which `assertInstalledRateLimitScope` refuses under
|
|
28
|
+
* a `'shared'` declaration. A boot that has to give the slot back uses `adoptRateLimitStore`.
|
|
29
|
+
*/
|
|
30
|
+
export const installRateLimitStore = (store: RateLimitStore): void => {
|
|
31
|
+
base = store;
|
|
32
|
+
baseChosen = true;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const installedRateLimitStore = (): RateLimitStore => current();
|
|
36
|
+
|
|
37
|
+
/** Test-only. Every frame and the base, back to one process' memory. */
|
|
38
|
+
export const resetRateLimitStore = (): void => {
|
|
39
|
+
frames.length = 0;
|
|
40
|
+
base = memoryRateLimitStore();
|
|
41
|
+
baseChosen = false;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface AdoptRateLimitStoreOptions {
|
|
45
|
+
/**
|
|
46
|
+
* `httpServer`'s rule: never over a store something already CHOSE. The boot installs its own
|
|
47
|
+
* before any server exists, and a server handed a different one must not move every action's
|
|
48
|
+
* counters out from under it — the chosen one stands, and `assertInstalledRateLimitScope` still
|
|
49
|
+
* holds it to the declaration.
|
|
50
|
+
*/
|
|
51
|
+
readonly keepChosen?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Push `store` as the one primitives spend from, and answer the undo that removes THIS frame and
|
|
56
|
+
* no other — whatever order the adopters stop in. The boot (`startServices`, `startRoles`) and
|
|
57
|
+
* `httpServer` all go through it, so every install has a matching, order-proof release.
|
|
58
|
+
*/
|
|
59
|
+
export const adoptRateLimitStore = (
|
|
60
|
+
store: RateLimitStore,
|
|
61
|
+
options: AdoptRateLimitStoreOptions = {},
|
|
62
|
+
): (() => void) => {
|
|
63
|
+
if (options.keepChosen === true && isChosen() && current() !== store) return () => undefined;
|
|
64
|
+
const frame = { store };
|
|
65
|
+
frames.push(frame);
|
|
66
|
+
return () => {
|
|
67
|
+
const at = frames.indexOf(frame);
|
|
68
|
+
if (at !== -1) frames.splice(at, 1);
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `assertRateLimitScope`'s rule for the primitives' half: a `'shared'` declaration says every
|
|
74
|
+
* number is the fleet's, and an action or query limit counted in one process' memory is not.
|
|
75
|
+
*/
|
|
76
|
+
export const assertInstalledRateLimitScope = (config: RateLimitConfig): void => {
|
|
77
|
+
if (config.scope !== 'shared') return;
|
|
78
|
+
if (current().scope !== 'shared') throw rateLimitNotShared('installed');
|
|
79
|
+
};
|
|
@@ -135,7 +135,7 @@ export interface PostgresRateLimitStore extends RateLimitStore {
|
|
|
135
135
|
*
|
|
136
136
|
* `nowMs` is required and comes from the SAME clock the takes use — `ctx.now().getTime()` in a
|
|
137
137
|
* task. There is no default, because the only defensible default would be this process' own
|
|
138
|
-
* `Date.now()`, and a store that reads a clock nobody handed it is the thing `
|
|
138
|
+
* `Date.now()`, and a store that reads a clock nobody handed it is the thing `rateLimiter`
|
|
139
139
|
* took a `Clock` to stop.
|
|
140
140
|
*/
|
|
141
141
|
purgeExpired(nowMs: number): Promise<number>;
|
|
@@ -150,14 +150,14 @@ export interface PostgresRateLimitStore extends RateLimitStore {
|
|
|
150
150
|
*
|
|
151
151
|
* // apps/web/server.ts — what PROVIDES it
|
|
152
152
|
* const client = db();
|
|
153
|
-
*
|
|
153
|
+
* httpServer({
|
|
154
154
|
* rateLimitStore: postgresRateLimitStore({
|
|
155
155
|
* executor: { query: (text, values) => client.query({ text, values }) },
|
|
156
156
|
* }),
|
|
157
157
|
* });
|
|
158
158
|
* ```
|
|
159
159
|
*
|
|
160
|
-
* `assertRateLimitScope` compares the two once, inside `
|
|
160
|
+
* `assertRateLimitScope` compares the two once, inside `httpPipeline`, and a `'shared'`
|
|
161
161
|
* declaration over any other store is `X_RATE_LIMIT_NOT_SHARED` before the socket opens.
|
|
162
162
|
*/
|
|
163
163
|
export function postgresRateLimitStore(
|
package/src/rate-limit-stage.ts
CHANGED
|
@@ -68,14 +68,23 @@ export const spendRequestBuckets = async (
|
|
|
68
68
|
limiter,
|
|
69
69
|
rateLimitSpends(
|
|
70
70
|
{ actorId: actor?.id ?? null, orgId: actor?.orgId ?? null, ip: ctx.ip, routeName },
|
|
71
|
-
{
|
|
72
|
-
route: ctx.route?.meta.rateLimit ?? config.defaultBucket,
|
|
73
|
-
tenant: config.tenantBucket,
|
|
74
|
-
},
|
|
71
|
+
{ route: routeBucketOf(ctx, config), tenant: config.tenantBucket },
|
|
75
72
|
),
|
|
76
73
|
);
|
|
77
74
|
};
|
|
78
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The ONE place that decides which bucket a route's caller spends here. A route whose handler
|
|
78
|
+
* spends its own declared limit (`rateLimitedBy: 'handler'` — every action and query route, whose
|
|
79
|
+
* primitive counts that limit on every surface) spends none: `default` beside it would cap an
|
|
80
|
+
* action that declared 1,000 at 120, which a declared limit never was.
|
|
81
|
+
*/
|
|
82
|
+
const routeBucketOf = (ctx: RequestContext, config: RateLimitConfig): string | null => {
|
|
83
|
+
const meta = ctx.route?.meta;
|
|
84
|
+
if (meta?.rateLimitedBy === 'handler') return null;
|
|
85
|
+
return meta?.rateLimit ?? config.defaultBucket;
|
|
86
|
+
};
|
|
87
|
+
|
|
79
88
|
/** The one key failed credentials are counted under: the address alone, never the route. */
|
|
80
89
|
const unauthenticatedKey = (ip: string | null): string =>
|
|
81
90
|
`${UNAUTHENTICATED_SCOPE}|ip:${ip ?? 'unknown'}`;
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Token-bucket rate limiting. The store is an interface so the same limiter runs in-memory in
|
|
2
2
|
// dev/tests and against a shared tier in a multi-replica deployment — installed through
|
|
3
|
-
// `
|
|
3
|
+
// `httpServer({ rateLimitStore })`, and refused at boot when its scope cannot keep the app's
|
|
4
4
|
// declaration; the bucket maths lives here so every driver agrees on the numbers.
|
|
5
5
|
import { type Clock, systemClock } from '@ultimat3/core';
|
|
6
6
|
import { httpCountInvalid } from './errors';
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
rateLimitScopeUnset,
|
|
12
12
|
tenantBucketUnknown,
|
|
13
13
|
} from './rate-limit-errors';
|
|
14
|
+
import { rateLimitHeaders } from './rate-limit-headers';
|
|
14
15
|
import { type RateLimitPeek, rateLimitPeek, refilledTokens } from './rate-limit-peek';
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -325,6 +326,13 @@ export interface RateLimitKeyParts {
|
|
|
325
326
|
readonly orgId: string | null;
|
|
326
327
|
readonly ip: string | null;
|
|
327
328
|
readonly routeName: string;
|
|
329
|
+
/**
|
|
330
|
+
* The subject when there is no actor, no org AND no address. Defaults to `ip:unknown` — every
|
|
331
|
+
* such request shares it, which is what the pipeline has always done. A caller that can name a
|
|
332
|
+
* narrower population (a job run nobody attributed) passes its own, so it does not share one
|
|
333
|
+
* bucket with every anonymous request whose address could not be read.
|
|
334
|
+
*/
|
|
335
|
+
readonly unattributed?: string | undefined;
|
|
328
336
|
}
|
|
329
337
|
|
|
330
338
|
/**
|
|
@@ -360,17 +368,20 @@ export interface RateLimitSpend {
|
|
|
360
368
|
*/
|
|
361
369
|
export const rateLimitSpends = (
|
|
362
370
|
parts: RateLimitKeyParts,
|
|
363
|
-
|
|
371
|
+
// `route: null` — the route's own limit is spent by its handler (`RouteMeta.rateLimitedBy`), so
|
|
372
|
+
// the caller key is not spent here at all; only the tenant allowance is.
|
|
373
|
+
buckets: { readonly route: string | null; readonly tenant: string | null },
|
|
364
374
|
): readonly RateLimitSpend[] => {
|
|
365
375
|
const subject =
|
|
366
376
|
parts.actorId !== null
|
|
367
377
|
? `actor:${parts.actorId}`
|
|
368
378
|
: parts.orgId !== null
|
|
369
379
|
? `org:${parts.orgId}`
|
|
370
|
-
:
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
]
|
|
380
|
+
: parts.ip !== null
|
|
381
|
+
? `ip:${parts.ip}`
|
|
382
|
+
: (parts.unattributed ?? 'ip:unknown');
|
|
383
|
+
const spends: RateLimitSpend[] =
|
|
384
|
+
buckets.route === null ? [] : [{ key: `${parts.routeName}|${subject}`, bucket: buckets.route }];
|
|
374
385
|
if (buckets.tenant !== null && parts.orgId !== null) {
|
|
375
386
|
spends.push({ key: `${TENANT_SCOPE}|org:${parts.orgId}`, bucket: buckets.tenant });
|
|
376
387
|
}
|
|
@@ -383,8 +394,8 @@ export interface RateLimiter {
|
|
|
383
394
|
/**
|
|
384
395
|
* The table this limiter resolves a bucket NAME against. Declared, never inferred — the same
|
|
385
396
|
* rule as `RateLimitStore.scope` and `@ultimat3/auth`'s `AuthLimiter.policy`, and for the same
|
|
386
|
-
* reason: `
|
|
387
|
-
* it actually holds. `
|
|
397
|
+
* reason: `rateLimiter` closes over its config, so nothing outside can see which buckets
|
|
398
|
+
* it actually holds. `httpPipeline` compares this against the buckets the ROUTES declare, and
|
|
388
399
|
* an unknown name is refused instead of falling through `bucketFor` to `default`.
|
|
389
400
|
*
|
|
390
401
|
* Optional only so an existing external implementation still type-checks; an absent table
|
|
@@ -399,7 +410,7 @@ export interface RateLimiter {
|
|
|
399
410
|
assert(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
|
|
400
411
|
}
|
|
401
412
|
|
|
402
|
-
export const
|
|
413
|
+
export const rateLimiter = (options: {
|
|
403
414
|
config: RateLimitConfig;
|
|
404
415
|
store?: RateLimitStore;
|
|
405
416
|
/**
|
|
@@ -407,7 +418,7 @@ export const createRateLimiter = (options: {
|
|
|
407
418
|
* BOTH production call sites (`server.ts`, `pipeline.ts`) build their limiter without an
|
|
408
419
|
* override — so the limiter that actually throttles requests could not be frozen by any test,
|
|
409
420
|
* while `@ultimat3/auth`'s credential limiter has taken an injected `Clock` since it shipped.
|
|
410
|
-
* Defaulted rather than required, the same shape as `
|
|
421
|
+
* Defaulted rather than required, the same shape as `requestContext`'s `init.clock`;
|
|
411
422
|
* `PipelineDeps.limiter` stays the one seam for handing the pipeline a limiter of your own,
|
|
412
423
|
* because a second `clock` beside it would be a second way to set one number.
|
|
413
424
|
*/
|
|
@@ -438,11 +449,7 @@ export const createRateLimiter = (options: {
|
|
|
438
449
|
buckets: options.config.buckets,
|
|
439
450
|
check,
|
|
440
451
|
peek: (key, bucketName) => store.peek(key, bucketFor(bucketName), now()),
|
|
441
|
-
headers: (decision) => (
|
|
442
|
-
'ratelimit-limit': String(decision.limit),
|
|
443
|
-
'ratelimit-remaining': String(decision.remaining),
|
|
444
|
-
'ratelimit-reset': String(Math.ceil((decision.resetAtMs - now()) / 1000)),
|
|
445
|
-
}),
|
|
452
|
+
headers: (decision) => rateLimitHeaders(decision, now()),
|
|
446
453
|
async assert(key, bucketName, cost = 1) {
|
|
447
454
|
const decision = await check(key, bucketName, cost);
|
|
448
455
|
if (!decision.allowed) throw rateLimited(key, decision.retryAfterSeconds);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// How one request arrived, as the frozen facts a token resolver is handed — never the raw
|
|
2
|
+
// `Headers`. A resolver given the headers would read `x-forwarded-for` itself and trust a hop the
|
|
3
|
+
// host never declared; the address here is the one the pipeline already resolved (`ctx.ip`).
|
|
4
|
+
// One shape for every bearer surface: this mount, and `@ultimat3/mcp`'s `POST /mcp`.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The request, as far as a credential decision may read it. Four facts and no more: a token bound
|
|
8
|
+
* to a network reads `address`, a resolver refusing browsers reads `origin`. Never the body (it is
|
|
9
|
+
* unread when the token is decided) and never another header — `Authorization` is the token itself.
|
|
10
|
+
*/
|
|
11
|
+
export interface RequestFacts {
|
|
12
|
+
/**
|
|
13
|
+
* The caller's address AS THE HOST RESOLVED IT — `ctx.ip`, a declared proxy's `x-forwarded-for`
|
|
14
|
+
* honoured per `trustedProxyHops`, else the socket. `null` when the host resolved none: a surface
|
|
15
|
+
* that cannot tell a trusted hop from a forged one never derives one from a header.
|
|
16
|
+
*/
|
|
17
|
+
readonly address: string | null;
|
|
18
|
+
/** `User-Agent`, verbatim — caller-controlled, so a hint for a rule and never an identity. */
|
|
19
|
+
readonly userAgent: string | null;
|
|
20
|
+
/**
|
|
21
|
+
* The `Origin` request header: which web origin a BROWSER says is calling. `null` when absent —
|
|
22
|
+
* every non-browser client — and the literal string `'null'` for an opaque origin, which is a
|
|
23
|
+
* different fact (a sandboxed frame or a `file:` page) and must not read as "no browser".
|
|
24
|
+
*/
|
|
25
|
+
readonly origin: string | null;
|
|
26
|
+
/** The URL path the request was sent to. No query string. */
|
|
27
|
+
readonly path: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RequestFactsInput {
|
|
31
|
+
readonly headers: Headers;
|
|
32
|
+
readonly url: URL | string;
|
|
33
|
+
/** What the host resolved — `ctx.ip`, or a transport's `seen.address`. Never a header read. */
|
|
34
|
+
readonly address: string | null | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function requestFacts(input: RequestFactsInput): RequestFacts {
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
address: input.address ?? null,
|
|
40
|
+
userAgent: input.headers.get('user-agent'),
|
|
41
|
+
origin: input.headers.get('origin'),
|
|
42
|
+
path: (typeof input.url === 'string' ? new URL(input.url) : input.url).pathname,
|
|
43
|
+
});
|
|
44
|
+
}
|
package/src/request.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { readCookie, readWithinLimit, renderThrowable } from '@ultimat3/core';
|
|
|
7
7
|
import type { RequestContext } from './context';
|
|
8
8
|
import { bodyInvalid, buildSkew } from './errors';
|
|
9
9
|
import type { Schema } from './validate';
|
|
10
|
-
import {
|
|
10
|
+
import { validateBody, validateSync } from './validate';
|
|
11
11
|
|
|
12
12
|
export type QueryValues = Readonly<Record<string, string | readonly string[]>>;
|
|
13
13
|
|
|
@@ -152,7 +152,7 @@ export class UltimateRequest {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
async body<Out>(schema: Schema<Out>): Promise<Out> {
|
|
155
|
-
const outcome = await
|
|
155
|
+
const outcome = await validateBody(schema, await this.bodyRaw());
|
|
156
156
|
if (!outcome.ok) throw bodyInvalid(this.pathname, outcome.issues);
|
|
157
157
|
return outcome.value;
|
|
158
158
|
}
|
package/src/response.ts
CHANGED
|
@@ -14,13 +14,13 @@ const withDefaults = (init: HeaderSource, defaults: Record<string, string>): Hea
|
|
|
14
14
|
return headers;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
export const
|
|
17
|
+
export const jsonResponse = <T>(body: T, init?: ResponseInit): Response =>
|
|
18
18
|
new Response(JSON.stringify(body), {
|
|
19
19
|
...init,
|
|
20
20
|
headers: withDefaults(init, { 'content-type': 'application/json; charset=utf-8' }),
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
export const
|
|
23
|
+
export const textResponse = (body: string, init?: ResponseInit): Response =>
|
|
24
24
|
new Response(body, {
|
|
25
25
|
...init,
|
|
26
26
|
headers: withDefaults(init, { 'content-type': 'text/plain; charset=utf-8' }),
|
package/src/route-cache.ts
CHANGED
|
@@ -31,7 +31,7 @@ const DELTA_SECONDS_FIELDS = [
|
|
|
31
31
|
* CLI's authorized-object hint all carry `maxAgeSeconds: 0`, so a floor of 1 would refuse the
|
|
32
32
|
* framework at its own boot.
|
|
33
33
|
*
|
|
34
|
-
* Called once per route by `
|
|
34
|
+
* Called once per route by `httpRouter`, which is the one way a `Route` becomes matchable — so
|
|
35
35
|
* every hint an app can serve has been through here, including the ones `@ultimat3/cli` mints for
|
|
36
36
|
* favicons, dev assets and storage objects.
|
|
37
37
|
*/
|
package/src/router.ts
CHANGED
|
@@ -76,6 +76,21 @@ export interface RouteMeta {
|
|
|
76
76
|
* refuses a configured bucket of the same name that says something else.
|
|
77
77
|
*/
|
|
78
78
|
readonly rateLimitBucket?: Bucket;
|
|
79
|
+
/**
|
|
80
|
+
* `'handler'`: the handler spends this route's own declared limit — an action's `invoke`, a
|
|
81
|
+
* query's read — because the primitive counts it on every surface (HTTP, MCP, the agent tool, a
|
|
82
|
+
* job). The `rate-limit` stage then spends no caller bucket for it, only the tenant allowance:
|
|
83
|
+
* spending `default` too would cap a primitive that declared more than `default` at `default`.
|
|
84
|
+
* Set, it wins: `rateLimit`/`rateLimitBucket` name a bucket the STAGE spends, and are not read.
|
|
85
|
+
*/
|
|
86
|
+
readonly rateLimitedBy?: 'handler';
|
|
87
|
+
/**
|
|
88
|
+
* The primitive this route projects, written by `@ultimat3/action`'s `toRoute` and
|
|
89
|
+
* `@ultimat3/query`'s `toQueryRoute`; absent on a plain route. Read by the bucket check
|
|
90
|
+
* (`rate-limit-buckets.ts`): since 25.0.0 a `rateLimit.buckets.<name>` named after a primitive
|
|
91
|
+
* limits nothing and is refused, and only an explicit marker can say which routes those are.
|
|
92
|
+
*/
|
|
93
|
+
readonly primitive?: 'action' | 'query';
|
|
79
94
|
readonly tags?: readonly string[];
|
|
80
95
|
readonly description?: string;
|
|
81
96
|
/**
|
|
@@ -159,7 +174,7 @@ const segmentsOf = (path: string): readonly string[] =>
|
|
|
159
174
|
.split('/')
|
|
160
175
|
.filter((segment) => segment.length > 0);
|
|
161
176
|
|
|
162
|
-
export const
|
|
177
|
+
export const httpRouter = (routes: readonly Route[]): RouteTable => {
|
|
163
178
|
const root = node();
|
|
164
179
|
for (const route of routes) {
|
|
165
180
|
// Before the trie is touched: a hint that cannot be emitted is refused where it was WRITTEN,
|
package/src/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
markReady,
|
|
16
16
|
onShutdown,
|
|
17
17
|
readyzPayload,
|
|
18
|
+
systemClock,
|
|
18
19
|
} from '@ultimat3/core';
|
|
19
20
|
import type { Server } from 'bun';
|
|
20
21
|
import { defineHttpConfig, type HttpConfig } from './config';
|
|
@@ -22,11 +23,12 @@ import { HttpError, serverNotStarted } from './errors';
|
|
|
22
23
|
import { disclosesHealthDetail } from './health-disclosure';
|
|
23
24
|
import type { ServerHooks } from './hooks';
|
|
24
25
|
import type { Middleware } from './middleware';
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
26
|
+
import { httpPipeline, type Pipeline } from './pipeline';
|
|
27
|
+
import { type RateLimitStore, rateLimiter } from './rate-limit';
|
|
27
28
|
import { withRouteBuckets } from './rate-limit-buckets';
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
29
|
+
import { adoptRateLimitStore } from './rate-limit-installed';
|
|
30
|
+
import { jsonResponse } from './response';
|
|
31
|
+
import { describeRoutes, httpRouter, type Route, type RouteDescription } from './router';
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* Beside its one caller rather than in `errors.ts`, which is at the 500-line ceiling — the
|
|
@@ -35,7 +37,7 @@ import { createRouter, describeRoutes, type Route, type RouteDescription } from
|
|
|
35
37
|
* A websocket mount and something already answering its path. Same code as two routes claiming
|
|
36
38
|
* one, because it is the same fact: one path, two declarations, and the framework picks — Bun's
|
|
37
39
|
* native route table is matched BEFORE `fetch`, so the route wins and the upgrade never reaches
|
|
38
|
-
* the mount. Refused at `
|
|
40
|
+
* the mount. Refused at `httpServer`, not discovered as a socket that will not open.
|
|
39
41
|
*/
|
|
40
42
|
const websocketPathTaken = (path: string, answered: string): HttpError =>
|
|
41
43
|
new HttpError({
|
|
@@ -63,7 +65,7 @@ export interface ServerOptions {
|
|
|
63
65
|
* Where the rate limiter keeps its counters. Omitted means `memoryRateLimitStore()`, which is
|
|
64
66
|
* one process' worth of state — correct for dev and tests, and N × every configured number for
|
|
65
67
|
* N replicas. An app that runs more than one process declares `rateLimit.scope: 'shared'` and
|
|
66
|
-
* passes a store that says the same, or `
|
|
68
|
+
* passes a store that says the same, or `httpServer` refuses here.
|
|
67
69
|
*/
|
|
68
70
|
readonly rateLimitStore?: RateLimitStore;
|
|
69
71
|
/**
|
|
@@ -142,7 +144,7 @@ export interface ServerHandle {
|
|
|
142
144
|
url(): string;
|
|
143
145
|
describe(): readonly RouteDescription[];
|
|
144
146
|
start(): ServerHandle;
|
|
145
|
-
/** Runs core's three-phase drain
|
|
147
|
+
/** Runs core's three-phase drain, on the deadline `drain.deadlineMs` set (`lifecycleForRole`). */
|
|
146
148
|
stop(): Promise<void>;
|
|
147
149
|
/**
|
|
148
150
|
* Runs one request through the entire lifecycle with no socket. This is the
|
|
@@ -151,20 +153,41 @@ export interface ServerHandle {
|
|
|
151
153
|
fetch(request: Request): Promise<Response>;
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
/** How often the `inflight` hook re-reads Bun's count of unfinished responses. */
|
|
157
|
+
const BODY_POLL_MS = 5;
|
|
158
|
+
|
|
154
159
|
const roleFromEnv = (): Role => (Bun.env['ROLE'] ?? 'web') as Role;
|
|
155
160
|
|
|
156
|
-
export const
|
|
161
|
+
export const httpServer = (options: ServerOptions): ServerHandle => {
|
|
162
|
+
// The store this server is handed is also the one actions and queries spend their declared
|
|
163
|
+
// `rateLimit:` from — adopted BEFORE the pipeline, whose boot check reads it, and never over a
|
|
164
|
+
// store the boot already chose (`keepChosen`). Given back on `stop()`, or by ANY refusal from
|
|
165
|
+
// here to the handle — the pipeline's checks, `configureLifecycle`, `assertMountPathFree` — so a
|
|
166
|
+
// boot that never produced a server leaves nothing behind it.
|
|
167
|
+
const releaseStore =
|
|
168
|
+
options.rateLimitStore === undefined
|
|
169
|
+
? () => undefined
|
|
170
|
+
: adoptRateLimitStore(options.rateLimitStore, { keepChosen: true });
|
|
171
|
+
try {
|
|
172
|
+
return buildServer(options, releaseStore);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
releaseStore();
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHandle => {
|
|
157
180
|
const role = options.role ?? roleFromEnv();
|
|
158
|
-
const table =
|
|
159
|
-
// Merged here as well as in `
|
|
181
|
+
const table = httpRouter(options.routes);
|
|
182
|
+
// Merged here as well as in `httpPipeline`, and for the store's sake: the limiter below is
|
|
160
183
|
// built from `config.rateLimit`, so a table without the routes' own buckets would resolve a
|
|
161
184
|
// declared name to `default` — the very hole this closes. `withRouteBuckets` is idempotent, so
|
|
162
185
|
// the pipeline's second pass changes nothing. `handle.config` is the merged one for the same
|
|
163
186
|
// reason: `server.config.rateLimit.buckets` has to be what the limiter runs on.
|
|
164
187
|
const config = withRouteBuckets(options.config ?? defineHttpConfig(), table.routes);
|
|
165
188
|
// The store feeds the limiter seam `PipelineDeps` already had, rather than becoming a second
|
|
166
|
-
// one: the bucket maths stays in `
|
|
167
|
-
const pipeline =
|
|
189
|
+
// one: the bucket maths stays in `rateLimiter`, so every driver agrees on the numbers.
|
|
190
|
+
const pipeline = httpPipeline({
|
|
168
191
|
table,
|
|
169
192
|
config,
|
|
170
193
|
...(options.hooks === undefined ? {} : { hooks: options.hooks }),
|
|
@@ -172,17 +195,14 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
172
195
|
...(options.rateLimitStore === undefined
|
|
173
196
|
? {}
|
|
174
197
|
: {
|
|
175
|
-
limiter:
|
|
198
|
+
limiter: rateLimiter({ config: config.rateLimit, store: options.rateLimitStore }),
|
|
176
199
|
}),
|
|
177
200
|
});
|
|
178
201
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
// "the app said 15 seconds" are different claims and `null` is what keeps them apart.
|
|
184
|
-
if (config.drainTimeoutMs !== null) configureLifecycle({ deadlineMs: config.drainTimeoutMs });
|
|
185
|
-
// Same rule: only a declared grace is applied. Core waits it out between the readiness flip and
|
|
202
|
+
// No deadline is set here. `drain.deadlineMs` is the one budget, applied per role by
|
|
203
|
+
// `lifecycleForRole` before any server exists; `http.drainTimeoutMs` re-set it on the web role
|
|
204
|
+
// only, after that, and was deleted in 25.0.0 for being a second answer (axiom 1).
|
|
205
|
+
// Only a declared grace is applied. Core waits it out between the readiness flip and
|
|
186
206
|
// the `accept` hook below, so endpoints stop routing here before the socket closes.
|
|
187
207
|
const readinessGraceMs = options.drain?.readinessGraceMs;
|
|
188
208
|
if (readinessGraceMs !== undefined) configureLifecycle({ readinessGraceMs });
|
|
@@ -190,6 +210,7 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
190
210
|
const mount = options.websocket;
|
|
191
211
|
let server: BunServer | undefined;
|
|
192
212
|
let unregister: (() => void) | undefined;
|
|
213
|
+
let unregisterBodies: (() => void) | undefined;
|
|
193
214
|
let unregisterClose: (() => void) | undefined;
|
|
194
215
|
let stopListening: (() => void) | undefined;
|
|
195
216
|
|
|
@@ -202,7 +223,7 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
202
223
|
* peer `healthDetailPeers` lists: these two paths answer outside the pipeline, unauthenticated.
|
|
203
224
|
*/
|
|
204
225
|
const healthResponse = (payload: HealthPayload, request: Request, socket: BunServer): Response =>
|
|
205
|
-
|
|
226
|
+
jsonResponse(
|
|
206
227
|
healthBody(
|
|
207
228
|
payload.body,
|
|
208
229
|
role,
|
|
@@ -342,14 +363,40 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
342
363
|
// 'accept' runs first on SIGTERM, but only after core's readiness grace: readyz flips to
|
|
343
364
|
// 503 the moment the drain starts, the socket stays open for `readinessGraceMs`, so the
|
|
344
365
|
// endpoints controller stops routing here before this closes it — not a 502 in between.
|
|
366
|
+
//
|
|
367
|
+
// NOT awaited: Bun's `stop(false)` stops the listener at once but resolves only when every
|
|
368
|
+
// request AND every websocket on it has closed — so one open browser tab held this hook, and
|
|
369
|
+
// every `accept` hook after it (worker, scheduler), until the drain deadline, leaving the
|
|
370
|
+
// `close` hooks 0 ms. Handlers are core's to wait for (`beginWork()`), bodies the `inflight`
|
|
371
|
+
// hook's below, sockets the `close` hook's `stop(true)`. The same shape as
|
|
372
|
+
// `realtime/src/sync-listen.ts`. A rejection is the close hook's to surface, not this one's.
|
|
345
373
|
unregister = onShutdown(
|
|
346
374
|
`http:${role}`,
|
|
347
|
-
|
|
348
|
-
|
|
375
|
+
() => {
|
|
376
|
+
server?.stop(false).catch(() => undefined);
|
|
349
377
|
},
|
|
350
378
|
{ phase: 'accept' },
|
|
351
379
|
);
|
|
352
|
-
//
|
|
380
|
+
// The BODIES. `beginWork()` ends when a handler returns its Response, and a streamed body —
|
|
381
|
+
// a page render, a file, an LLM stream — is still being written after that; awaiting
|
|
382
|
+
// `stop(false)` above was what used to wait for it, websockets and all. Bun's
|
|
383
|
+
// `pendingRequests` counts exactly the responses not yet finished (a client that went away
|
|
384
|
+
// included as finished), never an upgraded socket, so this waits for bodies alone and costs
|
|
385
|
+
// the request path nothing. Polled, and bounded by the drain's own deadline: a body that
|
|
386
|
+
// never ends (SSE) is cut by the `close` hook's `stop(true)`, not waited on forever.
|
|
387
|
+
unregisterBodies = onShutdown(
|
|
388
|
+
`http:${role}:bodies`,
|
|
389
|
+
async (reason) => {
|
|
390
|
+
while (
|
|
391
|
+
(server?.pendingRequests ?? 0) > 0 &&
|
|
392
|
+
systemClock.monotonic() < reason.deadlineAt
|
|
393
|
+
) {
|
|
394
|
+
await Bun.sleep(BODY_POLL_MS);
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
{ phase: 'inflight' },
|
|
398
|
+
);
|
|
399
|
+
// 'close' runs after core has waited out the in-flight phase, bodies included.
|
|
353
400
|
unregisterClose = onShutdown(
|
|
354
401
|
`http:${role}:close`,
|
|
355
402
|
async () => {
|
|
@@ -371,9 +418,12 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
371
418
|
// core's `shutdownHookCount()` exists to make visible. The shape `worker.ts`'s teardown
|
|
372
419
|
// holds: the unregisters are the closure's, not the drain's.
|
|
373
420
|
const releaseHooks = (): void => {
|
|
421
|
+
releaseStore();
|
|
374
422
|
unregister?.();
|
|
423
|
+
unregisterBodies?.();
|
|
375
424
|
unregisterClose?.();
|
|
376
425
|
unregister = undefined;
|
|
426
|
+
unregisterBodies = undefined;
|
|
377
427
|
unregisterClose = undefined;
|
|
378
428
|
};
|
|
379
429
|
if (server === undefined) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// How a route handler, a page loader or an action sets a cookie on the response it cannot build:
|
|
2
|
+
// one serialized line, from core's one serializer, appended to the context's response-header bag —
|
|
3
|
+
// the bag the `response` stage APPENDS `set-cookie` from, so two cookies are two lines, never one.
|
|
4
|
+
|
|
5
|
+
import { CookieInvalidError, type SetCookieOptions, serializeSetCookie } from '@ultimat3/core';
|
|
6
|
+
import { assertInRequest } from './context';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What identifies the cookie being cleared, plus the attributes its prefix or partition requires.
|
|
10
|
+
* A browser deletes only the cookie whose name, `Path` and `Domain` all match — `Partitioned` too
|
|
11
|
+
* for a CHIPS cookie — so these must be what `setCookie` was given. No `maxAge`, `expires` or
|
|
12
|
+
* `priority`: the clearing line decides those itself.
|
|
13
|
+
*/
|
|
14
|
+
export type DeleteCookieOptions = Pick<
|
|
15
|
+
SetCookieOptions,
|
|
16
|
+
'path' | 'domain' | 'secure' | 'httpOnly' | 'sameSite' | 'partitioned'
|
|
17
|
+
>;
|
|
18
|
+
|
|
19
|
+
const DELETE_FIX = 'deleteCookie(name, { path, domain }) # the one way to clear a cookie';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Clearing has ONE spelling, `deleteCookie`, so a reader searching for where a cookie dies finds
|
|
23
|
+
* every site. `maxAge: 0` and an `expires` already past both clear in a browser, so both are
|
|
24
|
+
* refused here rather than left as a second, unsearchable way to do the same thing.
|
|
25
|
+
*/
|
|
26
|
+
function refuseClearing(name: string, options: SetCookieOptions, now: Date): void {
|
|
27
|
+
if (options.maxAge === 0) {
|
|
28
|
+
throw new CookieInvalidError('maxAge', name, 'is 0, which deletes the cookie', DELETE_FIX);
|
|
29
|
+
}
|
|
30
|
+
const { expires } = options;
|
|
31
|
+
if (expires instanceof Date && expires.getTime() <= now.getTime()) {
|
|
32
|
+
throw new CookieInvalidError(
|
|
33
|
+
'expires',
|
|
34
|
+
name,
|
|
35
|
+
'is not after now, so it deletes the cookie',
|
|
36
|
+
DELETE_FIX,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Set cookie `name` on the response to the request in scope. Defaults are core's:
|
|
43
|
+
* `Path=/; HttpOnly; Secure; SameSite=Lax`. Every call APPENDS a line — the same name on two
|
|
44
|
+
* paths is two cookies — and an invalid cookie is `X_COOKIE_INVALID` before anything is appended.
|
|
45
|
+
* Outside a request it throws `X_NO_REQUEST`: a cookie nobody will receive is a silent failure.
|
|
46
|
+
*/
|
|
47
|
+
export const setCookie = (name: string, value: string, options: SetCookieOptions = {}): void => {
|
|
48
|
+
const ctx = assertInRequest('setCookie()');
|
|
49
|
+
refuseClearing(name, options, ctx.now());
|
|
50
|
+
ctx.headers.append('set-cookie', serializeSetCookie(name, value, options));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Clear cookie `name`: an empty value and `Max-Age=0`, with the `Path` and `Domain` it was set
|
|
55
|
+
* with (defaults the same as `setCookie`'s, so a cookie set with none is cleared with none).
|
|
56
|
+
*/
|
|
57
|
+
export const deleteCookie = (name: string, options: DeleteCookieOptions = {}): void => {
|
|
58
|
+
const ctx = assertInRequest('deleteCookie()');
|
|
59
|
+
ctx.headers.append('set-cookie', serializeSetCookie(name, '', { ...options, maxAge: 0 }));
|
|
60
|
+
};
|