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
Maintainers
Readme
elysia-nnn-socket
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
- Cài đặt
- Bắt đầu nhanh
- Client Library
- Cấu hình
- Quy ước đường dẫn
- Viết Room Module
- Vòng đời
- Join & Leave Room
- Namespace Module & Middleware
- Dynamic Rooms
[param] - Định dạng Message & Giao thức Ack
- Tham chiếu Socket API
- Hỗ trợ TypeScript
- Demo
- Khắc phục sự cố
- FAQ
- Hiệu suất
- Đóng góp
- Yêu cầu hệ thống
- License
Tính năng
- 🚀 Một endpoint, nhiều room — duy nhất
WS /ws, client emitroom:joinđể vào room bất kỳ - 🏠 Vòng đời dạng room —
beforeJoin/onJoin/on/onLeavecho 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-wide —
sockets/index.tscho phép event hoạt động xuyên suốt - 🛡️ Middleware kết nối —
_middleware.tschạ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.IO —
socket.emit(),socket.to(room),socket.send(),socket.data - 📦 TypeScript first —
RoomModule,NamespaceModule,Middleware,RoomContext
Cài đặt
bun add elysia-nnn-socket elysiaBắt đầu nhanh
- Tạo thư mục
sockets/trong dự án. - 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"- 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);- 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-clientSử 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 browserCấ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
.tshoặc.jsbị bỏ qua. - Segment
indexbị 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:
- Gói data thành
{ type: event, ...payload } - Gọi
JSON.stringify() - 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
onmap 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 + ackKhắ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 optionendpoint. - 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:
dirlà relative tớiprocess.cwd(). - Extension file: phải là
.tshoặ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ởibun 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:
ConnectionRegistrytrong bộ nhớ vớiSet<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/.
- Fork & branch
- Code + thêm test
bun testphải pass- 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
- GitHub
- NPM
- Client Library — TypeScript/JavaScript client cho server này
- nnn-socket.io — package Socket.IO tương ứng
