@ultimat3/http 1.2.0 → 3.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/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
- // in-memory in dev/tests and against Redis/Postgres in a multi-replica deployment;
3
- // the bucket maths lives here so every driver agrees on the numbers.
4
- import { rateLimited } from './errors';
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
- export const DEFAULT_RATE_LIMIT: RateLimitConfig = {
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
- /** Default driver: correct for one process, which is exactly dev and tests. */
74
- export const memoryRateLimitStore = (): RateLimitStore => {
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) ?? { tokens: bucket.capacity, lastMs: nowMs };
230
+ const state = buckets.get(key) ?? {
231
+ tokens: bucket.capacity,
232
+ lastMs: nowMs,
233
+ forgetAtMs: nowMs,
234
+ };
79
235
  buckets.set(key, state);
80
- return Promise.resolve(decide(state, bucket, cost, nowMs));
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
+ };
@@ -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
- /** Repeated keys become arrays; everything else stays a string for the schema to coerce. */
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
- /** Build id the client thinks it is running. See `assertBuild()`. */
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.buildId;
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.buildId;
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
- // multipart is streamed by the runtime; the declared length is the only guard.
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
- return Object.fromEntries(await this.raw.formData());
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 buffer = await this.raw.arrayBuffer();
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: 302 | 303 | 307 | 308 = 302): Response =>
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
- const vary = hint.vary ?? (hint.mode === 'public' ? ['accept-language'] : []);
118
- if (vary.length > 0) {
119
- const existing = response.headers.get('vary');
120
- const merged = new Set([...(existing === null ? [] : existing.split(/,\s*/)), ...vary]);
121
- response.headers.set('vary', [...merged].join(', '));
122
- }
123
- return response;
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
- out: Candidate[],
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, out);
217
+ if (staticChild !== undefined) candidates(staticChild, segments, index + 1, params, search);
184
218
 
185
219
  if (current.param !== undefined) {
186
- const next = { ...params, [current.param.name]: decodeURIComponent(segment) };
187
- candidates(current.param.node, segments, index + 1, next, out);
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 rest = segments.slice(index).map(decodeURIComponent).join('/');
192
- const next = { ...params, [current.wildcard.name]: rest };
193
- if (current.wildcard.node.routes.size > 0) {
194
- out.push({ node: current.wildcard.node, params: next });
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 found: Candidate[] = [];
210
- candidates(table.root, segments, 0, {}, found);
211
- if (found.length === 0) return { ok: false, reason: 'not-found' };
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) {