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

room-kit

v1.0.5

Published

Type-safe room membership, presence, and realtime messaging for Socket.IO.

Readme

room-kit

GitHub Repo stars NPM Version JSR Version TypeScript License: MIT

Small and type-safe room membership, presence, and realtime messaging for Socket.IO.

Install

npm install room-kit socket.io socket.io-client

Quick Start

// common.ts
import { defineRoomType } from "room-kit";

// Shared room schema used by both server and client.
type ChatMessage = {
  id: string;
  name: string;
  text: string;
  sentAt: string;
};

// The generic schema below drives the inferred server and client typing.
export const chatRoom = defineRoomType<{
  // Data required from the client to join a room.
  joinRequest: {
    roomId: string;
    roomKey: string;
    userName: string;
  };
  // Per-member metadata stored by the server.
  memberProfile: {
    userId: string;
    userName: string;
  };
  // Per-room metadata exposed to every joined client.
  roomProfile: {
    roomId: string;
    created: string;
  };
  // Private mutable state that only lives on the server.
  serverState: {
    roomKey: string;
    created: string;
    history: ChatMessage[];
  };
  // Named events the server can emit to room members.
  events: {
    message: ChatMessage;
    systemNotice: { text: string; sentAt: string };
  };
  // Typed request/response calls for validated mutations.
  rpc: {
    sendMessage: (input: { text: string }): Promise<{ id: string }>;
  };
}>({ 
  name: "chat", // namespace
  presence: "list" // allow clients to list room members
});
// server.ts
import { randomUUID } from "node:crypto";
import http from "node:http";
import { Server } from "socket.io";
import { ClientSafeError, serveRoomType } from "room-kit";
import { chatRoom } from "./common";

const httpServer = http.createServer();
const io = new Server(httpServer);

io.on("connection", (socket) => {
  // Attach room behavior to each socket connection.
  serveRoomType<typeof chatRoom, { userId: string }>(socket, chatRoom, {
    onAuth: async () => {
      // Replace with real session/JWT validation.
      // This is your trusted identity source.
      return { userId: socket.id };
    },
    initState: async (join) => ({
      // Runs once per room instance (first successful join).
      roomKey: join.roomKey,
      created: new Date().toISOString(),
      history: [],
    }),
    admit: async (join, ctx) => {
      // Admission gate for private rooms.
      // Throw ClientSafeError for messages safe to show users.
      if (ctx.serverState.roomKey !== join.roomKey) {
        throw new ClientSafeError("Invalid room key");
      }

      return {
        roomId: join.roomId,
        memberId: ctx.auth.userId,
        // This profile is returned to the joining member and stored server-side.
        memberProfile: {
          userId: ctx.auth.userId,
          userName: join.userName,
        },
        // Room metadata available to all joined members.
        roomProfile: {
          roomId: join.roomId,
          created: ctx.serverState.created,
        },
      };
    },
    events: {
      // Client emits are allowlisted by key in this object.
      // If you don't need client-originated events, omit this.
      message: async () => undefined,
    },
    rpc: {
      sendMessage: async ({ text }, ctx) => {
        // Prefer RPC for validated state-changing operations.
        // Build the canonical message once, then persist and broadcast it.
        const message = {
          id: randomUUID(),
          name: ctx.memberProfile.userName,
          text,
          sentAt: new Date().toISOString(),
        };
        ctx.serverState.history.push(message);
        await ctx.emit.message(message);
        return { id: message.id };
      },
    },
  });
});

httpServer.listen(3000);
// client.ts
import { io } from "socket.io-client";
import { createRoomClient } from "room-kit";
import { chatRoom } from "./common";

// Create the socket transport and bind the typed room client to it.
const socket = io("http://127.0.0.1:3000");
const chatClient = createRoomClient(socket, chatRoom);

// Join returns a typed room handle with events, RPC, and leave().
const joined = await chatClient.join({
  roomId: "team-alpha",
  roomKey: "secret",
  userName: "Ada",
});

// Event payload and metadata are both inferred from the room schema.
const cleanup = joined.listen({
  events: {
    message: (payload, meta) => {
      // meta.source.kind is "server" or "member".
      console.log(payload.text, meta.source.kind);
    },
  },
  presence: {
    onChange: (presence) => {
      console.log(`${presence.count} members online`);
    },
  },
});

// Fully typed request/response based on your room definition.
await joined.rpc.sendMessage({ text: "hello" });
cleanup();
// Cleanly leave the room when you're done.
await joined.leave();

Room Schema

