uninet
v0.1.2
Published
HTTP, WebSocket, and SSE server for Node.js — server only; use uninet-client for client (no dependency)
Maintainers
Readme
uninet
HTTP, WebSocket, and SSE server for Node.js. Server only; no client code. Use uninet-client for the client (separate package, no dependency between them).
Packages
| Package | Purpose | |--------|---------| | uninet (this) | Server only: createServer, HTTP, WS, SSE, middleware, upload, CORS, cookies, body validator. | | uninet-client | Client only: fetch, createWebSocket, createUninetClient. No dependency on uninet. |
npm install uninet # server
npm install uninet-client # client (in app or another repo)Positioning
- Simpler than Express — no view engine, one middleware model.
- Faster than Express — trie router, linear middleware, minimal allocations.
- Lighter than Socket.IO — optional
wsonly when you enable WebSocket. - Server + client split — uninet (server) and uninet-client (client) are separate packages with no dependency.
Comparison & benchmarks: COMPARISON.md — SDK/client compatibility (Socket.IO–style), features vs Express/Socket.IO/Fastify/Hono, performance & throughput.
Platform support: Server = Node.js (Windows, macOS, Linux). Client = Android, iOS, Windows, macOS, Linux — browser, Node, or React Native / JS runtimes (same API; see COMPARISON.md#11-platform-support).
Docs & examples:
- Features-example.md — har feature ke production-ready examples (min 2 per feature).
- webscokets-example.md — WebSocket: simple
ioAPI + app.ws() custom path.
Install
npm install uninetOptional dependencies:
- WebSocket server (
ws: true):npm install ws - Body validator with Zod:
npm install zod
Engine: Default is uWS for higher throughput. Use createServer({ engine: "node" }) to use Node's built-in HTTP server (e.g. for compatibility or testing).
API
Server
import { createServer, bodyParser, cookieParser, cors } from "uninet";
const app = createServer({
port: 3000,
host: "0.0.0.0",
ws: true,
sse: true,
timeout: 30_000,
pingInterval: 25_000, // heartbeat (ms); 0 = disabled
recoveryBufferSize: 100, // events stored for reconnection recovery
});
app.use(cors({ origin: "*", credentials: true }));
app.use(cookieParser("optional-secret")); // req.cookies, res.cookie(), res.clearCookie()
app.use(bodyParser()); // body already parsed (JSON, urlencoded, text)
app.use(async (req, res, next) => {
console.log(req.method, req.path);
await next();
});
app.get("/users", (req, res) => {
res.json({ users: [] });
});
app.post("/users", (req, res) => {
const body = req.body as { name: string };
res.status(201).json({ id: "1", ...body });
});
app.ws("/chat", {
open(ws, req, io) {
ws.emit("welcome", { id: ws.id });
ws.join("lobby");
},
message(ws, data, io) {
io.broadcast("message", { text: data.toString(), from: ws.id });
},
close(ws) {},
error(ws, err) {},
});
// From any HTTP controller: broadcast / send to particular socket or room
const io = app.getWsNamespace?.("/chat");
io?.broadcast("event", payload);
io?.to(socketId).emit("dm", payload);
io?.to(roomId).emit("room", payload);
app.sse("/events", (stream, req) => {
stream.send({ type: "ping" });
});
await app.listen();
// await app.close();HTTP handler
- RequestContext:
method,url,path,headers,query,params,body,cookies(with cookieParser),raw(IncomingMessage). - ResponseContext:
status(code),setHeader(name, value),json(data),sendPrepared(body, contentType?),text(data),redirect(url),cookie(name, value, options?),clearCookie(name),error(err),end(). For hot paths, preferres.sendPrepared(Buffer.from('{"ok":1}', "utf8"))to skipJSON.stringifyand avoid extra allocations.
Middleware
(req: RequestContext, res: ResponseContext, next: () => Promise<void>) => void | Promise<void>Linear execution; call next() to continue.
Body validator (Zod-style)
Validate req.body with any schema that has .parse(data). Works with Zod (optional: npm i zod).
import { createServer, bodyParser, bodyValidator } from "uninet";
import { z } from "zod";
const app = createServer({ port: 3000 });
app.use(bodyParser());
const createUserSchema = z.object({ name: z.string().min(1), email: z.string().email() });
app.post("/users", (req, res) => {
bodyValidator(createUserSchema)(req, res, () => {
const body = req.body; // typed & validated
res.status(201).json({ id: "1", ...body });
});
});On validation failure: 400 with { message: "Validation failed", errors: [...] } (Zod issues when available).
Middleware order (recommended)
cors → bodyParser → createUpload → auth → rateLimit → accessLog → securityHeaders → routesAdd-ons (middleware & helpers)
- rateLimit — Limit requests per key (IP or custom) per window:
app.use(rateLimit({ windowMs: 60_000, max: 100 })). UseignorePaths: ["/chat/poll"]to skip high-frequency paths. - accessLog — Log method, path, status, duration:
app.use(accessLog({ format: "short", ignorePaths: ["/chat/poll"] })). - securityHeaders — Set
X-Content-Type-Options,X-Frame-Options, optional HSTS:app.use(securityHeaders({ hsts: false })). - requestId — Add
X-Request-Idto request/response (or use client-provided):app.use(requestId()). Setsreq.requestId. - structuredLog — Span context per request:
req.span/res.spanwithtraceIdandspanId; JSON log on finish:app.use(structuredLog({ injectResponseHeaders: true })). Optional X-Trace-Id header propagation. - health — Health check handler for K8s/LB:
app.get("/health", health({ ready: () => db.isConnected() })). Returns 200/503. - gracefulShutdown — Listen for SIGTERM/SIGINT and call
app.close():gracefulShutdown(app, { onClosed: () => process.exit(0) }). - createStatic — Static file serving:
app.use(createStatic({ routePrefix: '/public', directory: path.join(__dirname, 'public'), etag: true, spa: true })). Usespa: truefor SPA fallback (serve index.html when file not found). - res.download(filePath, filename?) — Send file as attachment.
- req.context — Use
req.context = { userId }in middleware; pass data to handlers. - compress — Gzip response body when
Accept-Encoding: gzipand size > threshold:app.use(compress({ threshold: 1024 })). - WS auth — Reject WebSocket upgrade with 401 if auth fails:
app.ws('/chat', { auth: (req) => validateToken(req), open(...), message(...) }). - wsCompression — Enable WebSocket per-message deflate:
createServer({ ws: true, wsCompression: true }). - getStickyKey — Sticky session key for load balancers:
getStickyKey(req, 'x-session-id'); use with LB so same client hits same instance for WS. - Streaming response —
res.stream(async function* () { yield chunk; })(Transfer-Encoding: chunked). - Request stream — Use
streamBody: truein route options so body is not read; usereq.bodyStream(same asreq.raw) for streaming uploads:app.post('/upload', handler, { streamBody: true }). - Schema at route — Validate body/query before handler:
app.post('/users', handler, { body: userSchema, query: paginationSchema }). 400 with structured errors on failure. - SSE middleware — Global
app.use()middlewares run before SSE handlers; auth, rate limit, requestId, etc. apply to SSE routes. After middleware, the SSE stream is created and passed to the handler.
HTTP client (uninet-client)
Use uninet-client for fetch (axios-like):
import { fetch } from "uninet-client";
const res = await fetch("https://api.example.com", {
method: "POST",
json: { hello: "world" },
timeout: 5000,
});
const data = await res.json();Supports: method, headers, body, json, timeout, signal (AbortSignal).
Socket.IO-style WebSocket (io, broadcast, rooms)
io(WsNamespace): passed toopen(ws, req, io)andmessage(ws, data, io). Use from anywhere viaapp.getWsNamespace(path)(e.g. in HTTP controllers).io.broadcast(event, data)— send to all connected sockets in this path.io.to(socketId).emit(event, data)— send to one socket (byws.id).io.to(roomId).emit(event, data)— send to all sockets in a room.ws.id— unique socket id.ws.emit(event, data)— send to this socket only (client receives JSON{ type, data }).ws.join(roomId)/ws.leave(roomId)/ws.rooms()— room membership.- Acknowledgements:
io.to(id).emit("event", data, { ack: (err, data) => {} })— callback when client sends_ack. - Volatile:
io.broadcast("event", data, { volatile: true })— not stored for reconnection recovery. - Connection state recovery: server stores last N events per namespace; client sends
lastEventIdon reconnect and receives missed events. - Heartbeat:
pingIntervalin options sends ping/pong; disconnect if no pong. - HTTP fallback: GET
path/poll?lastEventId=and POSTpath/emitfor clients that fall back from WebSocket (unified client uses these). - Distributed WebSocket (Redis) — Optional adapter to sync broadcast/rooms across instances:
app.ws('/chat', handlers, { adapter: createRedisAdapter(redisClient) }). Use package uninet-adapter-redis (npm i uninet-adapter-redis redis). Same API; messages are published to Redis and delivered to local sockets on every instance.
Client (separate package: uninet-client)
For fetch, createWebSocket, and createUninetClient (WS-first, HTTP fallback, auto-reconnect, cookies), use the uninet-client package. No dependency on uninet.
npm install uninet-clientimport { createUninetClient, createWebSocket, fetch } from "uninet-client";import { createUninetClient } from "uninet";
const client = createUninetClient("http://localhost:3001", {
path: "/chat",
transports: ["ws", "http"],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
withCredentials: true, // send/store cookies
debug: false,
});
client.on("connect", () => {});
client.on("disconnect", () => {});
client.on("message", (data) => {});
client.on("eventName", (data) => {});
client.emit("eventName", payload, (err, data) => { /* ack */ });
client.connect();
// client.disconnect();- Auto-reconnect with exponential backoff.
- Cookies:
withCredentials: trueuses a cookie jar (send Cookie, store Set-Cookie). - Acknowledgements: pass a callback as third arg to
emit; server can reply with_ack.
Built-in upload (dependency-free, HTTP + WS)
No multer; pure Node (Buffer + fs). Prefer WS for file upload (chunked over WebSocket), fallback HTTP multipart.
HTTP multipart — single / multiple / multi-field:
import { createServer, createUpload } from "uninet";
const app = createServer({ port: 3000 });
app.use(
createUpload({
prefix: "file",
directory: "uploads",
sizeLimit: 5 * 1024 * 1024,
fieldName: "file",
multiple: true,
maxCount: 3,
allowedTypes: ["image", "pdf", "doc", "video"],
// fields: [{ name: "avatar", maxCount: 1 }, { name: "gallery", maxCount: 5 }],
})
);
app.post("/upload", (req, res) => {
const urls = req.files; // string[] or Record<string, string[]>
const body = req.body; // non-file fields
res.json({ success: true, files: urls });
});WS file upload (prefer for throughput) — client sends file:start, file:chunk (base64), file:end; server replies file:done with URL:
import { createWsFileReceiver } from "uninet";
const receiveFile = createWsFileReceiver({ directory: "uploads", prefix: "wsfile" });
app.ws("/upload", {
message(ws, data, io) {
try {
const p = JSON.parse(data.toString());
receiveFile(ws, p);
} catch {}
},
});WebSocket client (raw)
import { createWebSocket } from "uninet";
const ws = createWebSocket("wss://example.com", {
open() {},
message(data) {},
close() {},
error(err) {},
});Embedded mode
Pass an existing Node http.Server:
import * as http from "node:http";
import { createServer } from "uninet";
const server = http.createServer();
const app = createServer({ server, ws: true });
app.get("/health", (req, res) => res.json({ ok: true }));
await app.listen();
server.listen(3000);Types
All public types are exported from uninet: RequestContext, ResponseContext, Middleware, HttpHandler, WsHandlers, SseStream, CreateServerOptions, App, FetchOptions, FetchResponse, etc.
Error handling
createError(statusCode, message, code?)— create typed HTTP errors.res.error(err)— send JSON error (no stack in production).HttpError,isHttpError,formatError— for custom handling.
Dependency map
| Feature | Dependency | When |
|----------------|-------------|--------------------------|
| HTTP server | Node http | Always |
| SSE | None | Always (built-in) |
| WebSocket server | ws (optional) | When ws: true |
| WebSocket client | Global WebSocket or ws | When using createWebSocket |
| HTTP client | Global fetch (Node 18+) | When using fetch |
Performance notes
- Trie-based router: O(path segments), no regex.
- Body read once per request; parsed as JSON or text.
- Middleware runs in a single linear chain; no recursion.
- SSE uses raw
ServerResponse.write; no extra buffering. - Optional
wsis lazy-loaded when the first upgrade is handled. - Hot paths: Use
res.sendPrepared(Buffer.from('{"ok":1}', "utf8"))for static/semi-static JSON to skipJSON.stringify.
Author
Arun — LinkedIn · GitHub · pluskode.com
License
MIT
