crossws 0.4.5 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/vercel.d.ts +2 -0
- package/dist/THIRD-PARTY-LICENSES.md +1 -1
- package/dist/_chunks/_request.mjs +2 -2
- package/dist/_chunks/_types.d.mts +8 -8
- package/dist/_chunks/adapter.d.mts +458 -4
- package/dist/_chunks/adapter.mjs +90 -13
- package/dist/_chunks/bun.d.mts +5 -3
- package/dist/_chunks/bunny.d.mts +2 -2
- package/dist/_chunks/cloudflare.d.mts +3 -3
- package/dist/_chunks/deno.d.mts +2 -2
- package/dist/_chunks/error.mjs +1 -1
- package/dist/_chunks/libs/ws.mjs +53 -4
- package/dist/_chunks/node.d.mts +2 -2
- package/dist/_chunks/node.mjs +17 -11
- package/dist/_chunks/peer.mjs +38 -26
- package/dist/_chunks/sse.d.mts +2 -2
- package/dist/_chunks/web.d.mts +1 -1
- package/dist/adapters/bun.d.mts +1 -1
- package/dist/adapters/bun.mjs +18 -9
- package/dist/adapters/bunny.d.mts +1 -1
- package/dist/adapters/bunny.mjs +8 -6
- package/dist/adapters/cloudflare.d.mts +1 -1
- package/dist/adapters/cloudflare.mjs +16 -13
- package/dist/adapters/deno.d.mts +1 -1
- package/dist/adapters/deno.mjs +22 -9
- package/dist/adapters/node.d.mts +2 -2
- package/dist/adapters/node.mjs +1 -1
- package/dist/adapters/sse.d.mts +1 -1
- package/dist/adapters/sse.mjs +7 -5
- package/dist/adapters/uws.d.mts +5 -4
- package/dist/adapters/uws.mjs +17 -10
- package/dist/adapters/vercel.d.mts +25 -0
- package/dist/adapters/vercel.mjs +48 -0
- package/dist/index.d.mts +30 -7
- package/dist/index.mjs +78 -35
- package/dist/server/bun.d.mts +1 -1
- package/dist/server/bunny.d.mts +1 -1
- package/dist/server/cloudflare.d.mts +1 -1
- package/dist/server/default.d.mts +1 -1
- package/dist/server/deno.d.mts +1 -1
- package/dist/server/node.d.mts +1 -1
- package/dist/server/node.mjs +1 -1
- package/dist/sync.d.mts +2 -0
- package/dist/sync.mjs +200 -0
- package/dist/websocket/node.mjs +1 -1
- package/dist/websocket/sse.d.mts +1 -1
- package/package.json +23 -35
- package/sync.d.ts +2 -0
- package/dist/_chunks/rolldown-runtime.mjs +0 -24
|
@@ -62,7 +62,7 @@ const StubRequest = /* @__PURE__ */ (() => {
|
|
|
62
62
|
return Promise.resolve(new Blob());
|
|
63
63
|
}
|
|
64
64
|
bytes() {
|
|
65
|
-
return Promise.resolve(new Uint8Array());
|
|
65
|
+
return Promise.resolve(/* @__PURE__ */ new Uint8Array());
|
|
66
66
|
}
|
|
67
67
|
formData() {
|
|
68
68
|
return Promise.resolve(new FormData());
|
|
@@ -77,4 +77,4 @@ const StubRequest = /* @__PURE__ */ (() => {
|
|
|
77
77
|
Object.setPrototypeOf(StubRequest.prototype, globalThis.Request.prototype);
|
|
78
78
|
return StubRequest;
|
|
79
79
|
})();
|
|
80
|
-
export { StubRequest
|
|
80
|
+
export { StubRequest };
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
1
|
+
import { Hooks } from "./adapter.mjs";
|
|
2
|
+
import { BunOptions } from "./bun.mjs";
|
|
3
|
+
import { BunnyOptions } from "./bunny.mjs";
|
|
4
|
+
import { CloudflareOptions } from "./cloudflare.mjs";
|
|
5
|
+
import { DenoOptions } from "./deno.mjs";
|
|
6
|
+
import { NodeOptions } from "./node.mjs";
|
|
7
|
+
import { SSEOptions } from "./sse.mjs";
|
|
8
8
|
import { Server, ServerOptions, ServerPlugin, ServerRequest } from "srvx";
|
|
9
9
|
type WSOptions = Partial<Hooks> & {
|
|
10
10
|
resolve?: (req: ServerRequest) => Partial<Hooks> | Promise<Partial<Hooks>>;
|
|
@@ -20,4 +20,4 @@ type WSOptions = Partial<Hooks> & {
|
|
|
20
20
|
type ServerWithWSOptions = ServerOptions & {
|
|
21
21
|
websocket?: WSOptions;
|
|
22
22
|
};
|
|
23
|
-
export {
|
|
23
|
+
export { ServerWithWSOptions, WSOptions };
|
|
@@ -1,15 +1,379 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { WebSocket } from "./web.mjs";
|
|
2
2
|
declare class WSError extends Error {
|
|
3
3
|
constructor(...args: any[]);
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* A single pub/sub event relayed between crossws instances.
|
|
7
|
+
*
|
|
8
|
+
* This is the minimal unit a sync backplane needs to transport so that a
|
|
9
|
+
* `peer.publish()` (or `adapter.publish()`) on one instance reaches the
|
|
10
|
+
* subscribers connected to every other instance.
|
|
11
|
+
*/
|
|
12
|
+
interface SyncMessage {
|
|
13
|
+
/**
|
|
14
|
+
* Pub/sub namespace (matches {@link Peer.namespace}).
|
|
15
|
+
*
|
|
16
|
+
* An empty string means "all namespaces" and mirrors a server-side
|
|
17
|
+
* `adapter.publish(topic, data)` call without an explicit `namespace`.
|
|
18
|
+
*/
|
|
19
|
+
namespace: string;
|
|
20
|
+
/** Channel / topic name. */
|
|
21
|
+
topic: string;
|
|
22
|
+
/**
|
|
23
|
+
* Message payload.
|
|
24
|
+
*
|
|
25
|
+
* crossws normalizes payloads to a string or `Uint8Array` before handing
|
|
26
|
+
* them to the driver. Encoding for the wire (e.g. base64 for binary over a
|
|
27
|
+
* text transport) is the driver's responsibility.
|
|
28
|
+
*/
|
|
29
|
+
data: string | Uint8Array;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A live connection to a sync backplane, scoped to one crossws instance.
|
|
33
|
+
*
|
|
34
|
+
* Created by a {@link SyncAdapter}. crossws calls {@link SyncDriver.publish}
|
|
35
|
+
* for every local publish and expects {@link SyncDriver.subscribe} to invoke
|
|
36
|
+
* the supplied `deliver` callback for every message originating from *other*
|
|
37
|
+
* instances.
|
|
38
|
+
*/
|
|
39
|
+
interface SyncDriver {
|
|
40
|
+
/**
|
|
41
|
+
* Start receiving messages relayed from other instances.
|
|
42
|
+
*
|
|
43
|
+
* The driver MUST call `deliver` for every remote message; crossws then
|
|
44
|
+
* fans it out to local subscribers. The driver MUST NOT echo this
|
|
45
|
+
* instance's own publishes back (backplanes like Redis pub/sub echo by
|
|
46
|
+
* default — use the instance `id` from {@link SyncAdapter} to filter).
|
|
47
|
+
*/
|
|
48
|
+
subscribe(deliver: (message: SyncMessage) => void): MaybePromise<void>;
|
|
49
|
+
/** Relay a locally-published message to the other instances. */
|
|
50
|
+
publish(message: SyncMessage): MaybePromise<void>;
|
|
51
|
+
/** Optional teardown when the adapter shuts down. */
|
|
52
|
+
close?(): MaybePromise<void>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Factory for a {@link SyncDriver}.
|
|
56
|
+
*
|
|
57
|
+
* crossws calls it once per adapter instance and passes a stable random `id`
|
|
58
|
+
* the driver can use for echo suppression.
|
|
59
|
+
*/
|
|
60
|
+
type SyncAdapter = (ctx: {
|
|
61
|
+
id: string;
|
|
62
|
+
}) => SyncDriver;
|
|
63
|
+
/**
|
|
64
|
+
* Zero-dependency sync driver built on [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel).
|
|
65
|
+
*
|
|
66
|
+
* Bridges instances that share a `BroadcastChannel` registry. On Node.js, Deno
|
|
67
|
+
* and Bun that registry is scoped to a **single process** (it spans the main
|
|
68
|
+
* thread and its worker threads), so this is for in-process worker fan-out and
|
|
69
|
+
* tests — not separate OS processes (e.g. Node `cluster`/PM2 forks), which each
|
|
70
|
+
* have an isolated registry and will silently not sync. (Deno Deploy is the one
|
|
71
|
+
* exception: its `BroadcastChannel` spans isolates.)
|
|
72
|
+
*
|
|
73
|
+
* For multiple processes, hosts or regions you want a networked driver such as
|
|
74
|
+
* {@link redis} or {@link pgsql}.
|
|
75
|
+
*
|
|
76
|
+
* A `channel` name is required: it scopes the cluster, and a shared default
|
|
77
|
+
* would risk silently bridging unrelated servers running on the same host.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```js
|
|
81
|
+
* import { broadcastChannel } from "crossws/sync";
|
|
82
|
+
* const adapter = nodeAdapter({ hooks, sync: broadcastChannel({ channel: "my-app" }) });
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
declare function broadcastChannel(opts: {
|
|
86
|
+
channel: string;
|
|
87
|
+
}): SyncAdapter;
|
|
88
|
+
/**
|
|
89
|
+
* Structural subset of a Redis client used by {@link redis}, covering both
|
|
90
|
+
* [ioredis](https://github.com/redis/ioredis) and
|
|
91
|
+
* [node-redis](https://github.com/redis/node-redis). Kept structural so crossws
|
|
92
|
+
* stays dependency-free.
|
|
93
|
+
*
|
|
94
|
+
* The two clients shape pub/sub differently and {@link redis} bridges them:
|
|
95
|
+
* - **ioredis** — `subscribe(channel)` plus a shared `"message"` event whose
|
|
96
|
+
* listener receives `(channel, message)`; `duplicate()` returns a ready client.
|
|
97
|
+
* - **node-redis** — `subscribe(channel, listener)` with an inline listener that
|
|
98
|
+
* receives `(message, channel)`; `duplicate()` returns a client you must
|
|
99
|
+
* `connect()` first.
|
|
100
|
+
*/
|
|
101
|
+
interface RedisClientLike {
|
|
102
|
+
publish(channel: string, message: string): unknown;
|
|
103
|
+
/**
|
|
104
|
+
* ioredis: `subscribe(channel)`; node-redis: `subscribe(channel, listener)`
|
|
105
|
+
* (the listener receives `(message, channel)`).
|
|
106
|
+
*/
|
|
107
|
+
subscribe(channel: string, listener?: (message: string, channel: string) => void): unknown;
|
|
108
|
+
/** ioredis: shared `"message"` event (listener receives `(channel, message)`). */
|
|
109
|
+
on?(event: "message", listener: (channel: string, message: string) => void): unknown;
|
|
110
|
+
/** ioredis: detach the `"message"` listener on {@link SyncDriver.close}. */
|
|
111
|
+
off?(event: "message", listener: (channel: string, message: string) => void): unknown;
|
|
112
|
+
/** node-redis: a `duplicate()`d client starts disconnected and must `connect()`. */
|
|
113
|
+
connect?(): unknown;
|
|
114
|
+
/** Create a second connection for `SUBSCRIBE` (which blocks the connection). */
|
|
115
|
+
duplicate(): RedisClientLike;
|
|
116
|
+
/** Tear down the dedicated subscriber connection on {@link SyncDriver.close}. */
|
|
117
|
+
quit?(): unknown;
|
|
118
|
+
/** Present (camelCase) only on node-redis — used for auto-detection. */
|
|
119
|
+
pSubscribe?: unknown;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Networked sync driver over Redis pub/sub — the realistic multi-region
|
|
123
|
+
* backplane. Bring your own client; a dedicated `SUBSCRIBE` connection is
|
|
124
|
+
* derived from it via `duplicate()` (`SUBSCRIBE` blocks the connection it runs
|
|
125
|
+
* on, so it can't share the one used for `PUBLISH`).
|
|
126
|
+
*
|
|
127
|
+
* Works out of the box with both [ioredis](https://github.com/redis/ioredis)
|
|
128
|
+
* and [node-redis](https://github.com/redis/node-redis): the flavor is
|
|
129
|
+
* auto-detected (node-redis exposes camelCase commands such as `pSubscribe`),
|
|
130
|
+
* with an explicit `connector` escape hatch if detection ever guesses wrong.
|
|
131
|
+
*
|
|
132
|
+
* Binary payloads are base64-encoded so they survive Redis's text transport.
|
|
133
|
+
*
|
|
134
|
+
* A `channel` name is required: it scopes the cluster, and a shared default
|
|
135
|
+
* would risk silently bridging unrelated servers on the same Redis instance.
|
|
136
|
+
*
|
|
137
|
+
* Reconnect note: ioredis auto-resubscribes its channels after a dropped
|
|
138
|
+
* connection; node-redis does not restore subscriptions the same way, so a
|
|
139
|
+
* node-redis-backed instance may stop receiving relayed messages after a
|
|
140
|
+
* transient outage. Prefer ioredis where connection resilience matters.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```js
|
|
144
|
+
* // ioredis
|
|
145
|
+
* import Redis from "ioredis";
|
|
146
|
+
* import { redis } from "crossws/sync";
|
|
147
|
+
* const adapter = nodeAdapter({ hooks, sync: redis({ client: new Redis(), channel: "my-app" }) });
|
|
148
|
+
* ```
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```js
|
|
152
|
+
* // node-redis
|
|
153
|
+
* import { createClient } from "redis";
|
|
154
|
+
* import { redis } from "crossws/sync";
|
|
155
|
+
* const client = await createClient().connect();
|
|
156
|
+
* const adapter = nodeAdapter({ hooks, sync: redis({ client, channel: "my-app" }) });
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
declare function redis(opts: {
|
|
160
|
+
/** Redis client used to `PUBLISH`; a subscriber is `duplicate()`d from it. */client: RedisClientLike; /** Pub/sub channel to relay over. */
|
|
161
|
+
channel: string;
|
|
162
|
+
/**
|
|
163
|
+
* Client flavor. Defaults to auto-detection (node-redis exposes the camelCase
|
|
164
|
+
* `pSubscribe` command; ioredis does not). Set explicitly to override.
|
|
165
|
+
*/
|
|
166
|
+
connector?: "ioredis" | "node-redis";
|
|
167
|
+
}): SyncAdapter;
|
|
168
|
+
/**
|
|
169
|
+
* Structural subset of a PostgreSQL client used by {@link pgsql}, covering
|
|
170
|
+
* both [node-postgres](https://github.com/brianc/node-postgres) (`pg`) and
|
|
171
|
+
* [postgres.js](https://github.com/porsager/postgres). Kept structural so
|
|
172
|
+
* crossws stays dependency-free.
|
|
173
|
+
*
|
|
174
|
+
* The two clients shape `LISTEN`/`NOTIFY` differently and {@link pgsql}
|
|
175
|
+
* bridges them:
|
|
176
|
+
* - **node-postgres** — issue raw `LISTEN`/`pg_notify` via `query()` and receive
|
|
177
|
+
* a shared `"notification"` event whose listener gets `{ channel, payload }`.
|
|
178
|
+
* - **postgres.js** — dedicated `listen(channel, onnotify)` (resolves to a
|
|
179
|
+
* handle with `unlisten()`) and `notify(channel, payload)` helpers.
|
|
180
|
+
*/
|
|
181
|
+
interface PostgresClientLike {
|
|
182
|
+
/** node-postgres: run `LISTEN` / `SELECT pg_notify(...)` / `UNLISTEN`. */
|
|
183
|
+
query?(sql: string, values?: unknown[]): unknown;
|
|
184
|
+
/** node-postgres: shared `"notification"` event for inbound `NOTIFY`s. */
|
|
185
|
+
on?(event: "notification", listener: (msg: {
|
|
186
|
+
channel: string;
|
|
187
|
+
payload?: string;
|
|
188
|
+
}) => void): unknown;
|
|
189
|
+
/** node-postgres: detach the `"notification"` listener on {@link SyncDriver.close}. */
|
|
190
|
+
removeListener?(event: "notification", listener: (msg: {
|
|
191
|
+
channel: string;
|
|
192
|
+
payload?: string;
|
|
193
|
+
}) => void): unknown;
|
|
194
|
+
/**
|
|
195
|
+
* postgres.js: subscribe to a channel; resolves to a handle exposing
|
|
196
|
+
* `unlisten()`. Present only on postgres.js — used for auto-detection.
|
|
197
|
+
*/
|
|
198
|
+
listen?(channel: string, onnotify: (payload: string) => void): unknown;
|
|
199
|
+
/** postgres.js: send a `NOTIFY` to a channel. */
|
|
200
|
+
notify?(channel: string, payload: string): unknown;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Networked sync driver over PostgreSQL [`LISTEN`/`NOTIFY`](https://www.postgresql.org/docs/current/sql-notify.html)
|
|
204
|
+
* — a backplane for clusters that already run Postgres and would rather not add
|
|
205
|
+
* Redis. Bring your own client; unlike Redis `SUBSCRIBE`, Postgres `LISTEN` does
|
|
206
|
+
* not block the connection, so no `duplicate()` is needed: for node-postgres the
|
|
207
|
+
* *same* client both listens and notifies. (postgres.js `listen()` internally
|
|
208
|
+
* reserves its own dedicated connection — that's the client's concern, not ours.)
|
|
209
|
+
*
|
|
210
|
+
* Pass a single, dedicated [`Client`](https://node-postgres.com/apis/client), not
|
|
211
|
+
* a [`Pool`](https://node-postgres.com/apis/pool): pool `query()` runs `LISTEN`
|
|
212
|
+
* on an arbitrary backend that is then returned to the pool, so notifications
|
|
213
|
+
* would never reach a stable listener. A `Pool` is detected and rejected. For the
|
|
214
|
+
* same reason, don't share one client across two `pgsql()` drivers on the same
|
|
215
|
+
* channel — `close()` issues a single `UNLISTEN` that would silence the others.
|
|
216
|
+
*
|
|
217
|
+
* Works out of the box with both [node-postgres](https://github.com/brianc/node-postgres)
|
|
218
|
+
* (`pg`) and [postgres.js](https://github.com/porsager/postgres): the flavor is
|
|
219
|
+
* auto-detected (postgres.js exposes a `listen()` helper; node-postgres does
|
|
220
|
+
* not), with an explicit `connector` escape hatch if detection ever guesses
|
|
221
|
+
* wrong.
|
|
222
|
+
*
|
|
223
|
+
* Binary payloads are base64-encoded so they survive the text `NOTIFY` payload.
|
|
224
|
+
* Note Postgres caps a `NOTIFY` payload at 8000 bytes — keep relayed messages
|
|
225
|
+
* small (this is a transport limit, not a crossws one).
|
|
226
|
+
*
|
|
227
|
+
* A `channel` name is required: it scopes the cluster, so unrelated servers
|
|
228
|
+
* don't silently bridge through the same database. It is used verbatim as the
|
|
229
|
+
* notification channel name.
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```js
|
|
233
|
+
* // node-postgres (pg)
|
|
234
|
+
* import { Client } from "pg";
|
|
235
|
+
* import { pgsql } from "crossws/sync";
|
|
236
|
+
* const client = new Client();
|
|
237
|
+
* await client.connect();
|
|
238
|
+
* const adapter = nodeAdapter({ hooks, sync: pgsql({ client, channel: "my-app" }) });
|
|
239
|
+
* ```
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```js
|
|
243
|
+
* // postgres.js
|
|
244
|
+
* import postgresjs from "postgres";
|
|
245
|
+
* import { pgsql } from "crossws/sync";
|
|
246
|
+
* const sql = postgresjs();
|
|
247
|
+
* const adapter = nodeAdapter({ hooks, sync: pgsql({ client: sql, channel: "my-app" }) });
|
|
248
|
+
* ```
|
|
249
|
+
*/
|
|
250
|
+
declare function pgsql(opts: {
|
|
251
|
+
/** Connected Postgres client used to both `LISTEN` and `NOTIFY`. */client: PostgresClientLike; /** Notification channel to relay over. */
|
|
252
|
+
channel: string;
|
|
253
|
+
/**
|
|
254
|
+
* Client flavor. Defaults to auto-detection (postgres.js exposes a `listen()`
|
|
255
|
+
* helper; node-postgres does not). Set explicitly to override.
|
|
256
|
+
*/
|
|
257
|
+
connector?: "pg" | "postgres.js";
|
|
258
|
+
}): SyncAdapter;
|
|
259
|
+
/**
|
|
260
|
+
* Install the cluster relay in the **primary** process.
|
|
261
|
+
*
|
|
262
|
+
* Node `cluster` workers can't message each other directly — IPC only flows
|
|
263
|
+
* between each worker and the primary — so the primary must rebroadcast every
|
|
264
|
+
* worker's relay message to the others. Call this once in your primary process
|
|
265
|
+
* (before or after forking) so {@link cluster} drivers running in the workers
|
|
266
|
+
* can reach one another. It is a no-op when called from a worker, so guarding
|
|
267
|
+
* with `cluster.isPrimary` is optional.
|
|
268
|
+
*
|
|
269
|
+
* `node:cluster` is imported lazily so merely importing `crossws/sync` stays
|
|
270
|
+
* runtime-agnostic (it never loads in workerd/Deno where the module is unused).
|
|
271
|
+
*
|
|
272
|
+
* @example
|
|
273
|
+
* ```js
|
|
274
|
+
* import cluster from "node:cluster";
|
|
275
|
+
* import { availableParallelism } from "node:os";
|
|
276
|
+
* import { setupPrimaryCluster } from "crossws/sync";
|
|
277
|
+
*
|
|
278
|
+
* if (cluster.isPrimary) {
|
|
279
|
+
* setupPrimaryCluster();
|
|
280
|
+
* for (let i = 0; i < availableParallelism(); i++) cluster.fork();
|
|
281
|
+
* } else {
|
|
282
|
+
* // ... start your server with `sync: cluster({ channel: "my-app" })`
|
|
283
|
+
* }
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
declare function setupPrimaryCluster(): Promise<void>;
|
|
287
|
+
/**
|
|
288
|
+
* Zero-dependency sync driver over Node.js [`cluster`](https://nodejs.org/api/cluster.html)
|
|
289
|
+
* worker IPC — bridges forked processes on a **single host** (e.g. Node
|
|
290
|
+
* `cluster` or PM2 `instances`) without a network backplane.
|
|
291
|
+
*
|
|
292
|
+
* This fills the gap left by {@link broadcastChannel}, whose registry is scoped
|
|
293
|
+
* to one process and silently won't sync across forks. For multiple hosts or
|
|
294
|
+
* regions you still want a networked driver such as {@link redis} or
|
|
295
|
+
* {@link pgsql}.
|
|
296
|
+
*
|
|
297
|
+
* Requires the relay to be installed in the primary via
|
|
298
|
+
* {@link setupPrimaryCluster}; the driver itself runs in the workers. Workers
|
|
299
|
+
* can't message each other directly, so all relay flows worker → primary →
|
|
300
|
+
* workers. Binary payloads are base64-encoded so they survive default JSON IPC
|
|
301
|
+
* serialization (no `serialization: "advanced"` needed).
|
|
302
|
+
*
|
|
303
|
+
* A `channel` name is required: it scopes the cluster and lets multiple apps
|
|
304
|
+
* share one process tree without bridging into each other.
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* ```js
|
|
308
|
+
* import cluster from "node:cluster";
|
|
309
|
+
* import { setupPrimaryCluster, cluster as clusterSync } from "crossws/sync";
|
|
310
|
+
*
|
|
311
|
+
* if (cluster.isPrimary) {
|
|
312
|
+
* setupPrimaryCluster();
|
|
313
|
+
* cluster.fork();
|
|
314
|
+
* cluster.fork();
|
|
315
|
+
* } else {
|
|
316
|
+
* const ws = nodeAdapter({ hooks, sync: clusterSync({ channel: "my-app" }) });
|
|
317
|
+
* // ... start the server
|
|
318
|
+
* }
|
|
319
|
+
* ```
|
|
320
|
+
*/
|
|
321
|
+
declare function cluster(opts: {
|
|
322
|
+
channel: string;
|
|
323
|
+
}): SyncAdapter;
|
|
324
|
+
declare function encodeEnvelope(id: string, msg: SyncMessage): string;
|
|
325
|
+
declare function decodeEnvelope(raw: string): {
|
|
326
|
+
id: string;
|
|
327
|
+
msg: SyncMessage;
|
|
328
|
+
} | undefined;
|
|
329
|
+
/**
|
|
330
|
+
* Names of the built-in sync drivers exported from `crossws/sync`.
|
|
331
|
+
*
|
|
332
|
+
* Useful for automatic integration (e.g. Nitro) that needs to enumerate the
|
|
333
|
+
* available drivers without importing each one.
|
|
334
|
+
*/
|
|
335
|
+
declare const syncDrivers: readonly ["broadcastChannel", "redis", "pgsql", "cluster"];
|
|
336
|
+
/** Name of a built-in sync driver (see {@link syncDrivers}). */
|
|
337
|
+
type SyncDriverName = (typeof syncDrivers)[number];
|
|
338
|
+
/**
|
|
339
|
+
* Map from a {@link SyncDriverName} to the options object its driver factory
|
|
340
|
+
* accepts — derived from the factory signatures so it stays in sync.
|
|
341
|
+
*/
|
|
342
|
+
type SyncDriverOptions = {
|
|
343
|
+
broadcastChannel: Parameters<typeof broadcastChannel>[0];
|
|
344
|
+
redis: Parameters<typeof redis>[0];
|
|
345
|
+
pgsql: Parameters<typeof pgsql>[0];
|
|
346
|
+
cluster: Parameters<typeof cluster>[0];
|
|
347
|
+
};
|
|
5
348
|
declare const kNodeInspect: unique symbol;
|
|
6
349
|
interface PeerContext extends Record<string, unknown> {}
|
|
350
|
+
interface WaitForDrainOptions {
|
|
351
|
+
/**
|
|
352
|
+
* Resolve once {@link Peer.bufferedAmount} drops to or below this many bytes.
|
|
353
|
+
*
|
|
354
|
+
* @default 0
|
|
355
|
+
*/
|
|
356
|
+
threshold?: number;
|
|
357
|
+
/**
|
|
358
|
+
* Polling interval (in milliseconds) used to re-check {@link Peer.bufferedAmount}.
|
|
359
|
+
*
|
|
360
|
+
* @default 100
|
|
361
|
+
*/
|
|
362
|
+
pollInterval?: number;
|
|
363
|
+
/**
|
|
364
|
+
* Abort the wait (e.g. `AbortSignal.timeout(ms)`). The returned promise
|
|
365
|
+
* rejects with the signal's `reason`.
|
|
366
|
+
*/
|
|
367
|
+
signal?: AbortSignal;
|
|
368
|
+
}
|
|
7
369
|
interface AdapterInternal {
|
|
8
370
|
ws: unknown;
|
|
9
371
|
request: Request;
|
|
10
372
|
namespace: string;
|
|
11
373
|
peers?: Set<Peer>;
|
|
12
374
|
context?: PeerContext;
|
|
375
|
+
/** Optional sync backplane used to relay publishes to other instances. */
|
|
376
|
+
sync?: SyncDriver;
|
|
13
377
|
}
|
|
14
378
|
declare abstract class Peer<Internal extends AdapterInternal = AdapterInternal> {
|
|
15
379
|
#private;
|
|
@@ -40,6 +404,34 @@ declare abstract class Peer<Internal extends AdapterInternal = AdapterInternal>
|
|
|
40
404
|
get peers(): Set<Peer>;
|
|
41
405
|
/** All topics, this peer has been subscribed to. */
|
|
42
406
|
get topics(): Set<string>;
|
|
407
|
+
/**
|
|
408
|
+
* Number of bytes queued for transmission but not yet flushed to the client.
|
|
409
|
+
*
|
|
410
|
+
* Use this to apply backpressure: pause sending while it grows past a high
|
|
411
|
+
* watermark and resume once it drops (or on the `drain` hook). Returns `0` on
|
|
412
|
+
* adapters that do not expose a buffer signal. Refer to the
|
|
413
|
+
* [compatibility table](https://crossws.h3.dev/guide/peer#compatibility).
|
|
414
|
+
*/
|
|
415
|
+
get bufferedAmount(): number;
|
|
416
|
+
/**
|
|
417
|
+
* Wait until the send buffer drains to `threshold` bytes (default `0`).
|
|
418
|
+
*
|
|
419
|
+
* Resolves immediately when there is no backpressure (or on adapters that do
|
|
420
|
+
* not expose {@link Peer.bufferedAmount}). Otherwise it polls every
|
|
421
|
+
* `pollInterval` milliseconds until the buffer drains, also resolving early if
|
|
422
|
+
* the connection is no longer open so a send loop never hangs on a dropped
|
|
423
|
+
* client.
|
|
424
|
+
*
|
|
425
|
+
* ```ts
|
|
426
|
+
* for (const chunk of stream) {
|
|
427
|
+
* peer.send(chunk);
|
|
428
|
+
* if (peer.bufferedAmount > 1024 * 1024) {
|
|
429
|
+
* await peer.waitForDrain({ threshold: 256 * 1024 });
|
|
430
|
+
* }
|
|
431
|
+
* }
|
|
432
|
+
* ```
|
|
433
|
+
*/
|
|
434
|
+
waitForDrain(opts?: WaitForDrainOptions): Promise<void>;
|
|
43
435
|
abstract close(code?: number, reason?: string): void;
|
|
44
436
|
/** Abruptly close the connection */
|
|
45
437
|
terminate(): void;
|
|
@@ -51,8 +443,23 @@ declare abstract class Peer<Internal extends AdapterInternal = AdapterInternal>
|
|
|
51
443
|
abstract send(data: unknown, options?: {
|
|
52
444
|
compress?: boolean;
|
|
53
445
|
}): number | void | undefined;
|
|
54
|
-
/**
|
|
55
|
-
|
|
446
|
+
/**
|
|
447
|
+
* Send a message to subscribers of a topic.
|
|
448
|
+
*
|
|
449
|
+
* When a sync backplane is configured, the message is also relayed to the
|
|
450
|
+
* other crossws instances so their subscribers receive it too.
|
|
451
|
+
*/
|
|
452
|
+
publish(topic: string, data: unknown, options?: {
|
|
453
|
+
compress?: boolean;
|
|
454
|
+
}): void;
|
|
455
|
+
/**
|
|
456
|
+
* Adapter-specific, relay-free local fan-out to subscribers of a topic
|
|
457
|
+
* (excludes this peer). Implemented by each adapter; used both by
|
|
458
|
+
* {@link Peer.publish} and by the internal cross-instance delivery path.
|
|
459
|
+
*
|
|
460
|
+
* @internal Adapter extension point, not part of the stable public API.
|
|
461
|
+
*/
|
|
462
|
+
abstract _publish(topic: string, data: unknown, options?: {
|
|
56
463
|
compress?: boolean;
|
|
57
464
|
}): void;
|
|
58
465
|
toString(): string;
|
|
@@ -146,6 +553,14 @@ interface Hooks {
|
|
|
146
553
|
code?: number;
|
|
147
554
|
reason?: string;
|
|
148
555
|
}) => MaybePromise<void>;
|
|
556
|
+
/**
|
|
557
|
+
* The send buffer has drained after backpressure, so it is safe to resume
|
|
558
|
+
* sending. Pair with {@link Peer.bufferedAmount} to throttle senders.
|
|
559
|
+
*
|
|
560
|
+
* **Note:** Only emitted by adapters that expose a drain signal. Refer to the
|
|
561
|
+
* [compatibility table](https://crossws.h3.dev/guide/peer#compatibility).
|
|
562
|
+
*/
|
|
563
|
+
drain: (peer: Peer) => MaybePromise<void>;
|
|
149
564
|
/** An error occurs */
|
|
150
565
|
error: (peer: Peer, error: WSError) => MaybePromise<void>;
|
|
151
566
|
}
|
|
@@ -155,12 +570,51 @@ interface AdapterInstance {
|
|
|
155
570
|
compress?: boolean;
|
|
156
571
|
namespace?: string;
|
|
157
572
|
}) => void;
|
|
573
|
+
/**
|
|
574
|
+
* Gracefully shut the adapter down: close every connected peer (with the
|
|
575
|
+
* optional `code` / `reason`) and tear down the {@link AdapterInstance.sync}
|
|
576
|
+
* backplane. Any underlying server you created (e.g. an `http.Server` or a
|
|
577
|
+
* `WebSocketServer` passed via options) stays yours to close.
|
|
578
|
+
*/
|
|
579
|
+
readonly close: (code?: number, reason?: string) => Promise<void>;
|
|
580
|
+
/**
|
|
581
|
+
* Sync backplane driver, present when an adapter is created with `sync`.
|
|
582
|
+
*
|
|
583
|
+
* Closed automatically by {@link AdapterInstance.close}; it leaves any
|
|
584
|
+
* user-owned client (Redis/Postgres) connected.
|
|
585
|
+
*/
|
|
586
|
+
readonly sync?: SyncDriver;
|
|
587
|
+
}
|
|
588
|
+
/** Context passed to {@link AdapterOptions.onError} describing what failed. */
|
|
589
|
+
interface SyncErrorContext {
|
|
590
|
+
/**
|
|
591
|
+
* Which backplane operation failed:
|
|
592
|
+
* - `subscribe` — the initial subscription to the backplane.
|
|
593
|
+
* - `publish` — relaying a local publish out to the other instances.
|
|
594
|
+
* - `delivery` — fanning an inbound remote message out to local subscribers.
|
|
595
|
+
*/
|
|
596
|
+
stage: "subscribe" | "publish" | "delivery";
|
|
158
597
|
}
|
|
159
598
|
interface AdapterOptions {
|
|
160
599
|
resolve?: ResolveHooks;
|
|
161
600
|
getNamespace?: (request: Request) => string;
|
|
162
601
|
hooks?: Partial<Hooks>;
|
|
602
|
+
/**
|
|
603
|
+
* Optional sync backplane to relay pub/sub between multiple crossws
|
|
604
|
+
* instances (e.g. across regions/processes). Opt-in: when absent, pub/sub
|
|
605
|
+
* stays local to the instance, exactly as before.
|
|
606
|
+
*/
|
|
607
|
+
sync?: SyncAdapter;
|
|
608
|
+
/**
|
|
609
|
+
* Called when a {@link AdapterOptions.sync} backplane operation fails.
|
|
610
|
+
*
|
|
611
|
+
* Relay is fire-and-forget by design — a flaky backplane never throws into
|
|
612
|
+
* your `publish` call or crashes the process — so this callback is the only
|
|
613
|
+
* way to observe a degraded backplane (for logging, metrics or alerting).
|
|
614
|
+
* Defaults to `console.error`. Has no effect without `sync`.
|
|
615
|
+
*/
|
|
616
|
+
onError?: (error: unknown, context: SyncErrorContext) => void;
|
|
163
617
|
}
|
|
164
618
|
type Adapter<AdapterT extends AdapterInstance = AdapterInstance, Options extends AdapterOptions = AdapterOptions> = (options?: Options) => AdapterT;
|
|
165
619
|
declare function defineWebSocketAdapter<AdapterT extends AdapterInstance = AdapterInstance, Options extends AdapterOptions = AdapterOptions>(factory: Adapter<AdapterT, Options>): Adapter<AdapterT, Options>;
|
|
166
|
-
export {
|
|
620
|
+
export { Adapter, AdapterInstance, AdapterInternal, AdapterOptions, Hooks, Message, Peer, PeerContext, PostgresClientLike, RedisClientLike, ResolveHooks, SyncAdapter, SyncDriver, SyncDriverName, SyncDriverOptions, SyncErrorContext, SyncMessage, WSError, WaitForDrainOptions, broadcastChannel, cluster, decodeEnvelope, defineHooks, defineWebSocketAdapter, encodeEnvelope, pgsql, redis, setupPrimaryCluster, syncDrivers };
|
package/dist/_chunks/adapter.mjs
CHANGED
|
@@ -59,21 +59,98 @@ var AdapterHookable = class {
|
|
|
59
59
|
function defineHooks(hooks) {
|
|
60
60
|
return hooks;
|
|
61
61
|
}
|
|
62
|
-
|
|
62
|
+
const kNodeInspect = /*#__PURE__*/ Symbol.for("nodejs.util.inspect.custom");
|
|
63
|
+
function toBufferLike(val) {
|
|
64
|
+
if (val === void 0 || val === null) return "";
|
|
65
|
+
const type = typeof val;
|
|
66
|
+
if (type === "string") return val;
|
|
67
|
+
if (type === "number" || type === "boolean" || type === "bigint") return val.toString();
|
|
68
|
+
if (type === "function" || type === "symbol") return "{}";
|
|
69
|
+
if (val instanceof Uint8Array || val instanceof ArrayBuffer) return val;
|
|
70
|
+
if (isPlainObject(val)) return JSON.stringify(val);
|
|
71
|
+
return val;
|
|
72
|
+
}
|
|
73
|
+
function serializeMessage(val) {
|
|
74
|
+
const data = toBufferLike(val);
|
|
75
|
+
if (typeof data === "string") return data;
|
|
76
|
+
return data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
77
|
+
}
|
|
78
|
+
function toString(val) {
|
|
79
|
+
if (typeof val === "string") return val;
|
|
80
|
+
const data = toBufferLike(val);
|
|
81
|
+
if (typeof data === "string") return data;
|
|
82
|
+
let binary = "";
|
|
83
|
+
for (const byte of new Uint8Array(data)) binary += String.fromCharCode(byte);
|
|
84
|
+
return `data:application/octet-stream;base64,${btoa(binary)}`;
|
|
85
|
+
}
|
|
86
|
+
function isPlainObject(value) {
|
|
87
|
+
if (value === null || typeof value !== "object") return false;
|
|
88
|
+
const prototype = Object.getPrototypeOf(value);
|
|
89
|
+
if (prototype !== null && prototype !== Object.prototype && Object.getPrototypeOf(prototype) !== null) return false;
|
|
90
|
+
if (Symbol.iterator in value) return false;
|
|
91
|
+
if (Symbol.toStringTag in value) return Object.prototype.toString.call(value) === "[object Module]";
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
function adapterUtils(globalPeers, options, caps) {
|
|
95
|
+
const localPublish = (topic, message, pubOptions) => {
|
|
96
|
+
for (const peers of pubOptions?.namespace ? [globalPeers.get(pubOptions.namespace) || []] : globalPeers.values()) {
|
|
97
|
+
let firstPeerWithTopic;
|
|
98
|
+
for (const peer of peers) if (peer.topics.has(topic)) {
|
|
99
|
+
firstPeerWithTopic = peer;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
if (firstPeerWithTopic) {
|
|
103
|
+
firstPeerWithTopic.send(message, pubOptions);
|
|
104
|
+
firstPeerWithTopic._publish(topic, message, pubOptions);
|
|
105
|
+
if (caps?.nativePubSub && !pubOptions?.namespace) break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
let sync;
|
|
110
|
+
if (options?.sync) {
|
|
111
|
+
const report = (stage, error) => {
|
|
112
|
+
if (options.onError) options.onError(error, { stage });
|
|
113
|
+
else console.error(`[crossws] sync ${stage} failed:`, error);
|
|
114
|
+
};
|
|
115
|
+
const driver = options.sync({ id: crypto.randomUUID() });
|
|
116
|
+
const deliver = (msg) => {
|
|
117
|
+
try {
|
|
118
|
+
localPublish(msg.topic, msg.data, { namespace: msg.namespace || void 0 });
|
|
119
|
+
} catch (error) {
|
|
120
|
+
report("delivery", error);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
Promise.resolve(driver.subscribe(deliver)).catch((error) => report("subscribe", error));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
report("subscribe", error);
|
|
127
|
+
}
|
|
128
|
+
sync = {
|
|
129
|
+
subscribe: (deliver) => driver.subscribe(deliver),
|
|
130
|
+
publish: (msg) => {
|
|
131
|
+
try {
|
|
132
|
+
return Promise.resolve(driver.publish(msg)).catch((e) => report("publish", e));
|
|
133
|
+
} catch (error) {
|
|
134
|
+
report("publish", error);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
close: driver.close ? () => driver.close() : void 0
|
|
138
|
+
};
|
|
139
|
+
}
|
|
63
140
|
return {
|
|
64
141
|
peers: globalPeers,
|
|
142
|
+
sync,
|
|
65
143
|
publish(topic, message, options) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
144
|
+
localPublish(topic, message, options);
|
|
145
|
+
sync?.publish({
|
|
146
|
+
namespace: options?.namespace || "",
|
|
147
|
+
topic,
|
|
148
|
+
data: serializeMessage(message)
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
async close(code, reason) {
|
|
152
|
+
for (const peers of globalPeers.values()) for (const peer of peers) peer.close(code, reason);
|
|
153
|
+
await sync?.close?.();
|
|
77
154
|
}
|
|
78
155
|
};
|
|
79
156
|
}
|
|
@@ -89,4 +166,4 @@ function getPeers(globalPeers, namespace) {
|
|
|
89
166
|
function defineWebSocketAdapter(factory) {
|
|
90
167
|
return factory;
|
|
91
168
|
}
|
|
92
|
-
export {
|
|
169
|
+
export { AdapterHookable, adapterUtils, defineHooks, defineWebSocketAdapter, getPeers, kNodeInspect, serializeMessage, toBufferLike, toString };
|