npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

elysia-nnn-socket

v1.0.0

Published

Socket.IO-style single-endpoint WebSocket plugin for Elysia — file-based rooms with beforeJoin/onJoin/on/onLeave, namespace events, ack protocol

Readme

elysia-nnn-socket

npm version npm downloads license

English | Tiếng Việt

Phiên bản hiện tại: 1.0.0

Plugin WebSocket cho Elysia theo file-based, phong cách Socket.IO với room (beforeJoin / onJoin / on / onLeave), event namespace-wide và giao thức ack có sẵn — một endpoint duy nhất, nhiều room, một socket có thể tham gia nhiều room cùng lúc. Lấy cảm hứng từ nnn-socket.io.

Mục lục

Tính năng

  • 🚀 Một endpoint, nhiều room — duy nhất WS /ws, client emit room:join để vào room bất kỳ
  • 🏠 Vòng đời dạng roombeforeJoin / onJoin / on / onLeave cho mỗi file room
  • 🔁 Đa room trên 1 socket — một kết nối có thể join và leave nhiều room trong vòng đời
  • 📡 Event namespace-widesockets/index.ts cho phép event hoạt động xuyên suốt
  • 🛡️ Middleware kết nối_middleware.ts chạy 1 lần khi ws mở, có thể từ chối kết nối
  • Giao thức ack built-in — client gửi { type, __ack } và nhận response
  • 🎯 Room động — cú pháp [param] ánh xạ tên room sang params có kiểu
  • API giống Socket.IOsocket.emit(), socket.to(room), socket.send(), socket.data
  • 📦 TypeScript firstRoomModule, NamespaceModule, Middleware, RoomContext

Cài đặt

bun add elysia-nnn-socket elysia

Bắt đầu nhanh

  1. Tạo thư mục sockets/ trong dự án.
  2. Thêm handler files — mỗi file/folder là template của một room, không phải endpoint:
sockets/
  ├── _middleware.ts       # Tùy chọn: guard kết nối cho MỌI connection
  ├── index.ts             # Tùy chọn: namespace module — connection/disconnect/global events
  ├── chat/
  │   └── index.ts         # Room "chat" (join qua `room:join`)
  └── room/
      └── [id]/
          └── index.ts     # Room động "room/:id"
  1. Mount plugin (một endpoint, mặc định /ws):
import { Elysia } from "elysia";
import { nnnSocketElysia } from "elysia-nnn-socket";

const app = new Elysia()
  .use(nnnSocketElysia({ dir: "sockets", endpoint: "/ws" }))
  .listen(3000);
  1. Client kết nối một lần, sau đó join các room:
const ws = new WebSocket("ws://localhost:3000/ws");

// Vào room "chat"
ws.send(JSON.stringify({
  type: "room:join",
  room: "chat",
  __ack: "j1",
}));
ws.onmessage = (ev) => {
  const m = JSON.parse(ev.data);
  if (m.__ack === "j1") console.log("joined chat!", m);
};

Vậy là xong — một WebSocket, nhiều room.

Mẹo: Thay vì tự xử lý wire protocol, hãy dùng client library chính thức elysia-nnn-socket-client — nó tự động xử lý room:join/leave, ack tracking, event auto-prefix, và auto re-join. Xem Client Library bên dưới.

Client Library

Thay vì tự quản lý WebSocket messages, hãy dùng client TypeScript/JavaScript chính thức:

Cài đặt

bun add elysia-nnn-socket-client
# hoặc
npm install elysia-nnn-socket-client

Sử dụng cơ bản

import { createElysiaNnnClient } from "elysia-nnn-socket-client";

const client = createElysiaNnnClient({
  url: "http://localhost:3000",
  path: "/ws",
  query: { token: "..." }, // auth tùy chọn
});

await client.connect();

// Lấy room handle
const chat = client.room("chat");

// Lắng nghe events (tự động prefix "chat:")
chat.on("peer-joined", (data) => {
  console.log("Peer vào:", data.id);
});

chat.on("message", (payload) => {
  console.log(`${payload.from}: ${payload.text}`);
});

// Join room
const result = await chat.join();
console.log("Đã join:", result.room);

// Emit room events (tự động prefix)
chat.emit("message", { text: "Xin chào!" });

// Leave room
await chat.leave();
client.disconnect();

Room động

// Join room động với params
const room = client.room("room/[id]", { id: "abc123" });
await room.join();

// Params có sẵn
console.log(room.params); // { id: "abc123" }

room.emit("say", "Xin chào từ abc123!");

Hỗ trợ đa room

