@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 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": "0.0.1",
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": "^0.0.1",
34
- "@ultimat3/schema": "^0.0.1"
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
- /** `basePath` is stripped before matching so route paths never encode the mount point. */
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 (!pathname.startsWith(prefix)) return pathname;
58
- const rest = pathname.slice(prefix.length);
59
- return rest.length === 0 ? '/' : rest;
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 { type Actor, type Ctx, type Role, useContext, uuid } from '@ultimat3/core';
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
- actor: Actor | null;
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: null,
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
- export const HTTP_ERROR_CODES = [
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<HttpErrorCode, string>> = {
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, evaluated here exactly as it is in jobs, live queries and MCP tools',
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
- ctx.actor = await hooks.authenticate(request, ctx);
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 === null) {
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(`${ctx.method} ${url.pathname}`, () => execute(request, ctx)),
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
@@ -55,7 +55,8 @@ export class UltimateRequest {
55
55
  return this.ctx.params;
56
56
  }
57
57
 
58
- get actor(): Actor | null {
58
+ /** Never null — an unauthenticated call carries core's anonymous actor, same as `Ctx`. */
59
+ get actor(): Actor {
59
60
  return this.ctx.actor;
60
61
  }
61
62
 
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 { logger, onShutdown } from '@ultimat3/core';
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
- export type LifecycleState = 'idle' | 'starting' | 'ready' | 'draining' | 'stopped';
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
- stop(options?: { readonly timeoutMs?: number }): Promise<void>;
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
- let state: LifecycleState = 'idle';
63
- let server: BunServer | undefined;
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
- const health = (): Response =>
67
- json(
68
- { status: 'ok', role, state, buildId: config.buildId },
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
- // Readiness is the only thing a load balancer reads, so it must flip to 503 the
75
- // instant we start draining — before the socket closes, so in-flight work finishes.
76
- const ready = (): Response =>
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
- { status: state === 'ready' ? 'ready' : state, role, buildId: config.buildId },
79
- { status: state === 'ready' ? 200 : 503, headers: { 'cache-control': 'no-store' } },
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 += 1;
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
- inflight -= 1;
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: () => 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': () => health(),
131
- '/readyz': () => ready(),
143
+ '/healthz': () => healthResponse(healthzPayload()),
144
+ '/readyz': () => healthResponse(readyzPayload()),
132
145
  },
133
146
  fetch: (request, socket) => dispatch(request, socket),
134
147
  });
135
- state = 'ready';
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(stopOptions) {
142
- if (state === 'stopped') return;
143
- state = 'draining';
144
- const deadline = Date.now() + (stopOptions?.timeoutMs ?? config.drainTimeoutMs);
145
- while (inflight > 0 && Date.now() < deadline) await Bun.sleep(25);
146
- if (inflight > 0) {
147
- logger.warn(`ultimate ${role} draining timed out with ${inflight} in-flight requests`);
148
- }
149
- await server?.stop(true);
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 ArkType (`t`) is a default rather than a dependency of the HTTP layer.
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>;