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

@polytric/openws

v0.0.4

Published

Polytric OpenWS Runtime

Readme

Polytric OpenWS Framework

npm license

Polytric OpenWS Framework for JavaScript and TypeScript provides the runtime layer of the OpenWS stack.

It provides:

  • A single source of truth for WebSocket message contracts (the network spec)
  • Automatic binding between inbound messages and handler functions
  • Typed and ergonomic outbound message APIs (in JS and especially in TS)
  • Connection-scoped state (one connection -> one object)
  • Lifecycle hooks and consistent routing conventions
  • A stable spec artifact for tooling (UI, SDK generation, validation)

This package is framework-agnostic. Server integrations are provided by adapters (for example @polytric/fastify-openws).

OpenWS supports three authoring styles:

  1. Class-first using static configuration, supports both TS and JS without additional compilation setup
  2. Class-first with decorators, supports better type hints and developer ergonomics, TS works out of box, while JS needs to add a compilation step
  3. Fluent / functional spec definition

Installation

Core runtime and binder:

npm i @polytric/openws

Fastify adapter (server integration):

npm i @polytric/fastify-openws

Optional tooling (when you are ready):

npm i @polytric/openws-ui @polytric/openws-sdkgen

Core Concepts

Imagine you are building a miniature chat system with a server, a client, and a customer admin portal. Clients can create and join rooms, then send messages. The portal can request room status and other administrative information.

This translates to a network containing 3 roles in the OpenWS spec:

  • A client role that connects to server
  • A portal role that connects to server
  • A server role that forwards client messages based on room membership, and reports stats to portal

We will first explore the class-first approach to build this system, and walk through core concepts along the way. Once the mental model is established, the decorator and fluent style APIs should become self explanatory later. A basic understanding of the OpenWS spec will help too.

Roles

A role describes:

  • Which messages exist for that participant
  • The contract (payload shape) for each message
  • Metadata used to produce the normalized network spec

In the class-first style, to define a role you create a class with a static CONFIG object attached. The CONFIG object describes the role's messages as a map of:

  • messageName -> messageSpec

For example, to define a client role:

class Client {
    static CONFIG = {
        name: 'client',
        description: 'A chat client role',
        messages: {
            joinedRoom: {
                payload: S.obj({ joinerId: S.str, roomId: S.str }),
            },
            receivedMessage: {
                payload: S.obj({ senderId: S.str, roomId: S.str, text: S.str }),
            },
        },
    }
}

The same pattern applies to the portal role.

A role definition is intentionally declarative:

  • It can be consumed by the binder to produce APIs and validation rules.
  • It can be exported as a stable spec artifact for tooling.

HostRole and Handlers

While the OpenWS spec treats roles uniformly, the runtime distinguishes the participant running on the host machine. The host participant differs because it must:

  • Receive inbound messages and dispatch them to application logic
  • Send validated outbound messages to connected peers

The runtime defines hostRole as the role the host participant implements.

For remote roles, the CONFIG object declares messages.

For the host role, the CONFIG object declares handlers instead of messages:

  1. Host role defines handlers instead of messages.
  2. Host role defines async handler(payload, api) for each handler defined in CONFIG.

The api argument is the bound peer API for the connection that sent the message (for example a client API instance or a portal API instance). This is a critical part of the design: a handler receives both the validated payload and a capability-scoped API for responding.

Concretely, the server role (that handles createRoom, joinRoom, sendMessage and requestRoomStats) would look like this:

class Server {
    static CONFIG = {
        name: 'server',
        description: 'A chat server role',
        handlers: {
            createRoom: {
                payload: S.obj({ userId: S.str, roomId: S.str }),
                from: [Client],
            },
            joinRoom: {
                payload: S.obj({ userId: S.str, roomId: S.str }),
                from: [Client],
            },
            sendMessage: {
                payload: S.obj({ userId: S.str, roomId: S.str, text: S.str }),
                from: [Client, Portal],
            },
            requestRoomStats: {
                payload: S.obj({ roomId: S.str }),
                from: [Portal],
            },
        },
    }

    rooms: { [roomId: string]: { members: string[] } } = {}
    users: { [userId: string]: { userId: string; api: WS.Api<typeof Client> } } = {}

    async createRoom(
        { userId, roomId }: { userId: string; roomId: string },
        api: WS.Api<typeof Client>
    ) {
        this.rooms[roomId] = { members: [userId] }
        this.users[userId] = { userId, api }
        await api.joinedRoom({ roomId, joinerId: userId })
    }

    async joinRoom(
        { userId, roomId }: { userId: string; roomId: string },
        api: WS.Api<typeof Client>
    ) {
        this.rooms[roomId].members.push(userId)
        this.users[userId] = { userId, api }
        for (const member of this.rooms[roomId].members) {
            await this.users[member].api.joinedRoom({ roomId, joinerId: userId })
        }
    }

    async sendMessage(
        { userId, roomId, text }: { userId: string; roomId: string; text: string },
        _api: WS.Api<typeof Client> | WS.Api<typeof Portal>
    ) {
        for (const member of this.rooms[roomId].members) {
            if (member !== userId) {
                await this.users[member].api.receivedMessage({ roomId, senderId: userId, text })
            }
        }
    }

    async requestRoomStats({ roomId }: { roomId: string }, api: WS.Api<typeof Portal>) {
        await api.receivedRoomStats({ roomId, members: this.rooms[roomId].members })
    }
}

API Type Hints

Depending on the connected participant's role, a different api object is passed to the handler(payload, api) method.

In TypeScript, instead of using any for the api parameter, you can use Api<typeof RoleClass> to enable compiler type checks. This ensures that when a handler is invoked, the api object is statically constrained to the messages that the connected peer is allowed to receive.

This is shown consistently in the embedded examples above.

Message Rejection

The OpenWS spec allows modeling a sender whitelist per message. For example:

  • Only clients may call createRoom and joinRoom
  • Only the portal may call requestRoomStats
  • Both the client and the portal may call sendMessage

This framework faithfully models these constraints in message configuration and enforces them at runtime. When a participant sends a message it is not permitted to send, the runtime rejects the message before it reaches application logic.

In other words:

  • Message rejection is a protocol-level rule (derived from the spec), not a convention.
  • Handlers can assume the fromRole has already been validated against the message spec.

Example configuration:

            joinRoom: {
                payload: S.obj({ userId: S.str, roomId: S.str }),
                from: [Client],
            },

Handler Bindings

A key concept of this framework is handler bindings.

A handler binding maps messages defined in the OpenWS spec for the hostRole to a concrete handler implementation on the host role, while passing an instance of the connected role (client or portal in this example) as the api argument to the handler.

Bindings are created from binding(networkConfig), where the config contains the name, description and roles of the network.

The binder is responsible for:

  • Normalizing role configuration into a single network definition
  • Producing role-aware APIs for outbound messaging
  • Producing handler dispatch rules for inbound messaging
  • Providing access to the network spec for export and tooling

The binder is created like this:

const binder = WS.bindings({
    name: 'chat',
    description: 'A chat network',
    roles: [Server, Client, Portal],
})

In practice, you will typically keep the binder close to your application entrypoint:

  • It is a pure artifact derived from static configuration.
  • It can be reused across test harnesses and server integrations.
  • It is the easiest place to centralize network metadata (name, description, versioning fields, etc.).

Runtime and Sessions

A concrete runtime is created from the bindings.

The runtime is intentionally lightweight and framework-agnostic. It provides an API to create session objects. Once defined, the runtime will:

  • Validate message envelopes and payloads
  • Dispatch inbound messages to instance methods
  • Provide outbound APIs to talk to connected peers
const runtime = WS.runtime(binder)

const session1 = runtime.newSession(async (fromRole: string, messageName: string, payload: any) => {
    console.log(fromRole, messageName, payload)
})

A session object represents a connection established from a remote role to the host. Sessions provide a small, explicit lifecycle surface:

  • open(fromRole)
  • handleMessage(fromRole, messageName, payload)
  • close()
  • error(error)
await session1.open('client')
await session2.open('client')

await session1.handleMessage('client', 'createRoom', { userId: 'userA', roomId: 'room1' })
await session2.handleMessage('client', 'joinRoom', { userId: 'userB', roomId: 'room1' })
await session1.handleMessage('client', 'sendMessage', {
    userId: 'userA',
    roomId: 'room1',
    text: 'Hello, world!',
})
await session2.handleMessage('client', 'sendMessage', {
    userId: 'userB',
    roomId: 'room1',
    text: 'Hello, world!',
})

This separation of concerns is deliberate:

  • Bindings represent the protocol (the network) and dispatch rules.
  • The runtime represents protocol execution.
  • A session represents a single connection, including state and lifecycle.

Because sessions are independent of any WebSocket framework, they are also suitable for unit tests and simulation harnesses.

OpenWS Spec Generation

The binder (or bindings) object gives you access to the network definition in a form that can be converted into a full OpenWS spec.

In particular, you can use the fluent network spec builder from @polytric/openws-spec, and emit the spec using the binder's normalized network representation.

Conceptually:

import * as WS from '@polytric/openws-spec'

// binder.network is a normalized network definition derived from your CONFIG objects.
const spec = WS.spec('0.0.2').network(binder.network).valueOf()

The emitted spec is intended to be:

  • Stable enough for tooling
  • Machine-readable for SDK generation
  • Precise enough for validation and compatibility checks

Read more on the builder in the specification repository.

Framework Integration

The binder provides APIs to model messages, roles, network and runtime handlers for the host role. The runtime provides an API to integrate with WebSocket frameworks.

Notice the createSession and open(fromRole) split. Session creation is divided into two distinct steps because, in a naive WebSocket implementation, when a connection is established the identity of the participant on the other side of the connection is not established yet.

A typical integration flow is:

  1. A socket connects.
  2. The server creates a session immediately (createSession) to attach lifecycle and outbound send behavior.
  3. The server delays open(fromRole) until it can determine the role of the remote participant.
  4. For each inbound message frame, the server parses the envelope and calls handleMessage(...).
  5. On disconnect or error, the server calls close() or error(err).

This design allows you to keep transport concerns separate from protocol concerns:

  • The integration layer is responsible for sockets, frames, and connection establishment.
  • The session is responsible for protocol validation, dispatch, and role-aware messaging.

Establishing fromRole

You must decide how to determine the remote role. Common approaches include:

  • One endpoint per role (role is implied by the URL path)
  • A query parameter or header negotiated during the handshake
  • A dedicated initial message (an explicit "hello" or "identify" message)

The runtime does not mandate the strategy. It only requires that, before messages are dispatched, you call open(fromRole) with a valid role name from the network.

For security and correctness, role establishment should be treated as authentication or capability negotiation:

  • The server should not accept privileged messages until the role has been established.
  • If the role is determined by URL or headers, validate those inputs before calling open(fromRole).

Sending outbound messages

Frameworks typically send raw WebSocket frames (often JSON strings). The runtime expects the integration layer to provide a transport function that can send an encoded message envelope back to the socket.

In other words:

  • The session is responsible for protocol behavior and validation.
  • The integration layer is responsible for bytes on the wire.

As long as you can deliver a string (or buffer) to the socket, the session can remain framework-agnostic.

Example integration shape

The code below is intentionally conceptual. The canonical framework wiring should live in adapter packages (for example @polytric/fastify-openws) and in integration test cases.

If you maintain a minimal adapter in your application, it often looks like:

  • Create a session on connect
  • Parse inbound frames into (fromRole, messageName, payload)
  • Call session.open(fromRole) once
  • Call session.handleMessage(fromRole, messageName, payload) for each message
  • On close/error, call session.close() / session.error(err)

A few practical recommendations:

  • If role establishment happens via an initial "identify" message, perform open(fromRole) only after validating that message.
  • If you accept messages before open(fromRole), you must decide whether to buffer them or reject them. Rejecting by default is simpler and safer.
  • Always treat the inbound messageName as untrusted input and let the runtime enforce the allow-list from the spec.

Adapters

If you do not want to maintain your own framework wiring, use an adapter.

For Fastify, the adapter package is @polytric/fastify-openws. Adapters generally provide:

  • WebSocket route registration
  • Connection lifecycle management (session creation, close/error propagation)
  • Optional spec exposure endpoints for tooling in non-production builds

Adapters should be considered the canonical reference for framework wiring. Application code should remain focused on roles, handlers, and business logic.


Tooling

Once you have a stable spec artifact, you can enable additional tooling:

  • @polytric/openws-ui can render an interactive view of your network and messages
  • @polytric/openws-sdkgen can generate SDKs for other languages and environments

These tools are optional. Many applications begin with only the runtime binder and an adapter, and add tooling later.

Typical adoption sequence:

  1. Start with class-first roles, binder, runtime, and a server adapter.
  2. Export the spec when you need documentation or contract visibility.
  3. Add UI and SDK generation when you need broader integration across teams or languages.

Authoring Styles (Advanced)

The class-first static configuration style is the most direct approach for plain JavaScript. When you are ready for additional ergonomics, OpenWS also supports decorators and fluent APIs.

The remainder of this README is brief by design. It serves as orientation so you can recognize these styles when reading other examples.

Decorator style (TypeScript)

Decorator style is still class-first, but uses decorators to keep spec and implementation close together and to improve type inference in TS.

This style typically requires TypeScript (and decorator support) or an equivalent build step.

// Canonical example is embedded from tests.
import * as WS from '@polytric/openws/decorator'

@WS.role({ description: 'A chat client role' })
class Client {
    @WS.message({ payload: S.obj({ joinerId: S.str, roomId: S.str }) })
    async joinedRoom() {
        // reserved for later
    }

    @WS.message({ payload: S.obj({ senderId: S.str, roomId: S.str, text: S.str }) })
    async receivedMessage() {
        // reserved for later
    }
}

@WS.role({ description: 'A chat portal role' })
class Portal {
    @WS.message({ payload: S.obj({ roomId: S.str }) })
    async receivedRoomStats() {
        // reserved for later
    }
}

@WS.role({ description: 'A chat server role' })
class Server {
    rooms: { [roomId: string]: { members: string[] } } = {}
    users: { [userId: string]: { userId: string; api: WS.Api<typeof Client> } } = {}

    @WS.handler({ payload: S.obj({ userId: S.str, roomId: S.str }), from: [Client] })
    async createRoom(
        { userId, roomId }: { userId: string; roomId: string },
        api: WS.Api<typeof Client>
    ) {
        this.rooms[roomId] = { members: [userId] }
        this.users[userId] = { userId, api }
        await api.joinedRoom({ roomId, joinerId: userId })
    }

    @WS.handler({ payload: S.obj({ userId: S.str, roomId: S.str }), from: Client })
    async joinRoom(
        { userId, roomId }: { userId: string; roomId: string },
        api: WS.Api<typeof Client>
    ) {
        this.rooms[roomId].members.push(userId)
        this.users[userId] = { userId, api }
        await api.joinedRoom({ roomId, joinerId: userId })
    }

    @WS.handler({
        name: 'sendMessage',
        payload: S.obj({ userId: S.str, roomId: S.str, text: S.str }),
        from: [Client],
    })
    async sendMessage(
        { userId, roomId, text }: { userId: string; roomId: string; text: string },
        _api: WS.Api<typeof Client>
    ) {
        for (const member of this.rooms[roomId].members) {
            if (userId && member === userId) {
                continue
            }
            await this.users[member].api.receivedMessage({
                roomId,
                senderId: userId,
                text,
            })
        }
    }

    @WS.handler({
        name: 'sendMessage',
        from: [Portal],
        // Payload shape must stay identical. It's a limitation that needs to be addressed in the future.
        payload: S.obj({ userId: S.str, roomId: S.str, text: S.str }),
    })
    async sendMessagePortal({
        userId,
        roomId,
        text,
    }: {
        userId: string
        roomId: string
        text: string
    }) {
        for (const member of this.rooms[roomId].members) {
            await this.users[member].api.receivedMessage({ roomId, senderId: userId, text })
        }
    }

    @WS.handler({ payload: S.obj({ roomId: S.str }), from: [Portal] })
    async requestRoomStats({ roomId }: { roomId: string }, api: WS.Api<typeof Portal>) {
        await api.receivedRoomStats({ roomId })
    }
}

Use this style when:

  • You want stronger TS editor hints with minimal boilerplate
  • You prefer to colocate message declarations and handlers
  • You accept a build step for JS environments

Fluent / functional style

Fluent and functional APIs are intended for:

  • Programmatic spec generation
  • Library use cases where you need to assemble information piece by piece (this framework converts class-first representation to fluent representation under the hood)
  • Scenarios where composition and reuse are primary concerns
// Canonical example is embedded from tests.
import * as WS from '@polytric/openws/fluent'
import type { ApiProto } from '@polytric/openws/fluent'
import { validate } from '@polytric/openws-spec'

const globalCtx: AppContext = {
    rooms: {},
    users: {},
}

const clientRole = WS.role('client')
    .desc('A client role')
    .message(
        WS.message('joinedRoom')
            .payload(S.obj({ userId: S.str, roomId: S.str }))
            .desc('A room joined request')
    )
    .message(
        WS.message('receivedMessage')
            .payload(S.obj({ roomId: S.str, senderId: S.str, text: S.str, sentAt: S.int }))
            .desc('A message received request')
    )

const portalRole = WS.role('portal')
    .desc('A portal role')
    .message(
        WS.message('receivedRoomStats')
            .payload(S.obj({ roomId: S.str }))
            .desc('A room stats request')
    )

const serverRole = WS.role('server')
    .asHost()
    .desc('A server role')
    .endpoint(WS.endpoint('ws', 'localhost', 8082, '/chat'))
    .message(
        WS.message('createRoom')
            .payload(S.obj({ userId: S.str, roomId: S.str }))
            .desc('A room creation request')
    )
    .message(
        WS.message('joinRoom')
            .payload(S.obj({ userId: S.str, roomId: S.str }))
            .desc('A room join request')
    )
    .message(
        WS.message('sendMessage')
            .payload(S.obj({ userId: S.str, roomId: S.str, text: S.str }))
            .desc('A message send request')
    )
    .message(
        WS.message('requestRoomStats')
            .payload(S.obj({ roomId: S.str }))
            .desc('A room stats request')
    )

const network = WS.network('chat')
    .role(serverRole)
    .role(clientRole)
    .role(portalRole)
    .desc('A chat network')

const spec = WS.spec('0.0.1', 'Chat Example').network(network)
const specJson = spec.valueOf()
validate(specJson)

type AppContext = {
    rooms: { [roomId: string]: { users: Set<string> } }
    users: { [userId: string]: { userId: string; api: ApiProto } }
}

const binder = WS.bindings(network)
binder.fromRoles.client
    .on('createRoom', async (payload: { userId: string; roomId: string }, api: ApiProto) => {
        globalCtx.rooms[payload.roomId] = { users: new Set([payload.userId]) }
        globalCtx.users[payload.userId] = { userId: payload.userId, api }
        api.joinedRoom({ roomId: payload.roomId, userId: payload.userId })
    })
    .on('joinRoom', async (payload: { userId: string; roomId: string }, api: ApiProto) => {
        globalCtx.rooms[payload.roomId].users.add(payload.userId)
        globalCtx.users[payload.userId] = { userId: payload.userId, api }
        api.joinedRoom({ roomId: payload.roomId, userId: payload.userId })
    })
    .on(
        'sendMessage',
        async (payload: { userId: string; roomId: string; text: string }, api: ApiProto) => {
            const room = globalCtx.rooms[payload.roomId]
            if (!room) {
                throw new Error(`Room ${payload.roomId} not found`)
            }
            for (const userId of room.users) {
                const user = globalCtx.users[userId]
                if (!user || userId === payload.userId) {
                    continue
                }
                user.api.receivedMessage({
                    roomId: payload.roomId,
                    senderId: payload.userId,
                    text: payload.text,
                    sentAt: Date.now(),
                })
            }
        }
    )
    .on('requestRoomStats', async (payload: { roomId: string }, api: ApiProto) => {
        console.log('requestRoomStats', payload.roomId)
    })

binder.fromRoles.portal.on(
    'requestRoomStats',
    async (payload: { roomId: string }, api: ApiProto) => {
        console.log('requestRoomStats', payload.roomId)
    }
)

Use this style when:

  • Your spec is assembled dynamically (feature flags, plugins, modules)
  • You want a purely functional and composable spec definition surface

Summary

  • @polytric/openws is the JS/TS binder and runtime layer for OpenWS.
  • The network spec is the source of truth and can be exported for tooling.
  • The class-first style supports plain JavaScript without a build step and is recommended as a starting point.
  • Decorator and fluent styles are available for teams that prefer additional ergonomics or programmatic spec generation.