Search docs

Jump between documentation pages.

WebSocket + login throttle slice

Daloy closes the remaining expanded leftover items. The theme is narrow but practical: authentication entry points, upload boundaries, and WebSocket upgrades now have first-party helpers instead of copy-pasted local policy.

1. wsRateLimit()

wsRateLimit() adapts the existing rateLimit() shared-bucket primitive to the WebSocket upgrade boundary. Put the samegroupId on HTTP login routes and the WebSocket session route so an attacker cannot dodge the bucket by switching transports.

ts
import { App, loginThrottle, wsRateLimit } from "@daloyjs/core";

const app = new App({ env: "production" });

const authBucket = {
  windowMs: 60_000,
  max: 10,
  groupId: "auth-entry",
  keyGenerator: (ctx) => ctx.request.headers.get("x-user-key") ?? "global",
};

app.route({
  method: "POST",
  path: "/login",
  hooks: loginThrottle(authBucket),
  responses: { 200: { description: "ok" } },
  handler: async () => ({ status: 200 as const, body: { ok: true } }),
});

app.ws("/session", {
  beforeUpgrade: wsRateLimit(authBucket),
  open(conn) {
    conn.send("ready");
  },
});

2. loginThrottle()

loginThrottle() is the built-in preset for credential-entry routes. It combines a shared hard limit with a short progressive delay before the hard 429 response. By default it does not trust proxy IP headers; pass a keyGenerator or opt in to trustProxyHeaders: true only behind a trusted proxy.

ts
app.route({
  method: "POST",
  path: "/password-reset",
  hooks: loginThrottle({
    windowMs: 15 * 60_000,
    max: 5,
    groupId: "auth-entry",
    delayAfter: 2,
    delayMs: 250,
    maxDelayMs: 2_000,
  }),
  responses: { 204: { description: "accepted" } },
  handler: async () => ({ status: 204 as const }),
});

3. rotateSession()

rotateSession() watches session privilege fields and calls session.regenerate() after the handler when those fields change. It skips itself when the handler already regenerated the session, so explicit login flows keep their exact behavior.

ts
import { session, rotateSession } from "@daloyjs/core";

app.use(session({ secret: process.env.SESSION_SECRET! }));
app.use(rotateSession({ watch: ["userId", "roles", "tenantId"] }));

app.route({
  method: "POST",
  path: "/admin/promote",
  responses: { 200: { description: "ok" } },
  handler: async ({ state }) => {
    state.session.set("roles", ["admin"]);
    return { status: 200 as const, body: { ok: true } };
  },
});

4. Upload MIME and magic-byte guards

fileField() already enforced maxBytes and MIME allowlists. Add magicBytes: true to derive known signatures from accept, or pass custom signatures for private formats. The OpenAPI generator emits x-magic-bytes alongsidex-accept and x-max-bytes.

ts
fileField({
  maxBytes: 1_000_000,
  accept: ["image/png", "image/jpeg"],
  magicBytes: true,
});

fileField({
  accept: ["application/x-daloy"],
  magicBytes: [
    { mime: "application/x-daloy", bytes: [0x44, 0x4c, 0x59] },
  ],
});

5. requirePayloadAuth

OpenAPI security scheme builders accept requirePayloadAuth: true for schemes such as webhook signatures that must authenticate the request body. A route using that scheme cannot set auth.payload: false; Daloy throws at route registration. The public OpenAPI document uses x-daloy-require-payload-auth rather than leaking a non-spec field.

ts
const app = new App({
  openapi: {
    securitySchemes: {
      webhook: httpBearerScheme({ requirePayloadAuth: true }),
    },
  },
});

app.route({
  method: "POST",
  path: "/webhooks/provider",
  auth: { scheme: "webhook" },
  responses: { 204: { description: "accepted" } },
  handler: async () => ({ status: 204 as const }),
});

6. WebSocket safe defaults

app.ws() now normalizes safe runtime defaults for Node and Bun: close on excessive outbound backpressure, a 1 MiB backpressure limit, compression off by default, a non-zero idle timeout, and a 1 MiB inbound payload cap. In production under secureDefaults,perMessageDeflate: true is refused. Daloy also refuses amaxPayloadLengthlarger than a route body schema's declared maximum when the schema exposes one.

ts
app.ws("/events", {
  idleTimeout: 120,
  maxPayloadLength: 64 * 1024,
  closeOnBackpressureLimit: true,
  backpressureLimit: 1 * 1024 * 1024,
  perMessageDeflate: false,
  message(conn, data) {
    conn.send(data);
  },
});