@ultimat3/http 0.0.1 → 1.1.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/LICENSE +21 -0
- package/package.json +3 -3
- package/src/config.ts +8 -4
- package/src/context.ts +18 -4
- package/src/error-map.ts +10 -0
- package/src/errors.ts +26 -9
- package/src/pipeline.ts +28 -5
- package/src/request.ts +2 -1
- package/src/router.ts +15 -0
- package/src/server.ts +74 -38
- package/src/validate.ts +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 developerz.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.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": "
|
|
34
|
-
"@ultimat3/schema": "
|
|
33
|
+
"@ultimat3/core": "1.1.0",
|
|
34
|
+
"@ultimat3/schema": "1.1.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/config.ts
CHANGED
|
@@ -50,13 +50,17 @@ export interface HttpConfigInput {
|
|
|
50
50
|
readonly rateLimit?: Partial<RateLimitConfig>;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* `basePath` is stripped before matching so route paths never encode the mount point.
|
|
55
|
+
* Matching is on a segment boundary: a mount at `/api` owns `/api` and `/api/...` but
|
|
56
|
+
* never `/apix`, which is a different route whose first three characters happen to agree.
|
|
57
|
+
*/
|
|
54
58
|
export const stripBasePath = (pathname: string, basePath: string): string => {
|
|
55
59
|
if (basePath === '/' || basePath === '') return pathname;
|
|
56
60
|
const prefix = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
return
|
|
61
|
+
if (pathname === prefix) return '/';
|
|
62
|
+
if (!pathname.startsWith(`${prefix}/`)) return pathname;
|
|
63
|
+
return pathname.slice(prefix.length);
|
|
60
64
|
};
|
|
61
65
|
|
|
62
66
|
const env = (name: string): string | undefined => {
|
package/src/context.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
// The per-request context. It is created by the pipeline before any user code runs
|
|
2
2
|
// and published through core's ALS, which is why nothing in the framework has to
|
|
3
3
|
// thread a request object by hand — and why nothing can accidentally skip it.
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type Actor,
|
|
6
|
+
anonymousActor,
|
|
7
|
+
type Ctx,
|
|
8
|
+
isAnonymous,
|
|
9
|
+
type Role,
|
|
10
|
+
useContext,
|
|
11
|
+
uuid,
|
|
12
|
+
} from '@ultimat3/core';
|
|
5
13
|
import type { HttpConfig } from './config';
|
|
6
14
|
import type { AuthzDecision } from './hooks';
|
|
7
15
|
import type { RateLimitDecision } from './rate-limit';
|
|
@@ -29,7 +37,12 @@ export interface RequestContext {
|
|
|
29
37
|
parentSpanId: string | null;
|
|
30
38
|
params: RouteParams;
|
|
31
39
|
route: Route | undefined;
|
|
32
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Never null: core models "nobody" as the anonymous actor, and `asCtx` publishes this very
|
|
42
|
+
* object through ALS — so a null here would reach every `ctx.actor` reader in the framework
|
|
43
|
+
* as a contract violation that only shows up on the first unauthenticated request.
|
|
44
|
+
*/
|
|
45
|
+
actor: Actor;
|
|
33
46
|
locale: string;
|
|
34
47
|
tz: string;
|
|
35
48
|
buildId: string | null;
|
|
@@ -66,7 +79,7 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
|
|
|
66
79
|
parentSpanId: null,
|
|
67
80
|
params: {},
|
|
68
81
|
route: undefined,
|
|
69
|
-
actor:
|
|
82
|
+
actor: anonymousActor(),
|
|
70
83
|
locale: init.config.locale.default,
|
|
71
84
|
tz: init.config.tz.default,
|
|
72
85
|
buildId: null,
|
|
@@ -101,5 +114,6 @@ export interface ActorView {
|
|
|
101
114
|
readonly orgId?: string | null;
|
|
102
115
|
}
|
|
103
116
|
|
|
117
|
+
/** Anonymous reads as "no actor" so the rate limiter keys an unauthenticated call by IP. */
|
|
104
118
|
export const actorView = (actor: Actor | null): ActorView | null =>
|
|
105
|
-
actor === null ? null : (actor as unknown as ActorView);
|
|
119
|
+
actor === null || isAnonymous(actor) ? null : (actor as unknown as ActorView);
|
package/src/error-map.ts
CHANGED
|
@@ -29,7 +29,12 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
29
29
|
// @ultimat3/policy
|
|
30
30
|
X_POLICY_MISSING: 500,
|
|
31
31
|
X_PERMISSION_UNKNOWN: 500,
|
|
32
|
+
// @ultimat3/seo — a transform query the caller wrote, so the caller is the one who can fix it.
|
|
33
|
+
X_IMAGE_QUERY_INVALID: 400,
|
|
32
34
|
// @ultimat3/core
|
|
35
|
+
// The caller asked for a format the pipeline cannot produce (`?f=avif`): the request names an
|
|
36
|
+
// unsupported representation, which is 415 — not a 500, which would blame the server for it.
|
|
37
|
+
X_IMAGE_UNSUPPORTED: 415,
|
|
33
38
|
X_NOT_IMPLEMENTED: 501,
|
|
34
39
|
X_TIMEOUT: 504,
|
|
35
40
|
X_ABORTED: 499,
|
|
@@ -68,7 +73,12 @@ const asRecord = (value: unknown): Record<string, unknown> =>
|
|
|
68
73
|
export const factsOf = (error: unknown): ErrorFacts => {
|
|
69
74
|
const record = asRecord(error);
|
|
70
75
|
const code = str(record, 'code') ?? 'X_INTERNAL';
|
|
76
|
+
// The error's own title first: every `UltimateError` resolves one from the code registry at
|
|
77
|
+
// construction, so this renders the OWNING package's title — including the codes http only
|
|
78
|
+
// borrows (`X_FORBIDDEN` is policy's, `X_UNAUTHENTICATED` is auth's) and so cannot title itself.
|
|
79
|
+
// Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
|
|
71
80
|
const title =
|
|
81
|
+
str(record, 'title') ??
|
|
72
82
|
HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES] ??
|
|
73
83
|
str(record, 'message') ??
|
|
74
84
|
'unhandled server error';
|
package/src/errors.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
// The HTTP layer's stable error codes. Every throw in this package goes through a
|
|
2
2
|
// factory here so a code, a cause and an exact fix always travel together — the
|
|
3
3
|
// terminal, the dev overlay and `--json` all render the same three strings.
|
|
4
|
-
import { UltimateError } from '@ultimat3/core';
|
|
4
|
+
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/** Codes this package declares and owns. */
|
|
7
|
+
export const HTTP_OWNED_ERROR_CODES = [
|
|
7
8
|
'X_ROUTE_NOT_FOUND',
|
|
8
9
|
'X_METHOD_NOT_ALLOWED',
|
|
9
10
|
'X_BODY_INVALID',
|
|
10
|
-
'X_UNAUTHENTICATED',
|
|
11
|
-
'X_FORBIDDEN',
|
|
12
11
|
'X_RATE_LIMITED',
|
|
13
12
|
'X_BUILD_SKEW',
|
|
14
13
|
'X_ROUTE_CONFLICT',
|
|
@@ -16,15 +15,25 @@ export const HTTP_ERROR_CODES = [
|
|
|
16
15
|
'X_PIPELINE_NO_RESPONSE',
|
|
17
16
|
] as const;
|
|
18
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Codes this package throws but does NOT own. `X_FORBIDDEN` belongs to `@ultimat3/policy` and
|
|
20
|
+
* `X_UNAUTHENTICATED` to `@ultimat3/auth`; registering either here would throw
|
|
21
|
+
* `X_ERROR_CODE_DUPLICATE` at import. No titles for them either — the owner writes the one title
|
|
22
|
+
* every surface renders, and a copy kept here is a copy that goes stale without anything failing.
|
|
23
|
+
*/
|
|
24
|
+
export const HTTP_BORROWED_ERROR_CODES = ['X_UNAUTHENTICATED', 'X_FORBIDDEN'] as const;
|
|
25
|
+
|
|
26
|
+
/** Every code http can throw: the ones it owns plus the two it borrows. */
|
|
27
|
+
export const HTTP_ERROR_CODES = [...HTTP_OWNED_ERROR_CODES, ...HTTP_BORROWED_ERROR_CODES] as const;
|
|
28
|
+
|
|
29
|
+
export type HttpOwnedErrorCode = (typeof HTTP_OWNED_ERROR_CODES)[number];
|
|
19
30
|
export type HttpErrorCode = (typeof HTTP_ERROR_CODES)[number];
|
|
20
31
|
|
|
21
|
-
/** Human title per code. Kept next to the codes so one edit updates every surface. */
|
|
22
|
-
export const HTTP_ERROR_TITLES: Readonly<Record<
|
|
32
|
+
/** Human title per owned code. Kept next to the codes so one edit updates every surface. */
|
|
33
|
+
export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
|
|
23
34
|
X_ROUTE_NOT_FOUND: 'no route matches this request',
|
|
24
35
|
X_METHOD_NOT_ALLOWED: 'route exists but not for this method',
|
|
25
36
|
X_BODY_INVALID: 'request body failed its schema',
|
|
26
|
-
X_UNAUTHENTICATED: 'route requires an authenticated actor',
|
|
27
|
-
X_FORBIDDEN: 'policy denied this actor',
|
|
28
37
|
X_RATE_LIMITED: 'rate limit exhausted for this key',
|
|
29
38
|
X_BUILD_SKEW: 'client build id does not match the server build id',
|
|
30
39
|
X_ROUTE_CONFLICT: 'two routes claim the same path',
|
|
@@ -32,10 +41,19 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpErrorCode, string>> = {
|
|
|
32
41
|
X_PIPELINE_NO_RESPONSE: 'a pipeline stage produced no response',
|
|
33
42
|
};
|
|
34
43
|
|
|
44
|
+
// Registered at module load, unconditionally, in one call, so core's registry renders OUR title
|
|
45
|
+
// everywhere. Without this the registry humanises the code (`X_BUILD_SKEW` → "build skew"); with a
|
|
46
|
+
// presence guard, a package that claimed one of these first would silently keep its own title.
|
|
47
|
+
registerErrorCodes(
|
|
48
|
+
Object.fromEntries(Object.entries(HTTP_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
49
|
+
);
|
|
50
|
+
|
|
35
51
|
const docsFor = (code: HttpErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
36
52
|
|
|
37
53
|
/** Base class for every error this package throws. Never throw a bare `Error`. */
|
|
38
54
|
export class HttpError extends UltimateError {
|
|
55
|
+
override readonly name = 'HttpError';
|
|
56
|
+
|
|
39
57
|
constructor(init: { code: HttpErrorCode; cause: string; fix: string }) {
|
|
40
58
|
super({
|
|
41
59
|
code: init.code,
|
|
@@ -43,7 +61,6 @@ export class HttpError extends UltimateError {
|
|
|
43
61
|
fix: init.fix,
|
|
44
62
|
docs: docsFor(init.code),
|
|
45
63
|
});
|
|
46
|
-
this.name = 'HttpError';
|
|
47
64
|
}
|
|
48
65
|
}
|
|
49
66
|
|
package/src/pipeline.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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 { logger, runWithContext, withSpan } from '@ultimat3/core';
|
|
5
|
+
import { anonymousActor, isAnonymous, logger, runWithContext, withSpan } from '@ultimat3/core';
|
|
6
6
|
import { defineHttpConfig, type HttpConfig, stripBasePath } from './config';
|
|
7
7
|
import { actorView, asCtx, createRequestContext, elapsedMs, type RequestContext } from './context';
|
|
8
8
|
import { corsHeaders, preflight } from './cors';
|
|
@@ -95,7 +95,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
95
95
|
{
|
|
96
96
|
name: 'authz',
|
|
97
97
|
phase: 'request',
|
|
98
|
-
why: 'the last gate: one policy,
|
|
98
|
+
why: 'the last gate, and only for a route whose policy nothing downstream evaluates: one policy, decided here exactly as it is in jobs, live queries and MCP tools. A route marked enforcedBy: handler is skipped, because its handler holds the row this stage cannot load',
|
|
99
99
|
},
|
|
100
100
|
{ name: 'handler', phase: 'terminal', why: 'the only stage that is app code' },
|
|
101
101
|
{
|
|
@@ -204,9 +204,11 @@ const runners = (deps: PipelineDeps, config: HttpConfig, limiter: RateLimiter) =
|
|
|
204
204
|
|
|
205
205
|
auth: async (request, ctx) => {
|
|
206
206
|
if (hooks.authenticate !== undefined) {
|
|
207
|
-
|
|
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();
|
|
208
210
|
}
|
|
209
|
-
if (ctx.route?.meta.auth === 'required' && ctx.actor
|
|
211
|
+
if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
|
|
210
212
|
throw unauthenticated(ctx.url.pathname);
|
|
211
213
|
}
|
|
212
214
|
return undefined;
|
|
@@ -247,6 +249,10 @@ const runners = (deps: PipelineDeps, config: HttpConfig, limiter: RateLimiter) =
|
|
|
247
249
|
authz: async (request, ctx) => {
|
|
248
250
|
const route = ctx.route;
|
|
249
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;
|
|
250
256
|
if (hooks.authorize === undefined) {
|
|
251
257
|
// A declared policy with no evaluator is a wiring bug, and failing open
|
|
252
258
|
// here is exactly how a framework ends up with two authz systems.
|
|
@@ -385,7 +391,24 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
385
391
|
// The ALS scope is entered here, before stage 1, so every stage — and everything
|
|
386
392
|
// a handler calls — sees the same context object without threading it by hand.
|
|
387
393
|
return await runWithContext(asCtx(ctx), () =>
|
|
388
|
-
withSpan(
|
|
394
|
+
withSpan(
|
|
395
|
+
`${ctx.method} ${url.pathname}`,
|
|
396
|
+
async (span) => {
|
|
397
|
+
const response = await execute(request, ctx);
|
|
398
|
+
// The root span of every request carried no attributes at all, so an exporter got a
|
|
399
|
+
// name and a duration and nothing to correlate: which request, which outcome. These
|
|
400
|
+
// four are what a reader joins on — `x-request-id` off the response, the status the
|
|
401
|
+
// client saw, and the method/path split out of the span name.
|
|
402
|
+
span.setAttributes({
|
|
403
|
+
'http.request_id': ctx.requestId,
|
|
404
|
+
'http.method': ctx.method,
|
|
405
|
+
'http.route': url.pathname,
|
|
406
|
+
'http.status_code': response.status,
|
|
407
|
+
});
|
|
408
|
+
return response;
|
|
409
|
+
},
|
|
410
|
+
{ kind: 'server' },
|
|
411
|
+
),
|
|
389
412
|
);
|
|
390
413
|
},
|
|
391
414
|
};
|
package/src/request.ts
CHANGED
package/src/router.ts
CHANGED
|
@@ -42,6 +42,18 @@ export interface RouteMeta {
|
|
|
42
42
|
readonly auth: 'public' | 'required';
|
|
43
43
|
/** Name of the policy the authz stage must satisfy. Resolved by tier 3. */
|
|
44
44
|
readonly policy?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Which layer evaluates `policy`. `'pipeline'` — the default, and what a page route
|
|
47
|
+
* wants — means the `authz` stage decides through `ServerHooks.authorize`. `'handler'`
|
|
48
|
+
* means the handler is the one evaluation and the stage must not pre-judge.
|
|
49
|
+
*
|
|
50
|
+
* An action route says `'handler'` because `invoke` loads the row a row-level rule
|
|
51
|
+
* decides about, and the stage cannot: deciding here too would evaluate the same policy
|
|
52
|
+
* with `row: null`, deny the row's own author, and never reach the evaluation that had
|
|
53
|
+
* the row. Two authz systems is how every Meteor-like framework died — this field is
|
|
54
|
+
* which one is the system.
|
|
55
|
+
*/
|
|
56
|
+
readonly enforcedBy?: 'pipeline' | 'handler';
|
|
45
57
|
/** Validated in the body stage; also what the OpenAPI/MCP emitters read. */
|
|
46
58
|
readonly input?: Schema;
|
|
47
59
|
readonly render?: RenderMode;
|
|
@@ -216,6 +228,8 @@ export interface RouteDescription {
|
|
|
216
228
|
readonly params: readonly string[];
|
|
217
229
|
readonly auth: 'public' | 'required';
|
|
218
230
|
readonly policy: string | null;
|
|
231
|
+
/** Named, not inferred: a policy with no stated evaluator reads as an unguarded one. */
|
|
232
|
+
readonly enforcedBy: 'pipeline' | 'handler';
|
|
219
233
|
readonly render: RenderMode | null;
|
|
220
234
|
readonly rateLimit: string | null;
|
|
221
235
|
readonly tags: readonly string[];
|
|
@@ -240,6 +254,7 @@ export const describeRoutes = (table: RouteTable): readonly RouteDescription[] =
|
|
|
240
254
|
params: paramsOf(route.path),
|
|
241
255
|
auth: route.meta.auth,
|
|
242
256
|
policy: route.meta.policy ?? null,
|
|
257
|
+
enforcedBy: route.meta.enforcedBy ?? 'pipeline',
|
|
243
258
|
render: route.meta.render ?? null,
|
|
244
259
|
rateLimit: route.meta.rateLimit ?? null,
|
|
245
260
|
tags: route.meta.tags ?? [],
|
package/src/server.ts
CHANGED
|
@@ -2,8 +2,19 @@
|
|
|
2
2
|
// context, tracing and authz must be impossible to skip — a route is a data
|
|
3
3
|
// declaration, never a chance to hand-roll a request handler.
|
|
4
4
|
|
|
5
|
-
import type { Role } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
5
|
+
import type { HealthPayload, HealthState, Role } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
beginWork,
|
|
8
|
+
configureLifecycle,
|
|
9
|
+
drain,
|
|
10
|
+
healthzPayload,
|
|
11
|
+
lifecycleState,
|
|
12
|
+
logger,
|
|
13
|
+
markListening,
|
|
14
|
+
markReady,
|
|
15
|
+
onShutdown,
|
|
16
|
+
readyzPayload,
|
|
17
|
+
} from '@ultimat3/core';
|
|
7
18
|
import type { Server } from 'bun';
|
|
8
19
|
import { defineHttpConfig, type HttpConfig } from './config';
|
|
9
20
|
import { serverNotStarted } from './errors';
|
|
@@ -13,7 +24,8 @@ import { createPipeline, type Pipeline } from './pipeline';
|
|
|
13
24
|
import { json } from './response';
|
|
14
25
|
import { createRouter, describeRoutes, type Route, type RouteDescription } from './router';
|
|
15
26
|
|
|
16
|
-
|
|
27
|
+
/** Core owns the state machine; this alias exists so callers need one import. */
|
|
28
|
+
export type LifecycleState = HealthState;
|
|
17
29
|
|
|
18
30
|
/** `Server` is generic over its websocket payload; the `web` role does not use one. */
|
|
19
31
|
type BunServer = Server<unknown>;
|
|
@@ -38,7 +50,8 @@ export interface ServerHandle {
|
|
|
38
50
|
url(): string;
|
|
39
51
|
describe(): readonly RouteDescription[];
|
|
40
52
|
start(): ServerHandle;
|
|
41
|
-
|
|
53
|
+
/** Runs core's three-phase drain. The deadline is `config.drainTimeoutMs`. */
|
|
54
|
+
stop(): Promise<void>;
|
|
42
55
|
/**
|
|
43
56
|
* Runs one request through the entire lifecycle with no socket. This is the
|
|
44
57
|
* supported way to test routes: there is no second, "lighter" code path.
|
|
@@ -59,35 +72,36 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
59
72
|
...(options.middleware === undefined ? {} : { middleware: options.middleware }),
|
|
60
73
|
});
|
|
61
74
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
let inflight = 0;
|
|
75
|
+
// The one HTTP-owned knob feeds core's deadline, so there is a single drain budget.
|
|
76
|
+
configureLifecycle({ deadlineMs: config.drainTimeoutMs });
|
|
65
77
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
headers: { 'cache-control': 'no-store' },
|
|
71
|
-
},
|
|
72
|
-
);
|
|
78
|
+
let server: BunServer | undefined;
|
|
79
|
+
let unregister: (() => void) | undefined;
|
|
80
|
+
let unregisterClose: (() => void) | undefined;
|
|
81
|
+
let stopListening: (() => void) | undefined;
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Core owns the health state, the in-flight count and the drain deadline so every
|
|
85
|
+
* role reports identically. `HealthPayload` is `{ ok, status, body }` — core stays
|
|
86
|
+
* HTTP-free and hands us the status code as data, which we render here.
|
|
87
|
+
*/
|
|
88
|
+
const healthResponse = (payload: HealthPayload): Response =>
|
|
77
89
|
json(
|
|
78
|
-
{
|
|
79
|
-
{ status:
|
|
90
|
+
{ ...payload.body, role },
|
|
91
|
+
{ status: payload.status, headers: { 'cache-control': 'no-store' } },
|
|
80
92
|
);
|
|
81
93
|
|
|
82
94
|
const dispatch = async (request: Request, socket?: BunServer): Promise<Response> => {
|
|
83
|
-
inflight
|
|
95
|
+
// beginWork() is what makes the `inflight` drain phase correct: core cannot finish
|
|
96
|
+
// in-flight work it does not know about.
|
|
97
|
+
const done = beginWork();
|
|
84
98
|
try {
|
|
85
99
|
return await pipeline.handle(request, {
|
|
86
100
|
role,
|
|
87
101
|
ip: socket?.requestIP(request)?.address ?? null,
|
|
88
102
|
});
|
|
89
103
|
} finally {
|
|
90
|
-
|
|
104
|
+
done();
|
|
91
105
|
}
|
|
92
106
|
};
|
|
93
107
|
|
|
@@ -110,7 +124,7 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
110
124
|
role,
|
|
111
125
|
config,
|
|
112
126
|
pipeline,
|
|
113
|
-
state: () =>
|
|
127
|
+
state: () => lifecycleState(),
|
|
114
128
|
url: () => {
|
|
115
129
|
if (server === undefined) throw serverNotStarted('url()');
|
|
116
130
|
return server.url.origin;
|
|
@@ -118,7 +132,6 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
118
132
|
describe: () => describeRoutes(table),
|
|
119
133
|
fetch: (request) => dispatch(request),
|
|
120
134
|
start() {
|
|
121
|
-
state = 'starting';
|
|
122
135
|
server = Bun.serve({
|
|
123
136
|
port: config.port,
|
|
124
137
|
hostname: config.hostname,
|
|
@@ -127,28 +140,51 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
127
140
|
...nativeRoutes(),
|
|
128
141
|
// Health endpoints answer outside the pipeline on purpose: a draining or
|
|
129
142
|
// rate-limited process must still be able to say what it is doing.
|
|
130
|
-
'/healthz': () =>
|
|
131
|
-
'/readyz': () =>
|
|
143
|
+
'/healthz': () => healthResponse(healthzPayload()),
|
|
144
|
+
'/readyz': () => healthResponse(readyzPayload()),
|
|
132
145
|
},
|
|
133
146
|
fetch: (request, socket) => dispatch(request, socket),
|
|
134
147
|
});
|
|
135
|
-
|
|
148
|
+
|
|
149
|
+
// Tell core which socket we opened. A request to it is this process calling itself,
|
|
150
|
+
// so the test seal can let it through without an allowlist entry per random port.
|
|
151
|
+
stopListening = markListening(server.url.origin);
|
|
152
|
+
|
|
153
|
+
// 'accept' runs first on SIGTERM: readyz flips to 503 here, while the socket is
|
|
154
|
+
// still open, so the load balancer stops sending new work before we close it.
|
|
155
|
+
unregister = onShutdown(
|
|
156
|
+
`http:${role}`,
|
|
157
|
+
async () => {
|
|
158
|
+
await server?.stop(false);
|
|
159
|
+
},
|
|
160
|
+
{ phase: 'accept' },
|
|
161
|
+
);
|
|
162
|
+
// 'close' runs after core has waited out the in-flight phase.
|
|
163
|
+
unregisterClose = onShutdown(
|
|
164
|
+
`http:${role}:close`,
|
|
165
|
+
async () => {
|
|
166
|
+
await server?.stop(true);
|
|
167
|
+
server = undefined;
|
|
168
|
+
stopListening?.();
|
|
169
|
+
},
|
|
170
|
+
{ phase: 'close' },
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
markReady();
|
|
136
174
|
logger.info(`ultimate ${role} listening on ${server.url.origin}`);
|
|
137
|
-
// SIGTERM handling belongs to core so every role drains identically.
|
|
138
|
-
onShutdown(() => handle.stop());
|
|
139
175
|
return handle;
|
|
140
176
|
},
|
|
141
|
-
async stop(
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
177
|
+
async stop() {
|
|
178
|
+
if (server === undefined) return;
|
|
179
|
+
// Delegate to core so a manual stop() and a real SIGTERM take the identical
|
|
180
|
+
// three-phase path. The drain deadline is core's, not ours.
|
|
181
|
+
await drain('manual');
|
|
182
|
+
unregister?.();
|
|
183
|
+
unregisterClose?.();
|
|
184
|
+
// Idempotent: the close hook already released, unless the drain deadline cut it short.
|
|
185
|
+
stopListening?.();
|
|
186
|
+
stopListening = undefined;
|
|
150
187
|
server = undefined;
|
|
151
|
-
state = 'stopped';
|
|
152
188
|
logger.info(`ultimate ${role} stopped`);
|
|
153
189
|
},
|
|
154
190
|
};
|
package/src/validate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Validation goes through the Standard Schema interface, never through a vendor
|
|
2
|
-
// API, so
|
|
2
|
+
// API, so `t` is the schema layer's shipped default rather than a dependency of the HTTP layer.
|
|
3
3
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
4
4
|
|
|
5
5
|
export type Schema<Out = unknown> = StandardSchemaV1<unknown, Out>;
|