Skip to content

WebSocket routes in sub-app plugins fail silently with node adapter #62

Description

@vrajpal-jhala

What version of Elysia is running?

1.4.28

What version of Node Adapter are you using?

1.4.5

What platform is your computer?

Darwin (macOS) — also reproduced on Windows x64 Node 22 (see elysiajs/elysia#1616)

What steps can reproduce the bug?

Register a WebSocket route inside a sub-app plugin and mount it on a root app that uses the node adapter:

// ws.ts
import { Elysia, t } from 'elysia';
import { node } from '@elysiajs/node';

export const ws = new Elysia({ adapter: node() })
  .ws('/ws', {
    response: t.Any(),
    open(ws) { console.log('open'); },
    close(ws) { console.log('close'); },
  });

// index.ts
import { Elysia } from 'elysia';
import { node } from '@elysiajs/node';
import { ws } from './ws.js';

const app = new Elysia({ adapter: node() })
  .use(ws)
  .listen(3000);

Run with tsx watch src/index.ts and attempt to connect via ws://localhost:3000/ws.

What is the expected behavior?

WebSocket connection to /ws succeeds and the open/message/close handlers fire.

What do you see instead?

The connection fails with HTTP 404 or an EPIPE error. No WS handler fires.

Root cause (traced through source): each call to node() creates a new createWebSocketAdapter() instance with its own isolated store closure (ws.mjs line 16). The store is how the WS route handler and the crossws upgrade function communicate:

  • ws.handler (called during .ws()) writes store[id] = context
  • ws.createConfig(app) (called during .listen()) reads store[id] to complete the upgrade

When the sub-app and root app each call node() separately, they get different stores. The WS route handler (registered on the sub-app) writes to the sub-app's store, while crossws on the root app reads from the root app's store — always finding nothing — so the upgrade fails.

Additionally, Elysia's _use() merge (lines 1332–1335 of elysia/dist/index.mjs) calls this.add(method, path, handler, hooks) with the already-compiled handler closure from the sub-app's history. For method === "WS", add() (line 648) inserts this closure directly into the router without re-calling this["~adapter"].ws() — so the parent's adapter never gets a chance to re-register the route with its own store.

Additional information

Confirmed workarounds:

  1. Functional plugin — receives the parent app directly, so the parent's adapter and store are used:
export const wsPlugin = (app: Elysia) =>
  app.ws('/ws', { open(ws) { ... }, close(ws) { ... } });

app.use(wsPlugin);
  1. Shared adapter singleton — one node() call exported and imported everywhere:
// adapter.ts
import { node } from '@elysiajs/node';
export const nodeAdapter = node(); // single instance, shared store

// ws.ts
import { nodeAdapter } from './adapter.js';
export const ws = new Elysia({ adapter: nodeAdapter }).ws('/ws', { ... });

// index.ts
import { nodeAdapter } from './adapter.js';
const app = new Elysia({ adapter: nodeAdapter }).use(ws).listen(3000);

Suggested fix: In _use(), when encountering a "WS" method route from a plugin's history, call this["~adapter"].ws(this, path, hooks) with the original hooks instead of copying the pre-compiled handler closure. This ensures the parent app's adapter always owns WS route registration.

Suggested docs addition: Document that WS routes in sub-apps require either the functional plugin pattern or a shared adapter singleton. This issue has surfaced repeatedly: elysiajs/elysia#1616, elysiajs/elysia#1008, #38.

Related: elysiajs/elysia#1616 (same bug, no root cause identified in that thread)

Have you try removing the node_modules and bun.lockb and try again yet?

Yes

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions