@igoforth/ws-rpc 1.0.0

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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +446 -0
  3. package/dist/adapters/client.d.ts +117 -0
  4. package/dist/adapters/client.js +241 -0
  5. package/dist/adapters/cloudflare-do.d.ts +72 -0
  6. package/dist/adapters/cloudflare-do.js +192 -0
  7. package/dist/adapters/index.d.ts +13 -0
  8. package/dist/adapters/index.js +16 -0
  9. package/dist/adapters/server.d.ts +10 -0
  10. package/dist/adapters/server.js +122 -0
  11. package/dist/adapters/types.d.ts +125 -0
  12. package/dist/adapters/types.js +3 -0
  13. package/dist/codecs/cbor.d.ts +16 -0
  14. package/dist/codecs/cbor.js +36 -0
  15. package/dist/codecs/factory.d.ts +3 -0
  16. package/dist/codecs/factory.js +3 -0
  17. package/dist/codecs/index.d.ts +5 -0
  18. package/dist/codecs/index.js +5 -0
  19. package/dist/codecs/json.d.ts +4 -0
  20. package/dist/codecs/json.js +4 -0
  21. package/dist/codecs/msgpack.d.ts +16 -0
  22. package/dist/codecs/msgpack.js +34 -0
  23. package/dist/codecs-BmYG2d_U.js +0 -0
  24. package/dist/default-BkrMd28n.js +253 -0
  25. package/dist/default-xDNNMrg0.d.ts +129 -0
  26. package/dist/durable-MZjkvyS6.js +165 -0
  27. package/dist/errors-5BfreE63.js +96 -0
  28. package/dist/errors.d.ts +69 -0
  29. package/dist/errors.js +7 -0
  30. package/dist/factory-3ziwTuZe.js +132 -0
  31. package/dist/factory-C1v0AEHY.d.ts +101 -0
  32. package/dist/index-Be7jjS77.d.ts +1 -0
  33. package/dist/index.d.ts +14 -0
  34. package/dist/index.js +14 -0
  35. package/dist/interface-C4S-WCqW.d.ts +120 -0
  36. package/dist/json-54Z2bIIs.d.ts +22 -0
  37. package/dist/json-Bshec-bZ.js +41 -0
  38. package/dist/memory-Bqb3KEVr.js +48 -0
  39. package/dist/memory-D1nGjzzH.d.ts +41 -0
  40. package/dist/multi-peer-BAi9yVzp.js +242 -0
  41. package/dist/peers/default.d.ts +8 -0
  42. package/dist/peers/default.js +8 -0
  43. package/dist/peers/durable.d.ts +136 -0
  44. package/dist/peers/durable.js +9 -0
  45. package/dist/peers/index.d.ts +10 -0
  46. package/dist/peers/index.js +9 -0
  47. package/dist/protocol-DA84zrc2.d.ts +211 -0
  48. package/dist/protocol-_mpoOPp6.js +192 -0
  49. package/dist/protocol.d.ts +6 -0
  50. package/dist/protocol.js +6 -0
  51. package/dist/reconnect-CGAA_1Gf.js +26 -0
  52. package/dist/reconnect-DbcN0R_1.d.ts +35 -0
  53. package/dist/schema-CN5HHHku.d.ts +108 -0
  54. package/dist/schema.d.ts +2 -0
  55. package/dist/schema.js +43 -0
  56. package/dist/server-zTjpJpoX.d.ts +209 -0
  57. package/dist/sql-CCjc6Bid.js +142 -0
  58. package/dist/sql-DPmHOeZy.d.ts +131 -0
  59. package/dist/storage/index.d.ts +8 -0
  60. package/dist/storage/index.js +7 -0
  61. package/dist/storage/interface.d.ts +3 -0
  62. package/dist/storage/interface.js +0 -0
  63. package/dist/storage/memory.d.ts +7 -0
  64. package/dist/storage/memory.js +6 -0
  65. package/dist/storage/sql.d.ts +7 -0
  66. package/dist/storage/sql.js +6 -0
  67. package/dist/types-Be-qmQu0.d.ts +111 -0
  68. package/dist/types-D_psiH09.js +13 -0
  69. package/dist/types.d.ts +7 -0
  70. package/dist/types.js +3 -0
  71. package/dist/utils/index.d.ts +2 -0
  72. package/dist/utils/index.js +3 -0
  73. package/dist/utils/reconnect.d.ts +2 -0
  74. package/dist/utils/reconnect.js +3 -0
  75. package/package.json +156 -0
  76. package/src/adapters/client.ts +396 -0
  77. package/src/adapters/cloudflare-do.ts +346 -0
  78. package/src/adapters/index.ts +16 -0
  79. package/src/adapters/multi-peer.ts +404 -0
  80. package/src/adapters/server.ts +192 -0
  81. package/src/adapters/types.ts +202 -0
  82. package/src/codecs/cbor.ts +42 -0
  83. package/src/codecs/factory.ts +210 -0
  84. package/src/codecs/index.ts +30 -0
  85. package/src/codecs/json.ts +42 -0
  86. package/src/codecs/msgpack.ts +36 -0
  87. package/src/errors.ts +105 -0
  88. package/src/index.ts +102 -0
  89. package/src/peers/default.ts +433 -0
  90. package/src/peers/durable.ts +280 -0
  91. package/src/peers/index.ts +13 -0
  92. package/src/protocol.ts +306 -0
  93. package/src/schema.ts +167 -0
  94. package/src/storage/index.ts +20 -0
  95. package/src/storage/interface.ts +146 -0
  96. package/src/storage/memory.ts +84 -0
  97. package/src/storage/sql.ts +266 -0
  98. package/src/types.ts +158 -0
  99. package/src/utils/index.ts +9 -0
  100. package/src/utils/reconnect.ts +51 -0
package/src/index.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @igoforth/ws-rpc
3
+ *
4
+ * Bidirectional RPC over WebSocket with Zod schema validation, TypeScript inference,
5
+ * and Cloudflare Durable Object support.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * // Client
10
+ * import { RpcClient } from "@igoforth/ws-rpc/adapters/client";
11
+ *
12
+ * const client = new RpcClient({
13
+ * url: "wss://example.com/ws",
14
+ * localSchema: ClientSchema,
15
+ * remoteSchema: ServerSchema,
16
+ * provider: { clientMethod: async (input) => { ... } },
17
+ * });
18
+ *
19
+ * // Server (Cloudflare Durable Object)
20
+ * import { withRpc } from "@igoforth/ws-rpc/adapters/cloudflare-do";
21
+ *
22
+ * class MyDO extends withRpc(Actor, {
23
+ * localSchema: ServerSchema,
24
+ * remoteSchema: ClientSchema,
25
+ * }) { ... }
26
+ * ```
27
+ */
28
+
29
+ // Errors
30
+ export {
31
+ RpcConnectionClosed,
32
+ RpcError,
33
+ RpcMethodNotFoundError,
34
+ RpcRemoteError,
35
+ RpcTimeoutError,
36
+ RpcValidationError,
37
+ } from "./errors.js";
38
+ // Protocol
39
+ export {
40
+ createProtocol,
41
+ JsonProtocol,
42
+ type RpcError as RpcErrorMessage,
43
+ RpcErrorCodes,
44
+ RpcErrorSchema,
45
+ type RpcEvent,
46
+ RpcEventSchema,
47
+ type RpcMessage,
48
+ RpcMessageCodec,
49
+ RpcMessageSchema,
50
+ type RpcProtocol,
51
+ type RpcRequest,
52
+ RpcRequestSchema,
53
+ type RpcResponse,
54
+ RpcResponseSchema,
55
+ type RpcWireCodec,
56
+ } from "./protocol.js";
57
+ // Schema utilities
58
+ export {
59
+ type Driver,
60
+ type EventDef,
61
+ type EventHandler,
62
+ event,
63
+ type InferEventData,
64
+ type InferEvents,
65
+ type InferInput,
66
+ type InferMethods,
67
+ type InferOutput,
68
+ type MethodDef,
69
+ method,
70
+ type Provider,
71
+ type RpcSchema,
72
+ } from "./schema.js";
73
+ // Storage
74
+ export {
75
+ type AsyncPendingCallStorage,
76
+ type MaybePromise,
77
+ MemoryPendingCallStorage,
78
+ type PendingCall,
79
+ type PendingCallStorage,
80
+ SqlPendingCallStorage,
81
+ type StorageMode,
82
+ type SyncPendingCallStorage,
83
+ } from "./storage/index.js";
84
+ // Core types and WebSocket interfaces
85
+ export {
86
+ type IEventController,
87
+ type IMethodController,
88
+ type IMinWebSocket,
89
+ type IRpcConnection,
90
+ type IRpcOptions,
91
+ type IWebSocket,
92
+ type IWebSocketServer,
93
+ type WebSocketOptions,
94
+ WebSocketReadyState,
95
+ type WebSocketServerOptions,
96
+ } from "./types.js";
97
+ // Utilities
98
+ export {
99
+ calculateReconnectDelay,
100
+ defaultReconnectOptions,
101
+ type ReconnectOptions,
102
+ } from "./utils/index.js";
@@ -0,0 +1,433 @@
1
+ /**
2
+ * RPC Peer
3
+ *
4
+ * Core bidirectional RPC implementation. Both client and server are "peers"
5
+ * that can call methods on each other.
6
+ */
7
+
8
+ import { v7 as uuidv7 } from "uuid";
9
+ import {
10
+ RpcConnectionClosed,
11
+ RpcMethodNotFoundError,
12
+ RpcRemoteError,
13
+ RpcTimeoutError,
14
+ RpcValidationError,
15
+ } from "../errors.js";
16
+ import {
17
+ JsonProtocol,
18
+ type RpcError,
19
+ RpcErrorCodes,
20
+ type RpcEvent,
21
+ type RpcProtocol,
22
+ type RpcRequest,
23
+ type RpcResponse,
24
+ type WireInput,
25
+ } from "../protocol.js";
26
+ import type {
27
+ Driver,
28
+ EventDef,
29
+ EventHandler,
30
+ InferEventData,
31
+ Provider,
32
+ RpcSchema,
33
+ StringKeys,
34
+ } from "../schema.js";
35
+ import type { IMinWebSocket, IRpcOptions } from "../types.js";
36
+
37
+ /**
38
+ * Pending request tracking
39
+ */
40
+ interface PendingRequest {
41
+ resolve: (result: unknown) => void;
42
+ reject: (error: Error) => void;
43
+ timeout: ReturnType<typeof setTimeout>;
44
+ method: string;
45
+ }
46
+
47
+ /**
48
+ * Options for creating an RpcPeer
49
+ */
50
+ export interface RpcPeerOptions<
51
+ TLocalSchema extends RpcSchema,
52
+ TRemoteSchema extends RpcSchema,
53
+ > extends IRpcOptions<TLocalSchema, TRemoteSchema> {
54
+ /** Unique identifier for this peer (auto-generated if not provided) */
55
+ id?: string;
56
+ /** WebSocket instance */
57
+ ws: IMinWebSocket;
58
+ /** Implementation of local methods */
59
+ provider: Partial<Provider<TLocalSchema>>;
60
+ /**
61
+ * Protocol for encoding/decoding messages
62
+ *
63
+ * Defaults to JSON. Use createProtocol() with a binary codec for better performance.
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * import { createProtocol, RpcMessageSchema } from "@igoforth/ws-rpc/protocol";
68
+ * import { createMsgpackCodec } from "@igoforth/ws-rpc/codecs/msgpack";
69
+ *
70
+ * const peer = new RpcPeer({
71
+ * protocol: createProtocol(createMsgpackCodec(RpcMessageSchema)),
72
+ * // ...
73
+ * });
74
+ * ```
75
+ */
76
+ protocol?: RpcProtocol | undefined;
77
+ /** Handler for incoming events */
78
+ onEvent?: EventHandler<TRemoteSchema> | undefined;
79
+ /** Generate unique request IDs */
80
+ generateId?: (() => string) | undefined;
81
+ }
82
+
83
+ /**
84
+ * Bidirectional RPC peer
85
+ *
86
+ * Both sides of a WebSocket connection are "peers" - they each implement
87
+ * some methods (provider) and can call methods on the other side (driver).
88
+ */
89
+ export class RpcPeer<
90
+ TLocalSchema extends RpcSchema,
91
+ TRemoteSchema extends RpcSchema,
92
+ > {
93
+ /** Unique identifier for this peer */
94
+ readonly id: string;
95
+ /** WebSocket instance - protected for subclass access */
96
+ protected readonly ws: IMinWebSocket;
97
+ /** Protocol instance - protected for subclass access */
98
+ protected readonly protocol: RpcProtocol;
99
+ private readonly localSchema: TLocalSchema;
100
+ private readonly remoteSchema: TRemoteSchema;
101
+ private readonly provider: Partial<Provider<TLocalSchema>>;
102
+ private readonly onEventHandler?: RpcPeerOptions<
103
+ TLocalSchema,
104
+ TRemoteSchema
105
+ >["onEvent"];
106
+ private readonly defaultTimeout: number;
107
+ private readonly generateId: () => string;
108
+ private readonly pendingRequests = new Map<string, PendingRequest>();
109
+ private requestCounter = 0;
110
+ private closed = false;
111
+
112
+ /** Proxy for calling remote methods */
113
+ readonly driver: Driver<TRemoteSchema>;
114
+
115
+ constructor(options: RpcPeerOptions<TLocalSchema, TRemoteSchema>) {
116
+ this.id = options.id ?? uuidv7();
117
+ this.ws = options.ws;
118
+ this.protocol = options.protocol ?? JsonProtocol;
119
+ this.localSchema = options.localSchema;
120
+ this.remoteSchema = options.remoteSchema;
121
+ this.provider = options.provider;
122
+ this.onEventHandler = options.onEvent;
123
+ this.defaultTimeout = options.timeout ?? 30000;
124
+ this.generateId = options.generateId ?? (() => `${++this.requestCounter}`);
125
+
126
+ // Create driver proxy for calling remote methods
127
+ this.driver = this.createDriver();
128
+ }
129
+
130
+ /**
131
+ * Create a proxy that allows calling remote methods
132
+ */
133
+ private createDriver(): Driver<TRemoteSchema> {
134
+ const methods = this.remoteSchema.methods ?? {};
135
+ const driver: Record<string, (input: unknown) => Promise<unknown>> = {};
136
+
137
+ for (const methodName of Object.keys(methods)) {
138
+ driver[methodName] = (input: unknown) =>
139
+ this.callMethod(methodName, input);
140
+ }
141
+
142
+ return driver as Driver<TRemoteSchema>;
143
+ }
144
+
145
+ /**
146
+ * Call a remote method and wait for the response (used by driver proxy)
147
+ */
148
+ private async callMethod(
149
+ method: string,
150
+ input: unknown,
151
+ timeout?: number,
152
+ ): Promise<unknown> {
153
+ if (this.closed || this.ws.readyState !== 1) {
154
+ throw new RpcConnectionClosed();
155
+ }
156
+
157
+ const methodDef = this.remoteSchema.methods?.[method];
158
+ if (!methodDef) {
159
+ throw new RpcMethodNotFoundError(method);
160
+ }
161
+
162
+ // Validate input against schema
163
+ const parseResult = methodDef.input.safeParse(input);
164
+ if (!parseResult.success) {
165
+ throw new RpcValidationError(
166
+ `Invalid input for method '${method}'`,
167
+ parseResult.error,
168
+ );
169
+ }
170
+
171
+ const id = this.generateId();
172
+ const timeoutMs = timeout ?? this.defaultTimeout;
173
+
174
+ return new Promise((resolve, reject) => {
175
+ const timeoutHandle = setTimeout(() => {
176
+ this.pendingRequests.delete(id);
177
+ reject(new RpcTimeoutError(method, timeoutMs));
178
+ }, timeoutMs);
179
+
180
+ this.pendingRequests.set(id, {
181
+ resolve,
182
+ reject,
183
+ timeout: timeoutHandle,
184
+ method,
185
+ });
186
+
187
+ this.ws.send(this.protocol.createRequest(id, method, parseResult.data));
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Emit an event to the remote peer (fire-and-forget)
193
+ */
194
+ emit<K extends StringKeys<TLocalSchema["events"]>>(
195
+ event: K,
196
+ data: TLocalSchema["events"] extends Record<string, EventDef>
197
+ ? InferEventData<TLocalSchema["events"][K]>
198
+ : never,
199
+ ): void {
200
+ if (this.closed || this.ws.readyState !== 1) {
201
+ console.warn(`Cannot emit event '${String(event)}': connection closed`);
202
+ return;
203
+ }
204
+
205
+ const eventName = event;
206
+ const eventDef = this.localSchema.events?.[eventName];
207
+ if (!eventDef) {
208
+ console.warn(`Unknown event '${eventName}'`);
209
+ return;
210
+ }
211
+
212
+ // Validate data against schema
213
+ const parseResult = eventDef.data.safeParse(data);
214
+ if (!parseResult.success) {
215
+ console.warn(`Invalid data for event '${eventName}':`, parseResult.error);
216
+ return;
217
+ }
218
+
219
+ this.ws.send(this.protocol.createEvent(eventName, parseResult.data));
220
+ }
221
+
222
+ /**
223
+ * Handle an incoming WebSocket message
224
+ *
225
+ * Accepts string, ArrayBuffer, Uint8Array (including Node.js Buffer),
226
+ * or Uint8Array[] (for ws library's fragmented messages).
227
+ *
228
+ * @example
229
+ * ```ts
230
+ * // Works directly with ws library's message event
231
+ * ws.on("message", (data) => peer.handleMessage(data));
232
+ * ```
233
+ */
234
+ handleMessage(data: WireInput): void {
235
+ const message = this.protocol.safeDecodeMessage(data);
236
+ if (!message) {
237
+ console.error("Failed to parse RPC message");
238
+ return;
239
+ }
240
+
241
+ switch (message.type) {
242
+ case "rpc:request":
243
+ void this.handleRequest(message);
244
+ break;
245
+ case "rpc:response":
246
+ this.handleResponse(message);
247
+ break;
248
+ case "rpc:error":
249
+ this.handleError(message);
250
+ break;
251
+ case "rpc:event":
252
+ this.handleEvent(message);
253
+ break;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Handle an incoming RPC request
259
+ */
260
+ private async handleRequest(request: RpcRequest): Promise<void> {
261
+ const { id, method, params } = request;
262
+
263
+ const methodDef = this.localSchema.methods?.[method];
264
+ if (!methodDef) {
265
+ this.sendError(
266
+ id,
267
+ RpcErrorCodes.METHOD_NOT_FOUND,
268
+ `Method '${method}' not found`,
269
+ );
270
+ return;
271
+ }
272
+
273
+ // Validate input
274
+ const parseResult = methodDef.input.safeParse(params);
275
+ if (!parseResult.success) {
276
+ this.sendError(
277
+ id,
278
+ RpcErrorCodes.INVALID_PARAMS,
279
+ `Invalid params for '${method}'`,
280
+ parseResult.error,
281
+ );
282
+ return;
283
+ }
284
+
285
+ // Get handler from provider
286
+ const handler = this.provider[method as keyof typeof this.provider] as
287
+ | ((input: unknown) => Promise<unknown>)
288
+ | undefined;
289
+ if (!handler) {
290
+ this.sendError(
291
+ id,
292
+ RpcErrorCodes.METHOD_NOT_FOUND,
293
+ `Method '${method}' not implemented`,
294
+ );
295
+ return;
296
+ }
297
+
298
+ try {
299
+ // Call handler with correct `this` context
300
+ const result = await handler.call(this.provider, parseResult.data);
301
+
302
+ // Validate output
303
+ const outputResult = methodDef.output.safeParse(result);
304
+ if (!outputResult.success) {
305
+ this.sendError(
306
+ id,
307
+ RpcErrorCodes.INTERNAL_ERROR,
308
+ `Invalid output from '${method}'`,
309
+ outputResult.error,
310
+ );
311
+ return;
312
+ }
313
+
314
+ this.ws.send(this.protocol.createResponse(id, outputResult.data));
315
+ } catch (error) {
316
+ const message = error instanceof Error ? error.message : "Unknown error";
317
+ this.sendError(id, RpcErrorCodes.INTERNAL_ERROR, message);
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Handle an incoming RPC response
323
+ */
324
+ private handleResponse(response: RpcResponse): void {
325
+ const pending = this.pendingRequests.get(response.id);
326
+ if (!pending) {
327
+ console.warn(`Received response for unknown request: ${response.id}`);
328
+ return;
329
+ }
330
+
331
+ clearTimeout(pending.timeout);
332
+ this.pendingRequests.delete(response.id);
333
+ pending.resolve(response.result);
334
+ }
335
+
336
+ /**
337
+ * Handle an incoming RPC error
338
+ */
339
+ private handleError(error: RpcError): void {
340
+ const pending = this.pendingRequests.get(error.id);
341
+ if (!pending) {
342
+ console.warn(`Received error for unknown request: ${error.id}`);
343
+ return;
344
+ }
345
+
346
+ clearTimeout(pending.timeout);
347
+ this.pendingRequests.delete(error.id);
348
+ pending.reject(
349
+ new RpcRemoteError(pending.method, error.code, error.message, error.data),
350
+ );
351
+ }
352
+
353
+ /**
354
+ * Handle an incoming event
355
+ */
356
+ private handleEvent(event: RpcEvent): void {
357
+ if (!this.onEventHandler) {
358
+ return;
359
+ }
360
+
361
+ const eventDef = this.remoteSchema.events?.[event.event];
362
+ if (!eventDef) {
363
+ console.warn(`Unknown event: ${event.event}`);
364
+ return;
365
+ }
366
+
367
+ // Validate event data
368
+ const parseResult = eventDef.data.safeParse(event.data);
369
+ if (!parseResult.success) {
370
+ console.warn(
371
+ `Invalid data for event '${event.event}':`,
372
+ parseResult.error,
373
+ );
374
+ return;
375
+ }
376
+
377
+ // Cast is safe because we validated against the schema
378
+ (this.onEventHandler as (event: string, data: unknown) => void)(
379
+ event.event,
380
+ parseResult.data,
381
+ );
382
+ }
383
+
384
+ /**
385
+ * Send an error response
386
+ */
387
+ private sendError(
388
+ id: string,
389
+ code: number,
390
+ message: string,
391
+ data?: unknown,
392
+ ): void {
393
+ if (this.ws.readyState !== 1) return;
394
+ this.ws.send(this.protocol.createError(id, code, message, data));
395
+ }
396
+
397
+ /**
398
+ * Mark the peer as closed and reject all pending requests
399
+ */
400
+ close(): void {
401
+ this.closed = true;
402
+
403
+ // Reject all pending requests
404
+ for (const [, pending] of this.pendingRequests) {
405
+ clearTimeout(pending.timeout);
406
+ pending.reject(new RpcConnectionClosed());
407
+ }
408
+ this.pendingRequests.clear();
409
+ }
410
+
411
+ /**
412
+ * Check if the peer connection is open
413
+ */
414
+ get isOpen(): boolean {
415
+ return !this.closed && this.ws.readyState === 1;
416
+ }
417
+
418
+ /**
419
+ * Get the underlying WebSocket
420
+ *
421
+ * Use for advanced scenarios like DurableRpcPeer integration.
422
+ */
423
+ getWebSocket(): IMinWebSocket {
424
+ return this.ws;
425
+ }
426
+
427
+ /**
428
+ * Get the number of pending requests
429
+ */
430
+ get pendingCount(): number {
431
+ return this.pendingRequests.size;
432
+ }
433
+ }