npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@structure-ai/http

v0.4.1

Published

HTTP routes and handlers on @effect/platform HttpApi: OpenAPI docs, health probes, error mapping, CQRS bridge, Bun server.

Readme

@structure-ai/http

HTTP surface for @structure apps: schema-typed HttpApi routes over @effect/platform, OpenAPI docs, health probes, request correlation, problem-details error mapping, a CQRS endpoint bridge, graceful Bun serving — and rate-limiting middleware with shared or in-process buckets.

Quick start

import { Api, ApiEndpoint, ApiGroup, serve } from "@structure-ai/http";
import { Readiness } from "@structure-ai/runtime";
import { Effect, Layer, Schema } from "effect";

const hello = ApiGroup.make("hello").add(
  ApiEndpoint.get("greet", "/hello").addSuccess(Schema.Struct({ hello: Schema.String })),
);

const api = Api.make("demo").add(hello);

serve({ port: 3000 }).pipe(
  Layer.provide(HttpApiBuilder.api(api).pipe(Layer.provide(HelloLive))),
  Layer.provide(Readiness.layer),
  Layer.launch,
);

serve installs the standard middleware stack — correlation ids, one structured log line per request, boundary metrics, problem mapping — and graceful shutdown (readiness flips unready before the listener stops).

Configurable middleware stack

The standard stack is the default; a middleware option on serve/serveTestWith adapts it without giving up the graceful Bun composition (mounts, static dispatch, readiness flip and drain stay in place):

serve({
  port: 3000,
  middleware: {
    correlation: {
      incoming: "reject",                                  // never trust incoming ids
      headers: { request: "X-Trace-Id", correlation: null }, // emit one, custom name
    },
  },
});
  • middleware.correlation.incoming — "sanitize" (default, today's behavior), "reject" (always mint fresh ids), or a predicate (id) => boolean keeping only the ids it accepts. Header-safety (isSafeId) always applies: an unsafe id is never echoed, whatever the policy.
  • middleware.correlation.generate — mint ids with your own generator (default Correlation.newId).
  • middleware.correlation.headers — response header names: a string overrides the standard x-request-id/x-correlation-id, null suppresses that header. Incoming ids are always read from the standard names.

To reproduce an existing boundary contract exactly, middleware.boundary replaces the whole standard stack (correlation, logging, metrics, problem mapping) with your own middleware function. It receives the app with mounts and static dispatch already composed, plus the mounted api (for Middleware.routeLabel), and whatever it produces is served by the same server:

serve({
  port: 3000,
  middleware: {
    boundary: (app, api) =>
      Middleware.standard(app, {
        routeLabel: Middleware.routeLabel(api),
        correlation: { incoming: (id) => id.startsWith("acc-") },
      }),
  },
});

A replacement owns its failure mapping: unmatched routes fail RouteNotFound into the replacement, and the platform answers an unhandled one with its own empty 404 — bring Middleware.problems (as Middleware.standard does) when the problem taxonomy is wanted. correlation cannot be combined with boundary (the replacement owns its own); invalid options (malformed header names, duplicate names, unknown policies) fail the layer at startup with InvalidMiddlewareOptions.

Boundary telemetry

Every request produces one http request log line and a set of metrics, all labelled by the matched endpoint template (/things/:id), never by the requested path — a path segment can carry a share secret or an invitation token, and a template keeps metric cardinality bounded. Requests that match no endpoint are labelled (unmatched), so probed URLs never reach the logs either.

| Signal | Labels / annotations | | --- | --- | | log line http request (info) | method, route, status, durationMs, requestId, correlationId — never the path, query, headers or bodies | | http_server_calls_total, http_server_errors_total, http_server_duration_ms | method, route | | http_request_duration_seconds (histogram, seconds) | method, route, status | | span http_server | http.method, http.route |

Middleware.layer derives the templates from the mounted api (HttpApi.reflect, prefixes included). Composing the stack by hand: Middleware.standard(app, { routeLabel: Middleware.routeLabel(api, { extra: ["/docs", "/openapi.json"] }) }) — extra lists templates for routes mounted next to the api (docs, static files); without a resolver every request logs (unmatched).

Propagated x-request-id / x-correlation-id headers are reused only when they match ^[A-Za-z0-9_-]{1,64}$ (Middleware.isSafeId); anything else is replaced by a fresh uuid, which is what the response headers and every log line carry — a client cannot inject bytes into the log stream or span attributes through those headers.

One listener: HttpApi + raw mounts + static assets

The self-hosted shape (one image, one port) serves the HttpApi, raw web handlers such as makeAuthHandler from @structure-ai/auth or an MCP transport, and an embedded SPA from the same serve layer:

serve({
  port: 3000,
  gracePeriod: Duration.seconds(5),
  mounts: [
    { prefix: "/auth", handler: auth.handler },   // (Request) => Promise<Response>
    { prefix: "/mcp", handler: mcpHandler },
  ],
  static: {
    directory: "./dist",                          // the SPA build
    spaFallback: "index.html",                    // client-side routes
    cacheControl: (path) =>
      path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache",
  },
});

Precedence is fixed and does not depend on layer build order:

  1. Mounts — evaluated before the router; the longest matching prefix wins. A prefix matches itself and everything below it on a segment boundary (/api matches /api and /api/x, never /apix). Handlers get the request untouched (full path, query, body): prefix stripping is theirs, as it would be on their own Bun.serve. Prefixes must be absolute, without trailing slash (except /), query or fragment, and unique — violations fail the layer at startup with InvalidMounts.
  2. HttpApi routes — including Docs.layer() and Health.group, so the SPA fallback never shadows /docs or /health/*.
  3. Static assets — only for GET/HEAD the router did not match, under static.prefix (default /): an existing file below directory (a directory answers with its index.html, never a listing), else spaFallback when the request's Accept lists text/html (a navigation), else nothing.
  4. The 404 problem — every unmatched path ends in the HttpApi NotFound mapping.

Every branch runs inside the same middleware boundary: correlation headers are stamped on mount and static responses, each request gets its log line and metrics (api routes labelled by endpoint template, mounts by <prefix>/*, static files and the fallback as (unmatched) — see "Boundary telemetry"), and a mount handler that rejects is answered with the 500 problem carrying only the correlation id (the cause is logged). Mounts and static files share the readiness flip and the gracePeriod drain with the api routes. HttpApi-level middleware layers such as rateLimitLayer apply to the router only, not to mounts (auth handlers bring their own limits).

Static serving is bounded on purpose: decoded path segments only (no ./.., no dotfiles, no control bytes or backslashes), symlink targets are resolved and must stay inside the real root, every response carries x-content-type-options: nosniff, a weak etag (a matching if-none-match answers 304), last-modified and vary: accept-encoding; a precompressed sibling (<file>.br, then <file>.gz) is served with its content-encoding when the client accepts it. Content types come from the file extension. Range requests are not supported. Invalid options (prefix shape, unsafe spaFallback) fail the layer with InvalidStaticOptions.

Testing the composed surface

serveTest keeps its zero-config shape; serveTestWith({ mounts, static, gracePeriod }) builds the same stack as serve on a random port and exposes HttpServer for the address (it requires Readiness, like serve). packages/http/test/mounts.test.ts starts one server per composition and drives it with fetch.

Rate limiting

rateLimitLayer (or the rateLimit HttpApp middleware) enforces per-route-group budgets with points per sliding windowMillis and a blockMillis lockout (blockMillis: 0 means no lockout: a full window is denied until its oldest hit ages out, and Retry-After counts down to that moment):

import { makeInMemoryStore, rateLimitLayer, clientIp } from "@structure-ai/http";

const limiter = rateLimitLayer({
  store: makeInMemoryStore(),
  groups: [
    {
      label: "api",                        // bounded metric/log label
      rule: { points: 100, windowMillis: 60_000, blockMillis: 30_000 },
      match: (request) => request.url.startsWith("/api"),
      key: (request) => principalIdOr(request) ?? clientIp(request, { trustProxy }),
    },
    {
      // Login wall: keyed by ip AND email, charged only when the credential is refused.
      label: "login",
      rule: { points: 10, windowMillis: 15 * 60_000, blockMillis: 15 * 60_000 },
      match: (request) => request.method === "POST" && request.url.startsWith("/auth/login"),
      keys: (request) =>
        request.json.pipe(
          Effect.map((body) => [`ip:${clientIp(request, { trustProxy })}`, `email:${emailDigest(body)}`]),
          Effect.catchAll(() => Effect.succeed([`ip:${clientIp(request, { trustProxy })}`])),
        ),
      consumeWhen: (response) => response.status === 401,
    },
  ],
});
// provide `limiter` next to Middleware.layer in serve()

Semantics:

  • OPTIONS preflights and /health/* probes never consume budget;
  • every counted response — success or error — carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds until the budget is fully restored) and their X-RateLimit-* twins; a fail-open response after a store failure carries none;
  • a denied request gets 429 + Retry-After (seconds; problem body with the correlation id), the quota headers with remaining: 0, and a warning log tagged with the route label;
  • a group with consumeWhen is a consume-on-failure wall: the store is peeked before the handler (429 when a key is blocked, the handler never runs) and charged after it only when the predicate holds for the response. A handler failure is evaluated as the problem response the package would render for it (Unauthenticated/UnauthorizedProblem → 401, unknown errors → 500) and re-raised unchanged;
  • keys charges several budgets together under the group's rule (each key its own budget; refused when any is exhausted) — prefix keys by kind, they share one namespace; undefined entries are dropped and a request with no key is unlimited;
  • store failures fail open by default (counted as http_rate_limit_store_errors_total); onStoreError: "deny" fails closed;
  • metrics: http_rate_limit_consumed_total (actual charges) / http_rate_limit_blocked_total, tagged route.

Stores (RateLimitStore = consume + peek, both returning a RateLimitDecision with allowed, retryAfterMillis, limit, remaining, resetMillis):

| Store | Use | | --- | --- | | makeInMemoryStore() | Single replica: bounded key map, lazy sweep, atomic in one event loop turn. | | makeRedisStore({ url }) | Shared across replicas: atomic sliding window + block in one Lua EVAL (one script per operation) over a dependency-free RESP2 client (makeRedisClient). | | storeFromUrl(url?) | No URL → in-memory + startup warning stating single-replica scope; URL → Redis store. |

peek never spends a point or installs a block: a full window is refused until its oldest hit ages out; a block installed by consume is reported with its remaining time.

Keys come from the app (principal id, IP, route+principal…). clientIp(request, { trustProxy }) extracts the client IP and the option is required so the spoofable path is never picked by accident: with trustProxy: true the rightmost x-forwarded-for hop (the one your proxy appended) wins, then x-real-ip, then the socket; with trustProxy: false both headers are ignored and only the socket address counts — any client can set them. Set it from a setting that is true only when a proxy you operate terminates every connection. Because @structure-ai/http never depends on @structure-ai/authorization, principal-keyed groups resolve their key through a closure the app owns (e.g. reading Principal.current from its own composition).

Errors

Failures render as problem-details responses ({ error, message, correlationId?, issues? }): ValidationFailed → 400, Unauthenticated → 401, Unauthorized/PermissionDenied → 403, NotFound → 404, ConcurrencyConflict/IdempotencyMismatch/IdempotencyInFlight → 409, TooManyRequests → 429 (+ Retry-After), DispatchTimeout → 504, anything else → 500 with the correlation id only. Use withDefaultErrors(endpoint) to declare the set on an endpoint.

Taxonomy mapping validates fields, not just _tag; platform errors use their constructors. The CQRS bridge maps typed errors without flattening compound causes. Any defect produces a safe 500, and cancellation remains interrupted even when another failure is present. Missing static files still fall through to 404; unexpected filesystem failures produce 500.