Skip to content

Assess Bun native WebSocket pub/sub for broadcast() on Bun #671

Description

@vivek7405

Problem

webjs already uses Bun's native WebSocket TRANSPORT when running on Bun: packages/server/src/listener-bun.js (#511) configures Bun.serve({ ..., websocket: { open, message, close } }) and upgrades via server.upgrade (the bunUpgrade helper), with a BunWsAdapter (an EventEmitter) bridging Bun's server-level WS dispatch to the node ws-library contract (.on('message') / .on('close') / .send()), so a route.ts WS(ws, req) export works identically on both runtimes. (The Node shell uses the ws library in packages/server/src/websocket.js.) So the transport is already native on Bun, no change needed there.

The gap: because the adapter bridges to the lowest-common-denominator node ws contract, webjs does NOT use Bun's native WebSocket FEATURES beyond open/message/close. In particular broadcast() (packages/server/src/broadcast.js) does a generic per-client fan-out loop (for (const ws of clients) ws.send(msg)) on BOTH runtimes, rather than Bun's native pub/sub (ws.subscribe(topic) in the open handler plus server.publish(topic, msg) for fan-out). Native pub/sub is Bun's headline WS performance feature (dispatch handled in native code, far cheaper for large fan-out). Bun WS per-message compression and backpressure signals are likewise unused.

Design / approach

Decide (and implement if worthwhile) whether broadcast() on Bun should use server.publish instead of the per-client loop:

  • On Bun, in the WS open handler, ws.subscribe(pathname) (the topic is the route path the client connected to, the same key registerClient uses today).
  • broadcast(path, data) on Bun then calls server.publish(path, msg) (one native call) instead of iterating the client set.
  • Keep the Node path exactly as-is (the ws-library loop). This is a runtime-divergent INTERNAL: Bun uses topics, Node uses the client-set loop, same external broadcast() API and behaviour.

Tradeoff to weigh: native pub/sub is faster for large fan-out and offloads dispatch to Bun, but it splits the WS internals per runtime (more surface to keep in parity) and broadcast() would need the Bun server instance in scope. Also assess whether to surface Bun WS per-message compression and backpressure (ws.send return value / the drain handler). The decision should be backed by a fan-out benchmark (loop vs publish) at a realistic client count; if the win is marginal at webjs's expected scale, keeping the unified loop for simplicity is a legitimate outcome (record that decision).

Implementation notes (for the implementing agent)

  • Where to edit: packages/server/src/broadcast.js (registerClient + the broadcast() loop), packages/server/src/listener-bun.js (the BunWsAdapter, the websocket: { open, message, close } handlers, and bunUpgrade where registerClient is called at ~L113), and leave packages/server/src/websocket.js (the node ws path) unchanged.
  • The Bun server instance is created inside startBunListener (Bun.serve(...)); broadcast() would need access to it on Bun (thread it through, or have the Bun open handler subscribe + expose a Bun-specific publish path that broadcast() dispatches to when on Bun).
  • Landmines: the external broadcast() contract and the WS(ws, req) export must stay byte-identical across runtimes (the whole point of the BunWsAdapter). Do NOT regress the node path. A topic must be removed when a client disconnects (Bun auto-unsubscribes on close, but verify). Message serialization must match the loop path (same msg). Watch the dev re-import-per-connection note in route.ts WS handling.
  • Invariants: cross-runtime parity (Support both Bun and Node runtimes (first-class create + run) #508) and the WebSocket parity the BunWsAdapter exists to guarantee. This is runtime-sensitive code (the listener / WS path), so it is gated by the Bun-parity hook and MUST be proven on both runtimes.
  • Tests + docs: extend test/bun/listener.mjs (which already exercises the WS echo over a real Bun socket) to cover a broadcast() fan-out on Bun; add/keep the node-side WS broadcast coverage; if the behaviour or the broadcast() surface notes change, update agent-docs/built-ins.md (the broadcast section) and packages/server/AGENTS.md.

Acceptance criteria

  • A decision is recorded (with a fan-out benchmark, loop vs server.publish) on whether to adopt Bun native pub/sub for broadcast() on Bun.
  • If adopted: broadcast() uses server.publish on Bun and the per-client loop on Node, with byte-identical external behaviour; the node path is unchanged.
  • WebSocket parity holds: a route.ts WS export and broadcast() behave identically on Node and Bun (proven by test/bun/listener.mjs on both runtimes).
  • Cross-runtime tests cover the broadcast fan-out on both runtimes; a counterfactual where applicable.
  • Docs updated if the broadcast surface / behaviour notes change; otherwise N/A recorded.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions