Skip to content

Commit fdab2b7

Browse files
authored
feat(peer): backpressure support (#195)
1 parent 34e4fad commit fdab2b7

10 files changed

Lines changed: 179 additions & 2 deletions

File tree

docs/1.guide/2.hooks.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ const hooks = defineHooks({
4141
error(peer, error) {
4242
console.log("[ws] error", peer, error);
4343
},
44+
45+
drain(peer) {
46+
// Send buffer drained after backpressure — safe to resume sending.
47+
// Pair with `peer.bufferedAmount`. Not all adapters emit this.
48+
console.log("[ws] drain", peer);
49+
},
4450
});
4551
```
4652

docs/1.guide/3.peer.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,40 @@ All topics, this peer has been subscribed to.
6060

6161
Peer's pubsub namespace.
6262

63+
### `peer.bufferedAmount`
64+
65+
Number of bytes queued for transmission but not yet flushed to the client.
66+
67+
Use this to apply **backpressure**: pause sending while it grows past a high watermark and resume once it drops. This prevents a fast producer (for example a streaming async generator) from filling the adapter's send buffer faster than the client can drain it. The easiest way to wait is [`peer.waitForDrain()`](#peerwaitfordrain).
68+
69+
> [!NOTE]
70+
> Not all adapters expose a buffer signal; those return `0`. Refer to the [compatibility table](#compatibility) for more info.
71+
6372
## Instance methods
6473

6574
### `peer.send(message, { compress? })`
6675

6776
Send a message to the connected client.
6877

78+
### `peer.waitForDrain({ threshold?, pollInterval?, signal? })`
79+
80+
Returns a promise that resolves once [`peer.bufferedAmount`](#peerbufferedamount) drops to or below `threshold` bytes (default `0`), letting you `await` backpressure relief in a send loop:
81+
82+
```ts
83+
// Throttle a fast producer to the client's pace
84+
for (const chunk of stream) {
85+
peer.send(chunk);
86+
if (peer.bufferedAmount > 1024 * 1024 /* 1MB */) {
87+
await peer.waitForDrain({ threshold: 256 * 1024 /* 256KB */ });
88+
}
89+
}
90+
```
91+
92+
It resolves immediately when there is no backpressure (or on adapters that do not report `bufferedAmount`). Otherwise it polls every `pollInterval` ms (default `100`) until the buffer drains, and also resolves early if the connection is no longer open so a send loop never hangs on a dropped client. Pass a `signal` (e.g. `AbortSignal.timeout(ms)`) to abort the wait — the promise then rejects with the signal's reason.
93+
94+
> [!TIP]
95+
> For event-driven backpressure (instead of `await`ing in a loop), use the [`drain` hook](/guide/hooks) together with `peer.bufferedAmount`.
96+
6997
### `peer.subscribe(channel)`
7098

7199
Join a broadcast channel.
@@ -113,6 +141,8 @@ To gracefully close the connection, use `peer.close()`.
113141
| `publish()` / `subscribe()` |||[^1] |[^1] |[^1] ||[^1] |
114142
| `close()` ||||||||
115143
| `terminate()` ||[^2] |||||[^2] |
144+
| `bufferedAmount` ||[^8] |[^8] ||||[^8] |
145+
| `drain` hook ||[^9] |[^9] |[^9] |||[^9] |
116146
| `request` |||[^30] ||[^31] |[^31] ||
117147
| `remoteAddress` ||||||||
118148
| `websocket.url` ||||||||
@@ -145,3 +175,7 @@ To gracefully close the connection, use `peer.close()`.
145175
[^6]: [`websocket.readyState`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState) is polyfilled by tracking open/close events.
146176

147177
[^7]: Some runtimes have non standard values including `"nodebuffer"` and `"uint8array"`. crossws auto converts them for [`message.data`](/guide/message#messagedata).
178+
179+
[^8]: The runtime exposes no send-buffer signal, so `peer.bufferedAmount` reports `0`. (Cloudflare buffers and applies backpressure internally; SSE has no equivalent.)
180+
181+
[^9]: The runtime emits no drain signal. Poll [`peer.bufferedAmount`](#peerbufferedamount) instead where it is available.

src/adapters/bun.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ const bunAdapter: Adapter<BunAdapter, BunOptions> = (options = {}) => {
7373
peers.delete(peer);
7474
hooks.callHook("close", peer, { code, reason });
7575
},
76+
drain: (ws) => {
77+
const peers = getPeers(globalPeers, ws.data.namespace);
78+
const peer = getPeer(ws, peers);
79+
hooks.callHook("drain", peer);
80+
},
7681
},
7782
};
7883
};
@@ -109,6 +114,10 @@ class BunPeer extends Peer<{
109114
return this._internal.ws.data.context;
110115
}
111116

117+
override get bufferedAmount(): number {
118+
return this._internal.ws.getBufferedAmount();
119+
}
120+
112121
send(data: unknown, options?: { compress?: boolean }): number {
113122
return this._internal.ws.send(toBufferLike(data), options?.compress);
114123
}

src/adapters/node.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,17 @@ const nodeAdapter: Adapter<NodeAdapter, NodeOptions> = (options = {}) => {
8181
peers.delete(peer);
8282
hooks.callHook("error", peer, new WSError(error));
8383
});
84+
// `ws` has no drain event of its own; the underlying TCP socket emits
85+
// `drain` after a backpressured write flushes. Note this tracks the OS
86+
// socket buffer, which is an approximation of `peer.bufferedAmount` (the
87+
// latter also includes ws's internal sender queue) — treat it as a resume
88+
// nudge, not an exact "bufferedAmount reached 0" signal.
89+
const socket = (ws as WebSocketT & { _socket?: Duplex })._socket;
90+
const onDrain = () => hooks.callHook("drain", peer);
91+
socket?.on("drain", onDrain);
8492
ws.on("close", (code: number, reason: Buffer) => {
8593
peers.delete(peer);
94+
socket?.off("drain", onDrain);
8695
hooks.callHook("close", peer, {
8796
code,
8897
reason: reason?.toString(),
@@ -166,7 +175,7 @@ class NodePeer extends Peer<{
166175
binary: isBinary,
167176
...options,
168177
});
169-
return 0;
178+
return this._internal.ws.bufferedAmount;
170179
}
171180

172181
publish(topic: string, data: unknown, options?: { compress?: boolean }): void {

src/adapters/uws.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ const uwsAdapter: Adapter<UWSAdapter, UWSOptions> = (options = {}) => {
6161
const peer = getPeer(ws, peers);
6262
hooks.callHook("message", peer, new Message(message, peer));
6363
},
64+
drain(ws) {
65+
const peers = getPeers(globalPeers, ws.getUserData().namespace);
66+
const peer = getPeer(ws, peers);
67+
hooks.callHook("drain", peer);
68+
},
6469
open(ws) {
6570
const peers = getPeers(globalPeers, ws.getUserData().namespace);
6671
const peer = getPeer(ws, peers);

src/hooks.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,15 @@ export interface Hooks {
146146
/** A socket is closed */
147147
close: (peer: Peer, details: { code?: number; reason?: string }) => MaybePromise<void>;
148148

149+
/**
150+
* The send buffer has drained after backpressure, so it is safe to resume
151+
* sending. Pair with {@link Peer.bufferedAmount} to throttle senders.
152+
*
153+
* **Note:** Only emitted by adapters that expose a drain signal. Refer to the
154+
* [compatibility table](https://crossws.h3.dev/guide/peer#compatibility).
155+
*/
156+
drain: (peer: Peer) => MaybePromise<void>;
157+
149158
/** An error occurs */
150159
error: (peer: Peer, error: WSError) => MaybePromise<void>;
151160
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export type { Adapter, AdapterInstance, AdapterOptions } from "./adapter.ts";
1010
export type { Message } from "./message.ts";
1111

1212
// Peer
13-
export type { Peer, PeerContext, AdapterInternal } from "./peer.ts";
13+
export type { Peer, PeerContext, AdapterInternal, WaitForDrainOptions } from "./peer.ts";
1414

1515
// Error
1616
export type { WSError } from "./error.ts";

src/peer.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,28 @@ import { kNodeInspect } from "./utils.ts";
33

44
export interface PeerContext extends Record<string, unknown> {}
55

6+
export interface WaitForDrainOptions {
7+
/**
8+
* Resolve once {@link Peer.bufferedAmount} drops to or below this many bytes.
9+
*
10+
* @default 0
11+
*/
12+
threshold?: number;
13+
14+
/**
15+
* Polling interval (in milliseconds) used to re-check {@link Peer.bufferedAmount}.
16+
*
17+
* @default 100
18+
*/
19+
pollInterval?: number;
20+
21+
/**
22+
* Abort the wait (e.g. `AbortSignal.timeout(ms)`). The returned promise
23+
* rejects with the signal's `reason`.
24+
*/
25+
signal?: AbortSignal;
26+
}
27+
628
export interface AdapterInternal {
729
ws: unknown;
830
request: Request;
@@ -78,6 +100,69 @@ export abstract class Peer<Internal extends AdapterInternal = AdapterInternal> {
78100
return this._topics;
79101
}
80102

103+
/**
104+
* Number of bytes queued for transmission but not yet flushed to the client.
105+
*
106+
* Use this to apply backpressure: pause sending while it grows past a high
107+
* watermark and resume once it drops (or on the `drain` hook). Returns `0` on
108+
* adapters that do not expose a buffer signal. Refer to the
109+
* [compatibility table](https://crossws.h3.dev/guide/peer#compatibility).
110+
*/
111+
get bufferedAmount(): number {
112+
return (this._internal.ws as Partial<web.WebSocket>)?.bufferedAmount ?? 0;
113+
}
114+
115+
/**
116+
* Wait until the send buffer drains to `threshold` bytes (default `0`).
117+
*
118+
* Resolves immediately when there is no backpressure (or on adapters that do
119+
* not expose {@link Peer.bufferedAmount}). Otherwise it polls every
120+
* `pollInterval` milliseconds until the buffer drains, also resolving early if
121+
* the connection is no longer open so a send loop never hangs on a dropped
122+
* client.
123+
*
124+
* ```ts
125+
* for (const chunk of stream) {
126+
* peer.send(chunk);
127+
* if (peer.bufferedAmount > 1024 * 1024) {
128+
* await peer.waitForDrain({ threshold: 256 * 1024 });
129+
* }
130+
* }
131+
* ```
132+
*/
133+
waitForDrain(opts: WaitForDrainOptions = {}): Promise<void> {
134+
const threshold = opts.threshold ?? 0;
135+
if (this.bufferedAmount <= threshold) {
136+
return Promise.resolve();
137+
}
138+
const signal = opts.signal;
139+
if (signal?.aborted) {
140+
return Promise.reject(signal.reason);
141+
}
142+
return new Promise<void>((resolve, reject) => {
143+
const check = () => {
144+
// Resolve once drained, or if the socket left the OPEN (1) state — a
145+
// closed peer never drains, so this prevents a permanent hang + leak.
146+
if (this.bufferedAmount <= threshold || (this.websocket.readyState ?? 1) > 1) {
147+
cleanup();
148+
resolve();
149+
}
150+
};
151+
const onAbort = () => {
152+
cleanup();
153+
reject(signal!.reason);
154+
};
155+
const timer = setInterval(check, opts.pollInterval ?? 100);
156+
// Don't keep the event loop alive just for a pending drain check.
157+
timer.unref?.();
158+
const cleanup = () => {
159+
clearInterval(timer);
160+
signal?.removeEventListener("abort", onAbort);
161+
};
162+
signal?.addEventListener("abort", onAbort, { once: true });
163+
});
164+
}
165+
81166
abstract close(code?: number, reason?: string): void;
82167

83168
/** Abruptly close the connection */

test/fixture/_shared.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export function createDemo<T extends Adapter<any, any>>(
2828
peer.send({
2929
id: peer.id,
3030
remoteAddress: peer.remoteAddress,
31+
bufferedAmount: peer.bufferedAmount,
3132
context: peer.context,
3233
request: {
3334
url: peer.request?.url,
@@ -44,6 +45,10 @@ export function createDemo<T extends Adapter<any, any>>(
4445
});
4546
break;
4647
}
48+
case "waitForDrain": {
49+
peer.waitForDrain({ pollInterval: 10 }).then(() => peer.send("drained"));
50+
break;
51+
}
4752
case "peers": {
4853
peer.send({
4954
peers: [...peer.peers].map((p) => p.id),

test/tests.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,21 @@ export function wsTests(getURL: () => string, opts: WSTestOpts): void {
119119
}
120120
});
121121

122+
test("peer.bufferedAmount", async () => {
123+
const ws = await wsConnect(getURL(), { skip: 1 });
124+
await ws.send("debug");
125+
const { bufferedAmount } = await ws.next();
126+
// Adapters without a buffer signal report 0; capable ones report >= 0.
127+
expect(typeof bufferedAmount).toBe("number");
128+
expect(bufferedAmount).toBeGreaterThanOrEqual(0);
129+
});
130+
131+
test("peer.waitForDrain", async () => {
132+
const ws = await wsConnect(getURL(), { skip: 1 });
133+
await ws.send("waitForDrain");
134+
expect(await ws.next()).toBe("drained");
135+
});
136+
122137
test("peer.websocket", async () => {
123138
const ws = await wsConnect(getURL() + "?foo=bar", {
124139
skip: 1,

0 commit comments

Comments
 (0)