@ultimat3/http 30.9.0 → 31.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/src/server.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  systemClock,
20
20
  } from '@ultimat3/core';
21
21
  import type { Server } from 'bun';
22
+ import { BUN_MAX_REQUEST_BODY_SIZE, capStreamedBody } from './body-ceiling';
22
23
  import { defineHttpConfig, type HttpConfig } from './config';
23
24
  import { HttpError, serverNotStarted } from './errors';
24
25
  import { disclosesHealthDetail } from './health-disclosure';
@@ -26,9 +27,9 @@ import type { ServerHooks } from './hooks';
26
27
  import { socketIdleTimeoutSeconds } from './idle-timeout';
27
28
  import type { Middleware } from './middleware';
28
29
  import { httpPipeline, type Pipeline } from './pipeline';
29
- import { type RateLimitStore, rateLimiter } from './rate-limit';
30
+ import { rateLimiter } from './rate-limit';
30
31
  import { withRouteBuckets } from './rate-limit-buckets';
31
- import { adoptRateLimitStore } from './rate-limit-installed';
32
+ import { liveRateLimitStore } from './rate-limit-installed';
32
33
  import { jsonResponse } from './response';
33
34
  import { describeRoutes, httpRouter, type Route } from './router';
34
35
  import { serverWebSockets, type WebSocketOption } from './server-websocket';
