@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,109 @@
1
+ import type { ChannelConnectionPayload, ServerMessage } from "@covenant-rpc/core/channel";
2
+ import type { SidekickToServerConnection } from "@covenant-rpc/core/interfaces";
3
+ import { httpSidekickToServer } from "../interfaces/http";
4
+ import type { LoggerLevel } from "@covenant-rpc/core/logger";
5
+ import { Logger } from "../logger";
6
+ import { handleListenMessage, handleSendMessage, handleSubscribeMessage, handleUnlistenMessage, handleUnsubscribeMessage, type SidekickHandlerContext } from "./handlers";
7
+ import { getChannelTopicName, getResourceTopicName, type SidekickIncomingMessage, type SidekickOutgoingMessage } from "@covenant-rpc/core/sidekick/protocol";
8
+
9
+ export interface SidekickClient {
10
+ subscribe(topic: string): void;
11
+ unsubscribe(topic: string): void;
12
+ getId(): string;
13
+ directMessage(message: SidekickOutgoingMessage): void;
14
+ }
15
+
16
+ export interface SidekickState {
17
+ contextMap: Map<string, unknown>;
18
+ tokenMap: Map<string, ChannelConnectionPayload>;
19
+ usedTokenMap: Map<string, {
20
+ channel: string;
21
+ params: Record<string, string>
22
+ id: string;
23
+ }>
24
+ serverConnection: SidekickToServerConnection;
25
+ }
26
+
27
+ export type PublishFunction = (topic: string, message: SidekickOutgoingMessage) => Promise<void>;
28
+
29
+
30
+ export class Sidekick {
31
+ private publish: PublishFunction;
32
+ private state: SidekickState = {
33
+ contextMap: new Map(),
34
+ tokenMap: new Map(),
35
+ usedTokenMap: new Map(),
36
+ serverConnection: httpSidekickToServer("", ""),
37
+ };
38
+ private logger: Logger;
39
+
40
+ constructor(publishFunction: PublishFunction, logLevel?: LoggerLevel) {
41
+ this.publish = publishFunction
42
+ this.logger = new Logger(logLevel ?? "info", [
43
+ () => new Date().toUTCString(),
44
+ ]);
45
+ }
46
+
47
+ async updateResources(resources: string[]) {
48
+ const promises: (() => Promise<void>)[] = [];
49
+
50
+ for (const r of resources) {
51
+ promises.push(async () => {
52
+ const topic = getResourceTopicName(r);
53
+ await this.publish(topic, {
54
+ type: "updated",
55
+ resource: r,
56
+ });
57
+
58
+ });
59
+ }
60
+
61
+ await Promise.all(promises.map(p => p()));
62
+ }
63
+
64
+ async postServerMessage(message: ServerMessage) {
65
+ const topic = getChannelTopicName(message.channel, message.params);
66
+ await this.publish(topic, {
67
+ type: "message",
68
+ ...message
69
+ });
70
+ }
71
+
72
+ addConnection(payload: ChannelConnectionPayload) {
73
+ this.state.tokenMap.set(payload.token, payload);
74
+ }
75
+
76
+ async handleClientMessage(client: SidekickClient, message: SidekickIncomingMessage) {
77
+ const logger = this.logger.clone()
78
+ .pushPrefix(`Client: ${client.getId()}`)
79
+ .pushPrefix(`Type: ${message.type}`)
80
+
81
+ const ctx: SidekickHandlerContext = {
82
+ client,
83
+ logger,
84
+ state: this.state,
85
+ publish: this.publish,
86
+ }
87
+
88
+ switch (message.type) {
89
+ case "unsubscribe":
90
+ await handleUnsubscribeMessage(message, ctx);
91
+ break;
92
+ case "subscribe":
93
+ await handleSubscribeMessage(message, ctx);
94
+ break;
95
+ case "listen":
96
+ await handleListenMessage(message, ctx);
97
+ break;
98
+ case "unlisten":
99
+ await handleUnlistenMessage(message, ctx);
100
+ break;
101
+ case "send":
102
+ await handleSendMessage(message, ctx);
103
+ break;
104
+ }
105
+ }
106
+ }
107
+
108
+ export type UpdateListener = (resources: string[]) => Promise<void> | void;
109
+
@@ -0,0 +1,5 @@
1
+
2
+ export class SocketSidekick {
3
+ constructor(ws: WebSocket) {
4
+ }
5
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@covenant-rpc/server",
3
+ "module": "lib/index.ts",
4
+ "version": "0.1.3",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "exports": {
10
+ ".": "./lib/index.ts",
11
+ "./*": "./lib/*.ts"
12
+ },
13
+ "peerDependencies": {
14
+ "typescript": "^5"
15
+ },
16
+ "dependencies": {
17
+ "@covenant-rpc/core": "workspace:*",
18
+ "@covenant-rpc/ion": "workspace:*",
19
+ "@covenant-rpc/request-serializer": "workspace:*"
20
+ }
21
+ }
@@ -0,0 +1,481 @@
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 { httpClientToServer } from "@covenant-rpc/client/interfaces/http";
7
+ import { httpServerToSidekick } from "../lib/interfaces/http";
8
+ import { InternalSidekick } from "@covenant-rpc/sidekick/internal";
9
+
10
+ // Helper to create a mock HTTP server
11
+ async function createMockHttpServer(server: CovenantServer<any, any, any, any>) {
12
+ const handlers = new Map<string, (req: Request) => Promise<Response>>();
13
+
14
+ // Add server handler
15
+ handlers.set("server", (req) => server.handle(req));
16
+
17
+ const mockFetch = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
18
+ const requestUrl = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
19
+ const urlObj = new URL(requestUrl);
20
+ const type = urlObj.searchParams.get("type");
21
+
22
+ const request = new Request(requestUrl, init);
23
+
24
+ if (type === "procedure" || type === "connect" || type === "channel") {
25
+ const handler = handlers.get("server");
26
+ if (handler) {
27
+ return await handler(request);
28
+ }
29
+ }
30
+
31
+ return new Response("Not Found", { status: 404 });
32
+ };
33
+
34
+ // @ts-expect-error - mocking global fetch
35
+ global.fetch = mockFetch;
36
+
37
+ return {
38
+ cleanup: () => {
39
+ // @ts-expect-error - restore
40
+ global.fetch = undefined;
41
+ }
42
+ };
43
+ }
44
+
45
+ test("HTTP channel connection request", async () => {
46
+ const sidekick = new InternalSidekick();
47
+
48
+ const covenant = declareCovenant({
49
+ procedures: {},
50
+ channels: {
51
+ chat: channel({
52
+ clientMessage: z.object({ text: z.string() }),
53
+ serverMessage: z.object({ text: z.string() }),
54
+ connectionRequest: z.object({ username: z.string() }),
55
+ connectionContext: z.object({ userId: z.string() }),
56
+ params: ["roomId"],
57
+ }),
58
+ },
59
+ });
60
+
61
+ const server = new CovenantServer(covenant, {
62
+ contextGenerator: () => undefined,
63
+ derivation: () => {},
64
+ sidekickConnection: sidekick.getConnectionFromServer(),
65
+ });
66
+
67
+ sidekick.setServerCallback((channelName, params, data, context) =>
68
+ server.processChannelMessage(channelName, params, data, context)
69
+ );
70
+
71
+ server.defineChannel("chat", {
72
+ onConnect: ({ inputs, params }) => {
73
+ expect(inputs.username).toBe("Alice");
74
+ expect(params.roomId).toBe("room1");
75
+ return { userId: `user-${inputs.username}` };
76
+ },
77
+ onMessage: () => {},
78
+ });
79
+
80
+ server.assertAllDefined();
81
+
82
+ const mock = await createMockHttpServer(server);
83
+
84
+ const client = new CovenantClient(covenant, {
85
+ sidekickConnection: sidekick.getConnectionFromClient(),
86
+ serverConnection: httpClientToServer("http://localhost:3000", {}),
87
+ });
88
+
89
+ const result = await client.connect("chat", { roomId: "room1" }, {
90
+ username: "Alice",
91
+ });
92
+
93
+ expect(result.success).toBe(true);
94
+ expect(result.error).toBe(null);
95
+ if (result.success) {
96
+ expect(result.token).toBeDefined();
97
+ expect(typeof result.token).toBe("string");
98
+ }
99
+
100
+ mock.cleanup();
101
+ });
102
+
103
+ test("HTTP channel connection error handling", async () => {
104
+ const sidekick = new InternalSidekick();
105
+
106
+ const covenant = declareCovenant({
107
+ procedures: {},
108
+ channels: {
109
+ restricted: channel({
110
+ clientMessage: z.null(),
111
+ serverMessage: z.null(),
112
+ connectionRequest: z.object({ password: z.string() }),
113
+ connectionContext: z.object({ authenticated: z.boolean() }),
114
+ params: [],
115
+ }),
116
+ },
117
+ });
118
+
119
+ const server = new CovenantServer(covenant, {
120
+ contextGenerator: () => undefined,
121
+ derivation: () => {},
122
+ sidekickConnection: sidekick.getConnectionFromServer(),
123
+ });
124
+
125
+ sidekick.setServerCallback((channelName, params, data, context) =>
126
+ server.processChannelMessage(channelName, params, data, context)
127
+ );
128
+
129
+ server.defineChannel("restricted", {
130
+ onConnect: ({ inputs, reject }) => {
131
+ if (inputs.password !== "secret123") {
132
+ reject("Invalid password", "client");
133
+ }
134
+ return { authenticated: true };
135
+ },
136
+ onMessage: () => {},
137
+ });
138
+
139
+ server.assertAllDefined();
140
+
141
+ const mock = await createMockHttpServer(server);
142
+
143
+ const client = new CovenantClient(covenant, {
144
+ sidekickConnection: sidekick.getConnectionFromClient(),
145
+ serverConnection: httpClientToServer("http://localhost:3000", {}),
146
+ });
147
+
148
+ // Try with wrong password
149
+ const result = await client.connect("restricted", {}, {
150
+ password: "wrong",
151
+ });
152
+
153
+ expect(result.success).toBe(false);
154
+ if (!result.success) {
155
+ expect(result.error).toBeDefined();
156
+ expect(result.error.message).toBe("Invalid password");
157
+ expect(result.error.fault).toBe("client");
158
+ }
159
+
160
+ mock.cleanup();
161
+ });
162
+
163
+ test("HTTP server-to-sidekick message posting", async () => {
164
+ const receivedMessages: any[] = [];
165
+
166
+ // Create a mock sidekick HTTP endpoint
167
+ const mockSidekickFetch = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
168
+ const requestUrl = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
169
+ const urlObj = new URL(requestUrl);
170
+
171
+ if (urlObj.pathname === "/connection") {
172
+ const body = await (init?.body ? JSON.parse(init.body as string) : {});
173
+ receivedMessages.push({ type: "connection", ...body });
174
+ return new Response(null, { status: 200 });
175
+ }
176
+
177
+ if (urlObj.pathname === "/message") {
178
+ const body = await (init?.body ? JSON.parse(init.body as string) : {});
179
+ receivedMessages.push({ type: "message", ...body });
180
+ return new Response(null, { status: 200 });
181
+ }
182
+
183
+ if (urlObj.pathname === "/resources") {
184
+ const body = await (init?.body ? JSON.parse(init.body as string) : {});
185
+ receivedMessages.push({ type: "resources", ...body });
186
+ return new Response(null, { status: 200 });
187
+ }
188
+
189
+ return new Response("Not Found", { status: 404 });
190
+ };
191
+
192
+ // @ts-expect-error - mocking global fetch
193
+ global.fetch = mockSidekickFetch;
194
+
195
+ const sidekickConnection = httpServerToSidekick("http://localhost:4000", "test-key");
196
+
197
+ const covenant = declareCovenant({
198
+ procedures: {},
199
+ channels: {
200
+ notifications: channel({
201
+ clientMessage: z.null(),
202
+ serverMessage: z.object({ text: z.string() }),
203
+ connectionRequest: z.null(),
204
+ connectionContext: z.null(),
205
+ params: [],
206
+ }),
207
+ },
208
+ });
209
+
210
+ const server = new CovenantServer(covenant, {
211
+ contextGenerator: () => undefined,
212
+ derivation: () => {},
213
+ sidekickConnection,
214
+ });
215
+
216
+ server.defineChannel("notifications", {
217
+ onConnect: () => null,
218
+ onMessage: () => {},
219
+ });
220
+
221
+ server.assertAllDefined();
222
+
223
+ // Test adding connection
224
+ const connectionError = await sidekickConnection.addConnection({
225
+ token: "test-token-123",
226
+ channel: "notifications",
227
+ params: {},
228
+ context: null,
229
+ });
230
+
231
+ expect(connectionError).toBe(null);
232
+ expect(receivedMessages.length).toBe(1);
233
+ expect(receivedMessages[0].type).toBe("connection");
234
+ expect(receivedMessages[0].token).toBe("test-token-123");
235
+
236
+ // Test posting message
237
+ const messageError = await sidekickConnection.postMessage({
238
+ channel: "notifications",
239
+ params: {},
240
+ data: { text: "Hello!" },
241
+ });
242
+
243
+ expect(messageError).toBe(null);
244
+ expect(receivedMessages.length).toBe(2);
245
+ expect(receivedMessages[1].type).toBe("message");
246
+ expect(receivedMessages[1].data).toEqual({ text: "Hello!" });
247
+
248
+ // Test updating resources
249
+ const updateError = await sidekickConnection.update(["resource1", "resource2"]);
250
+
251
+ expect(updateError).toBe(null);
252
+ expect(receivedMessages.length).toBe(3);
253
+ expect(receivedMessages[2].type).toBe("resources");
254
+ expect(receivedMessages[2].resources).toEqual(["resource1", "resource2"]);
255
+
256
+ // @ts-expect-error - restore
257
+ global.fetch = undefined;
258
+ });
259
+
260
+ test("HTTP channel message from sidekick to server", async () => {
261
+ const receivedMessages: Array<{ text: string; userId: string }> = [];
262
+
263
+ const covenant = declareCovenant({
264
+ procedures: {},
265
+ channels: {
266
+ chat: channel({
267
+ clientMessage: z.object({ text: z.string() }),
268
+ serverMessage: z.null(),
269
+ connectionRequest: z.object({ username: z.string() }),
270
+ connectionContext: z.object({ userId: z.string() }),
271
+ params: ["roomId"],
272
+ }),
273
+ },
274
+ });
275
+
276
+ const server = new CovenantServer(covenant, {
277
+ contextGenerator: () => undefined,
278
+ derivation: () => {},
279
+ sidekickConnection: httpServerToSidekick("http://localhost:4000", "test-key"),
280
+ });
281
+
282
+ server.defineChannel("chat", {
283
+ onConnect: ({ inputs }) => {
284
+ return { userId: `user-${inputs.username}` };
285
+ },
286
+ onMessage: ({ inputs, context }) => {
287
+ receivedMessages.push({
288
+ text: inputs.text,
289
+ userId: context.userId,
290
+ });
291
+ },
292
+ });
293
+
294
+ server.assertAllDefined();
295
+
296
+ const mock = await createMockHttpServer(server);
297
+
298
+ // Simulate sidekick sending a message to the server
299
+ const response = await fetch("http://localhost:3000?type=channel", {
300
+ method: "POST",
301
+ headers: {
302
+ "Content-Type": "application/json",
303
+ "Authorization": "Bearer test-key",
304
+ },
305
+ body: JSON.stringify({
306
+ channel: "chat",
307
+ params: { roomId: "general" },
308
+ data: { text: "Hello from sidekick!" },
309
+ context: { userId: "user-Bob" },
310
+ }),
311
+ });
312
+
313
+ expect(response.status).toBe(204);
314
+ expect(receivedMessages.length).toBe(1);
315
+ expect(receivedMessages[0]).toEqual({
316
+ text: "Hello from sidekick!",
317
+ userId: "user-Bob",
318
+ });
319
+
320
+ mock.cleanup();
321
+ });
322
+
323
+ test("HTTP channel message error handling", async () => {
324
+ const covenant = declareCovenant({
325
+ procedures: {},
326
+ channels: {
327
+ moderated: channel({
328
+ clientMessage: z.object({ text: z.string() }),
329
+ serverMessage: z.null(),
330
+ connectionRequest: z.null(),
331
+ connectionContext: z.null(),
332
+ params: [],
333
+ }),
334
+ },
335
+ });
336
+
337
+ const server = new CovenantServer(covenant, {
338
+ contextGenerator: () => undefined,
339
+ derivation: () => {},
340
+ sidekickConnection: httpServerToSidekick("http://localhost:4000", "test-key"),
341
+ });
342
+
343
+ server.defineChannel("moderated", {
344
+ onConnect: () => null,
345
+ onMessage: ({ inputs, error }) => {
346
+ if (inputs.text.includes("spam")) {
347
+ error("Message contains spam", "client");
348
+ }
349
+ },
350
+ });
351
+
352
+ server.assertAllDefined();
353
+
354
+ const mock = await createMockHttpServer(server);
355
+
356
+ // Simulate sidekick sending a spam message
357
+ const response = await fetch("http://localhost:3000?type=channel", {
358
+ method: "POST",
359
+ headers: {
360
+ "Content-Type": "application/json",
361
+ "Authorization": "Bearer test-key",
362
+ },
363
+ body: JSON.stringify({
364
+ channel: "moderated",
365
+ params: {},
366
+ data: { text: "This is spam content" },
367
+ context: null,
368
+ }),
369
+ });
370
+
371
+ expect(response.status).toBe(400);
372
+ const errorBody = await response.json();
373
+ expect(errorBody.fault).toBe("client");
374
+ expect(errorBody.message).toContain("spam");
375
+
376
+ mock.cleanup();
377
+ });
378
+
379
+ test("HTTP connection with invalid channel", async () => {
380
+ const sidekick = new InternalSidekick();
381
+
382
+ const covenant = declareCovenant({
383
+ procedures: {},
384
+ channels: {
385
+ chat: channel({
386
+ clientMessage: z.null(),
387
+ serverMessage: z.null(),
388
+ connectionRequest: z.null(),
389
+ connectionContext: z.null(),
390
+ params: [],
391
+ }),
392
+ },
393
+ });
394
+
395
+ const server = new CovenantServer(covenant, {
396
+ contextGenerator: () => undefined,
397
+ derivation: () => {},
398
+ sidekickConnection: sidekick.getConnectionFromServer(),
399
+ });
400
+
401
+ server.defineChannel("chat", {
402
+ onConnect: () => null,
403
+ onMessage: () => {},
404
+ });
405
+
406
+ server.assertAllDefined();
407
+
408
+ const mock = await createMockHttpServer(server);
409
+
410
+ const client = new CovenantClient(covenant, {
411
+ sidekickConnection: sidekick.getConnectionFromClient(),
412
+ serverConnection: httpClientToServer("http://localhost:3000", {}),
413
+ });
414
+
415
+ // Try to connect to a channel that doesn't exist
416
+ // @ts-expect-error - intentionally using wrong channel name
417
+ const result = await client.connect("nonexistent", {}, null);
418
+
419
+ expect(result.success).toBe(false);
420
+ if (!result.success) {
421
+ expect(result.error).toBeDefined();
422
+ expect(result.error.fault).toBe("server");
423
+ }
424
+
425
+ mock.cleanup();
426
+ });
427
+
428
+ test("HTTP connection with invalid request data", async () => {
429
+ const sidekick = new InternalSidekick();
430
+
431
+ const covenant = declareCovenant({
432
+ procedures: {},
433
+ channels: {
434
+ chat: channel({
435
+ clientMessage: z.null(),
436
+ serverMessage: z.null(),
437
+ connectionRequest: z.object({
438
+ username: z.string(),
439
+ email: z.string().email(),
440
+ }),
441
+ connectionContext: z.null(),
442
+ params: [],
443
+ }),
444
+ },
445
+ });
446
+
447
+ const server = new CovenantServer(covenant, {
448
+ contextGenerator: () => undefined,
449
+ derivation: () => {},
450
+ sidekickConnection: sidekick.getConnectionFromServer(),
451
+ });
452
+
453
+ server.defineChannel("chat", {
454
+ onConnect: () => null,
455
+ onMessage: () => {},
456
+ });
457
+
458
+ server.assertAllDefined();
459
+
460
+ const mock = await createMockHttpServer(server);
461
+
462
+ const client = new CovenantClient(covenant, {
463
+ sidekickConnection: sidekick.getConnectionFromClient(),
464
+ serverConnection: httpClientToServer("http://localhost:3000", {}),
465
+ });
466
+
467
+ // Try to connect with invalid email
468
+ const result = await client.connect("chat", {}, {
469
+ username: "Alice",
470
+ email: "not-an-email",
471
+ });
472
+
473
+ expect(result.success).toBe(false);
474
+ if (!result.success) {
475
+ expect(result.error).toBeDefined();
476
+ expect(result.error.fault).toBe("client");
477
+ expect(result.error.message).toContain("Invalid");
478
+ }
479
+
480
+ mock.cleanup();
481
+ });