// Một socket có thể join nhiều room
const chat = client.room("chat");
const room1 = client.room("room/[id]", { id: "room1" });
const room2 = client.room("room/[id]", { id: "room2" });

await Promise.all([
  chat.join(),
  room1.join(),
  room2.join(),
]);

console.log(client.joinedRooms); // ["chat", "room/room1", "room/room2"]

Tự động re-join sau reconnect

const client = createElysiaNnnClient({
  url: "http://localhost:3000",
  autoRejoin: true, // mặc định: true
});

await client.connect();
await client.join("chat");

// Đóng socket (giữ joined rooms)
client.closeSocket();

// Reconnect → tự động re-join "chat"
await client.connect();

Namespace events

// Lắng nghe namespace-wide events (không có room prefix)
client.on("ping", (data) => {
  console.log("Server ping:", data);
});

// Emit namespace event
client.emit("ping", { timestamp: Date.now() });

Xử lý lỗi

import { NnnRoomError, NnnAckTimeoutError } from "elysia-nnn-socket-client";

try {
  await client.join("restricted-room");
} catch (err) {
  if (err instanceof NnnRoomError) {
    console.error("Join bị từ chối:", err.ack.error);
  } else if (err instanceof NnnAckTimeoutError) {
    console.error("Timeout chờ ack");
  }
}

Tham chiếu Client API

| API | Mô tả | |-----|-------| | createElysiaNnnClient(options) | Tạo client instance | | client.connect() | Mở kết nối WebSocket | | client.disconnect() | Đóng và xóa tất cả rooms | | client.connected | Kiểm tra đã kết nối | | client.joinedRooms | Mảng các room đã join | | client.room(name) | Lấy hoặc tạo room handle | | client.room(template, params) | Room động với params | | client.join(room) | Shorthand cho room().join() | | client.leave(room) | Shorthand cho room().leave() | | client.on(event, handler) | Lắng nghe namespace event | | client.emit(event, ...args) | Emit namespace event | | room.join() | Join room này | | room.leave() | Leave room này | | room.emit(event, ...args) | Emit room-scoped event | | room.on(event, handler) | Lắng nghe room event | | room.joined | Kiểm tra đã join | | room.params | Params của room động |

Để xem tài liệu đầy đủ, xem README của client package.

Ví dụ Client

Client package bao gồm các ví dụ hoạt động:

# Clone repo
git clone https://github.com/theanh-it/elysia-nnn-socket-client
cd elysia-nnn-socket-client

# Cài đặt dependencies
bun install

# Chạy examples (server phải đang chạy)
bun examples/basic.ts           # Connect/join/emit đơn giản
bun examples/chat-client.ts Alice  # CLI chat tương tác
bun examples/dynamic-room.ts room1  # Room động
# Hoặc mở examples/browser.html trong browser

Cấu hình

app.use(
  nnnSocketElysia({
    dir: "sockets",                    // Thư mục quét (mặc định: "sockets")
    endpoint: "/ws",                   // Endpoint WS duy nhất (mặc định: "/ws")
    silent: false,                     // Tắt logging (mặc định: false)
    joinEvent: "room:join",            // Tên event join mặc định (mặc định: "room:join")
    leaveEvent: "room:leave",          // Tên event leave mặc định (mặc định: "room:leave")
    onError: (error, filePath) => {    // Custom error handler
      console.error(`Lỗi trong ${filePath}:`, error);
    },
  })
);

Quy ước đường dẫn

| File trên disk | Tên room (template) | Ghi chú | |-------------------------------------|---------------------------|------------------------------------| | sockets/index.ts | — (namespace module) | Không phải room; xem bên dưới | | sockets/_middleware.ts | — (áp dụng cho tất cả) | Guard kết nối | | sockets/chat/index.ts | chat | Room tĩnh | | sockets/chat/lobby.ts | chat/lobby | File đặt tên trong folder room | | sockets/room/[id]/index.ts | room/:id | Động — [id]:id | | sockets/admin/[userId]/feed.ts | admin/:userId/feed | Nhiều segment động |

Quy tắc:

  • Extension .ts hoặc .js bị bỏ qua.
  • Segment index bị bỏ qua.
  • File bắt đầu bằng _ (ngoài _middleware.ts) bị bỏ qua.
  • Mỗi segment động được expose dưới dạng param có kiểu — đọc qua ctx.params.id.

Viết Room Module

Mỗi file trong sockets/ export default một object (một RoomModule). Mọi field đều tùy chọn, nhưng cần ít nhất một handler để room được đăng ký.

API giống Socket.IO

Plugin cung cấp một wrapper socket với các method quen thuộc:

