@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.
package/lib/server.ts ADDED
@@ -0,0 +1,453 @@
1
+ import type { ChannelMap, Covenant, ProcedureMap } from "@covenant-rpc/core";
2
+ import { procedureRequestBodySchema, type ProcedureDefinition, type ProcedureInputs, type ProcedureRequest, type ProcedureResponse } from "@covenant-rpc/core/procedure";
3
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
4
+ import { err, issuesToString, ok, type ArrayToMap, type AsyncResult, type MaybePromise } from "@covenant-rpc/core/utils";
5
+ import type { ServerToSidekickConnection } from "@covenant-rpc/core/interfaces";
6
+ import { channelConnectionRequestSchema, serverMessageWithContext, type ChannelConnectionResponse, type ChannelDefinition } from "@covenant-rpc/core/channel";
7
+ import { v } from "@covenant-rpc/core/validation";
8
+ import { procedureErrorFromUnknown, ThrowableProcedureError, ThrowableChannelError, channelErrorFromUnknown } from "@covenant-rpc/core/errors";
9
+ import { Logger, type LoggerLevel } from "./logger";
10
+ import ION from "@covenant-rpc/ion";
11
+
12
+
13
+ export type ProcedureDefinitionMap<T extends ProcedureMap, Context, Derivation> = {
14
+ [key in keyof T]: ProcedureDefinition<T[key], Context, Derivation> | undefined
15
+ }
16
+
17
+ export type ChannelDefinitionMap<T extends ChannelMap> = {
18
+ [key in keyof T]: ChannelDefinition<T[key]>
19
+ }
20
+
21
+ export type ContextGenerator<Context> =
22
+ (i: ProcedureInputs<unknown, undefined, undefined>) => MaybePromise<Context>
23
+
24
+ export type Derivation<Context, Derived> = (i: ProcedureInputs<undefined, Context, undefined>) => MaybePromise<Derived>;
25
+
26
+
27
+ export class CovenantServer<
28
+ P extends ProcedureMap,
29
+ C extends ChannelMap,
30
+ Context,
31
+ Derived,
32
+ > {
33
+ private covenant: Covenant<P, C>;
34
+ private contextGenerator: ContextGenerator<Context>;
35
+ private derivation: Derivation<Context, Derived>;
36
+ private sidekickConnection: ServerToSidekickConnection
37
+
38
+ private procedureDefinitions: ProcedureDefinitionMap<P, Context, Derived>;
39
+ private channelDefinitions: ChannelDefinitionMap<C>;
40
+ private logger: Logger;
41
+
42
+ constructor(covenant: Covenant<P, C>, {
43
+ contextGenerator,
44
+ derivation,
45
+ sidekickConnection,
46
+ logLevel,
47
+ }: {
48
+ contextGenerator: ContextGenerator<Context>,
49
+ derivation: Derivation<Context, Derived>,
50
+ sidekickConnection: ServerToSidekickConnection,
51
+ logLevel?: LoggerLevel,
52
+ }) {
53
+ this.covenant = covenant;
54
+ this.contextGenerator = contextGenerator;
55
+ this.derivation = derivation;
56
+ this.sidekickConnection = sidekickConnection;
57
+ this.logger = new Logger(logLevel ?? "info", [
58
+ () => new Date().toUTCString(),
59
+ ]);
60
+
61
+
62
+ // both of these fail. We leave them emtpy and let the user
63
+ // define them later. The `assertAllDefined can be used to do`
64
+ // a check to ensure all channels and procedures are defined
65
+ //
66
+ //@ts-expect-error see above
67
+ this.procedureDefinitions = {};
68
+ //@ts-expect-error see above
69
+ this.channelDefinitions = {};
70
+ }
71
+
72
+ defineProcedure<N extends keyof P>(name: N, definition: ProcedureDefinition<P[N], Context, Derived>) {
73
+ if (this.procedureDefinitions[name] !== undefined) {
74
+ throw new Error(`Tried to define ${String(name)} twice!`);
75
+ }
76
+
77
+ this.procedureDefinitions[name] = definition;
78
+ }
79
+
80
+ defineChannel<N extends keyof C>(name: N, definition: ChannelDefinition<C[N]>) {
81
+ if (this.channelDefinitions[name] !== undefined) {
82
+ throw new Error(`Tried to define ${String(name)} twice!`);
83
+ }
84
+
85
+ this.channelDefinitions[name] = definition;
86
+ }
87
+
88
+
89
+ async sendMessage<N extends keyof C>(
90
+ name: N,
91
+ params: ArrayToMap<C[N]["params"]>,
92
+ message: StandardSchemaV1.InferOutput<C[N]["serverMessage"]>
93
+ ): Promise<Error | null> {
94
+ this.logger.info(`Sending message to ${String(name)} with params ${JSON.stringify(params)}`);
95
+ return await this.sidekickConnection.postMessage({
96
+ channel: String(name),
97
+ params,
98
+ data: message,
99
+ });
100
+ }
101
+
102
+ async postChannelMessage<N extends keyof C>(
103
+ name: N,
104
+ params: ArrayToMap<C[N]["params"]>,
105
+ message: StandardSchemaV1.InferOutput<C[N]["serverMessage"]>
106
+ ): Promise<Error | null> {
107
+ return await this.sendMessage(name, params, message);
108
+ }
109
+
110
+ async processChannelMessage(channelName: string, params: Record<string, string>, data: any, context: any): Promise<{ fault: "client" | "server"; message: string } | null> {
111
+ let l = this.logger.sublogger(`CHANNEL ${channelName}`);
112
+ try {
113
+ const declaration = this.covenant.channels[channelName];
114
+ const definition = this.channelDefinitions[channelName];
115
+
116
+ if (!declaration || !definition) {
117
+ return {
118
+ fault: "server",
119
+ message: `Channel ${channelName} not found`,
120
+ };
121
+ }
122
+
123
+ // Validate client message
124
+ const validation = await declaration.clientMessage["~standard"].validate(data);
125
+ if (validation.issues) {
126
+ return {
127
+ fault: "client",
128
+ message: `Invalid message data: ${issuesToString(validation.issues)}`,
129
+ };
130
+ }
131
+
132
+ // Call onMessage handler
133
+ try {
134
+ await definition.onMessage({
135
+ inputs: validation.value,
136
+ params: params as any,
137
+ context,
138
+ error(reason: string, cause: "client" | "server"): never {
139
+ throw new ThrowableChannelError(reason, channelName, params, cause);
140
+ },
141
+ });
142
+
143
+ l.info(`Processed message successfully`);
144
+ return null;
145
+ } catch (e) {
146
+ const error = channelErrorFromUnknown(e, channelName, params);
147
+ l.error(`Message processing failed: ${error.message}`);
148
+ return {
149
+ fault: error.fault === "sidekick" ? "server" : error.fault,
150
+ message: error.message,
151
+ };
152
+ }
153
+ } catch (e) {
154
+ const error = e instanceof Error ? e.message : String(e);
155
+ l.error(`Unexpected error: ${error}`);
156
+ return {
157
+ fault: "server",
158
+ message: error,
159
+ };
160
+ }
161
+ }
162
+
163
+ assertAllDefined(): void {
164
+ for (const p of Object.keys(this.covenant.procedures)) {
165
+ if (this.procedureDefinitions[p] === undefined) {
166
+ this.logger.fatal(`Procedure ${p} was not defined`);
167
+ }
168
+ }
169
+
170
+ for (const c of Object.keys(this.covenant.channels)) {
171
+ if (this.channelDefinitions[c] === undefined) {
172
+ this.logger.fatal(`Channel ${c} was not defined`);
173
+ }
174
+ }
175
+ }
176
+
177
+ private async processProcedure(request: ProcedureRequest, newHeaders: Headers): Promise<ProcedureResponse> {
178
+ let l = this.logger.sublogger(`PROCEDURE ${request.procedure}`);
179
+ try {
180
+ const declaration = this.covenant.procedures[request.procedure];
181
+ const definition = this.procedureDefinitions[request.procedure];
182
+
183
+ if (!declaration || !definition) {
184
+ throw new ThrowableProcedureError(`Procedure ${request.procedure} not found`, 404);
185
+ }
186
+
187
+ const validationResult = await declaration.input["~standard"].validate(request.input);
188
+
189
+ if (validationResult.issues) {
190
+ throw new ThrowableProcedureError(`Error parsing procedure inputs: ${issuesToString(validationResult.issues)}`, 404);
191
+ }
192
+
193
+ const initialInputs: ProcedureInputs<any, undefined, undefined> = {
194
+ inputs: validationResult.value,
195
+ request,
196
+ ctx: undefined,
197
+ derived: undefined,
198
+ logger: l,
199
+ setHeader(name: string, value: string) {
200
+ newHeaders.set(name, value);
201
+ },
202
+ deleteHeader(name: string) {
203
+ newHeaders.delete(name);
204
+ },
205
+ error(message, code) {
206
+ throw new ThrowableProcedureError(message, code);
207
+ }
208
+ }
209
+
210
+ const ctx: Context = await this.contextGenerator(initialInputs);
211
+ const derived: Derived = await this.derivation({ ...initialInputs, ctx });
212
+ const result = await definition.procedure({ ...initialInputs, ctx, derived });
213
+ const resources = await definition.resources({ inputs: validationResult.value, ctx, outputs: result, logger: l });
214
+
215
+ if (declaration.type === "mutation") {
216
+ this.sidekickConnection.update(resources).then((e) => {
217
+ if (e !== null) {
218
+ l.error(`Failed to send resource updates for ${resources.toString()} - ${e.message}`);
219
+ }
220
+ });
221
+ }
222
+
223
+ l.info("Returning OK")
224
+ return {
225
+ status: "OK",
226
+ data: result,
227
+ resources,
228
+ }
229
+
230
+ } catch (e) {
231
+ const error = procedureErrorFromUnknown(e);
232
+ l.error(`Returning ERR ${error.code} - ${error.message}`);
233
+ return {
234
+ status: "ERR",
235
+ error,
236
+ }
237
+ }
238
+ }
239
+
240
+ private async handleProcedure(request: Request): Promise<Response> {
241
+ const { data: parsed, error, success } = await parseRequest(request);
242
+
243
+ if (!success) {
244
+ this.logger.error(`Failed parsing procedure request: ${error.message}`);
245
+ return new Response(`Error parsing request body. If you're a dev seeing this then this is probably my bad not yours. Create an issue on the covenant rpc github: ${error.message}`);
246
+ }
247
+
248
+ const headers = new Headers();
249
+ headers.set("Content-Type", "application/json");
250
+
251
+ const res = await this.processProcedure(parsed, headers);
252
+
253
+ const status = res.status === "OK" ? 201 : res.error.code;
254
+
255
+ return new Response(ION.stringify(res), {
256
+ headers,
257
+ status,
258
+ });
259
+ }
260
+
261
+ private async handleChannelMessage(request: Request): Promise<Response> {
262
+ let l = this.logger.sublogger(`CHANNEL_MESSAGE`);
263
+ try {
264
+ const bodyText = await request.text();
265
+ const body = ION.parse(bodyText);
266
+ const validation = v.parseSafe(body, serverMessageWithContext);
267
+
268
+ if (validation === null) {
269
+ throw new Error(`Invalid channel message: ${JSON.stringify(body)}`);
270
+ }
271
+
272
+ const { channel, params, data, context } = validation;
273
+
274
+ const result = await this.processChannelMessage(channel, params, data, context);
275
+
276
+ if (result !== null) {
277
+ l.error(`Channel message processing failed: ${result.fault} - ${result.message}`);
278
+ return new Response(ION.stringify(result), {
279
+ status: 400,
280
+ headers: { "Content-Type": "application/json" },
281
+ });
282
+ }
283
+
284
+ return new Response(null, {
285
+ status: 204,
286
+ });
287
+ } catch (e) {
288
+ const error = e instanceof Error ? e.message : String(e);
289
+ l.error(`Channel message failed: ${error}`);
290
+ return new Response(ION.stringify({ error }), {
291
+ status: 500,
292
+ headers: { "Content-Type": "application/json" },
293
+ });
294
+ }
295
+ }
296
+
297
+ private async handleConnectionRequest(request: Request): Promise<Response> {
298
+ let l = this.logger.sublogger(`CONNECTION`);
299
+
300
+ let channelName = "unknown";
301
+ let params: Record<string, string> = {};
302
+
303
+ try {
304
+ const bodyText = await request.text();
305
+ const body = ION.parse(bodyText);
306
+ const validation = v.parseSafe(body, channelConnectionRequestSchema);
307
+
308
+ if (validation === null) {
309
+ throw new Error(`Invalid connection request: ${JSON.stringify(body)}`);
310
+ }
311
+
312
+ channelName = validation.channel;
313
+ params = validation.params;
314
+ const data = validation.data;
315
+
316
+ const declaration = this.covenant.channels[channelName];
317
+ const definition = this.channelDefinitions[channelName];
318
+
319
+ if (!declaration || !definition) {
320
+ throw new ThrowableChannelError(
321
+ `Channel ${channelName} not found`,
322
+ channelName,
323
+ params,
324
+ "server"
325
+ );
326
+ }
327
+
328
+ // Validate connection request data
329
+ const connectionRequestValidation = await declaration.connectionRequest["~standard"].validate(data);
330
+ if (connectionRequestValidation.issues) {
331
+ throw new ThrowableChannelError(
332
+ `Invalid connection request data: ${issuesToString(connectionRequestValidation.issues)}`,
333
+ channelName,
334
+ params,
335
+ "client"
336
+ );
337
+ }
338
+
339
+ // Call onConnect handler
340
+ const context = await definition.onConnect({
341
+ inputs: connectionRequestValidation.value,
342
+ params: params as any,
343
+ reject(reason: string, cause: "client" | "server"): never {
344
+ throw new ThrowableChannelError(reason, channelName, params, cause);
345
+ },
346
+ });
347
+
348
+ // Generate token
349
+ const token = globalThis.crypto.randomUUID();
350
+
351
+ // Add connection to sidekick
352
+ await this.sidekickConnection.addConnection({
353
+ token,
354
+ channel: channelName,
355
+ params,
356
+ context,
357
+ });
358
+
359
+ l.info(`Connection established for ${channelName} with token ${token}`);
360
+
361
+ const response: ChannelConnectionResponse = {
362
+ channel: channelName,
363
+ params,
364
+ result: {
365
+ type: "OK",
366
+ token,
367
+ },
368
+ };
369
+
370
+ return new Response(ION.stringify(response), {
371
+ status: 200,
372
+ headers: { "Content-Type": "application/json" },
373
+ });
374
+ } catch (e) {
375
+ if (e instanceof ThrowableChannelError) {
376
+ const error = e.toChannelError();
377
+ l.error(`Connection rejected: ${error.message}`);
378
+
379
+ const response: ChannelConnectionResponse = {
380
+ channel: channelName,
381
+ params,
382
+ result: {
383
+ type: "ERROR",
384
+ error,
385
+ },
386
+ };
387
+
388
+ return new Response(ION.stringify(response), {
389
+ status: 400,
390
+ headers: { "Content-Type": "application/json" },
391
+ });
392
+ }
393
+
394
+ const error = e instanceof Error ? e.message : String(e);
395
+ l.error(`Connection failed: ${error}`);
396
+ return new Response(ION.stringify({ error }), {
397
+ status: 500,
398
+ headers: { "Content-Type": "application/json" },
399
+ });
400
+ }
401
+ }
402
+
403
+ async handle(request: Request): Promise<Response> {
404
+ const url = new URL(request.url);
405
+ const type = url.searchParams.get("type") ?? "procedure";
406
+
407
+ if (request.method !== "POST") {
408
+ return new Response("Covenant servers only handle POST requests", { status: 404 });
409
+ }
410
+
411
+ let response = new Response();
412
+
413
+ switch (type) {
414
+ case "channel":
415
+ response = await this.handleChannelMessage(request);
416
+ break;
417
+ case "procedure":
418
+ response = await this.handleProcedure(request);
419
+ break;
420
+ case "connect":
421
+ response = await this.handleConnectionRequest(request);
422
+ break;
423
+ }
424
+
425
+ return response;
426
+ }
427
+ }
428
+
429
+
430
+ export async function parseRequest(request: Request): AsyncResult<ProcedureRequest> {
431
+ try {
432
+ const bodyText = await request.text();
433
+ const body = ION.parse(bodyText);
434
+ const result = v.parseSafe(body, procedureRequestBodySchema);
435
+
436
+ if (result === null) {
437
+ throw new Error(`Failed to parse body as a ProcedureRequestBody: ${JSON.stringify(body)}`);
438
+ }
439
+ const url = new URL(request.url);
440
+
441
+ return ok({
442
+ headers: request.headers,
443
+ input: result.inputs,
444
+ procedure: result.procedure,
445
+ path: url.pathname,
446
+ url: url.toString(),
447
+ req: request
448
+ });
449
+ } catch (e) {
450
+ return err(e instanceof Error ? e : new Error(`Unknown error: ${e}`));
451
+ }
452
+
453
+ }
@@ -0,0 +1,173 @@
1
+ import type { PublishFunction, SidekickClient, SidekickState } from ".";
2
+ import type { Logger } from "@covenant-rpc/core/logger";
3
+ import type { ListenMessage, SendMessage, SubscribeMessage, UnlistenMessage, UnsubscribeMessage } from "@covenant-rpc/core/sidekick/protocol";
4
+ import { getChannelTopicName, getMapId, getResourceTopicName } from "@covenant-rpc/core/sidekick/protocol";
5
+
6
+
7
+ export interface SidekickHandlerContext {
8
+ state: SidekickState;
9
+ client: SidekickClient;
10
+ publish: PublishFunction;
11
+ logger: Logger;
12
+ }
13
+
14
+ export async function handleListenMessage(message: ListenMessage, { client, logger }: SidekickHandlerContext) {
15
+ for (const resource of message.resources) {
16
+ client.subscribe(getResourceTopicName(resource));
17
+ }
18
+
19
+ logger.info(`Listened to ${message.resources.toString()}`);
20
+
21
+ client.directMessage({
22
+ type: "listening",
23
+ resources: message.resources,
24
+ });
25
+ }
26
+
27
+ export async function handleUnlistenMessage(message: UnlistenMessage, { client, logger }: SidekickHandlerContext) {
28
+ for (const resource of message.resources) {
29
+ client.unsubscribe(getResourceTopicName(resource));
30
+ }
31
+ logger.info(`Unlistened to ${message.resources.toString()}`);
32
+
33
+ client.directMessage({
34
+ type: "unlistening",
35
+ resources: message.resources,
36
+ });
37
+ }
38
+
39
+ export async function handleSendMessage(message: SendMessage, { client, logger, state }: SidekickHandlerContext) {
40
+ // Check if token exists in unused tokens (not yet subscribed)
41
+ let payload = state.tokenMap.get(message.token);
42
+ let context: unknown = undefined;
43
+
44
+ if (payload) {
45
+ // Token exists but client hasn't subscribed yet - use the context from the payload
46
+ context = payload.context;
47
+
48
+ // Verify the channel and params match
49
+ if (payload.channel !== message.channel) {
50
+ logger.error(`Token is for channel ${payload.channel} but message is for ${message.channel}`);
51
+ client.directMessage({
52
+ type: "error",
53
+ error: {
54
+ channel: message.channel,
55
+ params: message.params,
56
+ fault: "client",
57
+ message: "Token channel mismatch",
58
+ }
59
+ });
60
+ return;
61
+ }
62
+ } else {
63
+ // Check if token has been used (client is subscribed)
64
+ const usedToken = state.usedTokenMap.get(message.token);
65
+ if (!usedToken || usedToken.id !== client.getId()) {
66
+ logger.error(`Invalid or unauthorized token for send`);
67
+ client.directMessage({
68
+ type: "error",
69
+ error: {
70
+ channel: message.channel,
71
+ params: message.params,
72
+ fault: "client",
73
+ message: "Invalid or unauthorized token",
74
+ }
75
+ });
76
+ return;
77
+ }
78
+
79
+ // Get context from context map
80
+ const topic = getChannelTopicName(usedToken.channel, usedToken.params);
81
+ const mapId = getMapId(client.getId(), topic);
82
+ context = state.contextMap.get(mapId);
83
+ }
84
+
85
+ const result = await state.serverConnection.sendMessage({
86
+ params: message.params,
87
+ channel: message.channel,
88
+ data: message.data,
89
+ context,
90
+ });
91
+
92
+ if (result !== null) {
93
+ logger.error(`Got bad response sending message: ${result.fault} - ${result.message}`);
94
+ client.directMessage({
95
+ type: "error",
96
+ error: result,
97
+ });
98
+ return;
99
+ }
100
+
101
+ logger.info(`Processed message successfully`);
102
+ }
103
+
104
+ export async function handleSubscribeMessage(message: SubscribeMessage, { client, logger, state }: SidekickHandlerContext) {
105
+ const payload = state.tokenMap.get(message.token);
106
+ if (!payload) {
107
+ logger.error("Failed to subscribe: bad input token");
108
+ // TODO - add a time delay here to avoid brute forcing input tokens?
109
+ // probably not. This level of abstraction doesn't feel right for that
110
+ client.directMessage({
111
+ type: "error",
112
+ error: {
113
+ fault: "client",
114
+ message: "Inputted invalid token for subscription",
115
+ channel: "???unknown",
116
+ params: {},
117
+ }
118
+ });
119
+ return;
120
+ }
121
+
122
+ const topic = getChannelTopicName(payload.channel, payload.params);
123
+ const mapId = getMapId(client.getId(), topic);
124
+
125
+ state.tokenMap.delete(message.token);
126
+ state.contextMap.set(mapId, payload.context);
127
+ client.subscribe(topic);
128
+ state.usedTokenMap.set(message.token, {
129
+ id: client.getId(),
130
+ channel: payload.channel,
131
+ params: payload.params,
132
+ })
133
+
134
+
135
+ logger.info(`Subscribed to ${topic}`);
136
+ client.directMessage({
137
+ type: "subscribed",
138
+ channel: payload.channel,
139
+ params: payload.params,
140
+ });
141
+ }
142
+
143
+ export async function handleUnsubscribeMessage(message: UnsubscribeMessage, { client, logger, state }: SidekickHandlerContext) {
144
+ const data = state.usedTokenMap.get(message.token);
145
+ const id = client.getId();
146
+ if (!data || data.id !== id) {
147
+ logger.error("Failed to unsubscribe: bad input token");
148
+ client.directMessage({
149
+ type: "error",
150
+ error: {
151
+ fault: "client",
152
+ message: "Inputted invalid token for unsubscription",
153
+ channel: "???unknown",
154
+ params: {},
155
+ }
156
+ })
157
+ return;
158
+ }
159
+ const topic = getChannelTopicName(data.channel, data.params);
160
+ const mapId = getMapId(id, topic);
161
+
162
+ state.usedTokenMap.delete(message.token);
163
+ state.contextMap.delete(mapId);
164
+ client.unsubscribe(topic);
165
+
166
+ logger.info(`Unsubscribed from ${topic}`);
167
+ client.directMessage({
168
+ type: "unsubscribed",
169
+ channel: data.channel,
170
+ params: data.params,
171
+ });
172
+
173
+ }