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

hotstate

v0.1.0

Published

Lightweight real-time state sync over WebSockets

Readme

hotstate

Real-time state synchronization over WebSocket. Server is the source of truth — clients get live updates automatically.

Three concepts

Stores — Shared state. Define it on the server, subscribe from the client. Changes sync automatically via patches or snapshots.

Functions — Request/response. Client calls a function, server handles it and returns a result. Like an API endpoint, but over WebSocket.

Events — Fire-and-forget messages. Server pushes data to clients without storing it. Good for notifications, logs, ephemeral signals.


Quick examples

Stores

Server owns the state, clients subscribe:

// server
import Hotstate from "hotstate";
const server = Hotstate();

const todos = server.createStore("Todos", { items: [] });

// mutate from anywhere on the server
todos.update((draft) => {
  draft.items.push({ id: 1, text: "Buy milk", done: false });
});

server.listen({ port: 4400 });
// client
import Hotstate from "hotstate/client";
const client = Hotstate();
client.connect("ws://localhost:4400");

const todos = client.subscribe("Todos", (state) => {
  console.log("Todos changed:", state);
});

await todos.ready();
console.log(todos.getState()); // { items: [{ id: 1, text: "Buy milk", done: false }] }

Functions

Client calls, server handles:

// server
server.handle("todos.add", (args) => {
  const id = Date.now();
  todos.update((draft) => {
    draft.items.push({ id, text: args.text, done: false });
  });
  return { id };
});
// client
const { id } = await client.call("todos.add", { text: "Buy milk" });

Events

Server pushes, clients listen:

// server — broadcast to all
server.emit("notification", { title: "Deployed", body: "v2.1 is live" });

// targeted — one client only
server.emit("alert", { message: "Your build finished" }, { clientId });
// client
client.on("notification", (data) => {
  console.log(data.title); // "Deployed"
});

Realistic example

A collaborative todo app with live rooms:

// ── server.js ───────────────────────────────────────────────

import Hotstate from "hotstate";
const server = Hotstate();

// Static stores
const todos = server.createStore("Todos", { items: [] });
const config = server.createStore("Config", { theme: "dark" });

// Dynamic store — instances created on demand
const rooms = server
  .createStore("Room:*", { messages: [], users: [] })
  .onCreate((roomId) => ({ name: roomId }))
  .onIdle((roomId) => rooms.reset(roomId));

// Functions
let nextId = 0;

server.handle("todos.add", (args) => {
  const id = ++nextId;
  todos.update((draft) => {
    draft.items.push({ id, text: args.text, done: false });
  });
  return { id };
});

server.handle("todos.toggle", (args) => {
  todos.update((draft) => {
    const todo = draft.items.find((t) => t.id === args.id);
    if (todo) todo.done = !todo.done;
  });
});

server.handle("room.send", (args, { clientId }) => {
  rooms.update(args.room, (draft) => {
    draft.messages.push({ from: clientId, text: args.text, ts: Date.now() });
  });
});

// Events
server.on("connection", (id) => {
  server.emit("user.joined", { id });
});

server.listen({ port: 4400 });
// ── client.js ───────────────────────────────────────────────

import Hotstate from "hotstate/client";
const client = Hotstate();
client.connect("ws://localhost:4400");
await client.ready();

// Subscribe to stores — renders update automatically via onChange
client.subscribe("Todos", (state) => {
  renderTodos(state.items);
});

client.subscribe("Room:general", (state) => {
  renderChat(state.messages);
});

// Call functions — only needs client.ready(), not store.ready()
await client.call("todos.add", { text: "Ship it" });
await client.call("room.send", { room: "general", text: "Hello!" });

// Listen for events
client.on("user.joined", ({ id }) => {
  console.log(`${id} joined`);
});

API Reference

Server

serve()

Create a server instance. Define stores and functions, then start listening.

import Hotstate from "hotstate"

const server = Hotstate()

// Define your stores and functions...
const todos = server.createStore("Todos", { items: [] })
server.handle("todos.add", args => { ... })

// Then start:
server.listen({ port: 4400 })

server.listen(options)

