Sign In

@ultimat3/http

Package Overview
Dependencies
Maintainers
1
Versions
20
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/http

Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors

Source
npmnpm
Version
5.0.0
Version published
Maintainers
1
Created
Source

@ultimat3/http 🌐

A thin, owned layer over Bun.serve. Not a framework-agnostic HTTP kit — the lifecycle belongs to us so ALS context, tracing, locale/tz and authz are impossible to skip.

What it owns

ConcernModule
server lifecycle, drain, /healthz + /readyzserver.ts
route table, matcher, describeRoutes()router.ts
the ordered request lifecyclepipeline.ts
typed request (params, query, body)request.ts
response constructors + problem()response.ts
code → status, factsOf()error-map.ts
token-bucket limiting, toBucketrate-limit.ts
CORS, CSP/HSTScors.ts, security-headers.ts
CSRF (origin proof for a credentialed write)csrf.ts
the request deadline and ctx.signaldeadline.ts
the caller's real address behind a proxyforwarded.ts
the inbound request id and trace, read before the spancorrelation.ts
dev error overlayoverlay.ts

The pipeline is the guarantee

request-id → admit → trace → context → locale → auth → rate-limit → csrf → body → authz
           → handler → cache-headers → (error-map) → response

Exported as PIPELINE_STAGES, each entry carrying a why. pipeline.test.ts asserts the order; /_x renders it. Ordering rules worth restating:

RuleReason
admit seconda draining or saturated process refuses before any work — no route match, no auth, no body
auth before rate-limitlimiter keys per actor/tenant, not per NAT address
csrf after authonly a caller holding an AMBIENT credential can be forged into; bearer and anonymous are exempt
csrf before bodya forged write never makes the server allocate its payload
rate-limit before bodya limited request never allocates its payload
body before authzpolicies take parsed input as their subject
cache-headers before responsea directive can never drop a security header

What the lifecycle refuses on the caller's behalf, As of 2026-08:

GuardAnswer
a body past bodyLimitBytesread through the stream and abandoned the instant the running total crosses the limit — content-length or not, multipart included — as X_BODY_INVALID
a request carrying an identity on an auth: 'public' routecache-control: private, never s-maxage; an anonymous one is shared-cacheable and keyed vary: accept-language, cookie
a cross-origin request from an origin the allow-list refusesno access-control-allow-origin, but always vary: origin, so a shared cache never answers an allowed origin out of the refusal's slot
cors.origins: ['*'] with credentials: trueX_CORS_CONFIG_INVALID at defineHttpConfig, because a browser accepts that pair from nobody
?next= carrying anything but a same-origin paththe fallback — including a value whose TAB/CR/LF a browser strips back into //evil.test
HSTSemitted only when the connection is affirmatively https (ctx.https); never by the zero-argument default
rateLimit.scope: 'shared' on a per-process storeX_RATE_LIMIT_NOT_SHARED at createServer, because N replicas each holding their own counters enforce N × every configured number
a route's own bucket and a configured bucket of that name disagreeingX_RATE_LIMIT_BUCKET_CONFLICT at createServer, because the loser would be a number someone read and nothing applied
an injected limiter that does not hold a bucket a route declaresX_RATE_LIMIT_BUCKET_UNBOUND at createPipeline, because the name would fall through to default — measured at 120 burst for a route declaring 5
a config that never declared rateLimit.scopeX_RATE_LIMIT_SCOPE_UNSET at defineHttpConfig. Breaking, As of 2026-08: 'process' used to be the default, so "nobody asked" and "the app said one replica" were the same value while the chart runs three
trustProxy: true with no trustedProxyHopsX_TRUST_PROXY_UNSET at defineHttpConfig. Breaking, As of 2026-08: trustProxy now defaults to false, and x-forwarded-for is read at entries.length - hops — never at [0], which is whatever the client typed
a credentialed unsafe method that cannot be shown to be same-originX_CSRF_BLOCKED (403). sec-fetch-site: same-origin, Origin equal to this app, or an Origin in cors.origins — anything else is refused before the body is read
a request past requestTimeoutMs (30s)ctx.signal aborts and the socket is answered X_TIMEOUT (504); a caller may shorten the deadline with x-request-timeout-ms, never lengthen it
the caller going away mid-requestctx.signal aborts on the inbound Request.signal too, so a closed tab unwinds cooperative work instead of holding its pool slot for the rest of the budget. Both halves are one signal (AbortSignal.any), and requestTimeoutMs: 0 still delivers the caller's
a request while the process is drainingX_DRAINING (503) + retry-after, which is what isDraining() was always documented to do here and had no reader for
a request past maxInflight (1000)X_OVERLOADED (503) + retry-after, shed in the admit stage before any work

handle() resolves to a Response, always — a stage that throws after the handler, or while rendering another stage's throw, degrades to X_PIPELINE_FINALIZE_FAILED (500, the stage named in cause) and the chain finishes that document instead. finalize.ts owns that promise.

Rate limiting holds where the app says it holds

The counters live in a RateLimitStore. memoryRateLimitStore() is the default and is one process' worth of state, so a deployment at replicas: 3 enforces every bucket three times over. The app declares which it needs and passes the store that provides it — the store is the only thing that knows where its counters live, and a framework that inferred the answer from the environment would get it wrong on the first deployment that scaled differently.

createServer({
  routes,
  config: defineHttpConfig({ rateLimit: { scope: 'shared' } }), // this limit is the fleet's
  rateLimitStore: myStore,                                      // whose own scope is 'shared'
});
DeclaredStoreResult
nothinganyX_RATE_LIMIT_SCOPE_UNSET at defineHttpConfigbreaking, As of 2026-08
'process'anyboots; the limit is per replica, which is what was asked for
'shared'scope: 'shared'boots; one bucket for the fleet
'shared'scope: 'process', or enabled: falseX_RATE_LIMIT_NOT_SHARED at boot

There is no default. 'process' used to be one, which made "the app never said" and "the app said one replica" the same value while docker/helm/values.yaml runs three — and X_RATE_LIMIT_NOT_SHARED only fires on a 'shared' declaration, so the silent case was exactly the one nobody declared. A limiter with enabled: false owes no declaration: nothing is enforced, so nothing can be wrong.

rateLimitStore feeds the PipelineDeps.limiter seam rather than sitting beside it: the bucket maths stays in createRateLimiter, so every driver agrees on the numbers. No shared store ships yet, As of 2026-08memoryRateLimitStore() is the only implementation in the framework.

The maths reads an injected Clock, defaulting to systemClock: createRateLimiter({ config, clock }). Breaking, As of 2026-08-19 — it took now?: () => number before and read Date.now() when nothing passed one, which both production call sites did, so the limiter that actually throttles a request could not be frozen. Replace now: () => t with clock: frozenClock(t); a limiter you build yourself still reaches the pipeline through PipelineDeps.limiter, which stays the only seam for one.

A route may bring its own bucket

meta.rateLimit names a bucket; meta.rateLimitBucket is the numbers that bucket must hold. withRouteBuckets registers them at construction — createServer and createPipeline both apply it, idempotently — because defineHttpConfig runs before any route exists and cannot have them. Without that half, a name nothing defined fell through bucketFor to default: an action declaring limit: 5 ran on 120 burst, and the number reached the OpenAPI document all the same.

DeclaredConfigured under the same nameResult
nothingdefault, unchanged — most routes
numbersnothingthe route's numbers, registered
numbersthe same numbersboots; a restatement is not a disagreement
numbersdifferent numbersX_RATE_LIMIT_BUCKET_CONFLICT at boot

Neither source wins a disagreement, because whichever lost would stay a number an author read and nothing enforced. toBucket — in this package, beside Bucket and the maths it validates, because action and query are the same tier and can never import each other — is the one conversion from a declaration's { limit, windowMs } to a bucket's { capacity, refillPerSecond }, and it refuses a pair the limiter could not run on — including one whose two halves look fine and whose division does not, like { limit: Number.MAX_VALUE, windowMs: 1 } computing to an infinite refill.

Registering into the config is only half of it, because the limiter resolves names against the table it closed over. RateLimiter.buckets publishes that table — declared, never inferred, the same rule as RateLimitStore.scope — and assertRouteBuckets compares it against the routes at construction. A limiter passed to PipelineDeps.limiter that cannot enforce a declared bucket is refused rather than rebound: a RateLimiter is opaque, so rebinding would mean discarding the store it carries, and a caller who built their own limiter may have meant their own numbers. Pass the storecreateServer({ routes, rateLimitStore }) — and the pipeline builds the limiter from the merged table for you.

Routing

Precedence is structural, not declaration-ordered: static > param > wildcard, depth-first with backtracking. A tie is X_ROUTE_CONFLICT at startup, never a coin flip. HEAD falls back to the GET route. meta.auth is required — a route cannot forget to declare its auth posture.

const handle = createServer({
  routes: [{ method: 'GET', path: '/posts/:id', meta: { name: 'posts.show', auth: 'public' },
             handler: (req) => json({ id: req.param('id') }) }],
  config: defineHttpConfig({ port: 3000 }),
  role: 'web',
}).start();

Static paths are registered in Bun's native routes table; param/wildcard paths fall through to fetch. Method resolution stays ours so a 405 still carries problem+json.

Errors

X_ROUTE_NOT_FOUND · X_METHOD_NOT_ALLOWED · X_BODY_INVALID · X_UNAUTHENTICATED · X_FORBIDDEN · X_RATE_LIMITED · X_BUILD_SKEW · X_ROUTE_CONFLICT · X_CORS_CONFIG_INVALID · X_RATE_LIMIT_NOT_SHARED

One factsOf() feeds three renderings — terminal, application/problem+json, dev overlay — so the code/cause/fix strings can never diverge.

Boundaries

Tier 2. Imports @ultimat3/core and @ultimat3/schema only. Authentication and policy evaluation arrive through ServerHooks, declared structurally, because @ultimat3/policy is a sibling tier. There is no plugin API: Middleware wraps a handler, the pipeline is everything else.

FAQs

Package last updated on 20 Aug 2026

Related posts