@ultimat3/http 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
1
+ // THE request lifecycle. An explicit, ordered array — not a middleware stack — because
2
+ // this order IS the framework's guarantee: context before user code, identity before
3
+ // rate limiting, validation before authz, authz before the handler. Nothing can skip a
4
+ // stage, and the array is exported so `/_x` renders it and pipeline.test.ts asserts it.
5
+ import { logger, runWithContext, withSpan } from '@ultimat3/core';
6
+ import { defineHttpConfig, type HttpConfig, stripBasePath } from './config';
7
+ import { actorView, asCtx, createRequestContext, elapsedMs, type RequestContext } from './context';
8
+ import { corsHeaders, preflight } from './cors';
9
+ import { factsOf } from './error-map';
10
+ import {
11
+ bodyInvalid,
12
+ forbidden,
13
+ methodNotAllowed,
14
+ pipelineNoResponse,
15
+ rateLimited,
16
+ routeNotFound,
17
+ unauthenticated,
18
+ } from './errors';
19
+ import type { ServerHooks } from './hooks';
20
+ import { negotiateLocale, readCookie, resolveTimeZone } from './locale';
21
+ import { compose, type Middleware } from './middleware';
22
+ import { overlayResponse, wantsOverlay } from './overlay';
23
+ import { createRateLimiter, type RateLimiter, rateLimitKey } from './rate-limit';
24
+ import { UltimateRequest } from './request';
25
+ import { applyCacheHeaders, type CacheHint, problem } from './response';
26
+ import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
27
+ import { securityHeaders } from './security-headers';
28
+ import { validate } from './validate';
29
+
30
+ export type StageName =
31
+ | 'request-id'
32
+ | 'trace'
33
+ | 'context'
34
+ | 'locale'
35
+ | 'auth'
36
+ | 'rate-limit'
37
+ | 'body'
38
+ | 'authz'
39
+ | 'handler'
40
+ | 'cache-headers'
41
+ | 'error-map'
42
+ | 'response';
43
+
44
+ /**
45
+ * `request` may short-circuit by returning a Response.
46
+ * `terminal` runs the route handler.
47
+ * `recover` runs only when something above threw.
48
+ * `finalize` always runs, on success and on failure.
49
+ */
50
+ export type StagePhase = 'request' | 'terminal' | 'recover' | 'finalize';
51
+
52
+ export interface StageDoc {
53
+ readonly name: StageName;
54
+ readonly phase: StagePhase;
55
+ /** Why the stage sits at this index. Rendered verbatim by the dev dashboard. */
56
+ readonly why: string;
57
+ }
58
+
59
+ export const PIPELINE_STAGES: readonly StageDoc[] = [
60
+ {
61
+ name: 'request-id',
62
+ phase: 'request',
63
+ why: 'first: every log line, span, error body and problem document quotes it, so it must exist before anything can fail',
64
+ },
65
+ {
66
+ name: 'trace',
67
+ phase: 'request',
68
+ why: 'before any I/O: a span started later would silently exclude auth and DB latency from the trace',
69
+ },
70
+ {
71
+ name: 'context',
72
+ phase: 'request',
73
+ why: 'creates the ambient context and binds the matched route; build skew is checked here because a stale client must be told to reload before it gets a 404 for a route it no longer knows',
74
+ },
75
+ {
76
+ name: 'locale',
77
+ phase: 'request',
78
+ why: 'before auth: even a 401 body is localised, and no date/money formatter may run without an explicit locale + IANA tz',
79
+ },
80
+ {
81
+ name: 'auth',
82
+ phase: 'request',
83
+ why: 'before rate limiting so the limiter keys per actor and per tenant instead of punishing a shared NAT address',
84
+ },
85
+ {
86
+ name: 'rate-limit',
87
+ phase: 'request',
88
+ why: 'before the body is read: a limited request must never make the server allocate its payload',
89
+ },
90
+ {
91
+ name: 'body',
92
+ phase: 'request',
93
+ why: 'before authz because policies take the parsed input as their subject (ownsPost(actor, input.postId))',
94
+ },
95
+ {
96
+ name: 'authz',
97
+ phase: 'request',
98
+ why: 'the last gate: one policy, evaluated here exactly as it is in jobs, live queries and MCP tools',
99
+ },
100
+ { name: 'handler', phase: 'terminal', why: 'the only stage that is app code' },
101
+ {
102
+ name: 'cache-headers',
103
+ phase: 'finalize',
104
+ why: 'after the handler so a handler can override the route default, and before response so a directive cannot drop a security header',
105
+ },
106
+ {
107
+ name: 'error-map',
108
+ phase: 'recover',
109
+ why: 'the single place a throw becomes a status: problem+json for agents and RPC, the dev overlay for browsers',
110
+ },
111
+ {
112
+ name: 'response',
113
+ phase: 'finalize',
114
+ why: 'last: CORS, security headers, server-timing and the accumulated context headers are merged onto whatever the stages produced',
115
+ },
116
+ ];
117
+
118
+ export type StageRun = (
119
+ request: UltimateRequest,
120
+ ctx: RequestContext,
121
+ ) => Response | undefined | Promise<Response | undefined>;
122
+
123
+ export interface Stage extends StageDoc {
124
+ readonly run: StageRun;
125
+ }
126
+
127
+ export interface PipelineDeps {
128
+ readonly table: RouteTable;
129
+ readonly config?: HttpConfig;
130
+ readonly hooks?: ServerHooks;
131
+ readonly middleware?: readonly Middleware[];
132
+ readonly limiter?: RateLimiter;
133
+ }
134
+
135
+ const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/;
136
+ const REQUEST_ID = /^[\w.:-]{8,128}$/;
137
+
138
+ /** Authenticated routes are never shared-cacheable; that default is not overridable. */
139
+ const defaultCache = (route: Route | undefined): CacheHint =>
140
+ route === undefined || route.meta.auth === 'required'
141
+ ? { mode: 'no-store' }
142
+ : { mode: 'public', maxAgeSeconds: 0, sMaxAgeSeconds: 60, staleWhileRevalidateSeconds: 600 };
143
+
144
+ const runners = (deps: PipelineDeps, config: HttpConfig, limiter: RateLimiter) => {
145
+ const hooks = deps.hooks ?? {};
146
+ const wrapped = new Map<Route, RouteHandler>();
147
+ const wrap = compose(deps.middleware ?? []);
148
+ for (const route of deps.table.routes) wrapped.set(route, wrap(route.handler));
149
+
150
+ const table: Record<StageName, StageRun> = {
151
+ 'request-id': (request, ctx) => {
152
+ const inbound = config.trustProxy ? request.header('x-request-id') : null;
153
+ if (inbound !== null && REQUEST_ID.test(inbound)) ctx.requestId = inbound;
154
+ ctx.headers.set('x-request-id', ctx.requestId);
155
+ return undefined;
156
+ },
157
+
158
+ trace: (request, ctx) => {
159
+ const inbound = request.header('traceparent');
160
+ const parsed = inbound === null ? null : TRACEPARENT.exec(inbound);
161
+ if (parsed !== null) {
162
+ ctx.traceId = parsed[1] ?? ctx.traceId;
163
+ ctx.parentSpanId = parsed[2] ?? null;
164
+ }
165
+ ctx.headers.set('x-trace-id', ctx.traceId);
166
+ return undefined;
167
+ },
168
+
169
+ context: (request, ctx) => {
170
+ // A preflight carries no credentials, so answering it after `auth` would 401
171
+ // every legitimate cross-origin call.
172
+ const answered = preflight(request.raw, config.cors);
173
+ if (answered !== undefined) return answered;
174
+
175
+ ctx.buildId = request.header(config.buildIdHeader);
176
+ request.assertBuild();
177
+
178
+ const pathname = stripBasePath(ctx.url.pathname, config.basePath);
179
+ const match = matchRoute(deps.table, ctx.method, pathname);
180
+ if (!match.ok) {
181
+ if (match.reason === 'not-found') throw routeNotFound(ctx.method, pathname);
182
+ ctx.headers.set('allow', match.allow.join(', '));
183
+ throw methodNotAllowed(ctx.method, pathname, match.allow);
184
+ }
185
+ ctx.route = match.route;
186
+ ctx.params = match.params;
187
+ return undefined;
188
+ },
189
+
190
+ locale: (request, ctx) => {
191
+ const cookies = request.header('cookie');
192
+ ctx.locale = negotiateLocale(
193
+ request.header('accept-language'),
194
+ config.locale,
195
+ readCookie(cookies, config.locale.cookie),
196
+ );
197
+ ctx.tz = resolveTimeZone(
198
+ request.header(config.tz.header) ?? readCookie(cookies, config.tz.cookie),
199
+ config.tz,
200
+ );
201
+ ctx.headers.set('content-language', ctx.locale);
202
+ return undefined;
203
+ },
204
+
205
+ auth: async (request, ctx) => {
206
+ if (hooks.authenticate !== undefined) {
207
+ ctx.actor = await hooks.authenticate(request, ctx);
208
+ }
209
+ if (ctx.route?.meta.auth === 'required' && ctx.actor === null) {
210
+ throw unauthenticated(ctx.url.pathname);
211
+ }
212
+ return undefined;
213
+ },
214
+
215
+ 'rate-limit': async (_request, ctx) => {
216
+ if (!config.rateLimit.enabled) return undefined;
217
+ const actor = actorView(ctx.actor);
218
+ const key = rateLimitKey({
219
+ actorId: actor?.id ?? null,
220
+ orgId: actor?.orgId ?? null,
221
+ ip: ctx.ip,
222
+ routeName: ctx.route?.meta.name ?? 'unmatched',
223
+ });
224
+ const decision = await limiter.check(
225
+ key,
226
+ ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
227
+ );
228
+ // Recorded before the throw so the 429 can carry Retry-After and the
229
+ // RateLimit-* headers rather than making the client guess.
230
+ ctx.rateLimit = decision;
231
+ for (const [name, value] of Object.entries(limiter.headers(decision))) {
232
+ ctx.headers.set(name, value);
233
+ }
234
+ if (!decision.allowed) throw rateLimited(key, decision.retryAfterSeconds);
235
+ return undefined;
236
+ },
237
+
238
+ body: async (request, ctx) => {
239
+ const schema = ctx.route?.meta.input;
240
+ if (schema === undefined) return undefined;
241
+ const outcome = await validate(schema, await request.bodyRaw());
242
+ if (!outcome.ok) throw bodyInvalid(ctx.url.pathname, outcome.issues);
243
+ ctx.input = outcome.value;
244
+ return undefined;
245
+ },
246
+
247
+ authz: async (request, ctx) => {
248
+ const route = ctx.route;
249
+ if (route === undefined || route.meta.policy === undefined) return undefined;
250
+ if (hooks.authorize === undefined) {
251
+ // A declared policy with no evaluator is a wiring bug, and failing open
252
+ // here is exactly how a framework ends up with two authz systems.
253
+ throw forbidden(ctx.url.pathname, `no authorizer wired for policy ${route.meta.policy}`);
254
+ }
255
+ const decision = await hooks.authorize(route, request, ctx);
256
+ ctx.authz = decision;
257
+ if (!decision.allowed) throw forbidden(ctx.url.pathname, decision.reason);
258
+ return undefined;
259
+ },
260
+
261
+ handler: async (request, ctx) => {
262
+ const route = ctx.route;
263
+ if (route === undefined) throw routeNotFound(ctx.method, ctx.url.pathname);
264
+ const handler = wrapped.get(route) ?? route.handler;
265
+ return await handler(request, ctx);
266
+ },
267
+
268
+ 'cache-headers': (_request, ctx) => {
269
+ const response = ctx.response;
270
+ if (response === undefined) return undefined;
271
+ if (!response.headers.has('cache-control')) {
272
+ applyCacheHeaders(response, ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route));
273
+ }
274
+ return undefined;
275
+ },
276
+
277
+ 'error-map': (request, ctx) => {
278
+ const error = ctx.error;
279
+ const facts = factsOf(error);
280
+ hooks.onError?.(error, ctx);
281
+ logger.error(`${facts.code}: ${facts.cause} [${ctx.requestId}]`);
282
+ if (config.dev && wantsOverlay(request.raw)) {
283
+ return overlayResponse(error, {
284
+ requestId: ctx.requestId,
285
+ method: ctx.method,
286
+ path: ctx.url.pathname,
287
+ buildId: config.buildId,
288
+ });
289
+ }
290
+ const retryAfter =
291
+ facts.code === 'X_RATE_LIMITED' && ctx.rateLimit !== undefined
292
+ ? { 'retry-after': String(ctx.rateLimit.retryAfterSeconds) }
293
+ : {};
294
+ return problem(error, {
295
+ instance: ctx.url.pathname,
296
+ requestId: ctx.requestId,
297
+ headers: retryAfter,
298
+ });
299
+ },
300
+
301
+ response: (request, ctx) => {
302
+ const response = ctx.response;
303
+ if (response === undefined) return undefined;
304
+ for (const [name, value] of ctx.headers) response.headers.set(name, value);
305
+ for (const [name, value] of Object.entries(
306
+ corsHeaders(config.cors, request.header('origin')),
307
+ )) {
308
+ response.headers.set(name, value);
309
+ }
310
+ for (const [name, value] of Object.entries(
311
+ securityHeaders(config.security, { https: ctx.https }),
312
+ )) {
313
+ response.headers.set(name, value);
314
+ }
315
+ response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
316
+ return undefined;
317
+ },
318
+ };
319
+ return table;
320
+ };
321
+
322
+ export interface HandleInit {
323
+ readonly role: RequestContext['role'];
324
+ readonly ip?: string | null;
325
+ }
326
+
327
+ export interface Pipeline {
328
+ readonly stages: readonly Stage[];
329
+ readonly config: HttpConfig;
330
+ /** Runs the full lifecycle for one request and always resolves to a Response. */
331
+ handle(request: Request, init: HandleInit): Promise<Response>;
332
+ }
333
+
334
+ export const createPipeline = (deps: PipelineDeps): Pipeline => {
335
+ const config = deps.config ?? defineHttpConfig();
336
+ const limiter = deps.limiter ?? createRateLimiter({ config: config.rateLimit });
337
+ const run = runners(deps, config, limiter);
338
+ const stages: readonly Stage[] = PIPELINE_STAGES.map((doc) => ({ ...doc, run: run[doc.name] }));
339
+
340
+ const byPhase = (phase: StagePhase): readonly Stage[] =>
341
+ stages.filter((stage) => stage.phase === phase);
342
+ const requestStages = byPhase('request');
343
+ const finalizeStages = byPhase('finalize');
344
+ const terminal = stages.find((stage) => stage.phase === 'terminal');
345
+ const recover = stages.find((stage) => stage.phase === 'recover');
346
+
347
+ const execute = async (request: UltimateRequest, ctx: RequestContext): Promise<Response> => {
348
+ try {
349
+ for (const stage of requestStages) {
350
+ const short = await stage.run(request, ctx);
351
+ if (short !== undefined) {
352
+ ctx.response = short;
353
+ break;
354
+ }
355
+ }
356
+ if (ctx.response === undefined && terminal !== undefined) {
357
+ ctx.response = (await terminal.run(request, ctx)) ?? new Response(null, { status: 204 });
358
+ }
359
+ } catch (error) {
360
+ ctx.error = error;
361
+ ctx.response =
362
+ (recover === undefined ? undefined : await recover.run(request, ctx)) ??
363
+ problem(error, { instance: ctx.url.pathname, requestId: ctx.requestId });
364
+ }
365
+ for (const stage of finalizeStages) {
366
+ const replaced = await stage.run(request, ctx);
367
+ if (replaced !== undefined) ctx.response = replaced;
368
+ }
369
+ return ctx.response ?? problem(pipelineNoResponse('response'));
370
+ };
371
+
372
+ return {
373
+ stages,
374
+ config,
375
+ async handle(raw, init) {
376
+ const url = new URL(raw.url);
377
+ const ctx = createRequestContext({
378
+ url,
379
+ method: raw.method,
380
+ role: init.role,
381
+ config,
382
+ ip: init.ip ?? null,
383
+ });
384
+ const request = new UltimateRequest(raw, ctx);
385
+ // The ALS scope is entered here, before stage 1, so every stage — and everything
386
+ // a handler calls — sees the same context object without threading it by hand.
387
+ return await runWithContext(asCtx(ctx), () =>
388
+ withSpan(`${ctx.method} ${url.pathname}`, () => execute(request, ctx)),
389
+ );
390
+ },
391
+ };
392
+ };
@@ -0,0 +1,146 @@
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';
5
+
6
+ export interface Bucket {
7
+ /** Burst size. */
8
+ readonly capacity: number;
9
+ readonly refillPerSecond: number;
10
+ }
11
+
12
+ export interface RateLimitDecision {
13
+ readonly allowed: boolean;
14
+ readonly limit: number;
15
+ readonly remaining: number;
16
+ readonly resetAtMs: number;
17
+ readonly retryAfterSeconds: number;
18
+ }
19
+
20
+ export interface RateLimitStore {
21
+ take(key: string, bucket: Bucket, cost: number, nowMs: number): Promise<RateLimitDecision>;
22
+ reset(key: string): Promise<void>;
23
+ }
24
+
25
+ export interface RateLimitConfig {
26
+ readonly enabled: boolean;
27
+ /** Named buckets; a route selects one via `meta.rateLimit`. `default` is required. */
28
+ readonly buckets: Readonly<Record<string, Bucket>>;
29
+ readonly defaultBucket: string;
30
+ }
31
+
32
+ export const DEFAULT_RATE_LIMIT: RateLimitConfig = {
33
+ enabled: true,
34
+ defaultBucket: 'default',
35
+ buckets: {
36
+ default: { capacity: 120, refillPerSecond: 2 },
37
+ // Login/signup style endpoints: slow, no burst.
38
+ auth: { capacity: 10, refillPerSecond: 0.2 },
39
+ mutation: { capacity: 30, refillPerSecond: 1 },
40
+ },
41
+ };
42
+
43
+ interface BucketState {
44
+ tokens: number;
45
+ lastMs: number;
46
+ }
47
+
48
+ const decide = (
49
+ state: BucketState,
50
+ bucket: Bucket,
51
+ cost: number,
52
+ nowMs: number,
53
+ ): RateLimitDecision => {
54
+ const elapsedSeconds = Math.max(0, (nowMs - state.lastMs) / 1000);
55
+ const tokens = Math.min(bucket.capacity, state.tokens + elapsedSeconds * bucket.refillPerSecond);
56
+ state.lastMs = nowMs;
57
+ const allowed = tokens >= cost;
58
+ state.tokens = allowed ? tokens - cost : tokens;
59
+ const deficit = allowed ? bucket.capacity - state.tokens : cost - state.tokens;
60
+ // A bucket that never refills would give an infinite reset; clamp to a day so the
61
+ // Retry-After header stays a number a client can act on.
62
+ const secondsToRefill =
63
+ bucket.refillPerSecond > 0 ? Math.min(86_400, deficit / bucket.refillPerSecond) : 86_400;
64
+ return {
65
+ allowed,
66
+ limit: bucket.capacity,
67
+ remaining: Math.floor(state.tokens),
68
+ resetAtMs: nowMs + Math.ceil(secondsToRefill * 1000),
69
+ retryAfterSeconds: allowed ? 0 : Math.max(1, Math.ceil(secondsToRefill)),
70
+ };
71
+ };
72
+
73
+ /** Default driver: correct for one process, which is exactly dev and tests. */
74
+ export const memoryRateLimitStore = (): RateLimitStore => {
75
+ const buckets = new Map<string, BucketState>();
76
+ return {
77
+ take(key, bucket, cost, nowMs) {
78
+ const state = buckets.get(key) ?? { tokens: bucket.capacity, lastMs: nowMs };
79
+ buckets.set(key, state);
80
+ return Promise.resolve(decide(state, bucket, cost, nowMs));
81
+ },
82
+ reset(key) {
83
+ buckets.delete(key);
84
+ return Promise.resolve();
85
+ },
86
+ };
87
+ };
88
+
89
+ export interface RateLimitKeyParts {
90
+ readonly actorId: string | null;
91
+ readonly orgId: string | null;
92
+ readonly ip: string | null;
93
+ readonly routeName: string;
94
+ }
95
+
96
+ /**
97
+ * Key precedence: actor > org > ip. An authenticated actor gets its own bucket so
98
+ * one noisy user cannot exhaust a whole tenant's allowance, and an anonymous
99
+ * request falls back to the connection address.
100
+ */
101
+ export const rateLimitKey = (parts: RateLimitKeyParts): string => {
102
+ const subject =
103
+ parts.actorId !== null
104
+ ? `actor:${parts.actorId}`
105
+ : parts.orgId !== null
106
+ ? `org:${parts.orgId}`
107
+ : `ip:${parts.ip ?? 'unknown'}`;
108
+ return `${parts.routeName}|${subject}`;
109
+ };
110
+
111
+ export interface RateLimiter {
112
+ check(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
113
+ headers(decision: RateLimitDecision): Record<string, string>;
114
+ /** Throws `X_RATE_LIMITED` when the bucket is empty. */
115
+ assert(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
116
+ }
117
+
118
+ export const createRateLimiter = (options: {
119
+ config: RateLimitConfig;
120
+ store?: RateLimitStore;
121
+ now?: () => number;
122
+ }): RateLimiter => {
123
+ const store = options.store ?? memoryRateLimitStore();
124
+ const now = options.now ?? (() => Date.now());
125
+ const bucketFor = (name: string): Bucket =>
126
+ options.config.buckets[name] ??
127
+ options.config.buckets[options.config.defaultBucket] ??
128
+ DEFAULT_RATE_LIMIT.buckets['default'] ?? { capacity: 60, refillPerSecond: 1 };
129
+
130
+ const check: RateLimiter['check'] = (key, bucketName, cost = 1) =>
131
+ store.take(key, bucketFor(bucketName), cost, now());
132
+
133
+ return {
134
+ check,
135
+ headers: (decision) => ({
136
+ 'ratelimit-limit': String(decision.limit),
137
+ 'ratelimit-remaining': String(decision.remaining),
138
+ 'ratelimit-reset': String(Math.ceil((decision.resetAtMs - now()) / 1000)),
139
+ }),
140
+ async assert(key, bucketName, cost = 1) {
141
+ const decision = await check(key, bucketName, cost);
142
+ if (!decision.allowed) throw rateLimited(key, decision.retryAfterSeconds);
143
+ return decision;
144
+ },
145
+ };
146
+ };
package/src/request.ts ADDED
@@ -0,0 +1,170 @@
1
+ // The typed request. Handlers never touch the raw `Request`: params, query and body
2
+ // only exist here in validated form, and actor/locale/tz are read from the request
3
+ // context so they cannot drift from what the pipeline resolved.
4
+
5
+ import type { Actor } from '@ultimat3/core';
6
+ import type { RequestContext } from './context';
7
+ import { bodyInvalid, buildSkew } from './errors';
8
+ import type { Schema } from './validate';
9
+ import { validate, validateSync } from './validate';
10
+
11
+ export type QueryValues = Readonly<Record<string, string | readonly string[]>>;
12
+
13
+ const contentTypeOf = (request: Request): string =>
14
+ (request.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase() ?? '';
15
+
16
+ /** Repeated keys become arrays; everything else stays a string for the schema to coerce. */
17
+ const parseQuery = (url: URL): QueryValues => {
18
+ const out: Record<string, string | string[]> = {};
19
+ for (const [key, value] of url.searchParams) {
20
+ const existing = out[key];
21
+ if (existing === undefined) out[key] = value;
22
+ else if (Array.isArray(existing)) existing.push(value);
23
+ else out[key] = [existing, value];
24
+ }
25
+ return out;
26
+ };
27
+
28
+ export class UltimateRequest {
29
+ readonly raw: Request;
30
+ readonly ctx: RequestContext;
31
+ #body: { parsed: unknown } | undefined;
32
+
33
+ constructor(raw: Request, ctx: RequestContext) {
34
+ this.raw = raw;
35
+ this.ctx = ctx;
36
+ }
37
+
38
+ get method(): string {
39
+ return this.ctx.method;
40
+ }
41
+
42
+ get url(): URL {
43
+ return this.ctx.url;
44
+ }
45
+
46
+ get pathname(): string {
47
+ return this.ctx.url.pathname;
48
+ }
49
+
50
+ get headers(): Headers {
51
+ return this.raw.headers;
52
+ }
53
+
54
+ get params(): Readonly<Record<string, string>> {
55
+ return this.ctx.params;
56
+ }
57
+
58
+ get actor(): Actor | null {
59
+ return this.ctx.actor;
60
+ }
61
+
62
+ get locale(): string {
63
+ return this.ctx.locale;
64
+ }
65
+
66
+ get tz(): string {
67
+ return this.ctx.tz;
68
+ }
69
+
70
+ get requestId(): string {
71
+ return this.ctx.requestId;
72
+ }
73
+
74
+ /** Build id the client thinks it is running. See `assertBuild()`. */
75
+ get buildId(): string | null {
76
+ return this.ctx.buildId;
77
+ }
78
+
79
+ header(name: string): string | null {
80
+ return this.raw.headers.get(name);
81
+ }
82
+
83
+ param(name: string): string {
84
+ const value = this.ctx.params[name];
85
+ if (value === undefined) {
86
+ throw bodyInvalid(this.pathname, [`no :${name} segment in the matched route path`]);
87
+ }
88
+ return value;
89
+ }
90
+
91
+ queryRaw(): QueryValues {
92
+ return parseQuery(this.ctx.url);
93
+ }
94
+
95
+ /**
96
+ * Query strings are always strings on the wire; the schema does the coercion
97
+ * (`t.integer`, `t.boolean`, …) so a handler never sees `'true'` or `'12'`.
98
+ */
99
+ query<Out>(schema: Schema<Out>): Out {
100
+ const outcome = validateSync(schema, this.queryRaw());
101
+ if (!outcome.ok) throw bodyInvalid(this.pathname, outcome.issues);
102
+ return outcome.value;
103
+ }
104
+
105
+ /** Parsed by content-type, cached, size-capped. Returns `undefined` for no body. */
106
+ async bodyRaw(): Promise<unknown> {
107
+ if (this.#body !== undefined) return this.#body.parsed;
108
+ const parsed = await this.#read();
109
+ this.#body = { parsed };
110
+ return parsed;
111
+ }
112
+
113
+ async body<Out>(schema: Schema<Out>): Promise<Out> {
114
+ const outcome = await validate(schema, await this.bodyRaw());
115
+ if (!outcome.ok) throw bodyInvalid(this.pathname, outcome.issues);
116
+ return outcome.value;
117
+ }
118
+
119
+ /**
120
+ * Version skew: the client sends its build id on every request. A mismatch means
121
+ * the client is holding stale RPC contracts, so it must reload rather than get a
122
+ * confusing validation error three layers down.
123
+ */
124
+ assertBuild(): void {
125
+ const server = this.ctx.config.buildId;
126
+ const client = this.ctx.buildId;
127
+ if (server === null || client === null || client === server) return;
128
+ throw buildSkew(client, server);
129
+ }
130
+
131
+ async #read(): Promise<unknown> {
132
+ if (this.method === 'GET' || this.method === 'HEAD') return undefined;
133
+ const limit = this.ctx.config.bodyLimitBytes;
134
+ const header = this.header('content-length');
135
+ // A missing content-length means "unknown", not "empty" — only an explicit 0 is
136
+ // an empty body. Getting this wrong silently drops every chunked request.
137
+ const declared = header === null ? null : Number.parseInt(header, 10);
138
+ if (declared !== null && Number.isFinite(declared) && declared > limit) {
139
+ throw bodyInvalid(this.pathname, [`body is ${declared} bytes, limit is ${limit}`]);
140
+ }
141
+ const type = contentTypeOf(this.raw);
142
+ if (type === '' || declared === 0) return undefined;
143
+
144
+ // multipart is streamed by the runtime; the declared length is the only guard.
145
+ if (type === 'multipart/form-data') {
146
+ try {
147
+ return Object.fromEntries(await this.raw.formData());
148
+ } catch (error) {
149
+ throw bodyInvalid(this.pathname, [`could not parse ${type}: ${String(error)}`]);
150
+ }
151
+ }
152
+
153
+ const buffer = await this.raw.arrayBuffer();
154
+ if (buffer.byteLength > limit) {
155
+ throw bodyInvalid(this.pathname, [`body is ${buffer.byteLength} bytes, limit is ${limit}`]);
156
+ }
157
+ if (buffer.byteLength === 0) return undefined;
158
+ const body = new TextDecoder().decode(buffer);
159
+ try {
160
+ if (type === 'application/json' || type.endsWith('+json')) return JSON.parse(body);
161
+ if (type === 'application/x-www-form-urlencoded') {
162
+ return Object.fromEntries(new URLSearchParams(body));
163
+ }
164
+ if (type.startsWith('text/')) return body;
165
+ } catch (error) {
166
+ throw bodyInvalid(this.pathname, [`could not parse ${type}: ${String(error)}`]);
167
+ }
168
+ throw bodyInvalid(this.pathname, [`unsupported content-type ${type}`]);
169
+ }
170
+ }