@igoforth/ws-rpc 1.0.0 → 1.0.1

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/README.md CHANGED
@@ -187,13 +187,10 @@ import { Actor } from "@cloudflare/actors";
187
187
  import { withRpc } from "@igoforth/ws-rpc/adapters/cloudflare-do";
188
188
  import { ServerSchema, ClientSchema } from "./schemas";
189
189
 
190
- // The mixin adds RPC capabilities to your Actor
191
- // Your class must implement the methods defined in localSchema
192
- export class GameRoom extends withRpc(Actor, {
193
- localSchema: ServerSchema,
194
- remoteSchema: ClientSchema,
195
- }) {
196
- private gameState = { players: [] as string[] };
190
+ // First, create an Actor with the RPC method implementations
191
+ // Methods from localSchema MUST be defined here for type checking
192
+ class GameRoomActor extends Actor<Env> {
193
+ protected gameState = { players: [] as string[] };
197
194
 
198
195
  // Implement methods from ServerSchema
199
196
  async getUser({ id }: { id: string }) {
@@ -203,10 +200,15 @@ export class GameRoom extends withRpc(Actor, {
203
200
  async createOrder({ product, quantity }: { product: string; quantity: number }) {
204
201
  return { orderId: crypto.randomUUID() };
205
202
  }
203
+ }
206
204
 
205
+ // Then apply the RPC mixin to get driver, emit, etc.
206
+ export class GameRoom extends withRpc(GameRoomActor, {
207
+ localSchema: ServerSchema,
208
+ remoteSchema: ClientSchema,
209
+ }) {
207
210
  // Use this.driver to call methods on connected clients
208
211
  async notifyAllPlayers() {
209
- // Call ping on all connected clients
210
212
  const results = await this.driver.ping({});
211
213
  console.log("Ping results:", results);
212
214
  }
@@ -396,25 +398,24 @@ class MyDO extends Actor<Env> {
396
398
 
397
399
  ## Performance
398
400
 
399
- Real WebSocket RPC round-trip benchmarks (localhost, Node.js):
401
+ Real WebSocket RPC round-trip benchmarks (GitHub Actions runner, Node.js 22):
400
402
 
401
403
  **Wire sizes:**
402
404
  | Payload | JSON | MessagePack | CBOR |
403
405
  |---------|------|-------------|------|
404
406
  | Small | 93 B | 71 B | 112 B |
405
- | Medium | 3.5 KB | 2.1 KB | 1.4 KB |
406
- | Large | 24.5 KB | 19.6 KB | 14.1 KB |
407
+ | Medium | 3.4 KB | 2.1 KB | 1.3 KB |
408
+ | Large | 24.4 KB | 19.5 KB | 14.1 KB |
407
409
 
408
410
  **Throughput (ops/sec):**
409
- | Payload | JSON | MessagePack | CBOR |
410
- |---------|------|-------------|------|
411
- | Small | 1,371 | 2,208 | **2,423** |
412
- | Medium | 2,218 | 2,221 | **2,249** |
413
- | Large | 1,334 | 1,245 | **1,562** |
414
-
415
- CBOR provides the best balance of speed and wire size for most payloads. MessagePack excels with smaller payloads. JSON is the most portable but largest.
416
-
417
- Run benchmarks yourself: `pnpm bench`
411
+ | Payload | JSON | MessagePack | CBOR | Fastest |
412
+ |---------|------|-------------|------|---------|
413
+ | Small | 0 | 0 | 0 | JSON |
414
+ | Medium | 0 | 0 | 0 | JSON |
415
+ | Large | 0 | 0 | 0 | JSON |
416
+
417
+ > Benchmarks run automatically via GitHub Actions. Results may vary based on runner load.
418
+ > Run locally with `pnpm bench` for your environment.
418
419
 
419
420
  ## Multi-Peer Driver Results
420
421
 
@@ -49,15 +49,20 @@ type RpcActorConstructor<TBase extends Constructor<Actor<unknown>>, TLocalSchema
49
49
  * events: {},
50
50
  * } as const;
51
51
  *
52
- * class MyDO extends withRpc(Actor, {
53
- * localSchema: ServerSchema,
54
- * remoteSchema: ClientSchema,
55
- * }) {
56
- * // Required: implement methods from ServerSchema
52
+ * // Define methods on the base Actor class
53
+ * class MyActorBase extends Actor<Env> {
54
+ * protected dataList: string[] = [];
55
+ *
57
56
  * async getData() {
58
57
  * return { data: this.dataList };
59
58
  * }
59
+ * }
60
60
  *
61
+ * // Apply the RPC mixin
62
+ * class MyDO extends withRpc(MyActorBase, {
63
+ * localSchema: ServerSchema,
64
+ * remoteSchema: ClientSchema,
65
+ * }) {
61
66
  * // Call methods on connected clients
62
67
  * async notifyClients() {
63
68
  * const results = await this.driver.clientMethod({ info: "update" });
@@ -102,15 +102,20 @@ var DOMultiPeer = class extends MultiPeerBase {
102
102
  * events: {},
103
103
  * } as const;
104
104
  *
105
- * class MyDO extends withRpc(Actor, {
106
- * localSchema: ServerSchema,
107
- * remoteSchema: ClientSchema,
108
- * }) {
109
- * // Required: implement methods from ServerSchema
105
+ * // Define methods on the base Actor class
106
+ * class MyActorBase extends Actor<Env> {
107
+ * protected dataList: string[] = [];
108
+ *
110
109
  * async getData() {
111
110
  * return { data: this.dataList };
112
111
  * }
112
+ * }
113
113
  *
114
+ * // Apply the RPC mixin
115
+ * class MyDO extends withRpc(MyActorBase, {
116
+ * localSchema: ServerSchema,
117
+ * remoteSchema: ClientSchema,
118
+ * }) {
114
119
  * // Call methods on connected clients
115
120
  * async notifyClients() {
116
121
  * const results = await this.driver.clientMethod({ info: "update" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igoforth/ws-rpc",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Bidirectional RPC over WebSocket with Zod schema validation, TypeScript inference, and Cloudflare Durable Object support",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,6 @@
38
38
  "types": "./dist/index.d.ts",
39
39
  "files": [
40
40
  "dist",
41
- "src",
42
41
  "LICENSE",
43
42
  "README.md"
44
43
  ],
@@ -1,396 +0,0 @@
1
- /**
2
- * RPC Client Adapter
3
- *
4
- * WebSocket client with auto-reconnect for Node.js/Bun environments.
5
- * Wraps RpcPeer with connection management.
6
- */
7
-
8
- import type { Constructor } from "type-fest";
9
- import { RpcPeer } from "../peers/default.js";
10
- import type { WireInput } from "../protocol.js";
11
- import type {
12
- Driver,
13
- EventDef,
14
- InferEventData,
15
- Provider,
16
- RpcSchema,
17
- StringKeys,
18
- } from "../schema.js";
19
- import {
20
- type IRpcOptions,
21
- type IWebSocket,
22
- type WebSocketOptions,
23
- WebSocketReadyState,
24
- } from "../types.js";
25
- import {
26
- calculateReconnectDelay,
27
- defaultReconnectOptions,
28
- type IAdapterHooks,
29
- type IConnectionAdapter,
30
- type ReconnectOptions,
31
- } from "./types.js";
32
-
33
- /**
34
- * Options for creating an RpcClient
35
- */
36
- export interface RpcClientOptions<
37
- TLocalSchema extends RpcSchema,
38
- TRemoteSchema extends RpcSchema,
39
- > extends IAdapterHooks<TRemoteSchema>,
40
- IRpcOptions<TLocalSchema, TRemoteSchema> {
41
- /** WebSocket URL to connect to */
42
- url: string;
43
- /** Implementation of local methods */
44
- provider: Provider<TLocalSchema>;
45
- /** Auto-reconnect options (set to false to disable) */
46
- reconnect?: ReconnectOptions | false;
47
- /** Automatically connect when client is created (default: false) */
48
- autoConnect?: boolean;
49
- /** WebSocket subprotocols */
50
- protocols?: string | string[];
51
- /** HTTP headers for WebSocket upgrade request (Bun/Node.js only) */
52
- headers?: Record<string, string>;
53
- /** Custom WebSocket constructor (defaults to global WebSocket) */
54
- WebSocket?: new (
55
- url: string,
56
- options?: string | string[] | WebSocketOptions,
57
- ) => IWebSocket;
58
- }
59
-
60
- /**
61
- * Connection state
62
- */
63
- export type ConnectionState =
64
- | "disconnected"
65
- | "connecting"
66
- | "connected"
67
- | "reconnecting";
68
-
69
- /**
70
- * RPC Client with auto-reconnect
71
- *
72
- * Manages WebSocket connection lifecycle and provides RPC capabilities.
73
- */
74
- export class RpcClient<
75
- TLocalSchema extends RpcSchema,
76
- TRemoteSchema extends RpcSchema,
77
- > implements IConnectionAdapter<TLocalSchema, TRemoteSchema>
78
- {
79
- readonly localSchema: TLocalSchema;
80
- readonly remoteSchema: TRemoteSchema;
81
- readonly provider: Provider<TLocalSchema>;
82
- readonly hooks: IAdapterHooks<TRemoteSchema> = {};
83
-
84
- private readonly url: string;
85
- private readonly reconnectOptions: Required<ReconnectOptions> | false;
86
- private readonly defaultTimeout: number;
87
- private readonly protocols: string | string[] | undefined;
88
- private readonly headers: Record<string, string> | undefined;
89
- private readonly WebSocketImpl: new (
90
- url: string,
91
- options?: string | string[] | WebSocketOptions,
92
- ) => IWebSocket;
93
-
94
- // Connection state
95
- private ws: IWebSocket | null = null;
96
- private peer: RpcPeer<TLocalSchema, TRemoteSchema> | null = null;
97
- private _state: ConnectionState = "disconnected";
98
- private reconnectAttempt = 0;
99
- private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
100
- private intentionalClose = false;
101
-
102
- constructor(options: RpcClientOptions<TLocalSchema, TRemoteSchema>) {
103
- this.url = options.url;
104
- this.localSchema = options.localSchema;
105
- this.remoteSchema = options.remoteSchema;
106
- this.provider = options.provider;
107
- this.reconnectOptions =
108
- options.reconnect === false
109
- ? false
110
- : { ...defaultReconnectOptions, ...options.reconnect };
111
- this.defaultTimeout = options.timeout ?? 30000;
112
- this.protocols = options.protocols;
113
- this.headers = options.headers;
114
- this.WebSocketImpl =
115
- options.WebSocket ?? (globalThis.WebSocket as Constructor<IWebSocket>);
116
-
117
- if (options.onConnect) this.hooks.onConnect = options.onConnect;
118
- if (options.onDisconnect) this.hooks.onDisconnect = options.onDisconnect;
119
- if (options.onReconnect) this.hooks.onReconnect = options.onReconnect;
120
- if (options.onReconnectFailed)
121
- this.hooks.onReconnectFailed = options.onReconnectFailed;
122
- if (options.onEvent) this.hooks.onEvent = options.onEvent;
123
-
124
- if (options.autoConnect) {
125
- void this.connect();
126
- }
127
- }
128
-
129
- /**
130
- * Current connection state
131
- */
132
- get state(): ConnectionState {
133
- return this._state;
134
- }
135
-
136
- /**
137
- * Whether the client is currently connected
138
- */
139
- get isConnected(): boolean {
140
- return this._state === "connected" && this.peer?.isOpen === true;
141
- }
142
-
143
- /**
144
- * Get the driver for calling remote methods
145
- *
146
- * @returns Driver proxy for calling remote methods
147
- * @throws Error if not connected
148
- */
149
- get driver(): Driver<TRemoteSchema> {
150
- if (!this.peer) {
151
- throw new Error("Not connected - call connect() first");
152
- }
153
- return this.peer.driver;
154
- }
155
-
156
- /**
157
- * Emit an event to the server (fire-and-forget)
158
- *
159
- * @param event - Event name from local schema
160
- * @param data - Event data matching the schema
161
- */
162
- emit<K extends StringKeys<TLocalSchema["events"]>>(
163
- event: K,
164
- data: TLocalSchema["events"] extends Record<string, EventDef>
165
- ? InferEventData<TLocalSchema["events"][K]>
166
- : never,
167
- ): void {
168
- if (!this.peer) {
169
- console.warn(`Cannot emit event '${String(event)}': not connected`);
170
- return;
171
- }
172
- this.peer.emit(event, data);
173
- }
174
-
175
- /**
176
- * Connect to the WebSocket server
177
- *
178
- * @returns Promise that resolves when connected
179
- * @throws Error if connection fails
180
- */
181
- async connect(): Promise<void> {
182
- if (this._state === "connected" || this._state === "connecting") {
183
- return;
184
- }
185
-
186
- this.intentionalClose = false;
187
- this._state = "connecting";
188
-
189
- return new Promise<void>((resolve, reject) => {
190
- try {
191
- // Use options object when headers are present (Bun/Node.js)
192
- // Fall back to protocols-only for browser compatibility
193
- let wsOptions: WebSocketOptions | string | string[] | undefined;
194
- if (this.headers) {
195
- wsOptions = { headers: this.headers };
196
- if (this.protocols) {
197
- wsOptions.protocols = this.protocols;
198
- }
199
- } else {
200
- wsOptions = this.protocols;
201
- }
202
- this.ws = new this.WebSocketImpl(this.url, wsOptions);
203
- } catch (error) {
204
- this._state = "disconnected";
205
- reject(error);
206
- return;
207
- }
208
-
209
- const onOpen = () => {
210
- cleanup();
211
- this.handleOpen();
212
- resolve();
213
- };
214
-
215
- const onError = (event: unknown) => {
216
- cleanup();
217
- this._state = "disconnected";
218
- reject(new Error(`WebSocket connection failed: ${event}`));
219
- };
220
-
221
- const onClose = (event: unknown) => {
222
- cleanup();
223
- this._state = "disconnected";
224
- const code =
225
- typeof event === "object" && event != null && "code" in event
226
- ? event.code
227
- : "Unknown code";
228
- const reason =
229
- typeof event === "object" && event != null && "reason" in event
230
- ? event.reason
231
- : "Unknown reason";
232
- reject(new Error(`WebSocket closed: ${code} ${reason}`));
233
- };
234
-
235
- const cleanup = () => {
236
- this.ws?.removeEventListener?.("open", onOpen);
237
- this.ws?.removeEventListener?.("error", onError);
238
- this.ws?.removeEventListener?.("close", onClose);
239
- };
240
-
241
- this.ws.addEventListener?.("open", onOpen);
242
- this.ws.addEventListener?.("error", onError);
243
- this.ws.addEventListener?.("close", onClose);
244
- });
245
- }
246
-
247
- /**
248
- * Disconnect from the server
249
- *
250
- * @param code - WebSocket close code (default: 1000)
251
- * @param reason - Close reason message (default: "Client disconnect")
252
- */
253
- disconnect(code = 1000, reason = "Client disconnect"): void {
254
- this.intentionalClose = true;
255
- this.cancelReconnect();
256
-
257
- if (this.peer) {
258
- this.peer.close();
259
- this.peer = null;
260
- }
261
-
262
- if (this.ws && this.ws.readyState !== WebSocketReadyState.CLOSED) {
263
- this.ws.close(code, reason);
264
- }
265
- this.ws = null;
266
-
267
- this._state = "disconnected";
268
- }
269
-
270
- /**
271
- * Handle WebSocket open event
272
- */
273
- private handleOpen(): void {
274
- if (!this.ws) return;
275
-
276
- this._state = "connected";
277
- this.reconnectAttempt = 0;
278
-
279
- // Create RPC peer
280
- this.peer = new RpcPeer({
281
- ws: this.ws,
282
- localSchema: this.localSchema,
283
- remoteSchema: this.remoteSchema,
284
- provider: this.provider,
285
- onEvent: this.hooks.onEvent,
286
- timeout: this.defaultTimeout,
287
- });
288
-
289
- // Set up WebSocket event handlers
290
- this.ws.onmessage = (event) => {
291
- if (typeof event === "object" && event != null && "data" in event)
292
- this.peer?.handleMessage(event.data as WireInput);
293
- else
294
- throw new Error(
295
- `Received invalid event type in RpcClient.ws.onmessage ${JSON.stringify(event)}`,
296
- );
297
- };
298
-
299
- this.ws.onclose = (event) => {
300
- if (
301
- typeof event === "object" &&
302
- event != null &&
303
- "code" in event &&
304
- "reason" in event &&
305
- typeof event.code === "number" &&
306
- typeof event.reason === "string"
307
- )
308
- this.handleClose(event.code, event.reason);
309
- else
310
- throw new Error(
311
- `Received invalid event type in RpcClient.ws.onclose ${JSON.stringify(event)}`,
312
- );
313
- };
314
-
315
- this.ws.onerror = (event) => {
316
- console.error("WebSocket error:", event);
317
- };
318
-
319
- this.hooks.onConnect?.();
320
- }
321
-
322
- /**
323
- * Handle WebSocket close event
324
- */
325
- private handleClose(code: number, reason: string): void {
326
- this.peer?.close();
327
- this.peer = null;
328
- this.ws = null;
329
-
330
- this.hooks.onDisconnect?.(code, reason);
331
-
332
- if (this.intentionalClose) {
333
- this._state = "disconnected";
334
- return;
335
- }
336
-
337
- // Attempt reconnection
338
- if (this.reconnectOptions !== false) {
339
- this.scheduleReconnect();
340
- } else {
341
- this._state = "disconnected";
342
- }
343
- }
344
-
345
- /**
346
- * Schedule a reconnection attempt
347
- */
348
- private scheduleReconnect(): void {
349
- if (this.reconnectOptions === false) return;
350
-
351
- const { maxAttempts } = this.reconnectOptions;
352
- if (maxAttempts > 0 && this.reconnectAttempt >= maxAttempts) {
353
- this._state = "disconnected";
354
- this.hooks.onReconnectFailed?.();
355
- return;
356
- }
357
-
358
- this._state = "reconnecting";
359
- const delay = calculateReconnectDelay(
360
- this.reconnectAttempt,
361
- this.reconnectOptions,
362
- );
363
- this.reconnectAttempt++;
364
-
365
- this.hooks.onReconnect?.(this.reconnectAttempt, delay);
366
-
367
- this.reconnectTimeout = setTimeout(() => {
368
- this.reconnectTimeout = null;
369
- void this.attemptReconnect();
370
- }, delay);
371
- }
372
-
373
- /**
374
- * Attempt to reconnect
375
- */
376
- private async attemptReconnect(): Promise<void> {
377
- try {
378
- await this.connect();
379
- } catch {
380
- // connect() failed during reconnection - schedule another attempt
381
- if (!this.intentionalClose && this.reconnectOptions !== false) {
382
- this.scheduleReconnect();
383
- }
384
- }
385
- }
386
-
387
- /**
388
- * Cancel any pending reconnection
389
- */
390
- private cancelReconnect(): void {
391
- if (this.reconnectTimeout) {
392
- clearTimeout(this.reconnectTimeout);
393
- this.reconnectTimeout = null;
394
- }
395
- }
396
- }