
Security News
Happy Birthday, Shai-Hulud
It has been one year since Shai-Hulud made its first appearance on npm.
A modular TypeScript-first server toolkit with typed HTTP routing, WebSocket support, and scalable transport primitives.
keno is a TypeScript-first server toolkit and modern client stack for building HTTP, WebSocket, and HTTP/2 applications with typed routing, familiar middleware, modular public imports, and scalable transport primitives.
keno was created to keep the small-app experience pleasant without painting larger systems into a corner.
Many Node server stacks feel strong in one dimension and awkward in another:
keno aims to keep one mental model across those layers instead:
app.use(...), app.get(...), and app.ws(...)permessage-deflateWebSocket implementationhost(...) and domain(...)listenMany(...) and keno/multi-serverreusePorthttp, https, and http2 transports, including RFC 8441 extended CONNECT for WebSockets over HTTP/2keno/client, keno/middleware, and keno/worker-poolnpm install keno
Runtime notes:
>=18.17.0globalThis.WebSocket, or with an injected constructorimport keno from "keno";
const app = keno();
app.use(keno.requestId());
app.use(keno.securityHeaders());
app.use(keno.json());
app.get("/users/:id", (request, response) => {
response.json({
id: request.params.id,
requestId: response.locals.requestId,
transport: request.transport,
});
});
app.ws("/events", (socket) => {
socket.sendText("ready");
socket.on("text", (message) => {
socket.sendText(`echo:${message}`);
});
});
await app.listen(3000).ready();
import { createHttpClient } from "keno/client";
type User = {
id: string;
name: string;
};
const client = createHttpClient({
baseURL: "https://api.myapp.com",
headers: {
authorization: "Bearer token",
},
timeout: 3000,
});
const user = await client
.get("/users/:id", {
params: {
id: "42",
},
})
.expectOk()
.json<User>();
const prepared = client.get("/users/:id", {
params: {
id: "42",
},
}).prepare();
const raw = await prepared.fetch();
console.log(user, raw.status);
keno is centered on a small set of runtime building blocks:
keno() and createApp()
KenoRouter
KenoRequest
KenoResponse
KenoWebSocket
HttpClient
KenoWebSocketClient
This keeps the everyday API small while still leaving room for host-aware routing, secure transports, concurrency, and shared contracts.
keno keeps the public surface modular through folder-based subpath exports:
keno, keno/application, keno/router, keno/request, keno/response, keno/typeskeno/middleware, keno/pluginskeno/client, keno/websocketkeno/certificates, keno/multi-server, keno/thread-cluster, keno/worker-poolExample:
import { createApp } from "keno/application";
import { json, requestId } from "keno/middleware";
import { createHttpClient } from "keno/client";
import { createWorkerPool, threaded } from "keno/worker-pool";
const app = createApp();
const client = createHttpClient();
app.use(requestId());
app.use(json());
The core router keeps a familiar shape on purpose:
import keno from "keno";
const app = keno();
const api = keno.Router();
app.use(async (request, response, next) => {
const startedAt = Date.now();
await next();
console.log(request.method, request.path, response.statusCode, Date.now() - startedAt);
});
api.get("/users/:id", (request, response) => {
response.json({
id: request.params.id,
});
});
app.use("/api", api);
app.host("api.local.test", api);
Built-in middleware today:
cors(...)json(...)text(...)requestId(...)securityHeaders(...)serveStatic(...) and static(...)Request helpers include:
request.params, request.query, request.cookies, and request.bodyrequest.get(...), request.header(...), request.cookie(...), and request.param(...)request.accepts(...), request.acceptsLanguages(...), request.acceptsEncodings(...), and request.acceptsCharsets(...)request.is(...), request.xhr, and request.hasBodyrequest.origin, request.host, request.hostname, request.ip, request.secure, and request.transportawait request.buffer(), await request.text(), and await request.json<T>()Response helpers include:
response.status(...), response.type(...), response.json(...), and response.send(...)response.cookie(...) and response.clearCookie(...)response.redirect(...)await response.sendFile(...) and await response.download(...)response.links(...), response.vary(...), response.location(...), and response.attachment(...)response.set(...), response.append(...), response.remove(...), and response.sendStatus(...)WebSocket routes use the same routing model as HTTP routes:
import keno from "keno";
const app = keno();
const rooms = keno.createWebSocketRooms();
app.ws("/chat/:room", (socket, request) => {
const room = request.params.room;
rooms.join(room, socket);
socket.sendText(`joined:${room}`);
socket.on("text", (message) => {
rooms.broadcast(room, {
message,
room,
type: "message",
});
});
socket.on("close", () => {
rooms.leave(socket);
});
});
Realtime features currently available:
app.ws(...) handlers with typed route paramspermessage-deflate supportcreateWebSocketRooms() for room membership and broadcastcreateWebSocketHeartbeat() for keepalive managementcreateWebSocketClient() in keno/clienthttp, https, and http2If your Node runtime does not expose globalThis.WebSocket, inject one explicitly:
import { WebSocket } from "ws";
import { createWebSocketClient } from "keno/client";
const client = createWebSocketClient("ws://127.0.0.1:3000/events", {
WebSocket,
});
Applications can register reusable behavior through plugins:
import keno from "keno";
const app = keno();
await app.register(keno.heartbeatPlugin, {
details: () => ({
region: "local",
transport: "http",
}),
name: "public-api",
});
await app.register(keno.openApiPlugin, {
title: "Public API Docs",
document: {
openapi: "3.1.0",
info: {
title: "Public API",
version: "1.0.0",
},
paths: {},
},
});
Built-in plugins:
heartbeatPluginopenApiPluginrequestLoggerPluginCustom plugins can be defined with definePlugin(...).
The HTTP client is designed to feel modern without getting in the way.
import { createHttpClient } from "keno/client";
const client = createHttpClient({
baseURL: "https://api.myapp.com",
});
const created = await client
.post("/users", {
body: {
name: "Keno",
},
})
.expect(201)
.json<{ id: string; name: string }>();
extend(...)const client = createHttpClient({
retry: {
attempts: 3,
baseDelayMs: 200,
},
timeout: 3000,
}).extend({
baseURL: "https://api.myapp.com",
headers: {
authorization: "Bearer token",
},
});
client.use(async (context, next) => {
console.log("request", context.request.method, context.request.url);
const response = await next();
console.log("response", response.status);
return response;
});
Prepared requests are useful when you want keno to structure the request, but you want to decide later how to send it:
const prepared = client.prepare("GET", "/users/:id", {
params: {
id: "42",
},
query: {
include: ["teams", "permissions"],
},
});
const request = prepared.toRequest();
const raw = await prepared.fetch();
const response = await prepared.response();
console.log(request.url, raw.status, response.ok);
The fluent builder exposes the same preparation flow:
const prepared = client.get("/users/:id", {
params: {
id: "42",
},
}).prepare();
Server and client code can share lightweight route definitions:
import {
createHttpClient,
defineHttpEndpoint,
defineHttpRoute,
defineHttpRoutes,
type HttpClientSchemaFromRoutes,
} from "keno/client";
type CreateUser = {
name: string;
};
type User = {
id: string;
name: string;
};
const routes = defineHttpRoutes(
defineHttpRoute("/users/:id", {
GET: defineHttpEndpoint<User, never, never, { id: string }>(),
}),
defineHttpRoute("/users", {
POST: defineHttpEndpoint<User, CreateUser>(),
}),
);
type Api = HttpClientSchemaFromRoutes<typeof routes>;
const client = createHttpClient<Api>({
baseURL: "https://api.myapp.com",
});
const user = await client.GET("/users/:id", {
params: {
id: "42",
},
});
const created = await client.POST("/users", {
body: {
name: "Ana",
},
});
keno can stay minimal for a single-process app, but it also includes higher-level transport and concurrency primitives when needed.
import keno from "keno";
import { temporaryTls } from "keno/certificates";
const app = keno();
const tls = await temporaryTls({
commonName: "127.0.0.1",
hosts: ["127.0.0.1"],
});
await app.listen({
host: "127.0.0.1",
port: 3000,
transport: "http2",
allowHTTP1: true,
tls,
}).ready();
import { createApp } from "keno/application";
import { createWorkerPool, threaded } from "keno/worker-pool";
const app = createApp();
const pool = createWorkerPool<{ value: number }, { result: number }>({
entry: new URL("./worker.ts", import.meta.url),
execArgv: ["--import", "tsx/esm"],
size: 4,
});
app.get(
"/cpu/:value",
threaded(pool, {
input: (request) => ({
value: Number(request.params.value),
}),
}),
);
Other transport and concurrency features:
listenMany(...) for one app behind multiple listenerskeno/multi-server exports for explicit multi-listener orchestrationcreateThreadCluster(...) in keno/thread-clusterthreaded: true listener support with reusePortkeno/certificatesThe repository ships with runnable examples you can start directly from the project root.
Core HTTP:
npm run example:basicnpm run example:client-httpnpm run example:crudnpm run example:hostsnpm run example:modular-importsnpm run example:multi-servernpm run example:content-negotiationnpm run example:webhook-textFiles and static delivery:
npm run example:static-sitenpm run example:download-centerWebSocket and realtime:
npm run example:client-websocketnpm run example:websocketnpm run example:realtime-chatnpm run example:http2Concurrency:
npm run example:thread-clusternpm run example:threaded-endpointsIf one of the default ports is already in use, override it per run:
PORT=3100 npm run example:basic
More details live in examples/README.md.
keno includes an in-repo benchmark harness and Autobahn runner.
Run the benchmark suite:
npm run bench
For a shorter pass:
npm run bench:quick
Run the Autobahn WebSocket conformance harness:
npm run autobahn
The benchmark harness currently covers:
Current comparison targets include raw Node.js plus frameworks such as express, fastify, koa, tinyhttp, hono, ws, @fastify/websocket, and websocket.
Latest stored snapshot:
bench/results/latest.json2026-04-03T22:21:35.129Zduration=5s, connections=50, pipelining=1, wsClients=20, wsMessages=500, cpuValue=28Benchmark numbers are environment-dependent, so treat them as a reproducible local snapshot rather than a universal ranking. The Vs keno column uses the plain keno server in the same benchmark group as the baseline.
| Runtime | Req/s | Latency (ms) | Vs keno |
|---|---|---|---|
| keno | 15325.60 | 3.04 | 1x |
| keno-plugin-stack | 11165.60 | 4.08 | 0.73x |
| keno-threaded | 39558.67 | 0.79 | 2.58x |
| node | 17083.20 | 2.27 | 1.11x |
| express | 10196 | 4.34 | 0.67x |
| fastify | 14831.20 | 3.01 | 0.97x |
| koa | 11700 | 3.78 | 0.76x |
| tinyhttp | 12285.60 | 3.60 | 0.80x |
| hono | 14005.60 | 3.06 | 0.91x |
| Runtime | Req/s | Latency (ms) | Vs keno |
|---|---|---|---|
| keno | 290 | 175.22 | 1x |
| keno-worker-pool | 1042.34 | 47.28 | 3.59x |
| keno-threaded | 1011.50 | 48.73 | 3.49x |
| node | 285 | 180.06 | 0.98x |
| express | 290 | 177.21 | 1x |
| fastify | 290 | 175.59 | 1x |
| koa | 285 | 178.35 | 0.98x |
| tinyhttp | 290 | 173.33 | 1x |
| hono | 290 | 172.65 | 1x |
| Runtime | Req/s | Latency (ms) | Vs keno |
|---|---|---|---|
| keno | 13074.67 | 3.17 | 1x |
| keno-threaded | 26235.20 | 1.39 | 2.01x |
| node | 7489.20 | 6.20 | 0.57x |
| express | 7130.80 | 6.47 | 0.55x |
| fastify | 7494 | 6.15 | 0.57x |
| koa | 5009.20 | 9.49 | 0.38x |
| tinyhttp | 7120.40 | 6.48 | 0.54x |
| Runtime | Msgs/s | Duration (ms) | Vs keno |
|---|---|---|---|
| keno | 26282.55 | 380.48 | 1x |
| keno-rooms | 27957.90 | 357.68 | 1.06x |
| keno-threaded | 47797.39 | 209.22 | 1.82x |
| ws | 34734.30 | 287.90 | 1.32x |
| fastify-websocket | 34821.78 | 287.18 | 1.32x |
| websocket | 28844.27 | 346.69 | 1.10x |
| Runtime | Msgs/s | Duration (ms) | Vs keno |
|---|---|---|---|
| keno | 7475.16 | 1337.76 | 1x |
| keno-rooms | 7809.26 | 1280.53 | 1.04x |
| keno-threaded | 11070.89 | 903.27 | 1.48x |
| ws | 9938.65 | 1006.17 | 1.33x |
| fastify-websocket | 11629.25 | 859.90 | 1.56x |
More details live in bench/README.md.
The repository is validated with:
npm run typechecknpm testnpm run buildFor the full local gate:
npm run check
FAQs
A modular TypeScript-first server toolkit with typed HTTP routing, WebSocket support, and scalable transport primitives.
We found that kenojs demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
It has been one year since Shai-Hulud made its first appearance on npm.

Research
/Security News
Operators behind PolinRider used a compromised GitHub account to plant malware in four development versions of a Packagist package with 700,000+ downloads.

Security News
GitHub Actions now supports cache-mode, a least-privilege control on the Actions cache aimed at the cache poisoning technique behind recent compromises.