Start a standalone WebSocket server.

server.listen({ port: 4400 }); // ws://localhost:4400
server.listen({ port: 4400, path: "/ws" }); // ws://localhost:4400/ws
server.listen({ port: 4400, host: "0.0.0.0" });

| Option | Default | Description | | ------------- | --------- | ---------------------------------------- | | port | 4400 | Port to listen on | | path | all paths | WebSocket endpoint path | | host | localhost | Bind address | | heartbeatMs | 30000 | Client keepalive interval (0 to disable) |

server.attach(httpServer, options?)

Attach to an existing HTTP server. Shares the same port — the WebSocket upgrades alongside your REST API.

server.attach(app.server); // ws://localhost:3000
server.attach(app.server, { path: "/ws" }); // ws://localhost:3000/ws

| Option | Default | Description | | ------------- | --------- | ---------------------------------------- | | path | all paths | WebSocket endpoint path | | heartbeatMs | 30000 | Client keepalive interval (0 to disable) |

server.createStore(name, data)

Create a store and return a handle. Both static and dynamic stores share the same handle API.

// Static store — single instance
const config = server.createStore("Config", { theme: "dark" });

// Dynamic store — multiple instances created on demand
const rooms = server.createStore("Room:*", { messages: [] });

Store handle

Static stores operate directly. Dynamic stores take a key as first argument:

// Static                                  // Dynamic
config.update(d => { d.theme = "light" })  rooms.update("general", d => { ... })
config.set({ theme: "dark" })              rooms.set("general", { messages: [] })
config.get()                               rooms.get("general")
config.has()                               rooms.has("general")
config.delete()                            rooms.delete("general")
config.reset()                             rooms.reset("general")
config.init()                              rooms.init("general")
config.isInitialized()                     rooms.isInitialized("general")
config.subscriberCount()                   rooms.subscriberCount("general")

| Method | Description | | ------------------- | ---------------------------------------------- | | update(fn) | Mutate via Immer draft — sends JSON Patch diff | | set(data) | Replace entire state — sends full snapshot | | get() | Read current state | | has() | Check if store instance exists | | delete() | Remove store, notify subscribers | | reset() | onTerminate → raw defaults, uninitialized | | init() | Eagerly create (runs onCreate) | | isInitialized() | true after init/subscribe/mutation | | subscriberCount() | Number of connected clients |

Lazy initialization

Stores are lazycreateStore() registers the definition, but onCreate only runs on first actual use (subscribe, mutation, or init()). This applies to both static and dynamic stores.

const config = server.createStore("Config", { theme: "dark" }).onCreate(() => {
  const saved = loadFromDb();
  return saved || { theme: "dark" };
});

config.isInitialized(); // false — onCreate hasn't run yet
config.init(); // triggers onCreate
config.isInitialized(); // true

Lifecycle hooks

All hooks work on both static and dynamic stores. Chainable.

const rooms = server
  .createStore("Room:*", { messages: [] })
  .onCreate((key) => {
    // Return overrides (merged with default data)
    // Runs on first subscribe, mutation, init — NOT on reset
    return { name: key };
  })
  .onTerminate((key) => {
    // Called before reset() or delete() — save state, clean up
    saveToDb(key, rooms.get(key));
  })
  .onIdle((key) => {
    // Last subscriber left — store still exists
    // Choose: reset, delete, or do nothing
    rooms.reset(key);
  });

For static stores, hooks receive null as key:

const config = server
  .createStore("Config", { theme: "dark" })
  .onCreate(() => loadConfigFromDb())
  .onTerminate(() => saveConfigToDb(config.get()));

| Hook | Fires when | Store after | | ------------- | ---------------------------------------- | --------------- | | onCreate | First use (subscribe, mutation, or init) | Initialized | | onTerminate | Before reset() or delete() | About to change | | onIdle | Last subscriber leaves | Still alive |

init/subscribe/mutation → onCreate → [initialized]
                                          ↓
                                     reset() → onTerminate → [uninitialized, raw defaults]
                                          ↓
                                     next use → onCreate again

                                     delete() → onTerminate → [gone]

                            last sub leaves → onIdle (you decide: reset/delete/nothing)