// sockets/chat/index.ts
import type { RoomModule } from "elysia-nnn-socket";

export default {
  // Chạy khi client emit `room:join` cho room này, TRƯỚC onJoin.
  // Gọi `next()` để chấp nhận; `next(new Error("..."))` để từ chối (ack trả về error).
  beforeJoin(socket, ctx, next) {
    // vd: validate auth, rate limit, ...
    next();
  },

  // Chạy sau khi `beforeJoin` resolve thành công.
  onJoin(socket, ctx) {
    // Broadcast cho các socket khác trong room (trừ sender)
    socket.to(ctx.room).emit("chat:peer-joined", {
      id: socket.id,
      at: Date.now(),
    });
  },

  // Event handlers. Key là tên event client gửi.
  // Event theo room dùng format "roomName:event" — xem [Định dạng Message](#định-dạng-message--giao-thức-ack).
  on: {
    message(socket, ctx, payload) {
      // Broadcast cho peers only. Sender tự render local phía client (xem
      // chat.html) nên broadcast server-side giữ peers-only.
      socket.to(ctx.room).emit("chat:message", {
        from: socket.id,
        text: payload.text,
        timestamp: Date.now(),
      });
    },

    typing(socket, ctx, isTyping) {
      // Broadcast cho người khác
      socket.to(ctx.room).emit("chat:typing", {
        from: socket.id,
        isTyping,
      });
    },
  },

  // Chạy khi socket rời room này (qua `room:leave` hoặc `ws.close()`).
  onLeave(socket, ctx) {
    socket.to(ctx.room).emit("chat:peer-left", {
      id: socket.id,
    });
  },
} satisfies RoomModule;

Auto-stringify

Mọi method emit(), to(), send() tự động:

  1. Gói data thành { type: event, ...payload }
  2. Gọi JSON.stringify()
  3. Gửi qua WebSocket

Không cần JSON.stringify() thủ công nữa.

Vòng đời

Client                                  Server (plugin)
  │                                          │
  │──── ws.connect("/ws") ──────────────────►│ _middleware.ts (root)
  │                                          │ namespace.connection(socket)
  │◄─── welcome (tùy chọn) ─────────────────│
  │                                          │
  │──── { type:"room:join", room:"chat" } ─►│ matchRoomName("chat")
  │                                          │ beforeJoin(socket, ctx, next)
  │◄─── { __ack:"j1", ok:true, ... } ───────│
  │                                          │ onJoin(socket, ctx)
  │◄─── peer-joined (broadcast) ────────────│
  │                                          │
  │──── { type:"chat:message", text:"hi" } ►│ mod.on.message(socket, ctx, payload)
  │◄─── message broadcast ──────────────────│
  │                                          │
  │──── { type:"room:leave", room:"chat" } ►│ onLeave(socket, ctx)
  │                                          │
  │──── ws.close() ─────────────────────────►│ namespace.disconnect(socket, code, reason)

Join & Leave Room

Plugin expose hai event built-in:

// Join một room (có thể kèm ack)
ws.send(JSON.stringify({
  type: "room:join",
  room: "chat",          // hoặc "room/abc123" cho room động
  __ack: "j1",           // tùy chọn — server reply qua ack
}));

// Leave một room (có thể kèm ack)
ws.send(JSON.stringify({
  type: "room:leave",
  room: "chat",
  __ack: "l1",
}));

Server ack (thành công):

{ __ack: "j1", ok: true, response: { ok: true, room: "chat", params: {} } }

Server ack (bị beforeJoin từ chối hoặc room không tồn tại):

{ __ack: "j1", ok: true, response: { ok: false, error: "forbidden" } }

Namespace Module & Middleware

sockets/index.ts (namespace module)

File đặc biệt — không phải room, mà là cấu hình cho cả namespace. Mọi handler chạy cho tất kỳ connection nào.

// sockets/index.ts
import type { NamespaceModule } from "elysia-nnn-socket";

export default {
  // Chạy 1 lần mỗi connection, sau khi `_middleware.ts` pass.
  connection(socket) {
    socket.emit("welcome", { id: socket.id });
  },

  // Event namespace-wide, không prefix, không theo room.
  // Client có thể gửi các event này từ bất kỳ room context nào.
  on: {
    ping(socket, payload) {
      socket.emit("pong", { echo: payload });
    },
  },

  // Chạy khi ws đóng (sau tất cả hook onLeave).
  disconnect(socket, code, reason) {
    console.log(`${socket.id} disconnected: ${code} ${reason}`);
  },
} satisfies NamespaceModule;

_middleware.ts (guard kết nối gốc)

Chạy 1 lần khi ws mở, trước hook connection. Có thể từ chối kết nối (ws sẽ đóng với code 1008).

// sockets/_middleware.ts
import type { Middleware } from "elysia-nnn-socket";

export default ((socket, next) => {
  // Kiểm tra auth nhanh. socket.raw.data cho access tới Elysia Context
  // (query, params, headers, body, v.v.)
  if (!socket.raw.data.query?.token) {
    return next(new Error("missing token"));
  }
  next();
}) satisfies Middleware;

Dynamic Rooms [param]

Segment động được expose dưới dạng param có kiểu trong ctx.params. Cùng một socket có thể join nhiều [id] khác nhau trong vòng đời của nó.

// sockets/room/[id]/index.ts
import type { RoomModule } from "elysia-nnn-socket";

export default {
  onJoin(socket, ctx) {
    socket.emit("room:joined", {
      roomId: ctx.params.id,    // "abc123"
    });

    socket.to(ctx.room).emit("room:peer-joined", {
      id: socket.id,
      roomId: ctx.params.id,
    });
  },

  on: {
    say(socket, ctx, text, ack) {
      // Broadcast cho peers only. Sender echo local (hoặc qua ack callback)
      // nên broadcast server-side giữ peers-only.
      socket.to(ctx.room).emit("room:said", {
        from: socket.id,
        roomId: ctx.params.id,
        text,
      });

      if (typeof ack === "function") ack({ ok: true, echoed: text });
    },
  },
} satisfies RoomModule;

Client:

ws.send(JSON.stringify({ type: "room:join", room: "room/abc123", __ack: "j1" }));
// → ctx.params.id === "abc123"

Định dạng Message & Giao thức Ack

Client → Server

Client phải gửi một JSON object với ít nhất field type. Các field còn lại trở thành positional payload args.

// Fire-and-forget đơn giản (event theo room "chat:message")
ws.send(JSON.stringify({ type: "chat:message", text: "hello" }));

// Event namespace-wide "ping"
ws.send(JSON.stringify({ type: "ping", value: 1 }));

// Với ack (server có thể trả lời)
ws.send(JSON.stringify({
  type: "chat:save",
  id: "doc-1",
  body: "...",
  __ack: "r1",          // bất kỳ string ID nào
}));

// Server handler signature: save(socket, ctx, id, body, ack)

Quy ước tên event cho event theo room:

  • Format: "<roomName>:<eventName>"
  • Ví dụ: chat:message, room/abc123:say
  • Match với key trong on map của room module

Server → Client (ack response)

Khi client gửi __ack, argument cuối cùng (positional) của handler là một function ack:

on: {
  save(socket, ctx, id, body, ack) {
    ack({ ok: true, id, savedAt: Date.now() });
  },
}

Client nhận:

// ws.onmessage fires với:
{ __ack: "r1", ok: true, response: { ok: true, id: "doc-1", savedAt: 1700000000000 } }

Nếu handler throw, ack tự động reply { ok: false, error }:

{ __ack: "r1", ok: true, response: { ok: false, error: "nope" } }

Event type không xác định

Gửi { type: "unknown-event" } bị drop silently — không lỗi, không log spam (trừ khi silent: false, lúc đó sẽ log warning).

Tham chiếu Socket API

| Property / Method | Mô tả | |-------------------|-------| | socket.id | Socket ID duy nhất | | socket.rooms | Set<string> của tất cả room socket hiện đang ở | | socket.data | Kho key-value per-connection (giống Socket.IO socket.data) | | socket.emit(event, ...args) | Gửi cho chính client này + mọi người trong các room đã join | | socket.to(room).emit(event, ...args) | Broadcast cho một room cụ thể (trừ sender) | | socket.send(event, ...args) | Gửi chỉ cho client này | | socket.nsp.to(room) | Helper broadcast namespace-wide | | socket.raw | Raw WebSocketContext cho Bun-native methods |

socket.data — lưu trữ per-connection

Lưu data tùy ý trên socket (sống suốt vòng đời connection):

beforeJoin(socket, ctx, next) {
  socket.data.username = socket.raw.data.query?.username ?? "guest";
  next();
}

on: {
  message(socket, ctx, text) {
    socket.emit("chat:message", {
      from: socket.data.username,
      text,
    });
  },
}

Hỗ trợ TypeScript

import type {
  RoomModule,
  NamespaceModule,
  Middleware,
  RoomContext,
  RoomEventHandler,
  NamespaceEventHandler,
  RoomGuard,
  AckFn,
  WebSocketContext,
  NnnSocketElysiaOptions,
  NnnSocket,
  BroadcastTarget,
} from "elysia-nnn-socket";

