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

@wc-bindable/remote

v0.4.0

Published

Remote proxy for wc-bindable protocol — connect Core and Shell over a network

Readme

@wc-bindable/remote

Remote proxy for the wc-bindable protocol — connect Core and Shell over a network.

Splits the HAWC Core/Shell boundary across a network using WebSocket. The server runs the real Core; the client gets a proxy EventTarget that works transparently with bind() and framework adapters.

Install

npm install @wc-bindable/remote

Architecture

Client (Browser)                        Server (Node / Deno / etc.)
┌──────────────────────┐  WebSocket   ┌──────────────────────┐
│  RemoteCoreProxy     │◄────────────►│  RemoteShellProxy    │
│  (EventTarget)       │              │                      │
│                      │              │  Core (EventTarget)  │
│  bind() just works   │              │  Business logic here │
└──────────────────────┘              └──────────────────────┘

  { type: "sync" }     ──────────►  Read current values
                       ◄──────────  { type: "sync", values: { ... } }
  set("url", "/api")  ──────────►  core.url = "/api"
  invoke("fetch")     ──────────►  core.fetch()
                      ◄──────────  update: loading = true
                      ◄──────────  update: value  = { ... }

Connection lifecycle

  1. The server creates a RemoteShellProxy, which subscribes to the Core's declared events and starts forwarding updates.
  2. The client creates a RemoteCoreProxy, which immediately sends a sync request.
  3. The server responds with the Core's current property values. Properties whose current value is undefined are omitted, matching local bind() initial synchronization. This means the initial sync cannot distinguish between "currently undefined" and "not present / never initialized" for a declared property; both appear as an omitted key.
  4. The client populates its cache and dispatches events for each value — bind() on the client side picks these up as if they were normal state changes.
  5. From this point, all Core events are forwarded in real time.
Client                              Server
  │                                   │  RemoteShellProxy created
  │                                   │  subscribes to declared Core events
  │  RemoteCoreProxy created          │
  │── { type: "sync" } ─────────────► │
  │                                   │  Read core.value, core.loading, ...
  │  ◄── { type: "sync",          ── │
  │        values: {                  │
  │          value: null,             │
  │          loading: false,          │
  │          error: null,             │
  │          status: 0                │
  │        }                          │
  │      }                            │
  │  Cache updated + events fired     │
  │  bind() delivers initial state    │
  │                                   │
  │── { type: "set", "url", "/api" }► │  core.url = "/api"
  │── { type: "cmd", "fetch", … } ──► │  core.fetch()
  │                                   │
  │  ◄── { type: "update",        ── │  core dispatches loading-changed
  │        name: "loading",           │
  │        value: true }              │
  │                                   │
  │  ◄── { type: "update",        ── │  core dispatches response;
  │        name: "value",             │  getters on the server split the
  │        value: { … } }            │  event into per-property updates
  │  ◄── { type: "update",        ── │  (e.g. `value` and `status` both
  │        name: "status",            │  driven by a shared event are sent
  │        value: 200 }               │  as two distinct updates).
  │                                   │
  │  ◄── { type: "return",        ── │  fetch() resolved
  │        id: "1", value: { … } }   │

Usage

Server

import { RemoteShellProxy, WebSocketServerTransport } from "@wc-bindable/remote";
import { MyFetchCore } from "./my-fetch-core.js";

// When a WebSocket connection is established:
const core = new MyFetchCore();
const transport = new WebSocketServerTransport(socket);
const shell = new RemoteShellProxy(core, transport);

RemoteShellProxy reads the declaration from core.constructor.wcBindable at runtime. That means the object you pass in must expose the declared static wcBindable on its effective constructor. If you wrap a Core in a Proxy, decorator, or mixin that changes the constructor chain, preserve or re-expose that static property on the final constructor before passing it to RemoteShellProxy.

WebSocketServerTransport expects incoming client messages to arrive either as text JSON frames or as UTF-8 binary bytes such as Node Buffer, Uint8Array, or ArrayBuffer. If your runtime surfaces Blob payloads for message events, prefer text frames or adapt the socket before passing it in, because the transport API is synchronous and does not await Blob.text().

Client

import { createRemoteCoreProxy, WebSocketClientTransport } from "@wc-bindable/remote";
import { bind } from "@wc-bindable/core";
import { MyFetchCore } from "./my-fetch-core.js"; // for wcBindable declaration only

const ws = new WebSocket("ws://localhost:3000");
const transport = new WebSocketClientTransport(ws);
const proxy = createRemoteCoreProxy(MyFetchCore.wcBindable, transport);

// bind() works exactly as if Core were local
bind(proxy, (name, value) => {
  console.log(name, value);
});

// Set input properties
proxy.set("url", "/api/users");

// Invoke commands
const result = await proxy.invoke("fetch");

With a framework adapter

