@ultimat3/http 1.1.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 +175 -294
- 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/pipeline.ts
CHANGED
|
@@ -1,71 +1,47 @@
|
|
|
1
|
-
// THE request lifecycle
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
pipelineNoResponse,
|
|
15
|
-
rateLimited,
|
|
16
|
-
routeNotFound,
|
|
17
|
-
unauthenticated,
|
|
18
|
-
} from './errors';
|
|
1
|
+
// THE request lifecycle: which stages exist, in what ORDER, why — and the one loop that drives a
|
|
2
|
+
// request through them. The order IS the framework's guarantee, an explicit array and not a
|
|
3
|
+
// middleware stack, so nothing can skip a stage and `/_x` and `pipeline.test.ts` can both read it.
|
|
4
|
+
// The other two thirds of the lifecycle are siblings: `stages.ts` owns what each stage does, and
|
|
5
|
+
// `finalize.ts` owns the promise that the tail always answers rather than rejecting.
|
|
6
|
+
import { recordRequest, runWithContext, withSpan } from '@ultimat3/core';
|
|
7
|
+
import { defineHttpConfig, type HttpConfig } from './config';
|
|
8
|
+
import { asCtx, createRequestContext, elapsedMs, type RequestContext } from './context';
|
|
9
|
+
import { readCorrelation } from './correlation';
|
|
10
|
+
import { type Deadline, startDeadline } from './deadline';
|
|
11
|
+
import { pipelineNoResponse } from './errors';
|
|
12
|
+
import { recoverWith, runFinalize } from './finalize';
|
|
13
|
+
import { clientAddress, clientUsedHttps } from './forwarded';
|
|
19
14
|
import type { ServerHooks } from './hooks';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
15
|
+
import type { Middleware } from './middleware';
|
|
16
|
+
import { peerIdentity } from './peer-identity';
|
|
17
|
+
import { assertRateLimitScope, createRateLimiter, type RateLimiter } from './rate-limit';
|
|
18
|
+
import { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
|
|
24
19
|
import { UltimateRequest } from './request';
|
|
25
|
-
import {
|
|
26
|
-
import
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
}
|
|
20
|
+
import { problem } from './response';
|
|
21
|
+
import type { RouteTable } from './router';
|
|
22
|
+
import {
|
|
23
|
+
type Stage,
|
|
24
|
+
type StageDoc,
|
|
25
|
+
type StagePhase,
|
|
26
|
+
stageRunners,
|
|
27
|
+
UNMATCHED_ROUTE,
|
|
28
|
+
} from './stages';
|
|
58
29
|
|
|
59
30
|
export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
60
31
|
{
|
|
61
32
|
name: 'request-id',
|
|
62
33
|
phase: 'request',
|
|
63
|
-
why: 'first: every log line, span, error body and problem document quotes it, so it must
|
|
34
|
+
why: 'first: every log line, span, error body and problem document quotes it, so it must be on the response before anything can fail',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'admit',
|
|
38
|
+
phase: 'request',
|
|
39
|
+
why: 'second, and before every other stage does any work: a draining process answers 503 here rather than accepting work it will abandon, and past maxInflight a request is shed with Retry-After before it can queue behind the pool that is already the bottleneck',
|
|
64
40
|
},
|
|
65
41
|
{
|
|
66
42
|
name: 'trace',
|
|
67
43
|
phase: 'request',
|
|
68
|
-
why: '
|
|
44
|
+
why: 'publishes the trace id the root span already carries — the span is started before stage 1, from the inbound traceparent, because one started here would exclude every stage above it and could not adopt the caller as its parent',
|
|
69
45
|
},
|
|
70
46
|
{
|
|
71
47
|
name: 'context',
|
|
@@ -87,6 +63,11 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
87
63
|
phase: 'request',
|
|
88
64
|
why: 'before the body is read: a limited request must never make the server allocate its payload',
|
|
89
65
|
},
|
|
66
|
+
{
|
|
67
|
+
name: 'csrf',
|
|
68
|
+
phase: 'request',
|
|
69
|
+
why: 'after auth so it only judges a caller holding an AMBIENT credential — a bearer token and an anonymous call are both exempt — and before body so a forged write never makes the server allocate its payload. CORS cannot cover this: application/x-www-form-urlencoded is a simple content type, so a cross-site form post is sent and executed and only the RESPONSE is withheld',
|
|
70
|
+
},
|
|
90
71
|
{
|
|
91
72
|
name: 'body',
|
|
92
73
|
phase: 'request',
|
|
@@ -115,216 +96,22 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
115
96
|
},
|
|
116
97
|
];
|
|
117
98
|
|
|
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
99
|
export interface PipelineDeps {
|
|
128
100
|
readonly table: RouteTable;
|
|
129
101
|
readonly config?: HttpConfig;
|
|
130
102
|
readonly hooks?: ServerHooks;
|
|
131
103
|
readonly middleware?: readonly Middleware[];
|
|
104
|
+
/**
|
|
105
|
+
* A limiter built elsewhere — `createServer({ rateLimitStore })` is the one supported way, and
|
|
106
|
+
* it hands over a limiter built from the SAME merged config this constructor would have built.
|
|
107
|
+
* One passed from anywhere else resolves bucket names against the table IT closed over, which
|
|
108
|
+
* is why `assertRouteBuckets` compares that table against the routes' declarations and refuses
|
|
109
|
+
* a limiter that cannot enforce one (`X_RATE_LIMIT_BUCKET_UNBOUND`) rather than letting the
|
|
110
|
+
* name fall through to `default`.
|
|
111
|
+
*/
|
|
132
112
|
readonly limiter?: RateLimiter;
|
|
133
113
|
}
|
|
134
114
|
|
|
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
|
-
// The hook says "anonymous" with null; the context says it with core's anonymous actor,
|
|
208
|
-
// because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
|
|
209
|
-
ctx.actor = (await hooks.authenticate(request, ctx)) ?? anonymousActor();
|
|
210
|
-
}
|
|
211
|
-
if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
|
|
212
|
-
throw unauthenticated(ctx.url.pathname);
|
|
213
|
-
}
|
|
214
|
-
return undefined;
|
|
215
|
-
},
|
|
216
|
-
|
|
217
|
-
'rate-limit': async (_request, ctx) => {
|
|
218
|
-
if (!config.rateLimit.enabled) return undefined;
|
|
219
|
-
const actor = actorView(ctx.actor);
|
|
220
|
-
const key = rateLimitKey({
|
|
221
|
-
actorId: actor?.id ?? null,
|
|
222
|
-
orgId: actor?.orgId ?? null,
|
|
223
|
-
ip: ctx.ip,
|
|
224
|
-
routeName: ctx.route?.meta.name ?? 'unmatched',
|
|
225
|
-
});
|
|
226
|
-
const decision = await limiter.check(
|
|
227
|
-
key,
|
|
228
|
-
ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
|
|
229
|
-
);
|
|
230
|
-
// Recorded before the throw so the 429 can carry Retry-After and the
|
|
231
|
-
// RateLimit-* headers rather than making the client guess.
|
|
232
|
-
ctx.rateLimit = decision;
|
|
233
|
-
for (const [name, value] of Object.entries(limiter.headers(decision))) {
|
|
234
|
-
ctx.headers.set(name, value);
|
|
235
|
-
}
|
|
236
|
-
if (!decision.allowed) throw rateLimited(key, decision.retryAfterSeconds);
|
|
237
|
-
return undefined;
|
|
238
|
-
},
|
|
239
|
-
|
|
240
|
-
body: async (request, ctx) => {
|
|
241
|
-
const schema = ctx.route?.meta.input;
|
|
242
|
-
if (schema === undefined) return undefined;
|
|
243
|
-
const outcome = await validate(schema, await request.bodyRaw());
|
|
244
|
-
if (!outcome.ok) throw bodyInvalid(ctx.url.pathname, outcome.issues);
|
|
245
|
-
ctx.input = outcome.value;
|
|
246
|
-
return undefined;
|
|
247
|
-
},
|
|
248
|
-
|
|
249
|
-
authz: async (request, ctx) => {
|
|
250
|
-
const route = ctx.route;
|
|
251
|
-
if (route === undefined || route.meta.policy === undefined) return undefined;
|
|
252
|
-
// The handler owns this route's single evaluation (`RouteMeta.enforcedBy`). Deciding
|
|
253
|
-
// here as well would be a second authz system holding strictly less than the first —
|
|
254
|
-
// no row — and it is the one that answers first, so it is the one that would win.
|
|
255
|
-
if (route.meta.enforcedBy === 'handler') return undefined;
|
|
256
|
-
if (hooks.authorize === undefined) {
|
|
257
|
-
// A declared policy with no evaluator is a wiring bug, and failing open
|
|
258
|
-
// here is exactly how a framework ends up with two authz systems.
|
|
259
|
-
throw forbidden(ctx.url.pathname, `no authorizer wired for policy ${route.meta.policy}`);
|
|
260
|
-
}
|
|
261
|
-
const decision = await hooks.authorize(route, request, ctx);
|
|
262
|
-
ctx.authz = decision;
|
|
263
|
-
if (!decision.allowed) throw forbidden(ctx.url.pathname, decision.reason);
|
|
264
|
-
return undefined;
|
|
265
|
-
},
|
|
266
|
-
|
|
267
|
-
handler: async (request, ctx) => {
|
|
268
|
-
const route = ctx.route;
|
|
269
|
-
if (route === undefined) throw routeNotFound(ctx.method, ctx.url.pathname);
|
|
270
|
-
const handler = wrapped.get(route) ?? route.handler;
|
|
271
|
-
return await handler(request, ctx);
|
|
272
|
-
},
|
|
273
|
-
|
|
274
|
-
'cache-headers': (_request, ctx) => {
|
|
275
|
-
const response = ctx.response;
|
|
276
|
-
if (response === undefined) return undefined;
|
|
277
|
-
if (!response.headers.has('cache-control')) {
|
|
278
|
-
applyCacheHeaders(response, ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route));
|
|
279
|
-
}
|
|
280
|
-
return undefined;
|
|
281
|
-
},
|
|
282
|
-
|
|
283
|
-
'error-map': (request, ctx) => {
|
|
284
|
-
const error = ctx.error;
|
|
285
|
-
const facts = factsOf(error);
|
|
286
|
-
hooks.onError?.(error, ctx);
|
|
287
|
-
logger.error(`${facts.code}: ${facts.cause} [${ctx.requestId}]`);
|
|
288
|
-
if (config.dev && wantsOverlay(request.raw)) {
|
|
289
|
-
return overlayResponse(error, {
|
|
290
|
-
requestId: ctx.requestId,
|
|
291
|
-
method: ctx.method,
|
|
292
|
-
path: ctx.url.pathname,
|
|
293
|
-
buildId: config.buildId,
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
const retryAfter =
|
|
297
|
-
facts.code === 'X_RATE_LIMITED' && ctx.rateLimit !== undefined
|
|
298
|
-
? { 'retry-after': String(ctx.rateLimit.retryAfterSeconds) }
|
|
299
|
-
: {};
|
|
300
|
-
return problem(error, {
|
|
301
|
-
instance: ctx.url.pathname,
|
|
302
|
-
requestId: ctx.requestId,
|
|
303
|
-
headers: retryAfter,
|
|
304
|
-
});
|
|
305
|
-
},
|
|
306
|
-
|
|
307
|
-
response: (request, ctx) => {
|
|
308
|
-
const response = ctx.response;
|
|
309
|
-
if (response === undefined) return undefined;
|
|
310
|
-
for (const [name, value] of ctx.headers) response.headers.set(name, value);
|
|
311
|
-
for (const [name, value] of Object.entries(
|
|
312
|
-
corsHeaders(config.cors, request.header('origin')),
|
|
313
|
-
)) {
|
|
314
|
-
response.headers.set(name, value);
|
|
315
|
-
}
|
|
316
|
-
for (const [name, value] of Object.entries(
|
|
317
|
-
securityHeaders(config.security, { https: ctx.https }),
|
|
318
|
-
)) {
|
|
319
|
-
response.headers.set(name, value);
|
|
320
|
-
}
|
|
321
|
-
response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
|
|
322
|
-
return undefined;
|
|
323
|
-
},
|
|
324
|
-
};
|
|
325
|
-
return table;
|
|
326
|
-
};
|
|
327
|
-
|
|
328
115
|
export interface HandleInit {
|
|
329
116
|
readonly role: RequestContext['role'];
|
|
330
117
|
readonly ip?: string | null;
|
|
@@ -333,14 +120,32 @@ export interface HandleInit {
|
|
|
333
120
|
export interface Pipeline {
|
|
334
121
|
readonly stages: readonly Stage[];
|
|
335
122
|
readonly config: HttpConfig;
|
|
336
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* Runs the full lifecycle for one request and always resolves to a Response — a stage that
|
|
125
|
+
* throws after the handler, or while rendering another stage's throw, degrades to the coded
|
|
126
|
+
* 500 (`X_PIPELINE_FINALIZE_FAILED`) rather than rejecting. A caller has a socket open.
|
|
127
|
+
*/
|
|
337
128
|
handle(request: Request, init: HandleInit): Promise<Response>;
|
|
338
129
|
}
|
|
339
130
|
|
|
340
131
|
export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
341
|
-
|
|
132
|
+
// Routes first, config second, and the merge here: `defineHttpConfig` cannot see a route, so a
|
|
133
|
+
// bucket a route declares only becomes real at the construction that holds both. Idempotent, so
|
|
134
|
+
// a config `createServer` already merged passes through unchanged.
|
|
135
|
+
const config = withRouteBuckets(deps.config ?? defineHttpConfig(), deps.table.routes);
|
|
342
136
|
const limiter = deps.limiter ?? createRateLimiter({ config: config.rateLimit });
|
|
343
|
-
|
|
137
|
+
// Here rather than in `createServer`: this is the one construction path every server, test and
|
|
138
|
+
// embedder shares, so a limiter that cannot keep the app's declaration is refused exactly once.
|
|
139
|
+
// Two halves of one question — where the counters live, and which buckets the limiter holds.
|
|
140
|
+
assertRateLimitScope(config.rateLimit, limiter);
|
|
141
|
+
assertRouteBuckets(limiter, deps.table.routes);
|
|
142
|
+
const run = stageRunners({
|
|
143
|
+
table: deps.table,
|
|
144
|
+
config,
|
|
145
|
+
limiter,
|
|
146
|
+
hooks: deps.hooks ?? {},
|
|
147
|
+
middleware: deps.middleware ?? [],
|
|
148
|
+
});
|
|
344
149
|
const stages: readonly Stage[] = PIPELINE_STAGES.map((doc) => ({ ...doc, run: run[doc.name] }));
|
|
345
150
|
|
|
346
151
|
const byPhase = (phase: StagePhase): readonly Stage[] =>
|
|
@@ -348,30 +153,45 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
348
153
|
const requestStages = byPhase('request');
|
|
349
154
|
const finalizeStages = byPhase('finalize');
|
|
350
155
|
const terminal = stages.find((stage) => stage.phase === 'terminal');
|
|
351
|
-
const
|
|
156
|
+
const recovered = recoverWith(stages.find((stage) => stage.phase === 'recover'));
|
|
352
157
|
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
break;
|
|
360
|
-
}
|
|
158
|
+
const runStages = async (request: UltimateRequest, ctx: RequestContext): Promise<void> => {
|
|
159
|
+
for (const stage of requestStages) {
|
|
160
|
+
const short = await stage.run(request, ctx);
|
|
161
|
+
if (short !== undefined) {
|
|
162
|
+
ctx.response = short;
|
|
163
|
+
return;
|
|
361
164
|
}
|
|
362
|
-
|
|
363
|
-
|
|
165
|
+
}
|
|
166
|
+
if (ctx.response === undefined && terminal !== undefined) {
|
|
167
|
+
ctx.response = (await terminal.run(request, ctx)) ?? new Response(null, { status: 204 });
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const execute = async (
|
|
172
|
+
request: UltimateRequest,
|
|
173
|
+
ctx: RequestContext,
|
|
174
|
+
deadline: Deadline,
|
|
175
|
+
): Promise<Response> => {
|
|
176
|
+
try {
|
|
177
|
+
const work = runStages(request, ctx);
|
|
178
|
+
if (deadline.expired === undefined) await work;
|
|
179
|
+
else {
|
|
180
|
+
// The abort is the cooperative half and app code that reads `ctx.signal` unwinds on its
|
|
181
|
+
// own; this race is the half that answers the SOCKET when it does not. `work` keeps its
|
|
182
|
+
// own handler either way — a rejection arriving after the deadline already won is still
|
|
183
|
+
// a rejection, and an unhandled one takes the process down.
|
|
184
|
+
void work.catch(() => undefined);
|
|
185
|
+
await Promise.race([work, deadline.expired]);
|
|
364
186
|
}
|
|
365
187
|
} catch (error) {
|
|
366
188
|
ctx.error = error;
|
|
367
|
-
ctx.response =
|
|
368
|
-
(recover === undefined ? undefined : await recover.run(request, ctx)) ??
|
|
369
|
-
problem(error, { instance: ctx.url.pathname, requestId: ctx.requestId });
|
|
370
|
-
}
|
|
371
|
-
for (const stage of finalizeStages) {
|
|
372
|
-
const replaced = await stage.run(request, ctx);
|
|
373
|
-
if (replaced !== undefined) ctx.response = replaced;
|
|
189
|
+
ctx.response = await recovered(request, ctx);
|
|
374
190
|
}
|
|
191
|
+
// `finalize.ts`, not a loop here: these two stages run after the request is already answered
|
|
192
|
+
// or already failed, so a throw of their own has nothing left to catch it — and `handle`
|
|
193
|
+
// promises a Response.
|
|
194
|
+
await runFinalize(finalizeStages, request, ctx, recovered);
|
|
375
195
|
return ctx.response ?? problem(pipelineNoResponse('response'));
|
|
376
196
|
};
|
|
377
197
|
|
|
@@ -380,12 +200,43 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
380
200
|
config,
|
|
381
201
|
async handle(raw, init) {
|
|
382
202
|
const url = new URL(raw.url);
|
|
203
|
+
// Before the context and before the span, in this order on purpose. `startSpan` resolves
|
|
204
|
+
// its parent from `currentSpanContext()`, which reads `ctx.traceId` — so a `traceparent`
|
|
205
|
+
// parsed by a stage arrived one frame too late, every time: the caller's trace was
|
|
206
|
+
// discarded and the root span kept an id the logs beside it never mentioned.
|
|
207
|
+
const correlation = readCorrelation(raw.headers, config);
|
|
208
|
+
const deadline = startDeadline({
|
|
209
|
+
headers: raw.headers,
|
|
210
|
+
config,
|
|
211
|
+
method: raw.method.toUpperCase(),
|
|
212
|
+
pathname: url.pathname,
|
|
213
|
+
});
|
|
214
|
+
const forwarded = {
|
|
215
|
+
headers: raw.headers,
|
|
216
|
+
config,
|
|
217
|
+
socketAddress: init.ip ?? null,
|
|
218
|
+
urlProtocol: url.protocol,
|
|
219
|
+
};
|
|
383
220
|
const ctx = createRequestContext({
|
|
384
221
|
url,
|
|
385
222
|
method: raw.method,
|
|
386
223
|
role: init.role,
|
|
387
224
|
config,
|
|
388
|
-
|
|
225
|
+
requestId: correlation.requestId,
|
|
226
|
+
traceId: correlation.traceId,
|
|
227
|
+
parentSpanId: correlation.parentSpanId,
|
|
228
|
+
// Resolved here, not in `server.ts`: `pipeline.fetch()` is the supported way to test a
|
|
229
|
+
// route and it must take the identical path a socket does.
|
|
230
|
+
ip: clientAddress(forwarded),
|
|
231
|
+
https: clientUsedHttps(forwarded),
|
|
232
|
+
// The mesh's assertion about the caller's certificate, read at the SAME hop index as the
|
|
233
|
+
// address — an identity from an untrusted hop authenticates, which is worse than none.
|
|
234
|
+
peer: peerIdentity(forwarded),
|
|
235
|
+
signal: deadline.signal,
|
|
236
|
+
// The context is what app code reaches through core's ALS; without the inbound headers
|
|
237
|
+
// on it, a cookie the server itself set could never be read back on the next request,
|
|
238
|
+
// and `ctx.session` had no way to exist.
|
|
239
|
+
requestHeaders: raw.headers,
|
|
389
240
|
});
|
|
390
241
|
const request = new UltimateRequest(raw, ctx);
|
|
391
242
|
// The ALS scope is entered here, before stage 1, so every stage — and everything
|
|
@@ -394,20 +245,50 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
394
245
|
withSpan(
|
|
395
246
|
`${ctx.method} ${url.pathname}`,
|
|
396
247
|
async (span) => {
|
|
397
|
-
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
248
|
+
// This package's ONE metrics call site. `finally`, not the happy line: `execute`
|
|
249
|
+
// answers every stage's throw with a problem response, but a counter that skipped the
|
|
250
|
+
// requests the server handled worst is the one an autoscaler must not have — so a
|
|
251
|
+
// rejection nothing here predicted still counts, at the 500 such a request gets from
|
|
252
|
+
// the caller either way.
|
|
253
|
+
let status = 500;
|
|
254
|
+
try {
|
|
255
|
+
const response = await execute(request, ctx, deadline);
|
|
256
|
+
status = response.status;
|
|
257
|
+
// The root span of every request carried no attributes at all, so an exporter got a
|
|
258
|
+
// name and a duration and nothing to correlate: which request, which outcome. These
|
|
259
|
+
// four are what a reader joins on — `x-request-id` off the response, the status the
|
|
260
|
+
// client saw, and the method/path split out of the span name.
|
|
261
|
+
span.setAttributes({
|
|
262
|
+
'http.request_id': ctx.requestId,
|
|
263
|
+
'http.method': ctx.method,
|
|
264
|
+
'http.route': url.pathname,
|
|
265
|
+
'http.status_code': response.status,
|
|
266
|
+
});
|
|
267
|
+
return response;
|
|
268
|
+
} finally {
|
|
269
|
+
// The span may carry the concrete path — a trace is sampled and thrown away. A
|
|
270
|
+
// metric is a stored series per label set, so this is the route PATTERN
|
|
271
|
+
// (`/posts/:id`), and `recordRequest` folds the status to its class for the same
|
|
272
|
+
// reason. Nothing here is attacker-chosen or per-user.
|
|
273
|
+
recordRequest({
|
|
274
|
+
method: ctx.method,
|
|
275
|
+
route: ctx.route?.path ?? UNMATCHED_ROUTE,
|
|
276
|
+
status,
|
|
277
|
+
durationMs: elapsedMs(ctx),
|
|
278
|
+
});
|
|
279
|
+
// A live timer keeps the event loop from going idle, so a process that answered
|
|
280
|
+
// every request would still refuse to exit for the length of one timeout.
|
|
281
|
+
deadline.clear();
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
kind: 'server',
|
|
286
|
+
// Explicit, and the whole point of parsing `traceparent` up here: without it
|
|
287
|
+
// `startSpan` falls back to `currentSpanContext()` — the context's own traceId, with
|
|
288
|
+
// an empty spanId — which produces a root span with no parent and a trace the caller
|
|
289
|
+
// never heard of.
|
|
290
|
+
...(correlation.parent === undefined ? {} : { parent: correlation.parent }),
|
|
409
291
|
},
|
|
410
|
-
{ kind: 'server' },
|
|
411
292
|
),
|
|
412
293
|
);
|
|
413
294
|
},
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Registration: a route that declares its own bucket puts it in the limiter's table. The bucket
|
|
2
|
+
// maths and the store stay in `rate-limit.ts`; this file is the one point where routes and config
|
|
3
|
+
// meet — `defineHttpConfig` runs before any route exists, so the table it builds cannot hold them,
|
|
4
|
+
// and a bucket name with nothing behind it falls through `bucketFor` to `default`.
|
|
5
|
+
|
|
6
|
+
import type { HttpConfig } from './config';
|
|
7
|
+
import { rateLimitBucketConflict, rateLimitBucketUnbound } from './errors';
|
|
8
|
+
import type { Bucket, RateLimiter } from './rate-limit';
|
|
9
|
+
import type { Route } from './router';
|
|
10
|
+
|
|
11
|
+
const same = (a: Bucket, b: Bucket): boolean =>
|
|
12
|
+
a.capacity === b.capacity && a.refillPerSecond === b.refillPerSecond;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The config with every route-declared bucket registered under the name that route selects.
|
|
16
|
+
*
|
|
17
|
+
* Precedence is refusal, never a winner: an equal restatement passes, and any disagreement — with
|
|
18
|
+
* a configured bucket, or with another route claiming the same name — is
|
|
19
|
+
* `X_RATE_LIMIT_BUCKET_CONFLICT` before the socket opens. Picking one would leave the other a
|
|
20
|
+
* number an author read, an OpenAPI document published and nothing enforced, which is the exact
|
|
21
|
+
* failure this seam closes. Same shape as `@ultimat3/auth`'s `AuthLimiter` policy check.
|
|
22
|
+
*
|
|
23
|
+
* Idempotent, so both construction paths (`createServer`, and `createPipeline` under it) can apply
|
|
24
|
+
* it: a second pass compares each bucket against the copy the first pass registered.
|
|
25
|
+
*/
|
|
26
|
+
export const withRouteBuckets = (config: HttpConfig, routes: readonly Route[]): HttpConfig => {
|
|
27
|
+
const declared = new Map<string, { readonly bucket: Bucket; readonly route: string }>();
|
|
28
|
+
for (const route of routes) {
|
|
29
|
+
const bucket = route.meta.rateLimitBucket;
|
|
30
|
+
const name = route.meta.rateLimit;
|
|
31
|
+
// Numbers with no name to file them under enforce nothing; `toRoute` always sets both.
|
|
32
|
+
if (bucket === undefined || name === undefined) continue;
|
|
33
|
+
const prior = declared.get(name);
|
|
34
|
+
const configured = config.rateLimit.buckets[name];
|
|
35
|
+
if (prior !== undefined && !same(prior.bucket, bucket)) {
|
|
36
|
+
throw rateLimitBucketConflict({
|
|
37
|
+
bucket: name,
|
|
38
|
+
otherRoute: prior.route,
|
|
39
|
+
route: route.meta.name,
|
|
40
|
+
other: prior.bucket,
|
|
41
|
+
declared: bucket,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (prior === undefined && configured !== undefined && !same(configured, bucket)) {
|
|
45
|
+
throw rateLimitBucketConflict({
|
|
46
|
+
bucket: name,
|
|
47
|
+
otherRoute: null,
|
|
48
|
+
route: route.meta.name,
|
|
49
|
+
other: configured,
|
|
50
|
+
declared: bucket,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
declared.set(name, { bucket, route: route.meta.name });
|
|
54
|
+
}
|
|
55
|
+
if (declared.size === 0) return config;
|
|
56
|
+
const buckets: Record<string, Bucket> = { ...config.rateLimit.buckets };
|
|
57
|
+
for (const [name, entry] of declared) buckets[name] = entry.bucket;
|
|
58
|
+
return { ...config, rateLimit: { ...config.rateLimit, buckets } };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The other half of registration: the limiter actually installed must hold what the routes
|
|
63
|
+
* declared. `withRouteBuckets` puts the numbers in the CONFIG, which is enough only when the
|
|
64
|
+
* pipeline builds the limiter from that config. A limiter handed in through `PipelineDeps.limiter`
|
|
65
|
+
* closed over a table of its own, and a name it does not hold falls through `bucketFor` to
|
|
66
|
+
* `default` — silently, and looser than the declaration. So the two tables are compared once, at
|
|
67
|
+
* construction, exactly as `assertRateLimitScope` compares the two scopes.
|
|
68
|
+
*
|
|
69
|
+
* A limiter that declares no table at all is refused for the same reason a per-process store under
|
|
70
|
+
* a `'shared'` declaration is: what cannot be shown to hold is not assumed to hold.
|
|
71
|
+
*/
|
|
72
|
+
export const assertRouteBuckets = (limiter: RateLimiter, routes: readonly Route[]): void => {
|
|
73
|
+
for (const route of routes) {
|
|
74
|
+
const bucket = route.meta.rateLimitBucket;
|
|
75
|
+
const name = route.meta.rateLimit;
|
|
76
|
+
if (bucket === undefined || name === undefined) continue;
|
|
77
|
+
const found = limiter.buckets?.[name];
|
|
78
|
+
if (found !== undefined && same(found, bucket)) continue;
|
|
79
|
+
throw rateLimitBucketUnbound({
|
|
80
|
+
bucket: name,
|
|
81
|
+
route: route.meta.name,
|
|
82
|
+
declared: bucket,
|
|
83
|
+
found: found ?? null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|