Skip to content

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

@vrajpal-jhala

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions