@ultimat3/http 1.2.0 → 2.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 +285 -0
- package/README.md +99 -2
- package/package.json +6 -3
- package/src/auth-redirect.ts +81 -0
- package/src/cache-policy.ts +24 -0
- package/src/config.ts +67 -7
- package/src/context.ts +187 -36
- package/src/correlation.ts +44 -0
- package/src/cors.ts +33 -5
- package/src/csrf.ts +85 -0
- package/src/deadline.ts +79 -0
- package/src/error-map.ts +204 -4
- package/src/errors.ts +334 -5
- package/src/finalize.ts +70 -0
- package/src/forwarded.ts +94 -0
- package/src/hooks.ts +39 -4
- package/src/index.ts +60 -23
- package/src/locale.ts +34 -82
- package/src/overlay-style.ts +43 -0
- package/src/overlay.ts +59 -41
- package/src/peer-identity.ts +107 -0
- package/src/pipeline.ts +148 -301
- package/src/rate-limit-buckets.ts +86 -0
- package/src/rate-limit.ts +197 -9
- package/src/redirect.ts +29 -0
- package/src/request.ts +47 -13
- package/src/response.ts +34 -8
- package/src/router.ts +63 -13
- package/src/security-headers.ts +34 -13
- package/src/server.ts +44 -7
- package/src/stages.ts +381 -0
- package/src/type-pins.ts +48 -0
- package/src/validate.ts +13 -2
package/src/rate-limit.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
// Token-bucket rate limiting. The store is an interface so the same limiter runs
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// Token-bucket rate limiting. The store is an interface so the same limiter runs in-memory in
|
|
2
|
+
// dev/tests and against a shared tier in a multi-replica deployment — installed through
|
|
3
|
+
// `createServer({ rateLimitStore })`, and refused at boot when its scope cannot keep the app's
|
|
4
|
+
// declaration; the bucket maths lives here so every driver agrees on the numbers.
|
|
5
|
+
import { rateLimited, rateLimitInvalid, rateLimitNotShared, rateLimitScopeUnset } from './errors';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where a limiter's counters live. A store says which it provides; `RateLimitConfig` says which
|
|
9
|
+
* the deployment requires, and the two are checked against each other once, at boot.
|
|
10
|
+
*/
|
|
11
|
+
export type RateLimitScope = 'process' | 'shared';
|
|
5
12
|
|
|
6
13
|
export interface Bucket {
|
|
7
14
|
/** Burst size. */
|
|
@@ -18,6 +25,8 @@ export interface RateLimitDecision {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export interface RateLimitStore {
|
|
28
|
+
/** Declared, never inferred: a driver knows where its counters live; nothing else does. */
|
|
29
|
+
readonly scope: RateLimitScope;
|
|
21
30
|
take(key: string, bucket: Bucket, cost: number, nowMs: number): Promise<RateLimitDecision>;
|
|
22
31
|
reset(key: string): Promise<void>;
|
|
23
32
|
}
|
|
@@ -27,9 +36,23 @@ export interface RateLimitConfig {
|
|
|
27
36
|
/** Named buckets; a route selects one via `meta.rateLimit`. `default` is required. */
|
|
28
37
|
readonly buckets: Readonly<Record<string, Bucket>>;
|
|
29
38
|
readonly defaultBucket: string;
|
|
39
|
+
/**
|
|
40
|
+
* What this deployment requires of the store. `'shared'` says these numbers are the whole
|
|
41
|
+
* fleet's allowance, and a per-process store then refuses to boot — because N replicas each
|
|
42
|
+
* holding their own counters enforce N × every number here, silently and only in production.
|
|
43
|
+
*/
|
|
44
|
+
readonly scope: RateLimitScope;
|
|
30
45
|
}
|
|
31
46
|
|
|
32
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Everything the framework can decide on its own. `scope` is NOT here: one process is the only
|
|
49
|
+
* thing a framework can promise without being told, and defaulting to it made "we did not ask"
|
|
50
|
+
* indistinguishable from "the app said one replica" — so the chart's `replicas: 3` enforced every
|
|
51
|
+
* number three times over, silently, and the boot check that exists for this
|
|
52
|
+
* (`assertRateLimitScope`) never fired because it only reads a `'shared'` declaration. The
|
|
53
|
+
* comment that used to sit here was right about the fact and wrong about the conclusion: ask.
|
|
54
|
+
*/
|
|
55
|
+
export const DEFAULT_RATE_LIMIT: Omit<RateLimitConfig, 'scope'> = {
|
|
33
56
|
enabled: true,
|
|
34
57
|
defaultBucket: 'default',
|
|
35
58
|
buckets: {
|
|
@@ -40,11 +63,90 @@ export const DEFAULT_RATE_LIMIT: RateLimitConfig = {
|
|
|
40
63
|
},
|
|
41
64
|
};
|
|
42
65
|
|
|
66
|
+
/**
|
|
67
|
+
* `defineHttpConfig`'s one resolver for this slice, so the refusal happens where an author can
|
|
68
|
+
* act on it rather than at the first request. A limiter that is switched off has nothing to be
|
|
69
|
+
* wrong about, so `enabled: false` needs no declaration — and reads as `'process'`.
|
|
70
|
+
*/
|
|
71
|
+
export const resolveRateLimitConfig = (
|
|
72
|
+
input: Partial<RateLimitConfig> | undefined,
|
|
73
|
+
): RateLimitConfig => {
|
|
74
|
+
const merged = { ...DEFAULT_RATE_LIMIT, ...input };
|
|
75
|
+
if (input?.scope !== undefined) return { ...merged, scope: input.scope };
|
|
76
|
+
if (!merged.enabled) return { ...merged, scope: 'process' };
|
|
77
|
+
throw rateLimitScopeUnset();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** How a route, an action or a query spells a limit before it becomes a `Bucket`. */
|
|
81
|
+
export interface RateLimitDeclaration {
|
|
82
|
+
/** The burst a caller may spend at once. */
|
|
83
|
+
readonly limit: number;
|
|
84
|
+
/** The window that refills it. */
|
|
85
|
+
readonly windowMs: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `{ limit, windowMs }` as the limiter's own vocabulary: `5 / 600_000ms` is five held, one back
|
|
90
|
+
* every two minutes. The only conversion between a declaration and the enforcement, so the
|
|
91
|
+
* numbers an OpenAPI operation publishes and the numbers `withRouteBuckets` registers cannot
|
|
92
|
+
* drift. It lives HERE, beside `Bucket` and the maths, because `@ultimat3/action` and
|
|
93
|
+
* `@ultimat3/query` are the same tier and can never import each other — a copy in one of them is
|
|
94
|
+
* a second answer to "what does this limit mean" for the other.
|
|
95
|
+
*
|
|
96
|
+
* **The COMPUTED rate is validated, not just the two declared halves.** The division is where a
|
|
97
|
+
* pair that reads fine becomes one the limiter cannot run on, in both directions:
|
|
98
|
+
* `{ limit: Number.MAX_VALUE, windowMs: 1 }` computes to `Infinity` — a bucket that never empties,
|
|
99
|
+
* which is the same "declared a limit, enforced nothing" as `windowMs: 0` — and a tiny limit over
|
|
100
|
+
* a huge window underflows to `0`, a bucket that never refills, so the endpoint is closed after
|
|
101
|
+
* its first burst rather than limited.
|
|
102
|
+
*/
|
|
103
|
+
export const toBucket = (owner: string, declared: RateLimitDeclaration): Bucket => {
|
|
104
|
+
const refuse = (reason: string): never => {
|
|
105
|
+
throw rateLimitInvalid({
|
|
106
|
+
owner,
|
|
107
|
+
limit: declared.limit,
|
|
108
|
+
windowMs: declared.windowMs,
|
|
109
|
+
reason,
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
// A capacity under one token cannot admit a single request, so the endpoint is closed, not
|
|
113
|
+
// limited — a policy's job, never a rate limit's.
|
|
114
|
+
if (!Number.isFinite(declared.limit) || declared.limit < 1) {
|
|
115
|
+
refuse('limit must be a finite number of at least 1 request');
|
|
116
|
+
}
|
|
117
|
+
if (!Number.isFinite(declared.windowMs) || declared.windowMs <= 0) {
|
|
118
|
+
refuse('windowMs must be finite and greater than zero');
|
|
119
|
+
}
|
|
120
|
+
const refillPerSecond = declared.limit / (declared.windowMs / 1000);
|
|
121
|
+
// Kept though the two checks above make it unreachable today: with `limit >= 1` and a finite
|
|
122
|
+
// window the smallest rate is ~5.6e-306, which is normal, not zero. It is the guard that has to
|
|
123
|
+
// move first if `limit >= 1` is ever relaxed.
|
|
124
|
+
if (!Number.isFinite(refillPerSecond) || refillPerSecond <= 0) {
|
|
125
|
+
refuse(
|
|
126
|
+
`the refill rate it computes to is ${refillPerSecond} per second, which is a bucket that never empties — nothing would be enforced`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return { capacity: declared.limit, refillPerSecond };
|
|
130
|
+
};
|
|
131
|
+
|
|
43
132
|
interface BucketState {
|
|
44
133
|
tokens: number;
|
|
45
134
|
lastMs: number;
|
|
135
|
+
/**
|
|
136
|
+
* The instant this entry becomes indistinguishable from a missing one: a bucket back at
|
|
137
|
+
* capacity answers exactly as a first-ever request does. `Infinity` for a bucket that never
|
|
138
|
+
* refills — only the cap can forget that one.
|
|
139
|
+
*/
|
|
140
|
+
forgetAtMs: number;
|
|
46
141
|
}
|
|
47
142
|
|
|
143
|
+
const forgetAt = (state: BucketState, bucket: Bucket, nowMs: number): number => {
|
|
144
|
+
const toFull = bucket.capacity - state.tokens;
|
|
145
|
+
if (toFull <= 0) return nowMs;
|
|
146
|
+
if (bucket.refillPerSecond <= 0) return Number.POSITIVE_INFINITY;
|
|
147
|
+
return nowMs + Math.ceil((toFull / bucket.refillPerSecond) * 1000);
|
|
148
|
+
};
|
|
149
|
+
|
|
48
150
|
const decide = (
|
|
49
151
|
state: BucketState,
|
|
50
152
|
bucket: Bucket,
|
|
@@ -56,6 +158,7 @@ const decide = (
|
|
|
56
158
|
state.lastMs = nowMs;
|
|
57
159
|
const allowed = tokens >= cost;
|
|
58
160
|
state.tokens = allowed ? tokens - cost : tokens;
|
|
161
|
+
state.forgetAtMs = forgetAt(state, bucket, nowMs);
|
|
59
162
|
const deficit = allowed ? bucket.capacity - state.tokens : cost - state.tokens;
|
|
60
163
|
// A bucket that never refills would give an infinite reset; clamp to a day so the
|
|
61
164
|
// Retry-After header stays a number a client can act on.
|
|
@@ -70,14 +173,69 @@ const decide = (
|
|
|
70
173
|
};
|
|
71
174
|
};
|
|
72
175
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Hard bound on tracked keys. A key is `route|subject`, so one subject throttled on N routes is
|
|
178
|
+
* N entries — a higher natural cardinality than an identity table, which is why this cap is
|
|
179
|
+
* larger than `@ultimat3/auth`'s. At ~200 bytes an entry that is a few megabytes, held.
|
|
180
|
+
*/
|
|
181
|
+
export const DEFAULT_MAX_RATE_LIMIT_KEYS = 20_000;
|
|
182
|
+
|
|
183
|
+
/** An idle store still sweeps this often, so a burst's state does not sit until the next one. */
|
|
184
|
+
const SWEEP_EVERY_MS = 60_000;
|
|
185
|
+
|
|
186
|
+
export interface MemoryRateLimitStore extends RateLimitStore {
|
|
187
|
+
/** Entries tracked right now — the bound, observable. */
|
|
188
|
+
readonly size: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Default driver: correct for one process, which is exactly dev and tests.
|
|
193
|
+
*
|
|
194
|
+
* Bounded, because the key falls back to the connection address: a scan rotating through an
|
|
195
|
+
* IPv6 /64 mints a fresh key per request, and an unbounded map turns that into an OOM. Two
|
|
196
|
+
* rules keep it flat. A refilled bucket is *forgotten*, not evicted — it answers exactly as a
|
|
197
|
+
* missing one, so dropping it costs nothing and a scanner's one-request buckets qualify within
|
|
198
|
+
* a second. Only if that is not enough does the cap evict live state, and then the entries
|
|
199
|
+
* closest to full go first: throwing away a spent bucket is what would hand the scanner a free
|
|
200
|
+
* reset, so the most-throttled key is the last one to go.
|
|
201
|
+
*/
|
|
202
|
+
export const memoryRateLimitStore = (
|
|
203
|
+
options: { readonly maxKeys?: number | undefined } = {},
|
|
204
|
+
): MemoryRateLimitStore => {
|
|
205
|
+
const maxKeys = Math.max(1, Math.floor(options.maxKeys ?? DEFAULT_MAX_RATE_LIMIT_KEYS));
|
|
206
|
+
const evictTo = Math.max(1, Math.floor(maxKeys * 0.9));
|
|
75
207
|
const buckets = new Map<string, BucketState>();
|
|
208
|
+
let lastSweepMs = Number.NEGATIVE_INFINITY;
|
|
209
|
+
|
|
210
|
+
const sweep = (nowMs: number): void => {
|
|
211
|
+
lastSweepMs = nowMs;
|
|
212
|
+
for (const [key, state] of buckets) {
|
|
213
|
+
if (state.forgetAtMs <= nowMs) buckets.delete(key);
|
|
214
|
+
}
|
|
215
|
+
if (buckets.size <= maxKeys) return;
|
|
216
|
+
// Batched down to `evictTo` so this sort is paid once per 10% of the cap, not per request.
|
|
217
|
+
const nearestFull = [...buckets.entries()].sort((a, b) => a[1].forgetAtMs - b[1].forgetAtMs);
|
|
218
|
+
for (const [key] of nearestFull) {
|
|
219
|
+
if (buckets.size <= evictTo) break;
|
|
220
|
+
buckets.delete(key);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
76
224
|
return {
|
|
225
|
+
scope: 'process',
|
|
226
|
+
get size() {
|
|
227
|
+
return buckets.size;
|
|
228
|
+
},
|
|
77
229
|
take(key, bucket, cost, nowMs) {
|
|
78
|
-
const state = buckets.get(key) ?? {
|
|
230
|
+
const state = buckets.get(key) ?? {
|
|
231
|
+
tokens: bucket.capacity,
|
|
232
|
+
lastMs: nowMs,
|
|
233
|
+
forgetAtMs: nowMs,
|
|
234
|
+
};
|
|
79
235
|
buckets.set(key, state);
|
|
80
|
-
|
|
236
|
+
const decision = decide(state, bucket, cost, nowMs);
|
|
237
|
+
if (buckets.size > maxKeys || nowMs - lastSweepMs >= SWEEP_EVERY_MS) sweep(nowMs);
|
|
238
|
+
return Promise.resolve(decision);
|
|
81
239
|
},
|
|
82
240
|
reset(key) {
|
|
83
241
|
buckets.delete(key);
|
|
@@ -109,6 +267,19 @@ export const rateLimitKey = (parts: RateLimitKeyParts): string => {
|
|
|
109
267
|
};
|
|
110
268
|
|
|
111
269
|
export interface RateLimiter {
|
|
270
|
+
/** The store's scope, carried up so the boot check has one thing to read. */
|
|
271
|
+
readonly scope: RateLimitScope;
|
|
272
|
+
/**
|
|
273
|
+
* The table this limiter resolves a bucket NAME against. Declared, never inferred — the same
|
|
274
|
+
* rule as `RateLimitStore.scope` and `@ultimat3/auth`'s `AuthLimiter.policy`, and for the same
|
|
275
|
+
* reason: `createRateLimiter` closes over its config, so nothing outside can see which buckets
|
|
276
|
+
* it actually holds. `createPipeline` compares this against the buckets the ROUTES declare, and
|
|
277
|
+
* an unknown name is refused instead of falling through `bucketFor` to `default`.
|
|
278
|
+
*
|
|
279
|
+
* Optional only so an existing external implementation still type-checks; an absent table
|
|
280
|
+
* cannot be checked, so it is refused exactly as a wrong one is.
|
|
281
|
+
*/
|
|
282
|
+
readonly buckets?: Readonly<Record<string, Bucket>> | undefined;
|
|
112
283
|
check(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
|
|
113
284
|
headers(decision: RateLimitDecision): Record<string, string>;
|
|
114
285
|
/** Throws `X_RATE_LIMITED` when the bucket is empty. */
|
|
@@ -131,6 +302,10 @@ export const createRateLimiter = (options: {
|
|
|
131
302
|
store.take(key, bucketFor(bucketName), cost, now());
|
|
132
303
|
|
|
133
304
|
return {
|
|
305
|
+
scope: store.scope,
|
|
306
|
+
// Published, not private: this is the table `bucketFor` above reads, and the boot check has
|
|
307
|
+
// no other way to learn what this limiter can enforce.
|
|
308
|
+
buckets: options.config.buckets,
|
|
134
309
|
check,
|
|
135
310
|
headers: (decision) => ({
|
|
136
311
|
'ratelimit-limit': String(decision.limit),
|
|
@@ -144,3 +319,16 @@ export const createRateLimiter = (options: {
|
|
|
144
319
|
},
|
|
145
320
|
};
|
|
146
321
|
};
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Boot, never the first request. A per-node store under a `'shared'` declaration is a limit that
|
|
325
|
+
* is quietly N × what the config says — the kind of wrong answer that only shows up as a flood
|
|
326
|
+
* nobody was throttling, at the worst hour. A process that cannot enforce what it was configured
|
|
327
|
+
* to enforce must not start; `enabled: false` is checked too, because a limit declared fleet-wide
|
|
328
|
+
* and then switched off is the same claim with nothing behind it.
|
|
329
|
+
*/
|
|
330
|
+
export const assertRateLimitScope = (config: RateLimitConfig, limiter: RateLimiter): void => {
|
|
331
|
+
if (config.scope !== 'shared') return;
|
|
332
|
+
if (!config.enabled) throw rateLimitNotShared('disabled');
|
|
333
|
+
if (limiter.scope !== 'shared') throw rateLimitNotShared('process');
|
|
334
|
+
};
|
package/src/redirect.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// How a handler that cannot return a `Response` still answers with one. An action's return
|
|
2
|
+
// value is its output schema, on every surface — HTTP, MCP, a job — so "answer 303" cannot be
|
|
3
|
+
// a return value without inventing a second protocol for one surface. It is recorded on the
|
|
4
|
+
// request context instead, and the surface that knows what a redirect means reads it back.
|
|
5
|
+
|
|
6
|
+
import type { RequestContext } from './context';
|
|
7
|
+
import { assertInRequest } from './context';
|
|
8
|
+
import type { RedirectIntent, RedirectStatus } from './response';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Answer this request with a `Location` instead of the handler's return value.
|
|
12
|
+
*
|
|
13
|
+
* 303 by default because the caller is a `<form method="post">`: 303 turns the follow-up into a
|
|
14
|
+
* GET, so a reload does not repost. A 302 here leaves the method up to the browser, and reposts.
|
|
15
|
+
*/
|
|
16
|
+
export const setRedirect = (location: string, status: RedirectStatus = 303): void => {
|
|
17
|
+
assertInRequest('setRedirect()').redirect = { location, status };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read and clear. Clearing is the point: the slot outlives the handler that set it, and a
|
|
22
|
+
* projection that only read it would redirect the *next* thing to look — an idempotent replay,
|
|
23
|
+
* a second action invoked in the same request — to a location it never asked for.
|
|
24
|
+
*/
|
|
25
|
+
export const takeRedirect = (ctx: RequestContext): RedirectIntent | undefined => {
|
|
26
|
+
const intent = ctx.redirect;
|
|
27
|
+
ctx.redirect = undefined;
|
|
28
|
+
return intent;
|
|
29
|
+
};
|
package/src/request.ts
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// context so they cannot drift from what the pipeline resolved.
|
|
4
4
|
|
|
5
5
|
import type { Actor } from '@ultimat3/core';
|
|
6
|
+
import { readWithinLimit } from '@ultimat3/core';
|
|
6
7
|
import type { RequestContext } from './context';
|
|
7
8
|
import { bodyInvalid, buildSkew } from './errors';
|
|
9
|
+
import { readCookie } from './locale';
|
|
8
10
|
import type { Schema } from './validate';
|
|
9
11
|
import { validate, validateSync } from './validate';
|
|
10
12
|
|
|
@@ -13,9 +15,19 @@ export type QueryValues = Readonly<Record<string, string | readonly string[]>>;
|
|
|
13
15
|
const contentTypeOf = (request: Request): string =>
|
|
14
16
|
(request.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase() ?? '';
|
|
15
17
|
|
|
16
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Repeated keys become arrays; everything else stays a string for the schema to coerce.
|
|
20
|
+
*
|
|
21
|
+
* `Object.create(null)`, never `{}`: on a plain object `out['__proto__']` never reads as
|
|
22
|
+
* `undefined` — it reads the inherited `Object.prototype` — so the FIRST `?__proto__=` took the
|
|
23
|
+
* repeated-key branch below and assigned an array through the `__proto__` SETTER, which accepts an
|
|
24
|
+
* object and swapped this object's prototype for it. One occurrence was enough. A null prototype
|
|
25
|
+
* has neither accessor, so `__proto__` is an ordinary key here, and `key in record` — how
|
|
26
|
+
* `coerceQuery` decides whether to coerce a declared property — stops answering true for every
|
|
27
|
+
* member of `Object.prototype`.
|
|
28
|
+
*/
|
|
17
29
|
const parseQuery = (url: URL): QueryValues => {
|
|
18
|
-
const out: Record<string, string | string[]> =
|
|
30
|
+
const out: Record<string, string | string[]> = Object.create(null);
|
|
19
31
|
for (const [key, value] of url.searchParams) {
|
|
20
32
|
const existing = out[key];
|
|
21
33
|
if (existing === undefined) out[key] = value;
|
|
@@ -72,15 +84,28 @@ export class UltimateRequest {
|
|
|
72
84
|
return this.ctx.requestId;
|
|
73
85
|
}
|
|
74
86
|
|
|
75
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Build id the CLIENT thinks it is running. See `assertBuild()`. Not `ctx.buildId`, which is
|
|
89
|
+
* core's meaning of the word — the build this PROCESS serves — and the one every other layer
|
|
90
|
+
* reads off the ambient context.
|
|
91
|
+
*/
|
|
76
92
|
get buildId(): string | null {
|
|
77
|
-
return this.ctx.
|
|
93
|
+
return this.ctx.clientBuildId;
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
header(name: string): string | null {
|
|
81
97
|
return this.raw.headers.get(name);
|
|
82
98
|
}
|
|
83
99
|
|
|
100
|
+
/**
|
|
101
|
+
* One decoded cookie. `hooks.authenticate` is handed this object and nothing else, so this is
|
|
102
|
+
* the seam a session lookup reads — a hand-rolled `Cookie` split in the app is the second
|
|
103
|
+
* parser this method exists to prevent.
|
|
104
|
+
*/
|
|
105
|
+
cookie(name: string): string | null {
|
|
106
|
+
return readCookie(this.raw.headers.get('cookie'), name);
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
param(name: string): string {
|
|
85
110
|
const value = this.ctx.params[name];
|
|
86
111
|
if (value === undefined) {
|
|
@@ -124,7 +149,7 @@ export class UltimateRequest {
|
|
|
124
149
|
*/
|
|
125
150
|
assertBuild(): void {
|
|
126
151
|
const server = this.ctx.config.buildId;
|
|
127
|
-
const client = this.ctx.
|
|
152
|
+
const client = this.ctx.clientBuildId;
|
|
128
153
|
if (server === null || client === null || client === server) return;
|
|
129
154
|
throw buildSkew(client, server);
|
|
130
155
|
}
|
|
@@ -142,21 +167,30 @@ export class UltimateRequest {
|
|
|
142
167
|
const type = contentTypeOf(this.raw);
|
|
143
168
|
if (type === '' || declared === 0) return undefined;
|
|
144
169
|
|
|
145
|
-
//
|
|
170
|
+
// One capped read for every content type, multipart included: the parser runs on bytes this
|
|
171
|
+
// process already agreed to hold, never on a stream it hands to the runtime unbounded.
|
|
172
|
+
const read = await readWithinLimit(this.raw.body, limit);
|
|
173
|
+
if ('over' in read) {
|
|
174
|
+
throw bodyInvalid(this.pathname, [`body is at least ${read.over} bytes, limit is ${limit}`]);
|
|
175
|
+
}
|
|
176
|
+
if (read.bytes.byteLength === 0) return undefined;
|
|
177
|
+
|
|
146
178
|
if (type === 'multipart/form-data') {
|
|
147
179
|
try {
|
|
148
|
-
|
|
180
|
+
// Re-parsed from the capped bytes, so the boundary comes from the header it was announced
|
|
181
|
+
// in — `Response` is the one multipart parser here, exactly as `Request` was.
|
|
182
|
+
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
183
|
+
// `SharedArrayBuffer`, which `Response` does not accept.
|
|
184
|
+
const form = await new Response(new Uint8Array(read.bytes), {
|
|
185
|
+
headers: { 'content-type': this.raw.headers.get('content-type') ?? type },
|
|
186
|
+
}).formData();
|
|
187
|
+
return Object.fromEntries(form);
|
|
149
188
|
} catch (error) {
|
|
150
189
|
throw bodyInvalid(this.pathname, [`could not parse ${type}: ${String(error)}`]);
|
|
151
190
|
}
|
|
152
191
|
}
|
|
153
192
|
|
|
154
|
-
const
|
|
155
|
-
if (buffer.byteLength > limit) {
|
|
156
|
-
throw bodyInvalid(this.pathname, [`body is ${buffer.byteLength} bytes, limit is ${limit}`]);
|
|
157
|
-
}
|
|
158
|
-
if (buffer.byteLength === 0) return undefined;
|
|
159
|
-
const body = new TextDecoder().decode(buffer);
|
|
193
|
+
const body = new TextDecoder().decode(read.bytes);
|
|
160
194
|
try {
|
|
161
195
|
if (type === 'application/json' || type.endsWith('+json')) return JSON.parse(body);
|
|
162
196
|
if (type === 'application/x-www-form-urlencoded') {
|
package/src/response.ts
CHANGED
|
@@ -50,10 +50,22 @@ export const stream = (
|
|
|
50
50
|
export const noContent = (init?: ResponseInit): Response =>
|
|
51
51
|
new Response(null, { ...init, status: 204 });
|
|
52
52
|
|
|
53
|
+
/** 301 is absent on purpose: a permanent redirect is a deploy decision, not an app one. */
|
|
54
|
+
export type RedirectStatus = 302 | 303 | 307 | 308;
|
|
55
|
+
|
|
53
56
|
/** 303 after a mutation, 302 otherwise — never 301 from application code. */
|
|
54
|
-
export const redirect = (location: string, status:
|
|
57
|
+
export const redirect = (location: string, status: RedirectStatus = 302): Response =>
|
|
55
58
|
new Response(null, { status, headers: { location } });
|
|
56
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A redirect a handler asked for but could not return — see `redirect.ts`. Kept beside
|
|
62
|
+
* `redirect()` so the intent and the Response it becomes cannot drift on status.
|
|
63
|
+
*/
|
|
64
|
+
export interface RedirectIntent {
|
|
65
|
+
readonly location: string;
|
|
66
|
+
readonly status: RedirectStatus;
|
|
67
|
+
}
|
|
68
|
+
|
|
57
69
|
/**
|
|
58
70
|
* RFC-9457. The body carries the framework's error contract verbatim: `code`,
|
|
59
71
|
* `cause`, `fix`, `docs`. An agent reading a failed response gets the same three
|
|
@@ -108,19 +120,33 @@ export const cacheControl = (hint: CacheHint): string => {
|
|
|
108
120
|
return parts.join(', ');
|
|
109
121
|
};
|
|
110
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Adds to the cache key without ever replacing it. `Vary` is a set, and two stages contribute to
|
|
125
|
+
* it — the cache stage names the request properties a body depends on, the CORS stage names the
|
|
126
|
+
* origin — so a `set` from the later one silently drops the earlier one's key and a CDN starts
|
|
127
|
+
* serving one variant for all of them.
|
|
128
|
+
*/
|
|
129
|
+
export const addVary = (response: Response, values: readonly string[]): Response => {
|
|
130
|
+
if (values.length === 0) return response;
|
|
131
|
+
const existing = response.headers.get('vary');
|
|
132
|
+
const merged = new Set([...(existing === null ? [] : existing.split(/,\s*/)), ...values]);
|
|
133
|
+
response.headers.set('vary', [...merged].join(', '));
|
|
134
|
+
return response;
|
|
135
|
+
};
|
|
136
|
+
|
|
111
137
|
/** Mutates the response headers in place — responses are per-request, never shared. */
|
|
112
138
|
export const applyCacheHeaders = (response: Response, hint: CacheHint): Response => {
|
|
113
139
|
response.headers.set('cache-control', cacheControl(hint));
|
|
114
140
|
if (hint.tags !== undefined && hint.tags.length > 0) {
|
|
115
141
|
response.headers.set('x-cache-tags', hint.tags.join(','));
|
|
116
142
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
response
|
|
122
|
-
|
|
123
|
-
|
|
143
|
+
// `cookie` is not optional on the shared path. A `public` response is stored by a CDN under the
|
|
144
|
+
// URL, and every session in this framework travels in a cookie — so without it the first
|
|
145
|
+
// signed-in render of a public page is what every later visitor is served.
|
|
146
|
+
return addVary(
|
|
147
|
+
response,
|
|
148
|
+
hint.vary ?? (hint.mode === 'public' ? ['accept-language', 'cookie'] : []),
|
|
149
|
+
);
|
|
124
150
|
};
|
|
125
151
|
|
|
126
152
|
export const withHeaders = (response: Response, headers: Record<string, string>): Response => {
|
package/src/router.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// (`X_ROUTE_CONFLICT`) rather than a coin flip.
|
|
13
13
|
import type { RequestContext } from './context';
|
|
14
14
|
import { routeConflict } from './errors';
|
|
15
|
+
import type { Bucket } from './rate-limit';
|
|
15
16
|
import type { UltimateRequest } from './request';
|
|
16
17
|
import type { CacheHint } from './response';
|
|
17
18
|
import type { Schema } from './validate';
|
|
@@ -60,6 +61,14 @@ export interface RouteMeta {
|
|
|
60
61
|
readonly cache?: CacheHint;
|
|
61
62
|
/** Named bucket from `rateLimit.buckets`. */
|
|
62
63
|
readonly rateLimit?: string;
|
|
64
|
+
/**
|
|
65
|
+
* The numbers that bucket MUST hold, when the route brings its own. Naming a bucket nothing
|
|
66
|
+
* defines is how a declared limit becomes the `default` one: the name fell through
|
|
67
|
+
* `bucketFor`, so an endpoint declaring 5 ran on 120. `withRouteBuckets` registers this into
|
|
68
|
+
* the limiter's table at construction — the one point where routes and config meet — and
|
|
69
|
+
* refuses a configured bucket of the same name that says something else.
|
|
70
|
+
*/
|
|
71
|
+
readonly rateLimitBucket?: Bucket;
|
|
63
72
|
readonly tags?: readonly string[];
|
|
64
73
|
readonly description?: string;
|
|
65
74
|
}
|
|
@@ -84,7 +93,9 @@ export type MatchResult =
|
|
|
84
93
|
readonly ok: false;
|
|
85
94
|
readonly reason: 'method-not-allowed';
|
|
86
95
|
readonly allow: readonly HttpMethod[];
|
|
87
|
-
}
|
|
96
|
+
}
|
|
97
|
+
/** A param or wildcard segment the request wrote as invalid percent-encoding. */
|
|
98
|
+
| { readonly ok: false; readonly reason: 'path-invalid'; readonly segment: string };
|
|
88
99
|
|
|
89
100
|
interface TrieNode {
|
|
90
101
|
readonly statics: Map<string, TrieNode>;
|
|
@@ -164,34 +175,65 @@ interface Candidate {
|
|
|
164
175
|
readonly params: RouteParams;
|
|
165
176
|
}
|
|
166
177
|
|
|
178
|
+
/** The walk's accumulator: the terminals it reached, and why a branch it could not take failed. */
|
|
179
|
+
interface Search {
|
|
180
|
+
readonly out: Candidate[];
|
|
181
|
+
/** First raw segment that would not percent-decode. Only set where a decode was attempted. */
|
|
182
|
+
undecodable: string | undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `undefined` instead of the bare `URIError` `decodeURIComponent('%ZZ')` throws. A pathname is
|
|
187
|
+
* whatever the client typed, and an exception from the match would leave the pipeline with
|
|
188
|
+
* `X_INTERNAL` — a 500, and a page for the on-call, for a request only the caller can fix.
|
|
189
|
+
*/
|
|
190
|
+
const decodeSegment = (segment: string): string | undefined => {
|
|
191
|
+
try {
|
|
192
|
+
return decodeURIComponent(segment);
|
|
193
|
+
} catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
167
198
|
/** Terminal nodes reachable for `segments`, in precedence order. */
|
|
168
199
|
const candidates = (
|
|
169
200
|
current: TrieNode,
|
|
170
201
|
segments: readonly string[],
|
|
171
202
|
index: number,
|
|
172
203
|
params: RouteParams,
|
|
173
|
-
|
|
204
|
+
search: Search,
|
|
174
205
|
): void => {
|
|
175
206
|
if (index === segments.length) {
|
|
176
|
-
if (current.routes.size > 0) out.push({ node: current, params });
|
|
207
|
+
if (current.routes.size > 0) search.out.push({ node: current, params });
|
|
177
208
|
return;
|
|
178
209
|
}
|
|
179
210
|
const segment = segments[index];
|
|
180
211
|
if (segment === undefined) return;
|
|
181
212
|
|
|
213
|
+
// Static segments are compared raw, never decoded, so a malformed escape only ever fails the
|
|
214
|
+
// branch that would have decoded it: a path that reaches no param or wildcard is the 404 it
|
|
215
|
+
// always was, and a static route still wins the precedence it always won.
|
|
182
216
|
const staticChild = current.statics.get(segment);
|
|
183
|
-
if (staticChild !== undefined) candidates(staticChild, segments, index + 1, params,
|
|
217
|
+
if (staticChild !== undefined) candidates(staticChild, segments, index + 1, params, search);
|
|
184
218
|
|
|
185
219
|
if (current.param !== undefined) {
|
|
186
|
-
const
|
|
187
|
-
|
|
220
|
+
const value = decodeSegment(segment);
|
|
221
|
+
if (value === undefined) search.undecodable ??= segment;
|
|
222
|
+
else {
|
|
223
|
+
const next = { ...params, [current.param.name]: value };
|
|
224
|
+
candidates(current.param.node, segments, index + 1, next, search);
|
|
225
|
+
}
|
|
188
226
|
}
|
|
189
227
|
|
|
190
228
|
if (current.wildcard !== undefined) {
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
229
|
+
const tail = segments.slice(index);
|
|
230
|
+
const decoded = tail.map(decodeSegment);
|
|
231
|
+
// `-1` indexes to `undefined`, so this is "the first segment that failed, or none".
|
|
232
|
+
const bad = tail[decoded.indexOf(undefined)];
|
|
233
|
+
if (bad !== undefined) search.undecodable ??= bad;
|
|
234
|
+
else if (current.wildcard.node.routes.size > 0) {
|
|
235
|
+
const next = { ...params, [current.wildcard.name]: decoded.join('/') };
|
|
236
|
+
search.out.push({ node: current.wildcard.node, params: next });
|
|
195
237
|
}
|
|
196
238
|
}
|
|
197
239
|
};
|
|
@@ -206,9 +248,17 @@ const routeFor = (candidate: Candidate, method: HttpMethod): Route | undefined =
|
|
|
206
248
|
|
|
207
249
|
export const matchRoute = (table: RouteTable, method: string, pathname: string): MatchResult => {
|
|
208
250
|
const segments = segmentsOf(pathname);
|
|
209
|
-
const
|
|
210
|
-
candidates(table.root, segments, 0, {},
|
|
211
|
-
|
|
251
|
+
const search: Search = { out: [], undecodable: undefined };
|
|
252
|
+
candidates(table.root, segments, 0, {}, search);
|
|
253
|
+
const found = search.out;
|
|
254
|
+
// A refused decode is only the answer when nothing matched: another branch reaching a route
|
|
255
|
+
// means the request named something real, and the failed decode was a road not taken.
|
|
256
|
+
if (found.length === 0) {
|
|
257
|
+
const { undecodable } = search;
|
|
258
|
+
if (undecodable !== undefined)
|
|
259
|
+
return { ok: false, reason: 'path-invalid', segment: undecodable };
|
|
260
|
+
return { ok: false, reason: 'not-found' };
|
|
261
|
+
}
|
|
212
262
|
|
|
213
263
|
const wanted = method.toUpperCase() as HttpMethod;
|
|
214
264
|
for (const candidate of found) {
|