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:
- 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);
- 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
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:
Run with
tsx watch src/index.tsand attempt to connect viaws://localhost:3000/ws.What is the expected behavior?
WebSocket connection to
/wssucceeds 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 newcreateWebSocketAdapter()instance with its own isolatedstoreclosure (ws.mjsline 16). Thestoreis how the WS route handler and the crossws upgrade function communicate:ws.handler(called during.ws()) writesstore[id] = contextws.createConfig(app)(called during.listen()) readsstore[id]to complete the upgradeWhen 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 ofelysia/dist/index.mjs) callsthis.add(method, path, handler, hooks)with the already-compiled handler closure from the sub-app's history. Formethod === "WS",add()(line 648) inserts this closure directly into the router without re-callingthis["~adapter"].ws()— so the parent's adapter never gets a chance to re-register the route with its own store.Additional information
Confirmed workarounds:
node()call exported and imported everywhere:Suggested fix: In
_use(), when encountering a"WS"method route from a plugin's history, callthis["~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_modulesandbun.lockband try again yet?Yes