@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/CHANGELOG.md +17 -0
- package/lib/adapters/vanilla.ts +9 -0
- package/lib/index.ts +11 -0
- package/lib/interfaces/direct.ts +116 -0
- package/lib/interfaces/empty.ts +9 -0
- package/lib/interfaces/http.ts +111 -0
- package/lib/interfaces/mock.ts +32 -0
- package/lib/logger.ts +79 -0
- package/lib/server.ts +453 -0
- package/lib/sidekick/handlers.ts +173 -0
- package/lib/sidekick/index.ts +109 -0
- package/lib/sidekick/socket.ts +5 -0
- package/package.json +21 -0
- package/tests/channel-http.test.ts +481 -0
- package/tests/channel.test.ts +689 -0
- package/tests/procedure.test.ts +238 -0
- package/tests/sidekick.test.ts +23 -0
- package/tests/validation-types.test.ts +122 -0
- package/tests/validation.test.ts +144 -0
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
|
+
}
|