Updating stores

Two ways to push state to clients:

| | update(fn) | set(data) | | ----------- | -------------------- | -------------------- | | Wire format | JSON Patch (diff) | Full snapshot | | Best for | Editing a few fields | Replacing everything |

Mix them freely:

config.update((d) => {
  d.theme = "light";
}); // patch
config.set({ theme: "dark", lang: "en" }); // full replace
config.update((d) => {
  d.lang = "pt";
}); // patch again

Global store accessors

server.stores["Config"]; // get handle by full name
server.hasStore("Room:general"); // check if a store instance exists
server.deleteStore("Config"); // delete by full name
server.listStores(); // ["Config", "Room:general", ...]

server.handle(name, handler)

Register a function:

server.handle("todos.add", (args, { clientId }) => {
  // args = whatever the client sent
  // clientId = caller's ID
  // return value is sent back to the client
  // throw to send an error
  // can be async
  return { id: 1 };
});

server.emit(channel, data, options?)

Send an event to clients.

server.emit("notification", { title: "Deployed" }); // all clients
server.emit("alert", { msg: "Done" }, { clientId: "c1" }); // specific client

server.on(event, listener)

Server lifecycle events:

| Event | Args | Description | | -------------- | ------------------ | ------------------------------ | | connection | clientId | Client connected | | disconnect | clientId | Client disconnected | | subscribe | name, clientId | Client subscribed to a store | | unsubscribe | name, clientId | Client unsubscribed | | storeCreated | name, key | Dynamic store instance created | | storeDeleted | name | Store deleted | | error | error, clientId? | Error occurred |

server.close()

Shut down. Terminates all connections and cleans up.


Client

Hotstate(options?)

Create a client instance. Set up subscriptions and listeners, then connect.

import Hotstate from "hotstate/client";

const client = Hotstate({
  reconnect: true, // auto-reconnect on disconnect
  reconnectDelayMs: 1000, // initial retry delay
  reconnectMaxMs: 10000, // max retry delay (exponential backoff)
  rpcTimeoutMs: 10000, // function call timeout
});

// Set up subscriptions before connecting
client.subscribe("Todos", (state) => renderTodos(state.items));

// Then connect
client.connect("ws://localhost:4400");

You can also pass the URL directly for quick setup:

const client = Hotstate("ws://localhost:4400"); // connects immediately

client.connect(url)

Connect to the server. Subscriptions registered before connecting will automatically sync once connected.

client.ready()

Resolves when the WebSocket connection is established.

Connection and readiness

  • await client.ready() only means the socket is open. It does not mean any store data has arrived yet.
  • await handle.ready() means that specific subscription has received its first snapshot.
  • Subscriptions can be registered before connect(). They will automatically subscribe once the socket connects.
  • Function calls only require client.ready(). Store reads require handle.ready().

client.subscribe(name, onChange?)

Subscribe to a store. Returns a handle immediately:

const todos = client.subscribe("Todos", (state) => {
  // fires on every state change (including initial snapshot)
  console.log(state);
});

todos.getState(); // current state (undefined until first snapshot)
todos.getServerState(); // raw server state (before optimistic overlay)
todos.hasData(); // true after first snapshot arrives
await todos.ready(); // resolves when first snapshot arrives
todos.unsubscribe(); // stop receiving updates

If the server cannot fulfill the subscription, handle.ready() rejects and the client also emits an error event.

Multiple subscribe() calls to the same store are supported. Each handle stays active independently until it is unsubscribed.

client.call(name, args)

Call a server function. Returns a promise:

const result = await client.call("todos.add", { text: "Buy milk" });
// result = { id: 1 }

Throws if the server handler throws or the call times out.

Optimistic updates

Optimistic state can be applied on the client before the server responds:

const todos = client.subscribe("Todos");
await todos.ready();

await client.call(
  "todos.add",
  { text: "New" },
  {
    store: "Todos",
    optimistic: (state) => ({
      ...state,
      items: [...state.items, { id: "temp", text: "New", done: false }],
    }),
  },
);
  • getState() returns the optimistic view.
  • getServerState() returns the last confirmed server snapshot.
  • If the RPC fails or times out, the optimistic overlay is rolled back automatically.

