@ultimat3/http 30.8.0 → 31.0.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/CLAUDE.md +44 -40
- package/README.md +48 -35
- package/package.json +7 -6
- package/src/adapter.ts +14 -0
- package/src/auth-redirect.ts +11 -3
- package/src/bearer-mount.ts +7 -5
- package/src/body-ceiling.ts +54 -0
- package/src/byte-range.ts +6 -2
- package/src/compress.ts +77 -6
- package/src/config.ts +29 -7
- package/src/context.ts +3 -2
- package/src/cors.ts +20 -0
- package/src/error-facts.ts +27 -8
- package/src/error-log-level.ts +16 -2
- package/src/error-map-http.ts +2 -0
- package/src/error-map-tier-1.ts +11 -3
- package/src/error-map-tier-2.ts +10 -0
- package/src/error-map-tier-3.ts +14 -0
- package/src/error-map-tier-4.ts +15 -0
- package/src/error-titles.ts +2 -0
- package/src/errors.ts +9 -5
- package/src/hooks.ts +44 -1
- package/src/index.ts +31 -86
- package/src/locale.ts +2 -2
- package/src/pipeline.ts +40 -11
- package/src/problem-meta.ts +8 -5
- package/src/rate-limit-buckets.ts +21 -3
- package/src/rate-limit-config.ts +123 -0
- package/src/rate-limit-degrade.ts +19 -4
- package/src/rate-limit-errors.ts +4 -4
- package/src/rate-limit-installed.ts +43 -53
- package/src/rate-limit-postgres.ts +10 -9
- package/src/rate-limit-redis.ts +176 -0
- package/src/rate-limit-stage.ts +3 -2
- package/src/rate-limit-store-deadline.ts +24 -6
- package/src/rate-limit.ts +13 -108
- package/src/server.ts +19 -37
- package/src/stages.ts +5 -2
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 {
|
|
30
|
+
import { rateLimiter } from './rate-limit';
|
|
30
31
|
import { withRouteBuckets } from './rate-limit-buckets';
|
|
31
|
-
import {
|
|
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
|
|
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
|
|
195
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: (
|
|
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
|
});
|