@ultimat3/http 24.0.0 → 25.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.
@@ -0,0 +1,40 @@
1
+ // A rate-limit decision as the three `RateLimit-*` response headers. One rendering, so the
2
+ // pipeline's limiter, a bearer mount's per-token allowance and a primitive's declared bucket can
3
+ // never answer a client in two shapes.
4
+
5
+ import type { RequestContext } from './context';
6
+ import type { RateLimitDecision } from './rate-limit';
7
+
8
+ /**
9
+ * Lowercase names, as every writer sets them on `ctx.headers`. The reset is whole seconds from
10
+ * `nowMs`, floored at 0: a decision read after its own reset passed is a full bucket, not a
11
+ * negative wait.
12
+ */
13
+ export const rateLimitHeaders = (
14
+ decision: RateLimitDecision,
15
+ nowMs: number,
16
+ ): Record<string, string> => ({
17
+ 'ratelimit-limit': String(decision.limit),
18
+ 'ratelimit-remaining': String(decision.remaining),
19
+ 'ratelimit-reset': String(Math.max(0, Math.ceil((decision.resetAtMs - nowMs) / 1000))),
20
+ });
21
+
22
+ /**
23
+ * A handler-spent bucket onto the answer (`RouteMeta.rateLimitedBy: 'handler'`), when it is the
24
+ * one closest to refusing — the rule the `rate-limit` stage follows across its own buckets:
25
+ * `remaining: 118` off the tenant's while the primitive's holds 1 tells a client to proceed and
26
+ * then refuses its next call. A refusal always wins. Set on the CONTEXT before the handler throws,
27
+ * so the `error-map` stage's 429 carries these headers and reads `Retry-After` off `ctx.rateLimit`.
28
+ */
29
+ export const publishRateLimit = (
30
+ ctx: RequestContext,
31
+ decision: RateLimitDecision,
32
+ nowMs: number,
33
+ ): void => {
34
+ const prior = ctx.rateLimit;
35
+ if (decision.allowed && prior !== undefined && prior.remaining < decision.remaining) return;
36
+ ctx.rateLimit = decision;
37
+ for (const [name, value] of Object.entries(rateLimitHeaders(decision, nowMs))) {
38
+ ctx.headers.set(name, value);
39
+ }
40
+ };
@@ -0,0 +1,79 @@
1
+ // The store a PRIMITIVE's declared `rateLimit:` is counted in — `@ultimat3/action`'s `invoke` and
2
+ // `@ultimat3/query`'s read path both spend from it, on every surface. One slot, here, so the two
3
+ // tier-3 packages (which cannot import each other) count in one place, and the boot fills it with
4
+ // the same instance it hands `httpServer({ rateLimitStore })`.
5
+
6
+ import type { RateLimitConfig, RateLimitStore } from './rate-limit';
7
+ import { memoryRateLimitStore } from './rate-limit';
8
+ import { rateLimitNotShared } from './rate-limit-errors';
9
+
10
+ /** What a process falls back to when nothing chose: one process' memory. */
11
+ let base: RateLimitStore = memoryRateLimitStore();
12
+ let baseChosen = false;
13
+ /**
14
+ * Adopted stores, newest last. A STACK of frames, each popped only by its own adopter: two
15
+ * servers started A-then-B and stopped A-then-B used to have A's stop restore what A replaced —
16
+ * reverting the slot under B while B still served. A frame object is its own identity, so the
17
+ * same store adopted twice is two frames.
18
+ */
19
+ const frames: { readonly store: RateLimitStore }[] = [];
20
+
21
+ const current = (): RateLimitStore => frames.at(-1)?.store ?? base;
22
+ const isChosen = (): boolean => baseChosen || frames.length > 0;
23
+
24
+ /**
25
+ * The embedder's one-line install, for a process that builds its own boot: the base everything
26
+ * else stacks over. Left at the default it is one process' memory — right for dev and tests, and
27
+ * N × every declared limit behind N replicas, which `assertInstalledRateLimitScope` refuses under
28
+ * a `'shared'` declaration. A boot that has to give the slot back uses `adoptRateLimitStore`.
29
+ */
30
+ export const installRateLimitStore = (store: RateLimitStore): void => {
31
+ base = store;
32
+ baseChosen = true;
33
+ };
34
+
35
+ export const installedRateLimitStore = (): RateLimitStore => current();
36
+
37
+ /** Test-only. Every frame and the base, back to one process' memory. */
38
+ export const resetRateLimitStore = (): void => {
39
+ frames.length = 0;
40
+ base = memoryRateLimitStore();
41
+ baseChosen = false;
42
+ };
43
+
44
+ export interface AdoptRateLimitStoreOptions {
45
+ /**
46
+ * `httpServer`'s rule: never over a store something already CHOSE. The boot installs its own
47
+ * before any server exists, and a server handed a different one must not move every action's
48
+ * counters out from under it — the chosen one stands, and `assertInstalledRateLimitScope` still
49
+ * holds it to the declaration.
50
+ */
51
+ readonly keepChosen?: boolean;
52
+ }
53
+
54
+ /**
55
+ * Push `store` as the one primitives spend from, and answer the undo that removes THIS frame and
56
+ * no other — whatever order the adopters stop in. The boot (`startServices`, `startRoles`) and
57
+ * `httpServer` all go through it, so every install has a matching, order-proof release.
58
+ */
59
+ export const adoptRateLimitStore = (
60
+ store: RateLimitStore,
61
+ options: AdoptRateLimitStoreOptions = {},
62
+ ): (() => void) => {
63
+ if (options.keepChosen === true && isChosen() && current() !== store) return () => undefined;
64
+ const frame = { store };
65
+ frames.push(frame);
66
+ return () => {
67
+ const at = frames.indexOf(frame);
68
+ if (at !== -1) frames.splice(at, 1);
69
+ };
70
+ };
71
+
72
+ /**
73
+ * `assertRateLimitScope`'s rule for the primitives' half: a `'shared'` declaration says every
74
+ * number is the fleet's, and an action or query limit counted in one process' memory is not.
75
+ */
76
+ export const assertInstalledRateLimitScope = (config: RateLimitConfig): void => {
77
+ if (config.scope !== 'shared') return;
78
+ if (current().scope !== 'shared') throw rateLimitNotShared('installed');
79
+ };
@@ -135,7 +135,7 @@ export interface PostgresRateLimitStore extends RateLimitStore {
135
135
  *
136
136
  * `nowMs` is required and comes from the SAME clock the takes use — `ctx.now().getTime()` in a
137
137
  * task. There is no default, because the only defensible default would be this process' own
138
- * `Date.now()`, and a store that reads a clock nobody handed it is the thing `createRateLimiter`
138
+ * `Date.now()`, and a store that reads a clock nobody handed it is the thing `rateLimiter`
139
139
  * took a `Clock` to stop.
140
140
  */
141
141
  purgeExpired(nowMs: number): Promise<number>;
@@ -150,14 +150,14 @@ export interface PostgresRateLimitStore extends RateLimitStore {
150
150
  *
151
151
  * // apps/web/server.ts — what PROVIDES it
152
152
  * const client = db();
153
- * createServer({
153
+ * httpServer({
154
154
  * rateLimitStore: postgresRateLimitStore({
155
155
  * executor: { query: (text, values) => client.query({ text, values }) },
156
156
  * }),
157
157
  * });
158
158
  * ```
159
159
  *
160
- * `assertRateLimitScope` compares the two once, inside `createPipeline`, and a `'shared'`
160
+ * `assertRateLimitScope` compares the two once, inside `httpPipeline`, and a `'shared'`
161
161
  * declaration over any other store is `X_RATE_LIMIT_NOT_SHARED` before the socket opens.
162
162
  */
163
163
  export function postgresRateLimitStore(
@@ -68,14 +68,23 @@ export const spendRequestBuckets = async (
68
68
  limiter,
69
69
  rateLimitSpends(
70
70
  { actorId: actor?.id ?? null, orgId: actor?.orgId ?? null, ip: ctx.ip, routeName },
71
- {
72
- route: ctx.route?.meta.rateLimit ?? config.defaultBucket,
73
- tenant: config.tenantBucket,
74
- },
71
+ { route: routeBucketOf(ctx, config), tenant: config.tenantBucket },
75
72
  ),
76
73
  );
77
74
  };
78
75
 
76
+ /**
77
+ * The ONE place that decides which bucket a route's caller spends here. A route whose handler
78
+ * spends its own declared limit (`rateLimitedBy: 'handler'` — every action and query route, whose
79
+ * primitive counts that limit on every surface) spends none: `default` beside it would cap an
80
+ * action that declared 1,000 at 120, which a declared limit never was.
81
+ */
82
+ const routeBucketOf = (ctx: RequestContext, config: RateLimitConfig): string | null => {
83
+ const meta = ctx.route?.meta;
84
+ if (meta?.rateLimitedBy === 'handler') return null;
85
+ return meta?.rateLimit ?? config.defaultBucket;
86
+ };
87
+
79
88
  /** The one key failed credentials are counted under: the address alone, never the route. */
80
89
  const unauthenticatedKey = (ip: string | null): string =>
81
90
  `${UNAUTHENTICATED_SCOPE}|ip:${ip ?? 'unknown'}`;
package/src/rate-limit.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Token-bucket rate limiting. The store is an interface so the same limiter runs in-memory in
2
2
  // dev/tests and against a shared tier in a multi-replica deployment — installed through
3
- // `createServer({ rateLimitStore })`, and refused at boot when its scope cannot keep the app's
3
+ // `httpServer({ rateLimitStore })`, and refused at boot when its scope cannot keep the app's
4
4
  // declaration; the bucket maths lives here so every driver agrees on the numbers.
5
5
  import { type Clock, systemClock } from '@ultimat3/core';
6
6
  import { httpCountInvalid } from './errors';
@@ -11,6 +11,7 @@ import {
11
11
  rateLimitScopeUnset,
12
12
  tenantBucketUnknown,
13
13
  } from './rate-limit-errors';
14
+ import { rateLimitHeaders } from './rate-limit-headers';
14
15
  import { type RateLimitPeek, rateLimitPeek, refilledTokens } from './rate-limit-peek';
15
16
 
16
17
  /**
@@ -325,6 +326,13 @@ export interface RateLimitKeyParts {
325
326
  readonly orgId: string | null;
326
327
  readonly ip: string | null;
327
328
  readonly routeName: string;
329
+ /**
330
+ * The subject when there is no actor, no org AND no address. Defaults to `ip:unknown` — every
331
+ * such request shares it, which is what the pipeline has always done. A caller that can name a
332
+ * narrower population (a job run nobody attributed) passes its own, so it does not share one
333
+ * bucket with every anonymous request whose address could not be read.
334
+ */
335
+ readonly unattributed?: string | undefined;
328
336
  }
329
337
 
330
338
  /**
@@ -360,17 +368,20 @@ export interface RateLimitSpend {
360
368
  */
361
369
  export const rateLimitSpends = (
362
370
  parts: RateLimitKeyParts,
363
- buckets: { readonly route: string; readonly tenant: string | null },
371
+ // `route: null` — the route's own limit is spent by its handler (`RouteMeta.rateLimitedBy`), so
372
+ // the caller key is not spent here at all; only the tenant allowance is.
373
+ buckets: { readonly route: string | null; readonly tenant: string | null },
364
374
  ): readonly RateLimitSpend[] => {
365
375
  const subject =
366
376
  parts.actorId !== null
367
377
  ? `actor:${parts.actorId}`
368
378
  : parts.orgId !== null
369
379
  ? `org:${parts.orgId}`
370
- : `ip:${parts.ip ?? 'unknown'}`;
371
- const spends: RateLimitSpend[] = [
372
- { key: `${parts.routeName}|${subject}`, bucket: buckets.route },
373
- ];
380
+ : parts.ip !== null
381
+ ? `ip:${parts.ip}`
382
+ : (parts.unattributed ?? 'ip:unknown');
383
+ const spends: RateLimitSpend[] =
384
+ buckets.route === null ? [] : [{ key: `${parts.routeName}|${subject}`, bucket: buckets.route }];
374
385
  if (buckets.tenant !== null && parts.orgId !== null) {
375
386
  spends.push({ key: `${TENANT_SCOPE}|org:${parts.orgId}`, bucket: buckets.tenant });
376
387
  }
@@ -383,8 +394,8 @@ export interface RateLimiter {
383
394
  /**
384
395
  * The table this limiter resolves a bucket NAME against. Declared, never inferred — the same
385
396
  * rule as `RateLimitStore.scope` and `@ultimat3/auth`'s `AuthLimiter.policy`, and for the same
386
- * reason: `createRateLimiter` closes over its config, so nothing outside can see which buckets
387
- * it actually holds. `createPipeline` compares this against the buckets the ROUTES declare, and
397
+ * reason: `rateLimiter` closes over its config, so nothing outside can see which buckets
398
+ * it actually holds. `httpPipeline` compares this against the buckets the ROUTES declare, and
388
399
  * an unknown name is refused instead of falling through `bucketFor` to `default`.
389
400
  *
390
401
  * Optional only so an existing external implementation still type-checks; an absent table
@@ -399,7 +410,7 @@ export interface RateLimiter {
399
410
  assert(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
400
411
  }
401
412
 
402
- export const createRateLimiter = (options: {
413
+ export const rateLimiter = (options: {
403
414
  config: RateLimitConfig;
404
415
  store?: RateLimitStore;
405
416
  /**
@@ -407,7 +418,7 @@ export const createRateLimiter = (options: {
407
418
  * BOTH production call sites (`server.ts`, `pipeline.ts`) build their limiter without an
408
419
  * override — so the limiter that actually throttles requests could not be frozen by any test,
409
420
  * while `@ultimat3/auth`'s credential limiter has taken an injected `Clock` since it shipped.
410
- * Defaulted rather than required, the same shape as `createRequestContext`'s `init.clock`;
421
+ * Defaulted rather than required, the same shape as `requestContext`'s `init.clock`;
411
422
  * `PipelineDeps.limiter` stays the one seam for handing the pipeline a limiter of your own,
412
423
  * because a second `clock` beside it would be a second way to set one number.
413
424
  */
@@ -438,11 +449,7 @@ export const createRateLimiter = (options: {
438
449
  buckets: options.config.buckets,
439
450
  check,
440
451
  peek: (key, bucketName) => store.peek(key, bucketFor(bucketName), now()),
441
- headers: (decision) => ({
442
- 'ratelimit-limit': String(decision.limit),
443
- 'ratelimit-remaining': String(decision.remaining),
444
- 'ratelimit-reset': String(Math.ceil((decision.resetAtMs - now()) / 1000)),
445
- }),
452
+ headers: (decision) => rateLimitHeaders(decision, now()),
446
453
  async assert(key, bucketName, cost = 1) {
447
454
  const decision = await check(key, bucketName, cost);
448
455
  if (!decision.allowed) throw rateLimited(key, decision.retryAfterSeconds);
@@ -0,0 +1,44 @@
1
+ // How one request arrived, as the frozen facts a token resolver is handed — never the raw
2
+ // `Headers`. A resolver given the headers would read `x-forwarded-for` itself and trust a hop the
3
+ // host never declared; the address here is the one the pipeline already resolved (`ctx.ip`).
4
+ // One shape for every bearer surface: this mount, and `@ultimat3/mcp`'s `POST /mcp`.
5
+
6
+ /**
7
+ * The request, as far as a credential decision may read it. Four facts and no more: a token bound
8
+ * to a network reads `address`, a resolver refusing browsers reads `origin`. Never the body (it is
9
+ * unread when the token is decided) and never another header — `Authorization` is the token itself.
10
+ */
11
+ export interface RequestFacts {
12
+ /**
13
+ * The caller's address AS THE HOST RESOLVED IT — `ctx.ip`, a declared proxy's `x-forwarded-for`
14
+ * honoured per `trustedProxyHops`, else the socket. `null` when the host resolved none: a surface
15
+ * that cannot tell a trusted hop from a forged one never derives one from a header.
16
+ */
17
+ readonly address: string | null;
18
+ /** `User-Agent`, verbatim — caller-controlled, so a hint for a rule and never an identity. */
19
+ readonly userAgent: string | null;
20
+ /**
21
+ * The `Origin` request header: which web origin a BROWSER says is calling. `null` when absent —
22
+ * every non-browser client — and the literal string `'null'` for an opaque origin, which is a
23
+ * different fact (a sandboxed frame or a `file:` page) and must not read as "no browser".
24
+ */
25
+ readonly origin: string | null;
26
+ /** The URL path the request was sent to. No query string. */
27
+ readonly path: string;
28
+ }
29
+
30
+ export interface RequestFactsInput {
31
+ readonly headers: Headers;
32
+ readonly url: URL | string;
33
+ /** What the host resolved — `ctx.ip`, or a transport's `seen.address`. Never a header read. */
34
+ readonly address: string | null | undefined;
35
+ }
36
+
37
+ export function requestFacts(input: RequestFactsInput): RequestFacts {
38
+ return Object.freeze({
39
+ address: input.address ?? null,
40
+ userAgent: input.headers.get('user-agent'),
41
+ origin: input.headers.get('origin'),
42
+ path: (typeof input.url === 'string' ? new URL(input.url) : input.url).pathname,
43
+ });
44
+ }
package/src/request.ts CHANGED
@@ -7,7 +7,7 @@ import { readCookie, readWithinLimit, renderThrowable } from '@ultimat3/core';
7
7
  import type { RequestContext } from './context';
8
8
  import { bodyInvalid, buildSkew } from './errors';
9
9
  import type { Schema } from './validate';
10
- import { validate, validateSync } from './validate';
10
+ import { validateBody, validateSync } from './validate';
11
11
 
12
12
  export type QueryValues = Readonly<Record<string, string | readonly string[]>>;
13
13
 
@@ -152,7 +152,7 @@ export class UltimateRequest {
152
152
  }
153
153
 
154
154
  async body<Out>(schema: Schema<Out>): Promise<Out> {
155
- const outcome = await validate(schema, await this.bodyRaw());
155
+ const outcome = await validateBody(schema, await this.bodyRaw());
156
156
  if (!outcome.ok) throw bodyInvalid(this.pathname, outcome.issues);
157
157
  return outcome.value;
158
158
  }
package/src/response.ts CHANGED
@@ -14,13 +14,13 @@ const withDefaults = (init: HeaderSource, defaults: Record<string, string>): Hea
14
14
  return headers;
15
15
  };
16
16
 
17
- export const json = <T>(body: T, init?: ResponseInit): Response =>
17
+ export const jsonResponse = <T>(body: T, init?: ResponseInit): Response =>
18
18
  new Response(JSON.stringify(body), {
19
19
  ...init,
20
20
  headers: withDefaults(init, { 'content-type': 'application/json; charset=utf-8' }),
21
21
  });
22
22
 
23
- export const text = (body: string, init?: ResponseInit): Response =>
23
+ export const textResponse = (body: string, init?: ResponseInit): Response =>
24
24
  new Response(body, {
25
25
  ...init,
26
26
  headers: withDefaults(init, { 'content-type': 'text/plain; charset=utf-8' }),
@@ -31,7 +31,7 @@ const DELTA_SECONDS_FIELDS = [
31
31
  * CLI's authorized-object hint all carry `maxAgeSeconds: 0`, so a floor of 1 would refuse the
32
32
  * framework at its own boot.
33
33
  *
34
- * Called once per route by `createRouter`, which is the one way a `Route` becomes matchable — so
34
+ * Called once per route by `httpRouter`, which is the one way a `Route` becomes matchable — so
35
35
  * every hint an app can serve has been through here, including the ones `@ultimat3/cli` mints for
36
36
  * favicons, dev assets and storage objects.
37
37
  */
package/src/router.ts CHANGED
@@ -76,6 +76,21 @@ export interface RouteMeta {
76
76
  * refuses a configured bucket of the same name that says something else.
77
77
  */
78
78
  readonly rateLimitBucket?: Bucket;
79
+ /**
80
+ * `'handler'`: the handler spends this route's own declared limit — an action's `invoke`, a
81
+ * query's read — because the primitive counts it on every surface (HTTP, MCP, the agent tool, a
82
+ * job). The `rate-limit` stage then spends no caller bucket for it, only the tenant allowance:
83
+ * spending `default` too would cap a primitive that declared more than `default` at `default`.
84
+ * Set, it wins: `rateLimit`/`rateLimitBucket` name a bucket the STAGE spends, and are not read.
85
+ */
86
+ readonly rateLimitedBy?: 'handler';
87
+ /**
88
+ * The primitive this route projects, written by `@ultimat3/action`'s `toRoute` and
89
+ * `@ultimat3/query`'s `toQueryRoute`; absent on a plain route. Read by the bucket check
90
+ * (`rate-limit-buckets.ts`): since 25.0.0 a `rateLimit.buckets.<name>` named after a primitive
91
+ * limits nothing and is refused, and only an explicit marker can say which routes those are.
92
+ */
93
+ readonly primitive?: 'action' | 'query';
79
94
  readonly tags?: readonly string[];
80
95
  readonly description?: string;
81
96
  /**
@@ -159,7 +174,7 @@ const segmentsOf = (path: string): readonly string[] =>
159
174
  .split('/')
160
175
  .filter((segment) => segment.length > 0);
161
176
 
162
- export const createRouter = (routes: readonly Route[]): RouteTable => {
177
+ export const httpRouter = (routes: readonly Route[]): RouteTable => {
163
178
  const root = node();
164
179
  for (const route of routes) {
165
180
  // Before the trie is touched: a hint that cannot be emitted is refused where it was WRITTEN,
package/src/server.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  markReady,
16
16
  onShutdown,
17
17
  readyzPayload,
18
+ systemClock,
18
19
  } from '@ultimat3/core';
19
20
  import type { Server } from 'bun';
20
21
  import { defineHttpConfig, type HttpConfig } from './config';
@@ -22,11 +23,12 @@ import { HttpError, serverNotStarted } from './errors';
22
23
  import { disclosesHealthDetail } from './health-disclosure';
23
24
  import type { ServerHooks } from './hooks';
24
25
  import type { Middleware } from './middleware';
25
- import { createPipeline, type Pipeline } from './pipeline';
26
- import { createRateLimiter, type RateLimitStore } from './rate-limit';
26
+ import { httpPipeline, type Pipeline } from './pipeline';
27
+ import { type RateLimitStore, rateLimiter } from './rate-limit';
27
28
  import { withRouteBuckets } from './rate-limit-buckets';
28
- import { json } from './response';
29
- import { createRouter, describeRoutes, type Route, type RouteDescription } from './router';
29
+ import { adoptRateLimitStore } from './rate-limit-installed';
30
+ import { jsonResponse } from './response';
31
+ import { describeRoutes, httpRouter, type Route, type RouteDescription } from './router';
30
32
 
31
33
  /**
32
34
  * Beside its one caller rather than in `errors.ts`, which is at the 500-line ceiling — the
@@ -35,7 +37,7 @@ import { createRouter, describeRoutes, type Route, type RouteDescription } from
35
37
  * A websocket mount and something already answering its path. Same code as two routes claiming
36
38
  * one, because it is the same fact: one path, two declarations, and the framework picks — Bun's
37
39
  * native route table is matched BEFORE `fetch`, so the route wins and the upgrade never reaches
38
- * the mount. Refused at `createServer`, not discovered as a socket that will not open.
40
+ * the mount. Refused at `httpServer`, not discovered as a socket that will not open.
39
41
  */
40
42
  const websocketPathTaken = (path: string, answered: string): HttpError =>
41
43
  new HttpError({
@@ -63,7 +65,7 @@ export interface ServerOptions {
63
65
  * Where the rate limiter keeps its counters. Omitted means `memoryRateLimitStore()`, which is
64
66
  * one process' worth of state — correct for dev and tests, and N × every configured number for
65
67
  * N replicas. An app that runs more than one process declares `rateLimit.scope: 'shared'` and
66
- * passes a store that says the same, or `createServer` refuses here.
68
+ * passes a store that says the same, or `httpServer` refuses here.
67
69
  */
68
70
  readonly rateLimitStore?: RateLimitStore;
69
71
  /**
@@ -142,7 +144,7 @@ export interface ServerHandle {
142
144
  url(): string;
143
145
  describe(): readonly RouteDescription[];
144
146
  start(): ServerHandle;
145
- /** Runs core's three-phase drain. The deadline is `config.drainTimeoutMs`. */
147
+ /** Runs core's three-phase drain, on the deadline `drain.deadlineMs` set (`lifecycleForRole`). */
146
148
  stop(): Promise<void>;
147
149
  /**
148
150
  * Runs one request through the entire lifecycle with no socket. This is the
@@ -151,20 +153,41 @@ export interface ServerHandle {
151
153
  fetch(request: Request): Promise<Response>;
152
154
  }
153
155
 
156
+ /** How often the `inflight` hook re-reads Bun's count of unfinished responses. */
157
+ const BODY_POLL_MS = 5;
158
+
154
159
  const roleFromEnv = (): Role => (Bun.env['ROLE'] ?? 'web') as Role;
155
160
 
156
- export const createServer = (options: ServerOptions): ServerHandle => {
161
+ export const httpServer = (options: ServerOptions): ServerHandle => {
162
+ // The store this server is handed is also the one actions and queries spend their declared
163
+ // `rateLimit:` from — adopted BEFORE the pipeline, whose boot check reads it, and never over a
164
+ // store the boot already chose (`keepChosen`). Given back on `stop()`, or by ANY refusal from
165
+ // here to the handle — the pipeline's checks, `configureLifecycle`, `assertMountPathFree` — so a
166
+ // boot that never produced a server leaves nothing behind it.
167
+ const releaseStore =
168
+ options.rateLimitStore === undefined
169
+ ? () => undefined
170
+ : adoptRateLimitStore(options.rateLimitStore, { keepChosen: true });
171
+ try {
172
+ return buildServer(options, releaseStore);
173
+ } catch (error) {
174
+ releaseStore();
175
+ throw error;
176
+ }
177
+ };
178
+
179
+ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHandle => {
157
180
  const role = options.role ?? roleFromEnv();
158
- const table = createRouter(options.routes);
159
- // Merged here as well as in `createPipeline`, and for the store's sake: the limiter below is
181
+ const table = httpRouter(options.routes);
182
+ // Merged here as well as in `httpPipeline`, and for the store's sake: the limiter below is
160
183
  // built from `config.rateLimit`, so a table without the routes' own buckets would resolve a
161
184
  // declared name to `default` — the very hole this closes. `withRouteBuckets` is idempotent, so
162
185
  // the pipeline's second pass changes nothing. `handle.config` is the merged one for the same
163
186
  // reason: `server.config.rateLimit.buckets` has to be what the limiter runs on.
164
187
  const config = withRouteBuckets(options.config ?? defineHttpConfig(), table.routes);
165
188
  // The store feeds the limiter seam `PipelineDeps` already had, rather than becoming a second
166
- // one: the bucket maths stays in `createRateLimiter`, so every driver agrees on the numbers.
167
- const pipeline = createPipeline({
189
+ // one: the bucket maths stays in `rateLimiter`, so every driver agrees on the numbers.
190
+ const pipeline = httpPipeline({
168
191
  table,
169
192
  config,
170
193
  ...(options.hooks === undefined ? {} : { hooks: options.hooks }),
@@ -172,17 +195,14 @@ export const createServer = (options: ServerOptions): ServerHandle => {
172
195
  ...(options.rateLimitStore === undefined
173
196
  ? {}
174
197
  : {
175
- limiter: createRateLimiter({ config: config.rateLimit, store: options.rateLimitStore }),
198
+ limiter: rateLimiter({ config: config.rateLimit, store: options.rateLimitStore }),
176
199
  }),
177
200
  });
178
201
 
179
- // The one HTTP-owned knob feeds core's deadline, so there is a single drain budget — and only
180
- // when this app DECLARED it. Unconditional, with `defineHttpConfig` defaulting the number, this
181
- // line reverted `configureLifecycle({ deadlineMs: 600_000 })` — the edit `X_SHUTDOWN_TIMEOUT`'s
182
- // own `fix:` prints — back to 15s on every boot that serves web, silently. "Nobody said" and
183
- // "the app said 15 seconds" are different claims and `null` is what keeps them apart.
184
- if (config.drainTimeoutMs !== null) configureLifecycle({ deadlineMs: config.drainTimeoutMs });
185
- // Same rule: only a declared grace is applied. Core waits it out between the readiness flip and
202
+ // No deadline is set here. `drain.deadlineMs` is the one budget, applied per role by
203
+ // `lifecycleForRole` before any server exists; `http.drainTimeoutMs` re-set it on the web role
204
+ // only, after that, and was deleted in 25.0.0 for being a second answer (axiom 1).
205
+ // Only a declared grace is applied. Core waits it out between the readiness flip and
186
206
  // the `accept` hook below, so endpoints stop routing here before the socket closes.
187
207
  const readinessGraceMs = options.drain?.readinessGraceMs;
188
208
  if (readinessGraceMs !== undefined) configureLifecycle({ readinessGraceMs });
@@ -190,6 +210,7 @@ export const createServer = (options: ServerOptions): ServerHandle => {
190
210
  const mount = options.websocket;
191
211
  let server: BunServer | undefined;
192
212
  let unregister: (() => void) | undefined;
213
+ let unregisterBodies: (() => void) | undefined;
193
214
  let unregisterClose: (() => void) | undefined;
194
215
  let stopListening: (() => void) | undefined;
195
216
 
@@ -202,7 +223,7 @@ export const createServer = (options: ServerOptions): ServerHandle => {
202
223
  * peer `healthDetailPeers` lists: these two paths answer outside the pipeline, unauthenticated.
203
224
  */
204
225
  const healthResponse = (payload: HealthPayload, request: Request, socket: BunServer): Response =>
205
- json(
226
+ jsonResponse(
206
227
  healthBody(
207
228
  payload.body,
208
229
  role,
@@ -342,14 +363,40 @@ export const createServer = (options: ServerOptions): ServerHandle => {
342
363
  // 'accept' runs first on SIGTERM, but only after core's readiness grace: readyz flips to
343
364
  // 503 the moment the drain starts, the socket stays open for `readinessGraceMs`, so the
344
365
  // endpoints controller stops routing here before this closes it — not a 502 in between.
366
+ //
367
+ // NOT awaited: Bun's `stop(false)` stops the listener at once but resolves only when every
368
+ // request AND every websocket on it has closed — so one open browser tab held this hook, and
369
+ // every `accept` hook after it (worker, scheduler), until the drain deadline, leaving the
370
+ // `close` hooks 0 ms. Handlers are core's to wait for (`beginWork()`), bodies the `inflight`
371
+ // hook's below, sockets the `close` hook's `stop(true)`. The same shape as
372
+ // `realtime/src/sync-listen.ts`. A rejection is the close hook's to surface, not this one's.
345
373
  unregister = onShutdown(
346
374
  `http:${role}`,
347
- async () => {
348
- await server?.stop(false);
375
+ () => {
376
+ server?.stop(false).catch(() => undefined);
349
377
  },
350
378
  { phase: 'accept' },
351
379
  );
352
- // 'close' runs after core has waited out the in-flight phase.
380
+ // The BODIES. `beginWork()` ends when a handler returns its Response, and a streamed body —
381
+ // a page render, a file, an LLM stream — is still being written after that; awaiting
382
+ // `stop(false)` above was what used to wait for it, websockets and all. Bun's
383
+ // `pendingRequests` counts exactly the responses not yet finished (a client that went away
384
+ // included as finished), never an upgraded socket, so this waits for bodies alone and costs
385
+ // the request path nothing. Polled, and bounded by the drain's own deadline: a body that
386
+ // never ends (SSE) is cut by the `close` hook's `stop(true)`, not waited on forever.
387
+ unregisterBodies = onShutdown(
388
+ `http:${role}:bodies`,
389
+ async (reason) => {
390
+ while (
391
+ (server?.pendingRequests ?? 0) > 0 &&
392
+ systemClock.monotonic() < reason.deadlineAt
393
+ ) {
394
+ await Bun.sleep(BODY_POLL_MS);
395
+ }
396
+ },
397
+ { phase: 'inflight' },
398
+ );
399
+ // 'close' runs after core has waited out the in-flight phase, bodies included.
353
400
  unregisterClose = onShutdown(
354
401
  `http:${role}:close`,
355
402
  async () => {
@@ -371,9 +418,12 @@ export const createServer = (options: ServerOptions): ServerHandle => {
371
418
  // core's `shutdownHookCount()` exists to make visible. The shape `worker.ts`'s teardown
372
419
  // holds: the unregisters are the closure's, not the drain's.
373
420
  const releaseHooks = (): void => {
421
+ releaseStore();
374
422
  unregister?.();
423
+ unregisterBodies?.();
375
424
  unregisterClose?.();
376
425
  unregister = undefined;
426
+ unregisterBodies = undefined;
377
427
  unregisterClose = undefined;
378
428
  };
379
429
  if (server === undefined) {
@@ -0,0 +1,60 @@
1
+ // How a route handler, a page loader or an action sets a cookie on the response it cannot build:
2
+ // one serialized line, from core's one serializer, appended to the context's response-header bag —
3
+ // the bag the `response` stage APPENDS `set-cookie` from, so two cookies are two lines, never one.
4
+
5
+ import { CookieInvalidError, type SetCookieOptions, serializeSetCookie } from '@ultimat3/core';
6
+ import { assertInRequest } from './context';
7
+
8
+ /**
9
+ * What identifies the cookie being cleared, plus the attributes its prefix or partition requires.
10
+ * A browser deletes only the cookie whose name, `Path` and `Domain` all match — `Partitioned` too
11
+ * for a CHIPS cookie — so these must be what `setCookie` was given. No `maxAge`, `expires` or
12
+ * `priority`: the clearing line decides those itself.
13
+ */
14
+ export type DeleteCookieOptions = Pick<
15
+ SetCookieOptions,
16
+ 'path' | 'domain' | 'secure' | 'httpOnly' | 'sameSite' | 'partitioned'
17
+ >;
18
+
19
+ const DELETE_FIX = 'deleteCookie(name, { path, domain }) # the one way to clear a cookie';
20
+
21
+ /**
22
+ * Clearing has ONE spelling, `deleteCookie`, so a reader searching for where a cookie dies finds
23
+ * every site. `maxAge: 0` and an `expires` already past both clear in a browser, so both are
24
+ * refused here rather than left as a second, unsearchable way to do the same thing.
25
+ */
26
+ function refuseClearing(name: string, options: SetCookieOptions, now: Date): void {
27
+ if (options.maxAge === 0) {
28
+ throw new CookieInvalidError('maxAge', name, 'is 0, which deletes the cookie', DELETE_FIX);
29
+ }
30
+ const { expires } = options;
31
+ if (expires instanceof Date && expires.getTime() <= now.getTime()) {
32
+ throw new CookieInvalidError(
33
+ 'expires',
34
+ name,
35
+ 'is not after now, so it deletes the cookie',
36
+ DELETE_FIX,
37
+ );
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Set cookie `name` on the response to the request in scope. Defaults are core's:
43
+ * `Path=/; HttpOnly; Secure; SameSite=Lax`. Every call APPENDS a line — the same name on two
44
+ * paths is two cookies — and an invalid cookie is `X_COOKIE_INVALID` before anything is appended.
45
+ * Outside a request it throws `X_NO_REQUEST`: a cookie nobody will receive is a silent failure.
46
+ */
47
+ export const setCookie = (name: string, value: string, options: SetCookieOptions = {}): void => {
48
+ const ctx = assertInRequest('setCookie()');
49
+ refuseClearing(name, options, ctx.now());
50
+ ctx.headers.append('set-cookie', serializeSetCookie(name, value, options));
51
+ };
52
+
53
+ /**
54
+ * Clear cookie `name`: an empty value and `Max-Age=0`, with the `Path` and `Domain` it was set
55
+ * with (defaults the same as `setCookie`'s, so a cookie set with none is cleared with none).
56
+ */
57
+ export const deleteCookie = (name: string, options: DeleteCookieOptions = {}): void => {
58
+ const ctx = assertInRequest('deleteCookie()');
59
+ ctx.headers.append('set-cookie', serializeSetCookie(name, '', { ...options, maxAge: 0 }));
60
+ };