client.on(channel, handler)

Listen for server events. Returns an unsubscribe function:

const unsub = client.on("notification", (data) => {
  console.log(data);
});

// later
unsub();

Also emits lifecycle events: connected, disconnected, reconnecting, error, storeDeleted.

Errors

  • Missing store subscription: handle.ready() rejects and client.on("error") fires.
  • RPC handler throws: client.call() rejects with the server error message.
  • RPC timeout: client.call() rejects with RPC timeout.
  • Disconnect during RPC: pending calls reject with Disconnected.
  • Store deleted on the server: client emits storeDeleted and local handles stop receiving updates.

Reconnection semantics

  • With reconnect: true, the client reconnects using exponential backoff between reconnectDelayMs and reconnectMaxMs.
  • Existing subscriptions are re-sent automatically after reconnect.
  • If the client missed patches while disconnected, it re-subscribes and accepts a fresh snapshot.
  • On reconnect, server state remains the source of truth. Any stale optimistic state is dropped.

client.destroy()

Disconnect and stop reconnecting.


Integrations

Server

hotstate attaches to any Node.js HTTP server. Use path to namespace the WebSocket endpoint when sharing a port with your API.

Express

import express from "express";
import { createServer } from "http";
import Hotstate from "hotstate";

const app = express();
const httpServer = createServer(app);

app.get("/api/health", (req, res) => res.json({ ok: true }));

const state = Hotstate();
state.createStore("Todos", { items: [] });
state.attach(httpServer, { path: "/ws" });

httpServer.listen(3000);
// API:       http://localhost:3000/api/health
// WebSocket: ws://localhost:3000/ws

Fastify

import Fastify from "fastify";
import Hotstate from "hotstate";

const app = Fastify();
app.get("/api/health", async () => ({ ok: true }));

const state = Hotstate();
state.createStore("Todos", { items: [] });

await app.listen({ port: 3000 });
state.attach(app.server, { path: "/ws" });

Hono (Node.js)

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import Hotstate from "hotstate";

const app = new Hono();
app.get("/api/health", (c) => c.json({ ok: true }));

const httpServer = serve({ fetch: app.fetch, port: 3000 });

const state = Hotstate();
state.createStore("Todos", { items: [] });
state.attach(httpServer, { path: "/ws" });

Standalone

import Hotstate from "hotstate";

const server = Hotstate();
server.createStore("Todos", { items: [] });
server.listen({ port: 4400 });

Client

Zustand

Bind all store state into a single Zustand store:

import { create } from "zustand";
import Hotstate from "hotstate/client";

const useStore = create(() => ({
  status: "disconnected",
  stores: {},
}));

const client = Hotstate();
client.bindZustand(useStore);

client.subscribe("Todos");
client.subscribe("Config");

client.connect("ws://localhost:3000/ws");

Use in React:

function TodoList() {
  const todos = useStore((s) => s.stores["Todos"]);
  const status = useStore((s) => s.status);

  if (!todos) return <div>Loading...</div>;

  return (
    <ul>
      {todos.items.map((t) => (
        <li key={t.id}>{t.text}</li>
      ))}
    </ul>
  );
}

Optimistic updates:

await client.call(
  "todos.add",
  { text: "New" },
  {
    store: "Todos",
    optimistic: (state) => ({
      ...state,
      items: [...state.items, { id: -1, text: "New", done: false }],
    }),
  },
);

Wire protocol

Binary MessagePack with single-letter keys for minimal overhead.

| Type | Direction | Description | | ------- | --------------- | -------------------- | | sub | Client → Server | Subscribe to a store | | unsub | Client → Server | Unsubscribe | | rpc | Client → Server | Function call | | snap | Server → Client | Full state snapshot | | patch | Server → Client | JSON Patch delta | | res | Server → Client | Function response | | del | Server → Client | Store deleted | | evt | Server → Client | Event |


Install

npm install hotstate

License

MIT