@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/pipeline.ts
CHANGED
|
@@ -1,78 +1,47 @@
|
|
|
1
|
-
// THE request lifecycle
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from '
|
|
13
|
-
import {
|
|
14
|
-
import { actorView, asCtx, createRequestContext, elapsedMs, type RequestContext } from './context';
|
|
15
|
-
import { corsHeaders, preflight } from './cors';
|
|
16
|
-
import { factsOf } from './error-map';
|
|
17
|
-
import {
|
|
18
|
-
bodyInvalid,
|
|
19
|
-
forbidden,
|
|
20
|
-
methodNotAllowed,
|
|
21
|
-
pipelineNoResponse,
|
|
22
|
-
rateLimited,
|
|
23
|
-
routeNotFound,
|
|
24
|
-
unauthenticated,
|
|
25
|
-
} 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';
|
|
26
14
|
import type { ServerHooks } from './hooks';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
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';
|
|
31
19
|
import { UltimateRequest } from './request';
|
|
32
|
-
import {
|
|
33
|
-
import
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
| 'locale'
|
|
42
|
-
| 'auth'
|
|
43
|
-
| 'rate-limit'
|
|
44
|
-
| 'body'
|
|
45
|
-
| 'authz'
|
|
46
|
-
| 'handler'
|
|
47
|
-
| 'cache-headers'
|
|
48
|
-
| 'error-map'
|
|
49
|
-
| 'response';
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* `request` may short-circuit by returning a Response.
|
|
53
|
-
* `terminal` runs the route handler.
|
|
54
|
-
* `recover` runs only when something above threw.
|
|
55
|
-
* `finalize` always runs, on success and on failure.
|
|
56
|
-
*/
|
|
57
|
-
export type StagePhase = 'request' | 'terminal' | 'recover' | 'finalize';
|
|
58
|
-
|
|
59
|
-
export interface StageDoc {
|
|
60
|
-
readonly name: StageName;
|
|
61
|
-
readonly phase: StagePhase;
|
|
62
|
-
/** Why the stage sits at this index. Rendered verbatim by the dev dashboard. */
|
|
63
|
-
readonly why: string;
|
|
64
|
-
}
|
|
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';
|
|
65
29
|
|
|
66
30
|
export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
67
31
|
{
|
|
68
32
|
name: 'request-id',
|
|
69
33
|
phase: 'request',
|
|
70
|
-
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',
|
|
71
40
|
},
|
|
72
41
|
{
|
|
73
42
|
name: 'trace',
|
|
74
43
|
phase: 'request',
|
|
75
|
-
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',
|
|
76
45
|
},
|
|
77
46
|
{
|
|
78
47
|
name: 'context',
|
|
@@ -94,6 +63,11 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
94
63
|
phase: 'request',
|
|
95
64
|
why: 'before the body is read: a limited request must never make the server allocate its payload',
|
|
96
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
|
+
},
|
|
97
71
|
{
|
|
98
72
|
name: 'body',
|
|
99
73
|
phase: 'request',
|
|
@@ -122,223 +96,22 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
122
96
|
},
|
|
123
97
|
];
|
|
124
98
|
|
|
125
|
-
export type StageRun = (
|
|
126
|
-
request: UltimateRequest,
|
|
127
|
-
ctx: RequestContext,
|
|
128
|
-
) => Response | undefined | Promise<Response | undefined>;
|
|
129
|
-
|
|
130
|
-
export interface Stage extends StageDoc {
|
|
131
|
-
readonly run: StageRun;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
99
|
export interface PipelineDeps {
|
|
135
100
|
readonly table: RouteTable;
|
|
136
101
|
readonly config?: HttpConfig;
|
|
137
102
|
readonly hooks?: ServerHooks;
|
|
138
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
|
+
*/
|
|
139
112
|
readonly limiter?: RateLimiter;
|
|
140
113
|
}
|
|
141
114
|
|
|
142
|
-
const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/;
|
|
143
|
-
const REQUEST_ID = /^[\w.:-]{8,128}$/;
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* The one label a request with no matched route may carry. Every 404 and every scan of `/wp-admin`
|
|
147
|
-
* would otherwise be its own rate-limit bucket and its own metric series — an attacker choosing
|
|
148
|
-
* the server's cardinality is how a Prometheus dies.
|
|
149
|
-
*/
|
|
150
|
-
const UNMATCHED_ROUTE = 'unmatched';
|
|
151
|
-
|
|
152
|
-
/** Authenticated routes are never shared-cacheable; that default is not overridable. */
|
|
153
|
-
const defaultCache = (route: Route | undefined): CacheHint =>
|
|
154
|
-
route === undefined || route.meta.auth === 'required'
|
|
155
|
-
? { mode: 'no-store' }
|
|
156
|
-
: { mode: 'public', maxAgeSeconds: 0, sMaxAgeSeconds: 60, staleWhileRevalidateSeconds: 600 };
|
|
157
|
-
|
|
158
|
-
const runners = (deps: PipelineDeps, config: HttpConfig, limiter: RateLimiter) => {
|
|
159
|
-
const hooks = deps.hooks ?? {};
|
|
160
|
-
const wrapped = new Map<Route, RouteHandler>();
|
|
161
|
-
const wrap = compose(deps.middleware ?? []);
|
|
162
|
-
for (const route of deps.table.routes) wrapped.set(route, wrap(route.handler));
|
|
163
|
-
|
|
164
|
-
const table: Record<StageName, StageRun> = {
|
|
165
|
-
'request-id': (request, ctx) => {
|
|
166
|
-
const inbound = config.trustProxy ? request.header('x-request-id') : null;
|
|
167
|
-
if (inbound !== null && REQUEST_ID.test(inbound)) ctx.requestId = inbound;
|
|
168
|
-
ctx.headers.set('x-request-id', ctx.requestId);
|
|
169
|
-
return undefined;
|
|
170
|
-
},
|
|
171
|
-
|
|
172
|
-
trace: (request, ctx) => {
|
|
173
|
-
const inbound = request.header('traceparent');
|
|
174
|
-
const parsed = inbound === null ? null : TRACEPARENT.exec(inbound);
|
|
175
|
-
if (parsed !== null) {
|
|
176
|
-
ctx.traceId = parsed[1] ?? ctx.traceId;
|
|
177
|
-
ctx.parentSpanId = parsed[2] ?? null;
|
|
178
|
-
}
|
|
179
|
-
ctx.headers.set('x-trace-id', ctx.traceId);
|
|
180
|
-
return undefined;
|
|
181
|
-
},
|
|
182
|
-
|
|
183
|
-
context: (request, ctx) => {
|
|
184
|
-
// A preflight carries no credentials, so answering it after `auth` would 401
|
|
185
|
-
// every legitimate cross-origin call.
|
|
186
|
-
const answered = preflight(request.raw, config.cors);
|
|
187
|
-
if (answered !== undefined) return answered;
|
|
188
|
-
|
|
189
|
-
ctx.buildId = request.header(config.buildIdHeader);
|
|
190
|
-
request.assertBuild();
|
|
191
|
-
|
|
192
|
-
const pathname = stripBasePath(ctx.url.pathname, config.basePath);
|
|
193
|
-
const match = matchRoute(deps.table, ctx.method, pathname);
|
|
194
|
-
if (!match.ok) {
|
|
195
|
-
if (match.reason === 'not-found') throw routeNotFound(ctx.method, pathname);
|
|
196
|
-
ctx.headers.set('allow', match.allow.join(', '));
|
|
197
|
-
throw methodNotAllowed(ctx.method, pathname, match.allow);
|
|
198
|
-
}
|
|
199
|
-
ctx.route = match.route;
|
|
200
|
-
ctx.params = match.params;
|
|
201
|
-
return undefined;
|
|
202
|
-
},
|
|
203
|
-
|
|
204
|
-
locale: (request, ctx) => {
|
|
205
|
-
const cookies = request.header('cookie');
|
|
206
|
-
ctx.locale = negotiateLocale(
|
|
207
|
-
request.header('accept-language'),
|
|
208
|
-
config.locale,
|
|
209
|
-
readCookie(cookies, config.locale.cookie),
|
|
210
|
-
);
|
|
211
|
-
ctx.tz = resolveTimeZone(
|
|
212
|
-
request.header(config.tz.header) ?? readCookie(cookies, config.tz.cookie),
|
|
213
|
-
config.tz,
|
|
214
|
-
);
|
|
215
|
-
ctx.headers.set('content-language', ctx.locale);
|
|
216
|
-
return undefined;
|
|
217
|
-
},
|
|
218
|
-
|
|
219
|
-
auth: async (request, ctx) => {
|
|
220
|
-
if (hooks.authenticate !== undefined) {
|
|
221
|
-
// The hook says "anonymous" with null; the context says it with core's anonymous actor,
|
|
222
|
-
// because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
|
|
223
|
-
ctx.actor = (await hooks.authenticate(request, ctx)) ?? anonymousActor();
|
|
224
|
-
}
|
|
225
|
-
if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
|
|
226
|
-
throw unauthenticated(ctx.url.pathname);
|
|
227
|
-
}
|
|
228
|
-
return undefined;
|
|
229
|
-
},
|
|
230
|
-
|
|
231
|
-
'rate-limit': async (_request, ctx) => {
|
|
232
|
-
if (!config.rateLimit.enabled) return undefined;
|
|
233
|
-
const actor = actorView(ctx.actor);
|
|
234
|
-
const key = rateLimitKey({
|
|
235
|
-
actorId: actor?.id ?? null,
|
|
236
|
-
orgId: actor?.orgId ?? null,
|
|
237
|
-
ip: ctx.ip,
|
|
238
|
-
routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
|
|
239
|
-
});
|
|
240
|
-
const decision = await limiter.check(
|
|
241
|
-
key,
|
|
242
|
-
ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
|
|
243
|
-
);
|
|
244
|
-
// Recorded before the throw so the 429 can carry Retry-After and the
|
|
245
|
-
// RateLimit-* headers rather than making the client guess.
|
|
246
|
-
ctx.rateLimit = decision;
|
|
247
|
-
for (const [name, value] of Object.entries(limiter.headers(decision))) {
|
|
248
|
-
ctx.headers.set(name, value);
|
|
249
|
-
}
|
|
250
|
-
if (!decision.allowed) throw rateLimited(key, decision.retryAfterSeconds);
|
|
251
|
-
return undefined;
|
|
252
|
-
},
|
|
253
|
-
|
|
254
|
-
body: async (request, ctx) => {
|
|
255
|
-
const schema = ctx.route?.meta.input;
|
|
256
|
-
if (schema === undefined) return undefined;
|
|
257
|
-
const outcome = await validate(schema, await request.bodyRaw());
|
|
258
|
-
if (!outcome.ok) throw bodyInvalid(ctx.url.pathname, outcome.issues);
|
|
259
|
-
ctx.input = outcome.value;
|
|
260
|
-
return undefined;
|
|
261
|
-
},
|
|
262
|
-
|
|
263
|
-
authz: async (request, ctx) => {
|
|
264
|
-
const route = ctx.route;
|
|
265
|
-
if (route === undefined || route.meta.policy === undefined) return undefined;
|
|
266
|
-
// The handler owns this route's single evaluation (`RouteMeta.enforcedBy`). Deciding
|
|
267
|
-
// here as well would be a second authz system holding strictly less than the first —
|
|
268
|
-
// no row — and it is the one that answers first, so it is the one that would win.
|
|
269
|
-
if (route.meta.enforcedBy === 'handler') return undefined;
|
|
270
|
-
if (hooks.authorize === undefined) {
|
|
271
|
-
// A declared policy with no evaluator is a wiring bug, and failing open
|
|
272
|
-
// here is exactly how a framework ends up with two authz systems.
|
|
273
|
-
throw forbidden(ctx.url.pathname, `no authorizer wired for policy ${route.meta.policy}`);
|
|
274
|
-
}
|
|
275
|
-
const decision = await hooks.authorize(route, request, ctx);
|
|
276
|
-
ctx.authz = decision;
|
|
277
|
-
if (!decision.allowed) throw forbidden(ctx.url.pathname, decision.reason);
|
|
278
|
-
return undefined;
|
|
279
|
-
},
|
|
280
|
-
|
|
281
|
-
handler: async (request, ctx) => {
|
|
282
|
-
const route = ctx.route;
|
|
283
|
-
if (route === undefined) throw routeNotFound(ctx.method, ctx.url.pathname);
|
|
284
|
-
const handler = wrapped.get(route) ?? route.handler;
|
|
285
|
-
return await handler(request, ctx);
|
|
286
|
-
},
|
|
287
|
-
|
|
288
|
-
'cache-headers': (_request, ctx) => {
|
|
289
|
-
const response = ctx.response;
|
|
290
|
-
if (response === undefined) return undefined;
|
|
291
|
-
if (!response.headers.has('cache-control')) {
|
|
292
|
-
applyCacheHeaders(response, ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route));
|
|
293
|
-
}
|
|
294
|
-
return undefined;
|
|
295
|
-
},
|
|
296
|
-
|
|
297
|
-
'error-map': (request, ctx) => {
|
|
298
|
-
const error = ctx.error;
|
|
299
|
-
const facts = factsOf(error);
|
|
300
|
-
hooks.onError?.(error, ctx);
|
|
301
|
-
logger.error(`${facts.code}: ${facts.cause} [${ctx.requestId}]`);
|
|
302
|
-
if (config.dev && wantsOverlay(request.raw)) {
|
|
303
|
-
return overlayResponse(error, {
|
|
304
|
-
requestId: ctx.requestId,
|
|
305
|
-
method: ctx.method,
|
|
306
|
-
path: ctx.url.pathname,
|
|
307
|
-
buildId: config.buildId,
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
const retryAfter =
|
|
311
|
-
facts.code === 'X_RATE_LIMITED' && ctx.rateLimit !== undefined
|
|
312
|
-
? { 'retry-after': String(ctx.rateLimit.retryAfterSeconds) }
|
|
313
|
-
: {};
|
|
314
|
-
return problem(error, {
|
|
315
|
-
instance: ctx.url.pathname,
|
|
316
|
-
requestId: ctx.requestId,
|
|
317
|
-
headers: retryAfter,
|
|
318
|
-
});
|
|
319
|
-
},
|
|
320
|
-
|
|
321
|
-
response: (request, ctx) => {
|
|
322
|
-
const response = ctx.response;
|
|
323
|
-
if (response === undefined) return undefined;
|
|
324
|
-
for (const [name, value] of ctx.headers) response.headers.set(name, value);
|
|
325
|
-
for (const [name, value] of Object.entries(
|
|
326
|
-
corsHeaders(config.cors, request.header('origin')),
|
|
327
|
-
)) {
|
|
328
|
-
response.headers.set(name, value);
|
|
329
|
-
}
|
|
330
|
-
for (const [name, value] of Object.entries(
|
|
331
|
-
securityHeaders(config.security, { https: ctx.https }),
|
|
332
|
-
)) {
|
|
333
|
-
response.headers.set(name, value);
|
|
334
|
-
}
|
|
335
|
-
response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
|
|
336
|
-
return undefined;
|
|
337
|
-
},
|
|
338
|
-
};
|
|
339
|
-
return table;
|
|
340
|
-
};
|
|
341
|
-
|
|
342
115
|
export interface HandleInit {
|
|
343
116
|
readonly role: RequestContext['role'];
|
|
344
117
|
readonly ip?: string | null;
|
|
@@ -347,14 +120,32 @@ export interface HandleInit {
|
|
|
347
120
|
export interface Pipeline {
|
|
348
121
|
readonly stages: readonly Stage[];
|
|
349
122
|
readonly config: HttpConfig;
|
|
350
|
-
/**
|
|
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
|
+
*/
|
|
351
128
|
handle(request: Request, init: HandleInit): Promise<Response>;
|
|
352
129
|
}
|
|
353
130
|
|
|
354
131
|
export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
355
|
-
|
|
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);
|
|
356
136
|
const limiter = deps.limiter ?? createRateLimiter({ config: config.rateLimit });
|
|
357
|
-
|
|
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
|
+
});
|
|
358
149
|
const stages: readonly Stage[] = PIPELINE_STAGES.map((doc) => ({ ...doc, run: run[doc.name] }));
|
|
359
150
|
|
|
360
151
|
const byPhase = (phase: StagePhase): readonly Stage[] =>
|
|
@@ -362,30 +153,45 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
362
153
|
const requestStages = byPhase('request');
|
|
363
154
|
const finalizeStages = byPhase('finalize');
|
|
364
155
|
const terminal = stages.find((stage) => stage.phase === 'terminal');
|
|
365
|
-
const
|
|
156
|
+
const recovered = recoverWith(stages.find((stage) => stage.phase === 'recover'));
|
|
366
157
|
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
break;
|
|
374
|
-
}
|
|
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;
|
|
375
164
|
}
|
|
376
|
-
|
|
377
|
-
|
|
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]);
|
|
378
186
|
}
|
|
379
187
|
} catch (error) {
|
|
380
188
|
ctx.error = error;
|
|
381
|
-
ctx.response =
|
|
382
|
-
(recover === undefined ? undefined : await recover.run(request, ctx)) ??
|
|
383
|
-
problem(error, { instance: ctx.url.pathname, requestId: ctx.requestId });
|
|
384
|
-
}
|
|
385
|
-
for (const stage of finalizeStages) {
|
|
386
|
-
const replaced = await stage.run(request, ctx);
|
|
387
|
-
if (replaced !== undefined) ctx.response = replaced;
|
|
189
|
+
ctx.response = await recovered(request, ctx);
|
|
388
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);
|
|
389
195
|
return ctx.response ?? problem(pipelineNoResponse('response'));
|
|
390
196
|
};
|
|
391
197
|
|
|
@@ -394,12 +200,43 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
394
200
|
config,
|
|
395
201
|
async handle(raw, init) {
|
|
396
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
|
+
};
|
|
397
220
|
const ctx = createRequestContext({
|
|
398
221
|
url,
|
|
399
222
|
method: raw.method,
|
|
400
223
|
role: init.role,
|
|
401
224
|
config,
|
|
402
|
-
|
|
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,
|
|
403
240
|
});
|
|
404
241
|
const request = new UltimateRequest(raw, ctx);
|
|
405
242
|
// The ALS scope is entered here, before stage 1, so every stage — and everything
|
|
@@ -409,13 +246,13 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
409
246
|
`${ctx.method} ${url.pathname}`,
|
|
410
247
|
async (span) => {
|
|
411
248
|
// This package's ONE metrics call site. `finally`, not the happy line: `execute`
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
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.
|
|
416
253
|
let status = 500;
|
|
417
254
|
try {
|
|
418
|
-
const response = await execute(request, ctx);
|
|
255
|
+
const response = await execute(request, ctx, deadline);
|
|
419
256
|
status = response.status;
|
|
420
257
|
// The root span of every request carried no attributes at all, so an exporter got a
|
|
421
258
|
// name and a duration and nothing to correlate: which request, which outcome. These
|
|
@@ -439,9 +276,19 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
439
276
|
status,
|
|
440
277
|
durationMs: elapsedMs(ctx),
|
|
441
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();
|
|
442
282
|
}
|
|
443
283
|
},
|
|
444
|
-
{
|
|
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 }),
|
|
291
|
+
},
|
|
445
292
|
),
|
|
446
293
|
);
|
|
447
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
|
+
};
|