defineRoomType<TSchema>(options) takes a runtime options object. TSchema controls the inferred API surface:

  • joinRequest: payload the client must send to join a room; it must include roomId.
  • memberProfile: per-member metadata stored by the server and exposed in membership snapshots.
  • roomProfile: per-room metadata returned on join and reused in server context; it must include roomId.
  • serverState: private mutable state owned by the server for each room instance.
  • events: named room events the server may emit and, if declared in handlers, accept from clients.
  • rpc: named request/response methods exposed to joined clients.

Runtime presence mode is configured in the defineRoomType options:

  • "none": no presence query support.
  • "count": only count support.
  • "list": count + paginated members.
  • default: "list" when presence is omitted.

Server Handlers

serveRoomType(socket, roomType, handlers, adapter?) accepts:

  • onAuth(socket): optional. Return false to reject the socket before room initialization.
  • onConnect(socket, auth): optional transport-connect hook attempted once when the socket handler is attached (after auth resolution).
  • revalidateAuth(socket, auth): optional per-request auth validation hook; return { kind: "ok", auth? } to continue or { kind: "reject" } to deny.
  • initState(joinRequest): initializes room server state on first join for a given room instance.
  • admit(joinRequest, ctx): optional admission gate. Return only the fields you want to override. Missing roomId, memberId, memberProfile, and roomProfile values are filled in by the server.
  • admit is still validated: any returned roomId must match the join request room id, and any returned roomProfile.roomId must match the resolved room id.
  • onJoin(memberProfile, ctx): called after a successful join.
  • onLeave(memberProfile, ctx): called on leave and during socket disconnect cleanup for joined rooms when auth is available for cleanup.
  • onDisconnect(socket, auth): optional transport-disconnect hook.
  • presencePolicy(ctx): optional server-side override for presence queries; the returned policy is clamped by the room's configured presence mode.
  • events: handlers for client-emitted events. Leave a key out to deny that client event.
  • rpc: handlers for client RPC calls.

Server context (ctx) includes:

  • ctx.name, ctx.roomId, ctx.auth, ctx.memberId, ctx.memberProfile
  • ctx.roomProfile, ctx.serverState
  • ctx.emit.<event>(payload) to emit to the current room
  • ctx.broadcast.emit.<event>(payload) to emit across the namespace
  • ctx.broadcast.toRoom(roomId).emit.<event>(payload) to target a room
  • ctx.broadcast.toMembers(memberIds).emit.<event>(payload) to target specific members
  • ctx.getPresence(), ctx.getPresenceCount(), ctx.listPresenceMembers({ offset, limit })

serveRoomType returns a handle:

  • handle.cleanup() unregisters listeners for that socket
  • handle.rooms() returns snapshots for all rooms on the namespace
  • handle.room(roomId) returns one room snapshot or undefined
  • handle.count(roomId) returns the current member count for a room (0 when the room does not exist; throws when room presence mode is "none")
  • handle.members(roomId, query) returns a paginated presence listing ({ count: 0, offset: 0, limit: 0, members: [] } when the room does not exist; throws when room presence mode is not "list")

Client API

createRoomClient(socket, roomType) returns:

  • client.name
  • client.connection.current ("connecting" | "connected" | "reconnecting" | "disconnected")
  • client.connection.onChange(handler) subscribes to transport-state changes and returns an unsubscribe function.
  • client.join(joinRequest) resolves to a joinedRoom handle.

joinedRoom includes:

  • joinedRoom.name, joinedRoom.roomId, joinedRoom.memberId, joinedRoom.roomProfile
  • joinedRoom.rpc.<name>(...args) for typed RPC calls
  • joinedRoom.emit.<event>(payload) for client-emitted room events
  • joinedRoom.on.<event>((payload, meta) => {}) for room event subscriptions
  • joinedRoom.listen({ events, presence }) batches event and presence subscriptions and returns one cleanup function
  • joinedRoom.leave() to leave the room and unregister the joined-room handle
  • joinedRoom.presence is part of the typed API when room presence mode is "count" or "list"; it exposes current, onChange(handler), count(), and list({ offset, limit }) when presence mode is "list".

Errors and Security

  • Throw ClientSafeError for messages you want sent to clients.
  • Non-ClientSafeError exceptions are sanitized to: "An internal server error occurred."
  • onAuth may return false to reject the socket before any room state is initialized.
  • RPC and event dispatch only allow own properties (Object.hasOwn) to prevent prototype-based handler access.
  • Client event names are default-deny unless explicitly declared in handlers.events.
  • Do not trust client payloads for authorization; derive identity in onAuth.
  • Validate runtime payload shapes in your handlers. TypeScript types are compile-time only.

Reconnect Behavior

  • Joined rooms are automatically replayed after socket reconnect.
  • Replay uses the original joinRequest payload.
  • If replay fails, that joined room is removed from the client registry.

Example App

A complete chat example is in the example directory:

cd example
npm install
npm start