@@ -64,13 +65,6 @@ export interface ServerOptions {
64
65
  readonly role?: Role;
65
66
  readonly hooks?: ServerHooks;
66
67
  readonly middleware?: readonly Middleware[];
67
- /**
68
- * Where the rate limiter keeps its counters. Omitted means `memoryRateLimitStore()`, which is
69
- * one process' worth of state — correct for dev and tests, and N × every configured number for
70
- * N replicas. An app that runs more than one process declares `rateLimit.scope: 'shared'` and
71
- * passes a store that says the same, or `httpServer` refuses here.
72
- */
73
- readonly rateLimitStore?: RateLimitStore;
74
68
  /**
75
69
  * A websocket served on THIS socket, beside the pipeline. Omitted, the `web` role opens no
76
70
  * websocket at all — which is what it did everywhere, and is why the sync node was only ever
@@ -164,25 +158,9 @@ const BODY_POLL_MS = 5;
164
158
 
165
159
  const roleFromEnv = (): Role => (Bun.env['ROLE'] ?? 'web') as Role;
166
160
 
167
- export const httpServer = (options: ServerOptions): ServerHandle => {
168
- // The store this server is handed is also the one actions and queries spend their declared
169
- // `rateLimit:` from — adopted BEFORE the pipeline, whose boot check reads it, and never over a
170
- // store the boot already chose (`keepChosen`). Given back on `stop()`, or by ANY refusal from
171
- // here to the handle — the pipeline's checks, `configureLifecycle`, `assertMountPathFree` — so a
172
- // boot that never produced a server leaves nothing behind it.
173
- const releaseStore =
174
- options.rateLimitStore === undefined
175
- ? () => undefined
176
- : adoptRateLimitStore(options.rateLimitStore, { keepChosen: true });
177
- try {
178
- return buildServer(options, releaseStore);
179
- } catch (error) {
180
- releaseStore();
181
- throw error;
182
- }
183
- };
161
+ export const httpServer = (options: ServerOptions): ServerHandle => buildServer(options);
184
162
 
185
- const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHandle => {
163
+ const buildServer = (options: ServerOptions): ServerHandle => {
186
164
  const role = options.role ?? roleFromEnv();
187
165
  const table = httpRouter(options.routes);
188
166
  // Merged here as well as in `httpPipeline`, and for the store's sake: the limiter below is
@@ -191,18 +169,17 @@ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHa
191
169
  // the pipeline's second pass changes nothing. `handle.config` is the merged one for the same
192
170
  // reason: `server.config.rateLimit.buckets` has to be what the limiter runs on.
193
171
  const config = withRouteBuckets(options.config ?? defineHttpConfig(), table.routes);
194
- // The store feeds the limiter seam `PipelineDeps` already had, rather than becoming a second
195
- // one: the bucket maths stays in `rateLimiter`, so every driver agrees on the numbers.
172
+ // The INSTALLED store — the one actions and queries spend their declared `rateLimit:` from —
173
+ // read on every request (`liveRateLimitStore`), so a route's bucket and a primitive's count in
174
+ // one place, and a store installed after this server was built is the one it spends from.
175
+ // It feeds the limiter seam `PipelineDeps` already had, rather than becoming a second one: the
176
+ // bucket maths stays in `rateLimiter`, so every driver agrees on the numbers.
196
177
  const pipeline = httpPipeline({
197
178
  table,
198
179
  config,
199
180
  ...(options.hooks === undefined ? {} : { hooks: options.hooks }),
200
181
  ...(options.middleware === undefined ? {} : { middleware: options.middleware }),
201
- ...(options.rateLimitStore === undefined
202
- ? {}
203
- : {
204
- limiter: rateLimiter({ config: config.rateLimit, store: options.rateLimitStore }),
205
- }),
182
+ limiter: rateLimiter({ config: config.rateLimit, store: liveRateLimitStore }),
206
183
  });
207
184
 
208
185
  // No deadline is set here. `drain.deadlineMs` is the one budget, applied per role by
@@ -254,9 +231,11 @@ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHa
254
231
  // in-flight work it does not know about.
255
232
  const done = beginWork();
256
233
  try {
257
- return await pipeline.handle(request, {
234
+ // The address off the socket's own Request, before the body is wrapped: `requestIP` keys on it.
235
+ const ip = socket?.requestIP(request)?.address ?? null;
236
+ return await pipeline.handle(capStreamedBody(request, config.maxRequestBodyBytes), {
258
237
  role,
259
- ip: socket?.requestIP(request)?.address ?? null,
238
+ ip,
260
239
  });
261
240
  } finally {
262
241
  done();
@@ -338,6 +317,10 @@ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHa
338
317
  development: config.dev,
339
318
  // Unset, Bun cuts a silent socket at 10 s whatever `requestTimeoutMs` promised.
340
319
  idleTimeout: socketIdleTimeoutSeconds(config.requestTimeoutMs),
320
+ // Off: Bun's own cap answered an empty, uncoded 413 before this process saw the request,
321
+ // and its 128 MiB default refused every larger signed upload. `maxRequestBodyBytes` is
322
+ // the ceiling, enforced coded in `admit` and on the stream (`body-ceiling.ts`).
323
+ maxRequestBodySize: BUN_MAX_REQUEST_BODY_SIZE,
341
324
  routes: {
342
325
  ...nativeRoutes(),
343
326
  // Health endpoints answer outside the pipeline on purpose: a draining or
@@ -432,7 +415,6 @@ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHa
432
415
  // core's `shutdownHookCount()` exists to make visible. The shape `worker.ts`'s teardown
433
416
  // holds: the unregisters are the closure's, not the drain's.
434
417
  const releaseHooks = (): void => {
435
- releaseStore();
436
418
  unregister?.();
437
419
  unregisterBodies?.();
438
420
  unregisterClose?.();
package/src/stages.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  reportError,
15
15
  } from '@ultimat3/core';
16
16
  import { signInRedirect } from './auth-redirect';
17
+ import { assertDeclaredBody } from './body-ceiling';
17
18
  import { finalizeCacheHeaders } from './cache-stage';
18
19
  import { compressResponse } from './compress';
19
20
  import { type HttpConfig, stripBasePath } from './config';
@@ -143,7 +144,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
143
144
  return undefined;
144
145
  },
145
146
 
146
- admit: (_request, ctx) => {
147
+ admit: (request, ctx) => {
147
148
  // Before the trace, the route match, auth, the body — everything. A refusal that costs as
148
149
  // much as a served request is not load shedding, and this is the stage that makes
149
150
  // "reject 40% fast, serve 60% at p99" expressible at all.
@@ -156,6 +157,8 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
156
157
  throw draining();
157
158
  }
158
159
  if (isDraining()) ctx.headers.set('connection', 'close');
160
+ // A declared body over the transport ceiling is refused before anything reads it.
161
+ assertDeclaredBody(request.raw, ctx.url.pathname, config.maxRequestBodyBytes);
159
162
  const ceiling = config.maxInflight;
160
163
  // `beginWork()` in `server.ts` counted THIS request before the pipeline was entered, so the
161
164
  // ceiling is compared against a number that already includes it.
@@ -350,7 +353,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
350
353
  // The other half of this is `@ultimat3/schema`'s, and it is the load-bearing one: an issue
351
354
  // message must stop echoing the rejected value at all. This change makes the value
352
355
  // redactable; it does not make it absent.
353
- ctx.logger[errorLogLevel(facts.status)](facts.code, {
356
+ ctx.logger[errorLogLevel(facts.status, error)](facts.code, {
354
357
  cause: facts.cause,
355
358
  status: facts.status,
356
359
  });