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

@insession/extension-whiteboard

v0.4.2

Published

A dependency-free, framework-agnostic Whiteboard state machine: shared free-draw strokes/shapes plus an optional "drawing telephone" relay game. The server holds the authoritative state; reduce() is a pure function.

Readme

@insession/extension-whiteboard

A dependency-free, server-authoritative Whiteboard state machine: a shared free-draw canvas (strokes + shapes) with an optional "drawing telephone" relay game bolted on.

Shared whiteboards ("everyone draws on the same canvas, live") are easy to get subtly wrong: an unbounded array of strokes grows forever, a malicious payload can smuggle megabytes into shared state, and a submitted image URL can point anywhere on the internet unless something checks it. A relay game on top adds its own class of bugs — phase timing, who's turn it is, what happens when someone never submits.

This package is that state machine, with none of the plumbing:

  • reduce is a pure function. (state, action, payload) => { state, effects } | null. No I/O, no timers started inside it — null means "ignore this action" (e.g. adding a shape past the cap, or an empty clear), and effects describe writes for you to perform (see below).
  • Confirmed strokes/shapes only. In-progress drawing (live cursor preview) is out of scope on purpose — that belongs on a lower-latency, unvalidated relay channel you build yourself. This module only cares about strokes/shapes once they're finished.
  • Every input is capped. Stroke count, points per stroke, shape count, shape text length, and serialized shape size are all bounded, so a malicious or buggy client can't grow shared state without limit.
  • The relay game is a small phase machine. prompt → draw → guess → ... → album, driven the same way as the rest of this SDK's timers: timerDelay/onTimer for expiry, with a grace period so a client's own auto-submit has a chance to land before a placeholder fills in for it.
  • restore is defensive by design. Feed it whatever your storage layer handed back — even malformed JSON — and it returns a safe state with caps applied. A relay game in progress does not survive a restart (same reasoning as a saved playback position: come back stopped, not resumed against a clock that's no longer valid).
  • The only "impure" thing anywhere in the package is Date.now().

Why a factory instead of plain exports

Accepting a submitted drawing's image URL (the submit-drawing action) means deciding whether that URL is trustworthy. This package can't know your storage's bucket, domain, or signing scheme, so it doesn't guess — you pass a predicate:

import { createWhiteboardState } from '@insession/extension-whiteboard';

const whiteboard = createWhiteboardState({
  isOwnImageUrl: (url) => url.startsWith('https://cdn.example.com/uploads/'),
});

isOwnImageUrl is required. There is no "accept everything" default — that would mean a host that forgets to pass it silently accepts arbitrary external URLs into shared state, which is exactly the kind of hole that goes unnoticed until it's exploited. A missing or non-function value throws immediately when you call createWhiteboardState.

The returned object bundles all five functions (defaultState, reduce, timerDelay, onTimer, restore) so you never have to remember which one needed the predicate and which didn't (only reduce actually reads it, via the submit-drawing action). defaultState is also available as a top-level named export, since it's the one function that's obviously independent of isOwnImageUrl even without looking at the implementation — useful if you just need a fallback/initial value.

Install

npm install @insession/extension-whiteboard

Published as a built package with both ESM (dist/index.js) and CommonJS (dist/index.cjs) entry points plus dist/index.d.ts types, no runtime dependencies.

Drop it into a space

If you are assembling a space with @insession/space, the whole integration is one line — the extension carries its own name, reducer, relay timers and persistence rules:

import { createSpace } from '@insession/space';
import { whiteboardExtension } from '@insession/extension-whiteboard';

const space = createSpace({
  extensions: [whiteboardExtension({ isOwnImageUrl: (url) => url.startsWith(MY_BUCKET) })],
});

space.dispatch('whiteboard', 'add-stroke', { stroke }); // -> [broadcast, clear-timer]

isOwnImageUrl is required here for exactly the reason it is required by createWhiteboardState — see below. Pass { name } to occupy a different key.

Nothing is imported from @insession/space to build that object: it satisfies that package's SpaceExtension structurally, so this package keeps its zero dependencies and everything below still works without it.

Usage

import {
  createWhiteboardState,
  type WhiteboardEffect,
  type WhiteboardState,
} from '@insession/extension-whiteboard';

const whiteboard = createWhiteboardState({
  isOwnImageUrl: (url) => url.startsWith('https://cdn.example.com/uploads/'),
});

// Somewhere you keep one WhiteboardState per board, e.g. a Map<boardId, WhiteboardState>.
let state: WhiteboardState = whiteboard.defaultState();

// A client action arrives over your transport (WebSocket, etc). `by` identifies
// the acting member; it's your call how you derive it (session, auth, ...).
function onClientAction(action: string, payload: unknown) {
  const result = whiteboard.reduce(state, action, payload as Record<string, unknown>);
  if (!result) return; // invalid or a no-op — nothing changed, nothing to broadcast
  for (const effect of result.effects) runEffect(effect); // see "Effects" below
  // A result without `state` means "run these effects, nothing changed" — the
  // live relay case. Don't broadcast the board or re-arm the timer for it.
  if (!('state' in result)) return;
  state = result.state;
  broadcastToBoard({ type: 'extension-whiteboard', state });
  scheduleRelayTimer();
}

// Drive relay-game phase transitions with your own timer (setTimeout, a job queue, ...).
let relayTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleRelayTimer() {
  clearTimeout(relayTimer);
  const delay = whiteboard.timerDelay(state);
  if (delay === null) return; // no relay game running — nothing to schedule
  relayTimer = setTimeout(() => {
    const result = whiteboard.onTimer(state);
    if (!result) return;
    for (const effect of result.effects) runEffect(effect);
    if (!('state' in result)) return;
    state = result.state;
    broadcastToBoard({ type: 'extension-whiteboard', state });
    scheduleRelayTimer();
  }, delay);
}

// Load from storage on board startup / first join.
function loadFromDb(raw: unknown) {
  state = whiteboard.restore(raw) ?? whiteboard.defaultState();
}

In-progress strokes (the live preview while someone is still drawing) are not part of this state machine — relay those over your transport directly, unvalidated and unsaved, and only hand a stroke to reduce's add-stroke once the pointer is released.

Actions

reduce(state, action, payload) accepts these action strings:

| Action | Payload | Effect | | --- | --- | --- | | add-stroke | { stroke } | Adds (or, on a matching id, replaces) a confirmed freedraw stroke. Oldest strokes are dropped past 2000. | | erase | { ids } | Removes strokes by id. No-op if none match. | | clear | — | Empties both strokes and shapes (a "clear the board" that only cleared strokes would leave shapes behind). No-op if already empty. | | add-shape | { shape } | Adds (or, on a matching id and type, replaces) a shape (rectangle/ellipse/connector/text/sticky/...). Rejected past 500 shapes, or if reusing an id with a different type. | | update-shape | { id, patch } | Partially updates a shape. id/type cannot be changed by a patch. No-op if the patch is empty/invalid. | | remove-shape | { ids } | Removes shapes by id. No-op if none match. | | set-mode | — | Always a no-op, kept only for backward compatibility with older clients. Setting game: null here would let anyone silently discard an in-progress relay game. | | join-game | { by } | Joins the relay game's lobby (lazily created on first join). Rejected once full (8 players) or once the game has started. | | leave-game | { by } | Leaves the lobby. Only valid while still in lobby. | | start-game | — | Starts the relay game (needs ≥2 players). Moves to the prompt phase. | | reset-game | — | Returns from album to a fresh lobby, keeping the same players. | | submit-prompt | { by, text } | Submits a prompt during the prompt phase. | | submit-drawing | { by, imageUrl } | Submits a drawing during the draw phase. imageUrl must satisfy isOwnImageUrl. | | submit-guess | { by, text } | Submits a guess during the guess phase. |

Any other action string returns null. Because the payload arrives over the wire, every field is treated as untrusted and validated at the point of use — reduce never throws on malformed input; it returns null instead.

API

| Export | Signature | Meaning | | --- | --- | --- | | createWhiteboardState | (options: { isOwnImageUrl: (url: string) => boolean }) => WhiteboardStateApi | Builds the API. Throws if isOwnImageUrl is missing or not a function. | | defaultState() | () => WhiteboardState | An empty, free-mode board with no relay game. Also available as a top-level export (see above). | | .reduce | (state, action, payload?) => { state, effects } \| null | Applies one action. null means "ignore" (invalid or a no-op). | | .timerDelay | (state) => number \| null | Milliseconds until the current relay phase expires (plus a grace period), or null if no relay game is running. | | .onTimer | (state) => { state, effects } \| null | Called once timerDelay elapses: fills a placeholder for anyone who hasn't submitted, then advances the round. null if there's no game. | | .restore | (raw: unknown) => WhiteboardState \| null | Normalizes state loaded from storage. null only for non-object input; otherwise strokes/shapes are filtered and capped, mode is always 'free', and game is always null. |

Effects

A finished relay game is the one thing here worth keeping past the session: the album — who drew what, in which order — is the payoff, and it disappears when everyone leaves.

| Effect | When | | --- | --- | | { type: 'persist-relay-history', players, chains } | The relay reaches its album, from either reduce or onTimer. | | { type: 'relay', payload } | A live drawing frame (relay action). Nothing changed — forward it to everyone but the sender and store nothing. |

relay is the one action that returns { effects } with no state. A live preview streams a frame per pointer move: worth forwarding to whoever is watching, worth nothing a second later. Going through the normal path would persist the board, broadcast it, and re-arm the relay phase timer on every frame — the last of which would keep a countdown that is supposed to run out from ever running out.

Its payload is opaque on purpose. What a frame contains — a partial stroke, a whole board at reduced fidelity, a cursor position — is a contract between your drawing client and your renderer, and it changes whenever that UI grows a feature. This package passes it through untouched; you decide how it goes on the wire. What it does decide is that the whiteboard accepts relay at all.

persist-relay-history is fired exactly once per game, on the edge into the album. A rematch (reset-game back to the lobby, then playing again) produces its own single effect. Free-draw edits produce none.

// The `runEffect` referenced in Usage above.
function runEffect(effect: WhiteboardEffect) {
  switch (effect.type) {
    case 'persist-relay-history':
      db.insertRelayHistory(spaceId, {
        finishedAt: Date.now(),
        players: effect.players,
        chains: effect.chains,
      });
      break;
    case 'relay':
      // Forward the frame to everyone but the sender. Store nothing.
      forwardToOthers({ type: 'whiteboard-relay', payload: effect.payload });
      break;
  }
}

Types

WhiteboardState, WhiteboardMode, WhiteboardStroke, WhiteboardStrokePoint, WhiteboardStrokeStyle, WhiteboardShape, WhiteboardShapeType, WhiteboardShapeStyle, AnchorType, PathType, ArrowHead, RelayPhase, RelayGame, RelayChainEntry, WhiteboardAction, WhiteboardPayload, WhiteboardEffect, WhiteboardReduceResult, and WhiteboardStateApi are all exported. reduce's action parameter is typed as string rather than WhiteboardAction on purpose — it sits at a wire boundary where the action name is untrusted input, and anything outside the known set falls through to null.

Why in-progress strokes aren't this module's concern

Only confirmed strokes (pointer released) belong in shared state — they're validated, persisted, and broadcast to everyone including late joiners (via restore). The live preview while someone is mid-stroke is high-frequency and disposable: relaying every pointer move through reduce, storage, and a full-state broadcast would be wasteful and unnecessary. Send that over your transport directly, unvalidated, and only call reduce's add-stroke once the stroke is done.

Why clear empties shapes too

"Clear the board" means the board goes back to blank — a clear that only removed strokes and left shapes behind would violate that expectation silently.

Why timerDelay adds a grace period

Clients auto-submit their in-progress prompt/drawing/guess the instant their local countdown reaches zero, but that auto-submit still has to cross the network. If onTimer's "fill placeholders for whoever hasn't submitted" pass fired the instant the phase's nominal duration elapsed, it could win the race against a submission that was already on its way and silently overwrite it with an empty placeholder. Adding a grace period to timerDelay gives the auto-submit a window to land first — submitToChain skips anyone already submitted, so onTimer simply leaves them alone when it does.

Test

node --test

Every test either uses inputs with no time dependency or freezes Date.now() for its duration, so the suite is fully deterministic — no real clocks, no wall-clock waits.

License

MIT