import { ConnectionRegistry, registry } from "elysia-nnn-socket";

Demo

git clone https://github.com/theanh-it/elysia-nnn-socket
cd elysia-nnn-socket
bun install
bun run demo          # http://localhost:3000

| URL | Mô tả | |-----|-------| | http://localhost:3000/ | Playground — kết nối tới /ws, join room, gửi event | | http://localhost:3000/chat | Demo chat nhiều tab với broadcast | | http://localhost:3000/room | UI room động — chọn ID, gửi message kèm ack |

Cấu trúc demo (demo/sockets/):

demo/sockets/
  ├── _middleware.ts        # log handshake cho mọi connection
  ├── index.ts              # namespace module: connection/on.ping/disconnect
  ├── chat/index.ts         # room tĩnh với broadcast + typing
  └── room/[id]/index.ts    # room động, params + ack

Khắc phục sự cố

Kết nối WebSocket thất bại

  • Kiểm tra handler export: đảm bảo mỗi file room export ít nhất một trong beforeJoin / onJoin / onLeave / on.
  • Kiểm tra endpoint: mặc định plugin mount ở /ws. Điều chỉnh qua option endpoint.
  • Console trình duyệt: xem lỗi kết nối.
  • Log server: bỏ silent: true để xem các room đã đăng ký.

beforeJoin từ chối bất ngờ

Nếu thấy ack join trả về { ok: false, error: "..." }, nghĩa là beforeJoin của bạn đã gọi next(new Error(...)). Đảm bảo:

  • next() được gọi (không quên) trên success path.
  • Lỗi throw synchronously HOẶC qua next(err) — không gọi cả hai.

Lỗi không tìm thấy module

  • Đường dẫn thư mục: dir là relative tới process.cwd().
  • Extension file: phải là .ts hoặc .js.
  • Định dạng export: dùng export default { ... }.

Lỗi TypeScript

  • Import type một cách tường minh: import type { RoomModule } from "elysia-nnn-socket".
  • Đảm bảo dist/index.d.ts đã được sinh ra bởi bun run build.

FAQ

H: Có thể dùng HTTP route và WebSocket route cùng nhau không?
T: Có! nnnSocketElysia trả về một Elysia instance — dùng .get(...), .post(...) v.v. trên cùng app, trước hoặc sau plugin.

H: Khác gì so với nnn-socket.io?
T: Cùng mô hình (rooms + namespace + scoped events), nhưng transport khác nhau:

| Khía cạnh | elysia-nnn-socket (cái này) | nnn-socket.io | |-----------|-------------------------------|-----------------| | Transport | Bun native WebSocket | Socket.IO | | Join room | Client emit room:join | Client emit room:join | | Số endpoint | 1 (cấu hình được, mặc định /ws) | 1 namespace + nhiều room | | Đa room trên 1 connection | ✅ Có | ✅ Có | | Framework server | Elysia | Standalone |

H: Một socket có thể join nhiều room không?
T: Có! Gửi nhiều event room:join. Mỗi room giữ state riêng (socket.rooms expose set đó).

H: Handler có thể async không?
T: Có — onJoin, beforeJoin, onLeave và mỗi on[key] đều chấp nhận async.

H: Đọc query / path params ở đâu?
T: socket.raw.data.query (chuẩn Elysia) hoặc ctx.params cho room-template params.

H: Có thể đăng ký thêm WebSocket route ngoài sockets/ không?
T: Có — .ws("/foo", { ... }) trên Elysia app như thường lệ.

H: Có client library không?
T: Có! Dùng elysia-nnn-socket-client — nó xử lý wire protocol, ack tracking, room events tự động prefix, và reconnect re-join. Xem Client Library.

Hiệu suất

  • Khởi động: rooms được scan một lần qua Bun.Glob (không overhead runtime).
  • Kết nối: Bun WS native, không có overhead Socket.IO.
  • Dispatch theo room: tra cứu O(1) qua scoped registry per-socket.
  • Broadcast: ConnectionRegistry trong bộ nhớ với Set<ws> cho mỗi room.

Đóng góp

PRs luôn welcome! Code style theo các pattern hiện có; tính năng mới cần đi kèm test trong tests/.

  1. Fork & branch
  2. Code + thêm test
  3. bun test phải pass
  4. Cập nhật README.md + README.vi.md

Yêu cầu hệ thống

  • Bun v1.2+
  • Elysia ^1.3+

License

MIT — xem LICENSE.

Tác giả

The Anh@theanh-it · [email protected]

Links