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

@fieldnotes/sync-server

v0.8.0

Published

Authoritative WebSocket relay server for Field Notes real-time sync

Readme

@fieldnotes/sync-server

Authoritative WebSocket relay server for @fieldnotes/sync.

The relay holds the canonical per-room canvas state and fans out element operations to every other connection in the room. Clients connect, request a snapshot to catch up, then stream upsert / remove / clear ops that the hub applies and forwards.

Pieces

  • SyncHub — transport-agnostic relay. Per-room canonical state lives behind an async HubBackend; each room processes messages on its own serial queue so concurrent edits to the same room never race, while different rooms run independently.
  • MemoryHubBackend — in-memory HubBackend (the default). Redis-backed and authenticated backends are planned. It reclaims a room's memory on clear, but retains state for uncleared / abandoned rooms — fine for dev / single-instance use. For a long-lived production process, use a Redis backend with a key TTL, or clear rooms you're done with.
  • createSyncServer — a runnable ws reference server. Connect with ?room=<id> in the query string; a missing room closes the socket with WS code 4400.
  • HubFanout — cross-instance live fan-out seam. SyncHub publishes each live op to the fanout and forwards ops it receives from other instances to its local connections. The default InMemoryHubFanout is in-process (a no-op for a single instance). For multiple relay instances to share live ops, pass a shared fanout via createSyncServer({ fanout }) (e.g. RedisHubFanout from @fieldnotes/sync-redis) together with a shared backend — a shared fanout alone leaves a new joiner's snapshot stale.

Usage

import { createSyncServer } from '@fieldnotes/sync-server';

const { close } = createSyncServer({ port: 8080 });
// ws://localhost:8080?room=my-room

Heartbeat

The server pings every client on an interval and terminates any that miss a pong, so half-open sockets (a backgrounded iPad, dropped WiFi) are reaped instead of silently leaking room membership. Configure via heartbeatIntervalMs (default 30000; 0 disables):

createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });

Browsers auto-pong at the protocol level, so no client change is needed.

Authentication

Pass an authenticate hook to gate connections:

import { createSyncServer } from '@fieldnotes/sync-server';

createSyncServer({
  port: 8080,
  authenticate: async ({ req, room }) => {
    const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
    const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
    return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
  },
});

authenticate({ req, room }) → { userId, role? } | null runs on each connection and may be sync or async. Returning null (or throwing) rejects the connection: the socket is closed with WS code 4401 and the connection is never admitted to the room — no membership, no snapshot. A resolved result admits the connection carrying its userId and optional role.

With no hook, rooms stay open: every connection is admitted anonymously with userId = connId. role is captured now and enforced in an upcoming release (role-based authorization and per-viewer visibility filtering).

Messages that arrive during an async auth (notably the client's initial request-snapshot) are queued and replayed once the connection is admitted, so the first snapshot is never lost to the auth round-trip.

Passing a token

A browser WebSocket can't set request headers, so pass the token as a URL query param (ws://relay?room=R&token=…) and read it from req.url in authenticate. URLs land in access/proxy logs, so prefer short-lived / single-use tokens. Non-browser clients can instead put the token in req.headers (e.g. Authorization), which authenticate reads directly.

Authorization

Pass an authorize hook to gate writes. The hub calls it before applying each data op (upsert / remove / clear) and drops denied ops — a denied op is not applied to room state and not forwarded to any other connection:

authorize(ctx) => boolean | Promise<boolean>

ctx is an AuthorizeContext:

{
  userId?: string;          // the connection's authenticated user (from authenticate)
  role?: string;            // the connection's role (from authenticate)
  room: string;
  op: SyncOp;               // the incoming upsert / remove / clear
  currentElement?: OwnedElement; // the STORED element, if this id already exists
}

currentElement is the element currently in room state for an upsert/remove of an existing id (typed OwnedElement = CanvasElement & { ownerId?: string }), and undefined for a new/absent id.

Ownership is server-stamped and un-forgeable. A new element's ownerId is set to the authenticated creator; on edit the stored owner is preserved; a client-supplied ownerId is always discarded. A policy can therefore trust currentElement.ownerId to enforce "own elements only".

With no hook, rooms are OPEN (allow-all — every op is accepted).

A copy-paste DM / player / display policy:

createSyncServer({
  port: 8080,
  authenticate: /* … supplies userId + role … */,
  authorize: ({ role, op, currentElement, userId }) => {
    if (role === 'dm') return true;
    if (role === 'display') return false;              // read-only monitor
    if (role === 'player') {                            // own elements only, never destructive
      if (op.kind === 'clear') return false;
      if (op.kind === 'upsert') return !currentElement || currentElement.ownerId === userId;
      if (op.kind === 'remove') return currentElement?.ownerId === userId;
      return false;
    }
    return false;
  },
});

Important:

  • Ownership-based authz requires authenticate to supply a STABLE userId. The no-hook anonymous default is userId = connId, which changes on every reconnect — a user would lose access to their own elements after reconnecting.
  • The authz path adds one backend.get (a Redis HGET) per data op — negligible for low-write use.
  • Reads / visibility (a player not receiving hidden content) are a separate, upcoming concern (D3). authorize gates writes only.
  • When authorize denies an op, the hub sends the offending client a corrective op so its optimistic local edit self-corrects immediately — no client change, no waiting for reconnect: a rejected new element is removed, a rejected edit/remove is re-upserted from the canonical stored element, and a rejected clear gets a fresh snapshot. The correction reuses existing op kinds and is sent only to the offending connection.

Read filtering

Pass a canRead hook to gate reads — what each viewer receives. Unlike client-side hiding, the hub filters ops before they leave the server, so hidden elements never reach an unauthorized client on either path: the live broadcast and the snapshot a client gets on join.

canRead({ userId, role, room, audience }) => boolean

audience is the opaque tag the client stamps on its own outgoing upserts via @fieldnotes/sync's resolveAudience (e.g. 'dm' / 'shared', derived from the app's layers). With no hook, everyone sees everything.

A two-tier DM / player policy — the relay's canRead paired with the client's resolveAudience:

// relay
createSyncServer({
  port: 8080,
  authenticate: /* … supplies userId + role … */,
  canRead: ({ role, audience }) => audience !== 'dm' || role === 'dm',
});

// client (@fieldnotes/sync)
new SyncClient({ store, transport, resolveAudience: (el) => layerOf(el) }); // → 'dm' | 'shared'

Moving an element between audiences re-evaluates visibility per viewer: a viewer who loses access gets a synthetic remove, one who gains it gets an add.

Preconditions:

  • Stable identity. Meaningful policy needs a stable userId/role from authenticate; the no-hook anonymous default is per-connection (userId = connId) and changes on reconnect.
  • Same canRead on every instance. Unlike authorize (which runs on the write's origin instance only), canRead runs on every instance — each re-filters fanned-out ops for its own local members — so all relay instances must inject the same canRead, alongside the existing shared-backend + shared-fanout requirement.
  • Labeling integrity. audience is client-asserted, so read secrecy is only as trustworthy as the authorize (write) policy that stops a player from stamping dm on content they shouldn't control.

A Redis HubBackend and cross-instance fan-out ship in @fieldnotes/sync-redis.