@ultimat3/http 1.0.0 → 1.2.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/package.json +3 -3
- package/src/pipeline.ts +48 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"test": "bun test"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "1.
|
|
34
|
-
"@ultimat3/schema": "1.
|
|
33
|
+
"@ultimat3/core": "1.2.0",
|
|
34
|
+
"@ultimat3/schema": "1.2.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/pipeline.ts
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
// this order IS the framework's guarantee: context before user code, identity before
|
|
3
3
|
// rate limiting, validation before authz, authz before the handler. Nothing can skip a
|
|
4
4
|
// stage, and the array is exported so `/_x` renders it and pipeline.test.ts asserts it.
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
anonymousActor,
|
|
7
|
+
isAnonymous,
|
|
8
|
+
logger,
|
|
9
|
+
recordRequest,
|
|
10
|
+
runWithContext,
|
|
11
|
+
withSpan,
|
|
12
|
+
} from '@ultimat3/core';
|
|
6
13
|
import { defineHttpConfig, type HttpConfig, stripBasePath } from './config';
|
|
7
14
|
import { actorView, asCtx, createRequestContext, elapsedMs, type RequestContext } from './context';
|
|
8
15
|
import { corsHeaders, preflight } from './cors';
|
|
@@ -135,6 +142,13 @@ export interface PipelineDeps {
|
|
|
135
142
|
const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/;
|
|
136
143
|
const REQUEST_ID = /^[\w.:-]{8,128}$/;
|
|
137
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
|
+
|
|
138
152
|
/** Authenticated routes are never shared-cacheable; that default is not overridable. */
|
|
139
153
|
const defaultCache = (route: Route | undefined): CacheHint =>
|
|
140
154
|
route === undefined || route.meta.auth === 'required'
|
|
@@ -221,7 +235,7 @@ const runners = (deps: PipelineDeps, config: HttpConfig, limiter: RateLimiter) =
|
|
|
221
235
|
actorId: actor?.id ?? null,
|
|
222
236
|
orgId: actor?.orgId ?? null,
|
|
223
237
|
ip: ctx.ip,
|
|
224
|
-
routeName: ctx.route?.meta.name ??
|
|
238
|
+
routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
|
|
225
239
|
});
|
|
226
240
|
const decision = await limiter.check(
|
|
227
241
|
key,
|
|
@@ -394,18 +408,38 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
394
408
|
withSpan(
|
|
395
409
|
`${ctx.method} ${url.pathname}`,
|
|
396
410
|
async (span) => {
|
|
397
|
-
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
411
|
+
// This package's ONE metrics call site. `finally`, not the happy line: `execute`
|
|
412
|
+
// absorbs app throws into a problem response, but a finalize stage can still throw on
|
|
413
|
+
// its own (immutable headers on a `Response.redirect`), and a counter that skips the
|
|
414
|
+
// requests the server handled worst is the one an autoscaler must not have. 500 is the
|
|
415
|
+
// status such a request gets from the caller either way.
|
|
416
|
+
let status = 500;
|
|
417
|
+
try {
|
|
418
|
+
const response = await execute(request, ctx);
|
|
419
|
+
status = response.status;
|
|
420
|
+
// The root span of every request carried no attributes at all, so an exporter got a
|
|
421
|
+
// name and a duration and nothing to correlate: which request, which outcome. These
|
|
422
|
+
// four are what a reader joins on — `x-request-id` off the response, the status the
|
|
423
|
+
// client saw, and the method/path split out of the span name.
|
|
424
|
+
span.setAttributes({
|
|
425
|
+
'http.request_id': ctx.requestId,
|
|
426
|
+
'http.method': ctx.method,
|
|
427
|
+
'http.route': url.pathname,
|
|
428
|
+
'http.status_code': response.status,
|
|
429
|
+
});
|
|
430
|
+
return response;
|
|
431
|
+
} finally {
|
|
432
|
+
// The span may carry the concrete path — a trace is sampled and thrown away. A
|
|
433
|
+
// metric is a stored series per label set, so this is the route PATTERN
|
|
434
|
+
// (`/posts/:id`), and `recordRequest` folds the status to its class for the same
|
|
435
|
+
// reason. Nothing here is attacker-chosen or per-user.
|
|
436
|
+
recordRequest({
|
|
437
|
+
method: ctx.method,
|
|
438
|
+
route: ctx.route?.path ?? UNMATCHED_ROUTE,
|
|
439
|
+
status,
|
|
440
|
+
durationMs: elapsedMs(ctx),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
409
443
|
},
|
|
410
444
|
{ kind: 'server' },
|
|
411
445
|
),
|