Skip to content

WebSocket beforeHandle runs twice per upgrade (double-consumes rate limits / auth) #63

Description

@jvidalv

What happens

A WebSocket route's beforeHandle hook runs twice for a single upgrade.

This matters beyond redundant work: beforeHandle is the documented place to put upgrade authorization and rate limiting, and both are side-effecting. In our app the hook consumes an upgrade-throttle token and validates the session, so every connection burned two tokens (the flood limit silently enforced half its configured value) and issued two session-auth database round trips.

Reproduction

npm install elysia@1.4.29 @elysiajs/node@1.4.5 ws@8
import { Elysia } from "elysia";
import { node } from "@elysiajs/node";
import WebSocket from "ws";

let calls = 0;

new Elysia({ adapter: node() })
  .ws("/probe", {
    beforeHandle() {
      calls++;
    },
    open() {},
  })
  .listen(31890);

await new Promise((r) => setTimeout(r, 500));

const sock = new WebSocket("ws://127.0.0.1:31890/probe");
await new Promise((res) => {
  sock.on("open", res);
  sock.on("error", res);
});

console.log("beforeHandle invocations for ONE upgrade:", calls);
process.exit(0);

Expected: 1
Actual: 2

Cause

dist/ws.mjs registers the route by passing the whole options object (which contains beforeHandle) to app.route(...), so Elysia runs beforeHandle as part of its normal route lifecycle. The adapter's own route handler then invokes the same hook a second time:

// dist/ws.mjs:46
app.route(
  "WS",
  path,
  // @ts-ignore
  async (context) => {
    ...
    // dist/ws.mjs:75
    if (typeof options.beforeHandle === "function") {
      const result = options.beforeHandle(context);
      if (result instanceof Promise) await result;
    }

Since Elysia has already executed the hook via the lifecycle attached to that same options object, the explicit call at line 75 is a second invocation.

Environment

  • @elysiajs/node 1.4.5 (latest published)
  • elysia 1.4.29
  • Node.js 24.18.0
  • macOS (arm64)

Workaround

For anyone hitting this before a fix lands: deduplicate by the concrete Request object, which is stable across both invocations.

function oncePerRequest(operation) {
  const runs = new WeakMap();
  return async (context) => {
    let run = runs.get(context.request);
    if (!run) {
      run = operation(context).then(
        () => ({ failed: false, status: context.set.status }),
        (error) => ({ error, failed: true, status: context.set.status }),
      );
      runs.set(context.request, run);
    }
    const outcome = await run;
    context.set.status = outcome.status;
    if (outcome.failed) throw outcome.error;
  };
}

The status/error replay matters because the adapter may build a second context around the same Request, so a rejection in the first run still has to reject the second caller.

Possibly related

While tracing this I noticed dist/ws.mjs:117 intercepts any message whose payload merely contains the substring "ping" and routes it to ws.pong() instead of message():

async message(ws, message) {
  if (message.includes("ping")) {
    try {
      return void ws.pong(message);
    } catch (error) {
      handleErrors?.(ws, error);
    }
  }

A JSON heartbeat like {"type":"ping"} matches that substring test. In my minimal repro the handler still received the message because ws.pong() threw and the catch fell through, so I can't demonstrate it cleanly and I'm not filing it as a separate bug. But a substring match against arbitrary user payloads looks unintended regardless: any application message containing "ping" anywhere ({"action":"shipping"}) is a candidate for interception depending on whether pong succeeds. Happy to open a separate issue if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions