actions/ws_outbound_writer.ts

The outbound side of one server WebSocket connection — a byte-budgeted queue of frames, the pump that hands them to the socket, and the deadlines on what the socket has not yet written.

Every frame the server sends a connection goes through WsOutboundWriter.push: a response, an error frame, a notification, a broadcast, a server-initiated request. BackendWebsocketTransport owns one writer per connection and is its only caller.

What waits

A frame pushed at an idle connection — nothing waiting, nothing unwritten in the runtime's socket buffer, and no other frame pushed in the same synchronous run — goes straight to the socket. Every other frame waits in the writer's queue: the rest of a burst pushed in one synchronous run (a loop with no await in it gives the pump no turn), and anything pushed while the socket's buffer is at or over WS_WRITE_HIGH_WATER_BYTES. The pump hands waiting frames over in order — on the microtask after a push, and whenever the buffer has drained below the high-water mark.

What waits is held to the connection's budget (OutboundBudget): each waiting frame is charged its UTF-8 length plus OUTBOUND_FRAME_OVERHEAD_BYTES, and a frame is always admitted when nothing is waiting. A frame handed to the socket is no longer charged — the budget bounds what waits, and the write deadline bounds what is in flight.

A frame that does not fit is reported as an overflow and not queued; the owner closes the connection for it (WS_CLOSE_OUTBOUND_OVERFLOW).

The queue is a linked list: a frame handed to the socket is unlinked, so the writer holds the frames that wait and no others, however long the queue goes without emptying.

Closing

close sends the close frame and takes nothing more. With 'flush' it first runs the pump's rule once: waiting frames are handed to the socket while its buffer is below WS_WRITE_HIGH_WATER_BYTES. Whatever still waits is discarded, with either backlog choice. So a frame sent to an otherwise idle connection just before a close reaches the client ahead of the close frame, and a frame queued behind a backed-up socket does not. After a close a socket whose buffer the writer can read ("Runtimes") holds less than the high-water mark plus one frame, and the close frame.

Deadlines

  • A frame: bytes the socket's buffer still holds WS_WRITE_STALL_TIMEOUT after they were handed to it mean the client is not reading. The writer discards what waits, reports the stall, and cuts the socket with no close frame (the client sees an abnormal close, 1006).
  • A close frame: a close still unwritten WS_CLOSE_WRITE_TIMEOUT after close cuts the socket the same way, so a closed connection whose client stopped reading does not hold its buffered frames for the runtime's own close timeout — where the runtime can cut a socket ("Runtimes").

Runtimes

The buffer is read through the socket Hono's WSContext wraps (raw): bufferedAmount on Node's ws and on Deno's standard WebSocket, getBufferedAmount() on Bun's ServerWebSocket. A socket that exposes neither reads as never backed up: its frames are still budgeted while they wait for the pump, but nothing detects a stall. The cut is terminate(), which Node's ws and Bun's ServerWebSocket have. Deno's standard WebSocket has no way to drop a socket without the closing handshake, so there a stalled connection is closed with WS_CLOSE_OUTBOUND_OVERFLOW instead — a frame the client reads only if it starts reading again — and the close deadline cuts nothing: a closed socket whose client is not reading stays in the runtime, holding what its buffer held at the close ("Closing"), until the runtime or the client ends it.

view source

Declarations
#

8 declarations

WS_CLOSE_WRITE_TIMEOUT
#

actions/ws_outbound_writer.ts view source

1000 import {WS_CLOSE_WRITE_TIMEOUT} from '@fuzdev/fuz_app/actions/ws_outbound_writer.js';

How long a close frame may stay unwritten before the socket is cut without it (module doc, "Deadlines"). Same value as the Rust spine's bound on the same write.

WS_WRITE_HIGH_WATER_BYTES
#

actions/ws_outbound_writer.ts view source

number import {WS_WRITE_HIGH_WATER_BYTES} from '@fuzdev/fuz_app/actions/ws_outbound_writer.js';

How many unwritten bytes a socket's buffer may hold before the pump stops handing it waiting frames. Below it the pump keeps writing, so a runtime that reports a frame as buffered until its write completes (Deno) is not held to one frame per turn; at or over it frames wait in the budgeted queue instead of the runtime's unbounded buffer.

WS_WRITE_STALL_TIMEOUT
#

actions/ws_outbound_writer.ts view source

10000 import {WS_WRITE_STALL_TIMEOUT} from '@fuzdev/fuz_app/actions/ws_outbound_writer.js';

How long bytes handed to a socket may stay unwritten in its buffer before the connection is dropped (module doc, "Deadlines"). Equal to DEFAULT_PEER_REQUEST_TIMEOUT: a server-initiated request that cannot even be written within its own reply deadline has already failed. Same value as the Rust spine's WS_WRITE_STALL_TIMEOUT.

The deadline covers the bytes in the buffer when it starts, not progress: a client taking them too slowly to finish inside it is dropped the same way, and reconnects.

WsOutboundBacklog
#

actions/ws_outbound_writer.ts view source

WsOutboundBacklog

type "flush" | "discard"

import type {WsOutboundBacklog} from '@fuzdev/fuz_app/actions/ws_outbound_writer.js';

What WsOutboundWriter.close does with the frames still waiting.

WsOutboundPush
#

actions/ws_outbound_writer.ts view source

WsOutboundPush

type { kind: "accepted"; } | { kind: "refused"; } | { kind: "overflow"; overflow: OutboundOverflow; }

import type {WsOutboundPush} from '@fuzdev/fuz_app/actions/ws_outbound_writer.js';

The outcome of one WsOutboundWriter.push.

WsOutboundStall
#

actions/ws_outbound_writer.ts view source

WsOutboundStall import type {WsOutboundStall} from '@fuzdev/fuz_app/actions/ws_outbound_writer.js';

What a stalled connection held when its write deadline passed.

queued_bytes

Payload bytes that were waiting, now discarded.

type number

queued_frames

Frames that were waiting, now discarded.

type number

buffered_bytes

Bytes the socket's buffer still held.

type number

WsOutboundWriter
#

actions/ws_outbound_writer.ts view source

import {WsOutboundWriter} from '@fuzdev/fuz_app/actions/ws_outbound_writer.js';

The byte-budgeted outbound queue and write pump of one WebSocket connection (module doc).

constructor

type new (options: WsOutboundWriterOptions): WsOutboundWriter

options

push

Send text as one frame: straight to the socket when the connection is idle, else queued behind what waits when it fits the budget (module doc, "What waits"). Never waits and never throws.

type (text: string, bytes?: number | undefined): WsOutboundPush

text

the frame's text

type string

bytes?

the UTF-8 length of text when the caller has measured it — a fan-out measures one frame once for its recipients; measured here when omitted. Trusted, not checked against text: a wrong value corrupts the budget

type number
optional

accepted, refused once the writer has closed, or overflow when the frame did not fit — the caller closes the connection

mutates

  • this — queues the frame or writes it to the socket, and schedules the pump

close

Close the socket with code and reason, and take nothing more.

With backlog: 'flush' the frames still waiting are handed to the socket ahead of the close frame under the pump's rule — while the socket's buffer is below WS_WRITE_HIGH_WATER_BYTES — so a reply sent to an otherwise idle connection just before a close is not lost to it. What still waits after that is dropped, as all of it is with 'discard' (module doc, "Closing"). Either way the close frame gets WS_CLOSE_WRITE_TIMEOUT to be written, after which a socket still holding unwritten bytes is cut where the runtime can cut one. A no-op once the writer has closed.

type (code: number, reason: string, backlog: WsOutboundBacklog, on_settled?: (() => void) | undefined): void

code

the close code

type number

reason

the close reason

type string

backlog

what to do with the frames still waiting

on_settled?

called once the close deadline has passed, unless dispose ran first

type () => void
optional
returns void

throws

  • whatever - the socket's `close` throws, with the writer already closed

mutates

  • this — empties the queue, stops the pump, starts the close deadline

dispose

The socket is gone: drop what waits and stop every timer. Idempotent.

type (): void

returns void

mutates

  • this — empties the queue and clears the pump and deadline timers

queued_bytes

Payload bytes waiting for the socket, without the per-frame overhead.

type number

getter

queued_frames

Frames waiting for the socket.

type number

getter

WsOutboundWriterOptions
#

actions/ws_outbound_writer.ts view source

WsOutboundWriterOptions import type {WsOutboundWriterOptions} from '@fuzdev/fuz_app/actions/ws_outbound_writer.js';

Options for WsOutboundWriter.

ws

The socket to write to.

type WSContext<unknown>

max_bytes

The byte budget of the frames waiting for the socket.

type number

on_stall

Called once when a write deadline passes, after the writer discarded what was waiting and before it cuts the socket. The owner removes the connection and logs.

type (stall: WsOutboundStall): void

stall

returns void

log

Logger for a socket that throws on a send or a cut; null silences it.

type Logger | null

Depends on
#

Imported by
#