// React — the proxy is an EventTarget, so useWcBindable works via bind()
import { createRemoteCoreProxy, WebSocketClientTransport } from "@wc-bindable/remote";
import { bind } from "@wc-bindable/core";

const ws = new WebSocket("ws://localhost:3000");
const transport = new WebSocketClientTransport(ws);
// proxy is created once here, so the empty dependency list is intentional.
const proxy = createRemoteCoreProxy(MyFetchCore.wcBindable, transport);

// Subscribe with bind() and feed into React state
const [values, setValues] = useState({});
useEffect(() => {
  return bind(proxy, (name, value) => {
    setValues((prev) => ({ ...prev, [name]: value }));
  });
}, []);

Custom transport

The ClientTransport and ServerTransport interfaces are intentionally minimal. Implement them to use any transport — MessagePort, BroadcastChannel, WebTransport, etc.

import type { ClientTransport, ClientMessage, ServerMessage } from "@wc-bindable/remote";

class MyCustomTransport implements ClientTransport {
  send(message: ClientMessage): void { /* ... */ }
  onMessage(handler: (message: ServerMessage) => void): void { /* ... */ }
}

Custom transport implementations must guarantee message ordering (FIFO). The protocol relies on this to ensure consistency between sync responses and subsequent event messages without sequence numbers.

This package does not implement built-in back-pressure or queue limits. In particular, WebSocketClientTransport buffers pre-open outbound messages without a cap, RemoteCoreProxy still keeps pending acknowledged requests in memory until they settle or hit their timeout, and RemoteShellProxy buffers synchronous update messages while a sync snapshot is being built. In production, bound these at a higher layer with admission control, connection quotas, reverse-proxy limits, or per-client rate limiting if untrusted or slow peers are possible.

API

| Export | Description | |---|---| | createRemoteCoreProxy(declaration, transport) | Create a client-side proxy. Returns an EventTarget compatible with bind(). | | RemoteCoreProxy | The underlying proxy class (use createRemoteCoreProxy for property access support). | | RemoteShellProxy | Server-side proxy that connects a real Core to the transport. | | WebSocketClientTransport | ClientTransport implementation using the standard WebSocket API. | | WebSocketServerTransport | ServerTransport implementation using any WebSocketLike object. |

RemoteCoreProxy

| Method | Description | |---|---| | set(name, value) | Set an input property on the remote Core. Fire-and-forget — see "Error handling" below. | | setWithAck(name, value) | Set an input property and wait for the server to acknowledge or reject it. | | setWithAckOptions(name, value, options) | Set an input property with lifecycle options such as AbortSignal and timeoutMs, without changing the wire payload sent to the server. | | invoke(name, ...args) | Invoke a command on the remote Core. Returns a Promise that settles when the server replies, the send fails, the transport closes, or the default 30s timeout expires. | | invokeWithOptions(name, args, options) | Invoke a command with explicit wire arguments and lifecycle options such as AbortSignal and timeoutMs. The legacy invokeWithOptions(name, options, ...args) form remains supported for compatibility, but it is ambiguous when the first wire argument is itself an array. | | reconnect(transport) | Attach a fresh client transport after the previous one closed. Existing bind() subscribers stay attached and a new sync request is sent immediately. | | dispose() | Reject pending invocations and stop processing future transport messages for this proxy instance. |

Error handling

  • invoke() errors on the server are serialized and delivered as throw messages, which reject the returned Promise. When the server throws an Error, the payload preserves at least name and message, and includes stack when available. If the thrown value itself is not JSON-serializable, RemoteShellProxy falls back to a serializable RemoteShellProxyError payload instead of leaving the client request pending.
  • setWithAckOptions() supports AbortSignal and timeoutMs. Aborting rejects the client-side Promise and forgets the pending response; it does not send a cancellation message to the server. Timeouts reject with TimeoutError and also clear the pending entry. setWithAck() uses the same behavior with a default 30s timeout.
  • invokeWithOptions() supports AbortSignal and timeoutMs. Prefer invokeWithOptions(name, args, options) so the wire arguments stay explicit and are harder to confuse with the options object. The legacy invokeWithOptions(name, options, ...args) form is still accepted for backward compatibility, but it cannot disambiguate a first wire argument that is itself an array. If a command's first or only argument is an array, migrate that call site to the explicit form and pass the full wire argument list as args instead, for example invokeWithOptions("save", [[1, 2, 3]], options). Aborting rejects the client-side Promise and forgets the pending response; it does not send a cancellation message to the server. Timeouts reject with TimeoutError and also clear the pending entry. invoke() uses the same behavior with a default 30s timeout.
  • Timeout configuration: pass timeoutMs to override the default 30s deadline, or timeoutMs: 0 to disable the built-in timeout for an individual call. Invalid timeout values (negative or non-finite) are surfaced as RangeError rejections from the returned Promise rather than synchronous throws. If the initial sync response does not advertise capabilities.setAck, setWithAck() and setWithAckOptions() reject instead of waiting forever against a legacy server.
  • Back-pressure is not built in. Pending acknowledgements, pre-open WebSocket sends, and sync-time queued updates are all unbounded in-memory queues. If a peer can stall or flood the connection, enforce your own limits above this package.
  • Transport close rejects all pending invoke() calls with Transport closed and leaves the proxy disconnected until you call reconnect() with a new transport.
  • dispose() is terminal: it rejects all pending requests with RemoteCoreProxy disposed and causes subsequent set(), invoke(), and reconnect() calls to fail immediately.
  • set() validates the input name on the client before sending, so undeclared names fail immediately. It also throws synchronously if the proxy is already disconnected or if the transport send fails while trying to enqueue the message. For declared inputs on a healthy transport, it remains fire-and-forget: there is no response id and no server-side success/error is delivered back to the client. If a buggy or stale client still sends an undeclared input, RemoteShellProxy drops it and logs console.warn.
  • setWithAck() sends the same mutation with a request id and waits for a return/throw response. Use it when the caller needs server-side validation feedback such as type mismatches, read-only assignments, or conversion failures. It requires the server to advertise capabilities.setAck in the initial sync response; legacy servers that omit that capability are rejected once detected. Use setWithAckOptions() when you also need client-side cancellation.
  • Fire-and-forget setter failures on plain set() are still caught and logged via console.error on the server so they do not escape the transport's message handler or terminate the connection.
  • Server send failures while forwarding sync, update, return, or throw messages are caught and logged via console.error. The failing message is dropped; the connection is not closed automatically.
  • Server transport teardown: if the ServerTransport implements onClose(), RemoteShellProxy disposes itself automatically. If the transport also implements dispose(), RemoteShellProxy.dispose() calls it so message/close listeners can be released. WebSocketServerTransport does both for standard WebSocket and Node ws close events.

RemoteShellProxy

| Method | Description | |---|---| | constructor(core, transport) | Connect a Core to the transport. Subscribes to Core events and listens for client messages. | | dispose() | Unsubscribe from Core events and release transport-owned listeners if supported. Call when the connection closes. |

Message protocol

Client → Server:
  { type: "sync" }
  { type: "set", name: string, value: unknown, id?: string }
  { type: "cmd", name: string, id: string, args: unknown[] }

Server → Client:
  { type: "sync", values: Record<string, unknown>, capabilities?: { setAck?: boolean }, getterFailures?: string[] }
  { type: "update", name: string, value: unknown }
  { type: "return", id: string, value: unknown }
  { type: "throw", id: string, error: unknown }

Design decisions

Why sync instead of automatic initial push? The server subscribes to future Core events, but it does not push initial values during construction. The sync request/response pattern ensures the client receives initial state only when it is ready to process it.

Why no sequence numbers? WebSocket guarantees FIFO message ordering, and JavaScript's single-threaded execution ensures that sync responses and event messages are enqueued in a consistent order. A sync response always reflects the Core's state at the time the request was processed; any events dispatched after that point are sent after the sync response. To preserve that guarantee even when a getter emits a synchronous side-effect event while the server is building the sync snapshot, RemoteShellProxy buffers those update messages and flushes them only after the sync response is sent. This makes sequence numbers unnecessary under these constraints.

If a declared getter throws while building sync, the server logs the failure and includes that property name in getterFailures. The client preserves its previous cached value for those names on re-sync; only properties omitted without a getter failure are treated as having reverted to undefined. On the very first sync, however, there is no prior cached value to restore, so an omitted property is simply absent on the client until a later update or re-sync provides a concrete value.

Why is the wire protocol property-centric, not event-centric? A Core may declare multiple properties that share the same event and differ only by getter — for example, value and status both driven by my-fetch:response with detail.value and detail.status. Sending the raw event name and detail would collapse those properties on the client side. Instead, RemoteShellProxy registers one listener per declared property, applies that property's getter on the server, and sends a distinct { type: "update", name, value } message for each property. The client updates its cache by name and dispatches a synthetic per-property event so local bind() can discriminate.

Why are getters not applied on the client? Because the server already applies them. The wire value is the already-extracted per-property value, and the client proxy rewrites each property's event to a synthetic per-property name (@wc-bindable/remote:<name>) so the default getter (e => e.detail) is always sufficient. As a consequence, getter functions do not need to be serializable — but note that this also means addEventListener on the proxy with the original Core event name will not fire. When migrating existing code to Remote, treat bind() or property access as required; code that directly subscribed to Core event names must be rewritten.

What values can cross the wire? Messages are encoded with JSON.stringify, so only JSON-serializable values round-trip faithfully. Values such as Date, Map, Set, BigInt, functions, class instances, or cyclic objects will be transformed, dropped, or throw during serialization. If serialization fails while the server is sending sync, update, or return, RemoteShellProxy logs the failure and drops that message. For throw responses, it instead falls back to a serializable RemoteShellProxyError payload so the client request can still reject.

License

MIT