@covenant-rpc/server 0.1.3

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.
@@ -0,0 +1,689 @@
1
+ import { z } from "zod";
2
+ import { test, expect } from "bun:test";
3
+ import { declareCovenant, channel } from "@covenant-rpc/core";
4
+ import { CovenantServer } from "../lib/server";
5
+ import { CovenantClient } from "@covenant-rpc/client";
6
+ import { directClientToServer } from "../lib/interfaces/direct";
7
+ import { InternalSidekick } from "../../sidekick/internal";
8
+
9
+ test("basic channel connection", async () => {
10
+ const sidekick = new InternalSidekick();
11
+
12
+ const covenant = declareCovenant({
13
+ procedures: {},
14
+ channels: {
15
+ chat: channel({
16
+ clientMessage: z.object({
17
+ text: z.string(),
18
+ }),
19
+ serverMessage: z.object({
20
+ text: z.string(),
21
+ timestamp: z.number(),
22
+ }),
23
+ connectionRequest: z.object({
24
+ username: z.string(),
25
+ }),
26
+ connectionContext: z.object({
27
+ userId: z.string(),
28
+ }),
29
+ params: ["roomId"],
30
+ }),
31
+ },
32
+ });
33
+
34
+ const server = new CovenantServer(covenant, {
35
+ contextGenerator: () => undefined,
36
+ derivation: () => {},
37
+ sidekickConnection: sidekick.getConnectionFromServer(),
38
+ });
39
+
40
+ sidekick.setServerCallback((channelName, params, data, context) =>
41
+ server.processChannelMessage(channelName, params, data, context)
42
+ );
43
+
44
+ server.defineChannel("chat", {
45
+ onConnect: ({ inputs, params }) => {
46
+ expect(inputs.username).toBe("Alice");
47
+ expect(params.roomId).toBe("room1");
48
+ return {
49
+ userId: `user-${inputs.username}`,
50
+ };
51
+ },
52
+ onMessage: ({ inputs, params, context }) => {
53
+ expect(inputs).toBeDefined();
54
+ expect(params.roomId).toBe("room1");
55
+ expect(context.userId).toBe("user-Alice");
56
+ },
57
+ });
58
+
59
+ server.assertAllDefined();
60
+
61
+ const client = new CovenantClient(covenant, {
62
+ sidekickConnection: sidekick.getConnectionFromClient(),
63
+ serverConnection: directClientToServer(server, {}),
64
+ });
65
+
66
+ // Connect to the channel
67
+ const result = await client.connect("chat", { roomId: "room1" }, {
68
+ username: "Alice",
69
+ });
70
+
71
+ expect(result.success).toBe(true);
72
+ expect(result.error).toBe(null);
73
+ if (result.success) {
74
+ expect(result.token).toBeDefined();
75
+ }
76
+ });
77
+
78
+ test("client sending messages through channel", async () => {
79
+ const sidekick = new InternalSidekick();
80
+ const receivedMessages: Array<{ text: string; userId: string }> = [];
81
+
82
+ const covenant = declareCovenant({
83
+ procedures: {},
84
+ channels: {
85
+ chat: channel({
86
+ clientMessage: z.object({
87
+ text: z.string(),
88
+ }),
89
+ serverMessage: z.object({
90
+ text: z.string(),
91
+ }),
92
+ connectionRequest: z.object({
93
+ username: z.string(),
94
+ }),
95
+ connectionContext: z.object({
96
+ userId: z.string(),
97
+ }),
98
+ params: ["roomId"],
99
+ }),
100
+ },
101
+ });
102
+
103
+ const server = new CovenantServer(covenant, {
104
+ contextGenerator: () => undefined,
105
+ derivation: () => {},
106
+ sidekickConnection: sidekick.getConnectionFromServer(),
107
+ });
108
+
109
+ sidekick.setServerCallback((channelName, params, data, context) =>
110
+ server.processChannelMessage(channelName, params, data, context)
111
+ );
112
+
113
+ server.defineChannel("chat", {
114
+ onConnect: ({ inputs }) => {
115
+ return {
116
+ userId: `user-${inputs.username}`,
117
+ };
118
+ },
119
+ onMessage: ({ inputs, context }) => {
120
+ receivedMessages.push({
121
+ text: inputs.text,
122
+ userId: context.userId,
123
+ });
124
+ },
125
+ });
126
+
127
+ server.assertAllDefined();
128
+
129
+ const client = new CovenantClient(covenant, {
130
+ sidekickConnection: sidekick.getConnectionFromClient(),
131
+ serverConnection: directClientToServer(server, {}),
132
+ });
133
+
134
+ const result = await client.connect("chat", { roomId: "room1" }, {
135
+ username: "Bob",
136
+ });
137
+
138
+ expect(result.success).toBe(true);
139
+ if (!result.success) return;
140
+
141
+ // Send a message
142
+ await client.send("chat", { roomId: "room1" }, result.token, {
143
+ text: "Hello, world!",
144
+ });
145
+
146
+ // Wait for message to be processed
147
+ await new Promise(resolve => setTimeout(resolve, 50));
148
+
149
+ expect(receivedMessages).toEqual([
150
+ {
151
+ text: "Hello, world!",
152
+ userId: "user-Bob",
153
+ },
154
+ ]);
155
+ });
156
+
157
+ test("server sending messages to client", async () => {
158
+ const sidekick = new InternalSidekick();
159
+ const receivedMessages: Array<{ text: string }> = [];
160
+
161
+ const covenant = declareCovenant({
162
+ procedures: {},
163
+ channels: {
164
+ notifications: channel({
165
+ clientMessage: z.null(),
166
+ serverMessage: z.object({
167
+ text: z.string(),
168
+ }),
169
+ connectionRequest: z.object({
170
+ userId: z.string(),
171
+ }),
172
+ connectionContext: z.object({
173
+ userId: z.string(),
174
+ }),
175
+ params: [],
176
+ }),
177
+ },
178
+ });
179
+
180
+ const server = new CovenantServer(covenant, {
181
+ contextGenerator: () => undefined,
182
+ derivation: () => {},
183
+ sidekickConnection: sidekick.getConnectionFromServer(),
184
+ });
185
+
186
+ sidekick.setServerCallback((channelName, params, data, context) =>
187
+ server.processChannelMessage(channelName, params, data, context)
188
+ );
189
+
190
+ server.defineChannel("notifications", {
191
+ onConnect: ({ inputs }) => {
192
+ return {
193
+ userId: inputs.userId,
194
+ };
195
+ },
196
+ onMessage: () => {},
197
+ });
198
+
199
+ server.assertAllDefined();
200
+
201
+ const client = new CovenantClient(covenant, {
202
+ sidekickConnection: sidekick.getConnectionFromClient(),
203
+ serverConnection: directClientToServer(server, {}),
204
+ });
205
+
206
+ const result = await client.connect("notifications", {}, {
207
+ userId: "user123",
208
+ });
209
+
210
+ expect(result.success).toBe(true);
211
+ if (!result.success) return;
212
+
213
+ // Subscribe to receive messages
214
+ const unsubscribe = await client.subscribe(
215
+ "notifications",
216
+ {},
217
+ result.token,
218
+ (message) => {
219
+ receivedMessages.push(message);
220
+ }
221
+ );
222
+
223
+ // Wait for subscription to be established
224
+ await new Promise(resolve => setTimeout(resolve, 50));
225
+
226
+ // Server sends a message
227
+ await server.postChannelMessage("notifications", {}, {
228
+ text: "New notification!",
229
+ });
230
+
231
+ // Wait for message to propagate
232
+ await new Promise(resolve => setTimeout(resolve, 50));
233
+
234
+ expect(receivedMessages).toEqual([
235
+ {
236
+ text: "New notification!",
237
+ },
238
+ ]);
239
+
240
+ unsubscribe();
241
+ });
242
+
243
+ test("bidirectional channel communication", async () => {
244
+ const sidekick = new InternalSidekick();
245
+ const serverReceivedMessages: string[] = [];
246
+ const clientReceivedMessages: string[] = [];
247
+
248
+ const covenant = declareCovenant({
249
+ procedures: {},
250
+ channels: {
251
+ chat: channel({
252
+ clientMessage: z.object({
253
+ text: z.string(),
254
+ }),
255
+ serverMessage: z.object({
256
+ text: z.string(),
257
+ from: z.string(),
258
+ }),
259
+ connectionRequest: z.object({
260
+ username: z.string(),
261
+ }),
262
+ connectionContext: z.object({
263
+ username: z.string(),
264
+ }),
265
+ params: ["roomId"],
266
+ }),
267
+ },
268
+ });
269
+
270
+ const server = new CovenantServer(covenant, {
271
+ contextGenerator: () => undefined,
272
+ derivation: () => {},
273
+ sidekickConnection: sidekick.getConnectionFromServer(),
274
+ });
275
+
276
+ sidekick.setServerCallback((channelName, params, data, context) =>
277
+ server.processChannelMessage(channelName, params, data, context)
278
+ );
279
+
280
+ server.defineChannel("chat", {
281
+ onConnect: ({ inputs }) => {
282
+ return {
283
+ username: inputs.username,
284
+ };
285
+ },
286
+ onMessage: async ({ inputs, params, context }) => {
287
+ serverReceivedMessages.push(inputs.text);
288
+
289
+ // Echo the message back
290
+ await server.postChannelMessage("chat", params, {
291
+ text: inputs.text,
292
+ from: context.username,
293
+ });
294
+ },
295
+ });
296
+
297
+ server.assertAllDefined();
298
+
299
+ const client = new CovenantClient(covenant, {
300
+ sidekickConnection: sidekick.getConnectionFromClient(),
301
+ serverConnection: directClientToServer(server, {}),
302
+ });
303
+
304
+ const result = await client.connect("chat", { roomId: "general" }, {
305
+ username: "Charlie",
306
+ });
307
+
308
+ expect(result.success).toBe(true);
309
+ if (!result.success) return;
310
+
311
+ // Subscribe to receive echoed messages
312
+ const unsubscribe = await client.subscribe(
313
+ "chat",
314
+ { roomId: "general" },
315
+ result.token,
316
+ (message) => {
317
+ clientReceivedMessages.push(`${message.from}: ${message.text}`);
318
+ }
319
+ );
320
+
321
+ await new Promise(resolve => setTimeout(resolve, 50));
322
+
323
+ // Send a message
324
+ await client.send("chat", { roomId: "general" }, result.token, {
325
+ text: "Hello from client!",
326
+ });
327
+
328
+ // Wait for round trip
329
+ await new Promise(resolve => setTimeout(resolve, 100));
330
+
331
+ expect(serverReceivedMessages).toEqual(["Hello from client!"]);
332
+ expect(clientReceivedMessages).toEqual(["Charlie: Hello from client!"]);
333
+
334
+ unsubscribe();
335
+ });
336
+
337
+ test("multiple clients on same channel", async () => {
338
+ const sidekick = new InternalSidekick();
339
+
340
+ const covenant = declareCovenant({
341
+ procedures: {},
342
+ channels: {
343
+ broadcast: channel({
344
+ clientMessage: z.null(),
345
+ serverMessage: z.object({
346
+ announcement: z.string(),
347
+ }),
348
+ connectionRequest: z.object({
349
+ name: z.string(),
350
+ }),
351
+ connectionContext: z.object({
352
+ name: z.string(),
353
+ }),
354
+ params: [],
355
+ }),
356
+ },
357
+ });
358
+
359
+ const server = new CovenantServer(covenant, {
360
+ contextGenerator: () => undefined,
361
+ derivation: () => {},
362
+ sidekickConnection: sidekick.getConnectionFromServer(),
363
+ });
364
+
365
+ sidekick.setServerCallback((channelName, params, data, context) =>
366
+ server.processChannelMessage(channelName, params, data, context)
367
+ );
368
+
369
+ server.defineChannel("broadcast", {
370
+ onConnect: ({ inputs }) => {
371
+ return { name: inputs.name };
372
+ },
373
+ onMessage: () => {},
374
+ });
375
+
376
+ server.assertAllDefined();
377
+
378
+ // Create two clients
379
+ const client1 = new CovenantClient(covenant, {
380
+ sidekickConnection: sidekick.getConnectionFromClient(),
381
+ serverConnection: directClientToServer(server, {}),
382
+ });
383
+
384
+ const client2 = new CovenantClient(covenant, {
385
+ sidekickConnection: sidekick.getConnectionFromClient(),
386
+ serverConnection: directClientToServer(server, {}),
387
+ });
388
+
389
+ const result1 = await client1.connect("broadcast", {}, { name: "Client1" });
390
+ const result2 = await client2.connect("broadcast", {}, { name: "Client2" });
391
+
392
+ expect(result1.success).toBe(true);
393
+ expect(result2.success).toBe(true);
394
+ if (!result1.success || !result2.success) return;
395
+
396
+ const messages1: string[] = [];
397
+ const messages2: string[] = [];
398
+
399
+ const unsub1 = await client1.subscribe("broadcast", {}, result1.token, (msg) => {
400
+ messages1.push(msg.announcement);
401
+ });
402
+
403
+ const unsub2 = await client2.subscribe("broadcast", {}, result2.token, (msg) => {
404
+ messages2.push(msg.announcement);
405
+ });
406
+
407
+ await new Promise(resolve => setTimeout(resolve, 50));
408
+
409
+ // Server broadcasts to all clients
410
+ await server.postChannelMessage("broadcast", {}, {
411
+ announcement: "System update!",
412
+ });
413
+
414
+ await new Promise(resolve => setTimeout(resolve, 50));
415
+
416
+ // Both clients should receive the message
417
+ expect(messages1).toEqual(["System update!"]);
418
+ expect(messages2).toEqual(["System update!"]);
419
+
420
+ unsub1();
421
+ unsub2();
422
+ });
423
+
424
+ test("channel connection error handling", async () => {
425
+ const sidekick = new InternalSidekick();
426
+
427
+ const covenant = declareCovenant({
428
+ procedures: {},
429
+ channels: {
430
+ restricted: channel({
431
+ clientMessage: z.null(),
432
+ serverMessage: z.null(),
433
+ connectionRequest: z.object({
434
+ password: z.string(),
435
+ }),
436
+ connectionContext: z.object({
437
+ authenticated: z.boolean(),
438
+ }),
439
+ params: [],
440
+ }),
441
+ },
442
+ });
443
+
444
+ const server = new CovenantServer(covenant, {
445
+ contextGenerator: () => undefined,
446
+ derivation: () => {},
447
+ sidekickConnection: sidekick.getConnectionFromServer(),
448
+ });
449
+
450
+ sidekick.setServerCallback((channelName, params, data, context) =>
451
+ server.processChannelMessage(channelName, params, data, context)
452
+ );
453
+
454
+ server.defineChannel("restricted", {
455
+ onConnect: ({ inputs, reject }) => {
456
+ if (inputs.password !== "secret") {
457
+ reject("Invalid password", "client");
458
+ }
459
+ return { authenticated: true };
460
+ },
461
+ onMessage: () => {},
462
+ });
463
+
464
+ server.assertAllDefined();
465
+
466
+ const client = new CovenantClient(covenant, {
467
+ sidekickConnection: sidekick.getConnectionFromClient(),
468
+ serverConnection: directClientToServer(server, {}),
469
+ });
470
+
471
+ // Try to connect with wrong password
472
+ const result = await client.connect("restricted", {}, {
473
+ password: "wrong",
474
+ });
475
+
476
+ expect(result.success).toBe(false);
477
+ expect(result.error).toBeDefined();
478
+ if (!result.success) {
479
+ expect(result.error.message).toBe("Invalid password");
480
+ expect(result.error.fault).toBe("client");
481
+ }
482
+ });
483
+
484
+ test("channel message error handling", async () => {
485
+ const sidekick = new InternalSidekick();
486
+
487
+ const covenant = declareCovenant({
488
+ procedures: {},
489
+ channels: {
490
+ moderated: channel({
491
+ clientMessage: z.object({
492
+ text: z.string(),
493
+ }),
494
+ serverMessage: z.null(),
495
+ connectionRequest: z.object({
496
+ username: z.string(),
497
+ }),
498
+ connectionContext: z.object({
499
+ username: z.string(),
500
+ }),
501
+ params: [],
502
+ }),
503
+ },
504
+ });
505
+
506
+ const server = new CovenantServer(covenant, {
507
+ contextGenerator: () => undefined,
508
+ derivation: () => {},
509
+ sidekickConnection: sidekick.getConnectionFromServer(),
510
+ });
511
+
512
+ sidekick.setServerCallback((channelName, params, data, context) =>
513
+ server.processChannelMessage(channelName, params, data, context)
514
+ );
515
+
516
+ server.defineChannel("moderated", {
517
+ onConnect: ({ inputs }) => {
518
+ return { username: inputs.username };
519
+ },
520
+ onMessage: ({ inputs, error }) => {
521
+ if (inputs.text.includes("banned")) {
522
+ error("Message contains banned words", "client");
523
+ }
524
+ },
525
+ });
526
+
527
+ server.assertAllDefined();
528
+
529
+ const client = new CovenantClient(covenant, {
530
+ sidekickConnection: sidekick.getConnectionFromClient(),
531
+ serverConnection: directClientToServer(server, {}),
532
+ });
533
+
534
+ const result = await client.connect("moderated", {}, {
535
+ username: "User1",
536
+ });
537
+
538
+ expect(result.success).toBe(true);
539
+ if (!result.success) return;
540
+
541
+ // Try to send a message with banned content
542
+ try {
543
+ await client.send("moderated", {}, result.token, {
544
+ text: "This is banned content",
545
+ });
546
+ expect(true).toBe(false); // Should not reach here
547
+ } catch (error: any) {
548
+ expect(error.message).toContain("banned words");
549
+ }
550
+ });
551
+
552
+ test("channel with multiple params", async () => {
553
+ const sidekick = new InternalSidekick();
554
+
555
+ const covenant = declareCovenant({
556
+ procedures: {},
557
+ channels: {
558
+ thread: channel({
559
+ clientMessage: z.object({
560
+ text: z.string(),
561
+ }),
562
+ serverMessage: z.object({
563
+ text: z.string(),
564
+ }),
565
+ connectionRequest: z.null(),
566
+ connectionContext: z.object({
567
+ connected: z.boolean(),
568
+ }),
569
+ params: ["forumId", "threadId"],
570
+ }),
571
+ },
572
+ });
573
+
574
+ const server = new CovenantServer(covenant, {
575
+ contextGenerator: () => undefined,
576
+ derivation: () => {},
577
+ sidekickConnection: sidekick.getConnectionFromServer(),
578
+ });
579
+
580
+ sidekick.setServerCallback((channelName, params, data, context) =>
581
+ server.processChannelMessage(channelName, params, data, context)
582
+ );
583
+
584
+ server.defineChannel("thread", {
585
+ onConnect: ({ params }) => {
586
+ expect(params.forumId).toBe("tech");
587
+ expect(params.threadId).toBe("123");
588
+ return { connected: true };
589
+ },
590
+ onMessage: ({ params }) => {
591
+ expect(params.forumId).toBe("tech");
592
+ expect(params.threadId).toBe("123");
593
+ },
594
+ });
595
+
596
+ server.assertAllDefined();
597
+
598
+ const client = new CovenantClient(covenant, {
599
+ sidekickConnection: sidekick.getConnectionFromClient(),
600
+ serverConnection: directClientToServer(server, {}),
601
+ });
602
+
603
+ const result = await client.connect(
604
+ "thread",
605
+ { forumId: "tech", threadId: "123" },
606
+ null
607
+ );
608
+
609
+ expect(result.success).toBe(true);
610
+ if (!result.success) return;
611
+
612
+ await client.send(
613
+ "thread",
614
+ { forumId: "tech", threadId: "123" },
615
+ result.token,
616
+ { text: "Test message" }
617
+ );
618
+
619
+ await new Promise(resolve => setTimeout(resolve, 50));
620
+ });
621
+
622
+ test("channel unsubscribe", async () => {
623
+ const sidekick = new InternalSidekick();
624
+ const receivedMessages: string[] = [];
625
+
626
+ const covenant = declareCovenant({
627
+ procedures: {},
628
+ channels: {
629
+ updates: channel({
630
+ clientMessage: z.null(),
631
+ serverMessage: z.object({
632
+ update: z.string(),
633
+ }),
634
+ connectionRequest: z.null(),
635
+ connectionContext: z.null(),
636
+ params: [],
637
+ }),
638
+ },
639
+ });
640
+
641
+ const server = new CovenantServer(covenant, {
642
+ contextGenerator: () => undefined,
643
+ derivation: () => {},
644
+ sidekickConnection: sidekick.getConnectionFromServer(),
645
+ });
646
+
647
+ sidekick.setServerCallback((channelName, params, data, context) =>
648
+ server.processChannelMessage(channelName, params, data, context)
649
+ );
650
+
651
+ server.defineChannel("updates", {
652
+ onConnect: () => null,
653
+ onMessage: () => {},
654
+ });
655
+
656
+ server.assertAllDefined();
657
+
658
+ const client = new CovenantClient(covenant, {
659
+ sidekickConnection: sidekick.getConnectionFromClient(),
660
+ serverConnection: directClientToServer(server, {}),
661
+ });
662
+
663
+ const result = await client.connect("updates", {}, null);
664
+ expect(result.success).toBe(true);
665
+ if (!result.success) return;
666
+
667
+ const unsubscribe = await client.subscribe("updates", {}, result.token, (msg) => {
668
+ receivedMessages.push(msg.update);
669
+ });
670
+
671
+ await new Promise(resolve => setTimeout(resolve, 50));
672
+
673
+ // Send first message
674
+ await server.postChannelMessage("updates", {}, { update: "Update 1" });
675
+ await new Promise(resolve => setTimeout(resolve, 50));
676
+
677
+ expect(receivedMessages).toEqual(["Update 1"]);
678
+
679
+ // Unsubscribe
680
+ unsubscribe();
681
+ await new Promise(resolve => setTimeout(resolve, 50));
682
+
683
+ // Send second message - should not be received
684
+ await server.postChannelMessage("updates", {}, { update: "Update 2" });
685
+ await new Promise(resolve => setTimeout(resolve, 50));
686
+
687
+ // Should still only have the first message
688
+ expect(receivedMessages).toEqual(["Update 1"]);
689
+ });