@sockethub/server 5.0.0-alpha.10

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 (46) hide show
  1. package/LICENSE +165 -0
  2. package/README.md +130 -0
  3. package/bin/sockethub +4 -0
  4. package/dist/defaults.json +36 -0
  5. package/dist/index.js +166465 -0
  6. package/dist/index.js.map +1877 -0
  7. package/dist/platform.js +103625 -0
  8. package/dist/platform.js.map +1435 -0
  9. package/package.json +100 -0
  10. package/res/socket.io.js +4908 -0
  11. package/res/sockethub-client.js +631 -0
  12. package/res/sockethub-client.min.js +19 -0
  13. package/src/bootstrap/init.d.ts +21 -0
  14. package/src/bootstrap/init.test.ts +211 -0
  15. package/src/bootstrap/init.ts +160 -0
  16. package/src/bootstrap/load-platforms.ts +151 -0
  17. package/src/config.test.ts +33 -0
  18. package/src/config.ts +98 -0
  19. package/src/defaults.json +36 -0
  20. package/src/index.ts +68 -0
  21. package/src/janitor.test.ts +211 -0
  22. package/src/janitor.ts +157 -0
  23. package/src/listener.ts +173 -0
  24. package/src/middleware/create-activity-object.test.ts +30 -0
  25. package/src/middleware/create-activity-object.ts +22 -0
  26. package/src/middleware/expand-activity-stream.test.data.ts +351 -0
  27. package/src/middleware/expand-activity-stream.test.ts +77 -0
  28. package/src/middleware/expand-activity-stream.ts +37 -0
  29. package/src/middleware/store-credentials.test.ts +85 -0
  30. package/src/middleware/store-credentials.ts +16 -0
  31. package/src/middleware/validate.test.data.ts +259 -0
  32. package/src/middleware/validate.test.ts +44 -0
  33. package/src/middleware/validate.ts +73 -0
  34. package/src/middleware.test.ts +184 -0
  35. package/src/middleware.ts +71 -0
  36. package/src/platform-instance.test.ts +531 -0
  37. package/src/platform-instance.ts +360 -0
  38. package/src/platform.test.ts +375 -0
  39. package/src/platform.ts +358 -0
  40. package/src/process-manager.ts +88 -0
  41. package/src/routes.test.ts +54 -0
  42. package/src/routes.ts +61 -0
  43. package/src/sentry.test.ts +106 -0
  44. package/src/sentry.ts +19 -0
  45. package/src/sockethub.ts +198 -0
  46. package/src/util.ts +5 -0
@@ -0,0 +1,360 @@
1
+ import { type ChildProcess, fork } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import debug from "debug";
4
+
5
+ import { type JobDataDecrypted, JobQueue } from "@sockethub/data-layer";
6
+ import type {
7
+ ActivityStream,
8
+ CompletedJobHandler,
9
+ InternalActivityStream,
10
+ Logger,
11
+ PlatformConfig,
12
+ } from "@sockethub/schemas";
13
+ import type { Socket } from "socket.io";
14
+
15
+ import config from "./config.js";
16
+ import { getSocket } from "./listener.js";
17
+ import { __dirname } from "./util.js";
18
+
19
+ // collection of platform instances, stored by `id`
20
+ export const platformInstances = new Map<string, PlatformInstance>();
21
+
22
+ export interface PlatformInstanceParams {
23
+ identifier: string;
24
+ platform: string;
25
+ parentId?: string;
26
+ actor?: string;
27
+ }
28
+
29
+ type EnvFormat = {
30
+ DEBUG?: string;
31
+ REDIS_URL: string;
32
+ };
33
+
34
+ interface MessageFromPlatform extends Array<string | ActivityStream> {
35
+ 0: string;
36
+ 1: ActivityStream;
37
+ 2: string;
38
+ }
39
+
40
+ export interface MessageFromParent extends Array<string | unknown> {
41
+ 0: string;
42
+ 1: unknown;
43
+ }
44
+
45
+ export default class PlatformInstance {
46
+ id: string;
47
+ flaggedForTermination = false;
48
+ queue: JobQueue;
49
+ JobQueue: typeof JobQueue;
50
+ getSocket: typeof getSocket;
51
+ readonly global: boolean = false;
52
+ readonly completedJobHandlers: Map<string, CompletedJobHandler> = new Map();
53
+ config: PlatformConfig;
54
+ readonly name: string;
55
+ process: ChildProcess;
56
+ readonly debug: Logger;
57
+ readonly parentId: string;
58
+ readonly sessions: Set<string> = new Set();
59
+ readonly sessionCallbacks: object = {
60
+ close: (() => new Map())(),
61
+ message: (() => new Map())(),
62
+ };
63
+ private readonly actor?: string;
64
+
65
+ constructor(params: PlatformInstanceParams) {
66
+ this.id = params.identifier;
67
+ this.name = params.platform;
68
+ this.parentId = params.parentId;
69
+ if (params.actor) {
70
+ this.actor = params.actor;
71
+ } else {
72
+ this.global = true;
73
+ }
74
+
75
+ this.debug = debug(`sockethub:server:platform-instance:${this.id}`);
76
+ const env: EnvFormat = {
77
+ REDIS_URL: config.get("redis:url") as string,
78
+ };
79
+ if (process.env.DEBUG) {
80
+ env.DEBUG = process.env.DEBUG;
81
+ }
82
+
83
+ this.createQueue();
84
+ this.initProcess(this.parentId, this.name, this.id, env);
85
+ this.createGetSocket();
86
+ }
87
+
88
+ createQueue() {
89
+ this.JobQueue = JobQueue;
90
+ }
91
+
92
+ initProcess(parentId: string, name: string, id: string, env: EnvFormat) {
93
+ // spin off a process
94
+ this.process = fork(
95
+ join(__dirname, "platform.js"),
96
+ [parentId, name, id],
97
+ { env: env },
98
+ );
99
+ }
100
+
101
+ createGetSocket() {
102
+ this.getSocket = getSocket;
103
+ }
104
+
105
+ /**
106
+ * Destroys all references to this platform instance, internal listeners and controlled processes
107
+ */
108
+ public async shutdown() {
109
+ this.debug("platform process shutdown");
110
+ this.flaggedForTermination = true;
111
+
112
+ try {
113
+ this.process.removeAllListeners("close");
114
+ this.process.unref();
115
+ this.process.kill();
116
+ } catch (e) {
117
+ // needs to happen
118
+ }
119
+
120
+ try {
121
+ await this.queue.shutdown();
122
+ this.queue = undefined;
123
+ } catch (e) {
124
+ // this needs to happen
125
+ }
126
+
127
+ try {
128
+ platformInstances.delete(this.id);
129
+ } catch (e) {
130
+ // this needs to happen
131
+ }
132
+ }
133
+
134
+ /**
135
+ * When jobs are completed or failed, we prepare the results and send them to the client socket
136
+ */
137
+ public initQueue(secret: string) {
138
+ this.queue = new this.JobQueue(
139
+ this.parentId,
140
+ this.id,
141
+ secret,
142
+ config.get("redis"),
143
+ );
144
+
145
+ this.queue.on(
146
+ "completed",
147
+ async (
148
+ job: JobDataDecrypted,
149
+ result: ActivityStream | undefined,
150
+ ) => {
151
+ await this.handleJobResult("completed", job, result);
152
+ },
153
+ );
154
+
155
+ this.queue.on(
156
+ "failed",
157
+ async (
158
+ job: JobDataDecrypted,
159
+ result: ActivityStream | undefined,
160
+ ) => {
161
+ await this.handleJobResult("failed", job, result);
162
+ },
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Register listener to be called when the process emits a message.
168
+ * @param sessionId ID of socket connection that will receive messages from platform emits
169
+ */
170
+ public registerSession(sessionId: string) {
171
+ if (!this.sessions.has(sessionId)) {
172
+ this.sessions.add(sessionId);
173
+ for (const type of Object.keys(this.sessionCallbacks)) {
174
+ const cb = this.callbackFunction(type, sessionId);
175
+ this.process.on(type, cb);
176
+ this.sessionCallbacks[type].set(sessionId, cb);
177
+ }
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Sends a message to client (user), can be registered with an event emitted from the platform
183
+ * process.
184
+ * @param sessionId ID of the socket connection to send the message to
185
+ * @param msg ActivityStream object to send to client
186
+ */
187
+ public async sendToClient(sessionId: string, msg: InternalActivityStream) {
188
+ return this.getSocket(sessionId).then(
189
+ (socket: Socket) => {
190
+ try {
191
+ // this property should never be exposed externally
192
+ // biome-ignore lint/performance/noDelete: <explanation>
193
+ delete msg.sessionSecret;
194
+ } finally {
195
+ msg.context = this.name;
196
+ if (
197
+ msg.type === "error" &&
198
+ typeof msg.actor === "undefined" &&
199
+ this.actor
200
+ ) {
201
+ // ensure an actor is present if not otherwise defined
202
+ msg.actor = { id: this.actor, type: "unknown" };
203
+ }
204
+ socket.emit("message", msg as ActivityStream);
205
+ }
206
+ },
207
+ (err) => this.debug(`sendToClient ${err}`),
208
+ );
209
+ }
210
+
211
+ // send message to every connected socket associated with this platform instance.
212
+ private broadcastToSharedPeers(sessionId: string, msg: ActivityStream) {
213
+ for (const sid of this.sessions.values()) {
214
+ if (sid !== sessionId) {
215
+ this.debug(`broadcasting message to ${sid}`);
216
+ this.sendToClient(sid, msg);
217
+ }
218
+ }
219
+ }
220
+
221
+ // handle job results coming in on the queue from platform instances
222
+ private async handleJobResult(
223
+ state: string,
224
+ job: JobDataDecrypted,
225
+ result: ActivityStream | undefined,
226
+ ) {
227
+ let payload = result; // some platforms return new AS objects as result
228
+ if (state === "failed") {
229
+ payload = job.msg; // failures always use original AS job object
230
+ payload.error = result
231
+ ? result.toString()
232
+ : "job failed for unknown reason";
233
+ }
234
+ this.debug(
235
+ `${job.title} ${state}${payload?.error ? `: ${payload.error}` : ""}`,
236
+ );
237
+
238
+ if (!payload || typeof payload === "string") {
239
+ payload = job.msg;
240
+ }
241
+
242
+ // send result to client
243
+ const callback = this.completedJobHandlers.get(job.title);
244
+ if (callback) {
245
+ callback(payload);
246
+ this.completedJobHandlers.delete(job.title);
247
+ } else {
248
+ await this.sendToClient(job.sessionId, payload);
249
+ }
250
+
251
+ if (payload) {
252
+ // let all related peers know of result as an independent message
253
+ // (not as part of a job completion, or failure)
254
+ this.broadcastToSharedPeers(job.sessionId, payload);
255
+ }
256
+
257
+ // persistent
258
+ if (
259
+ this.config.persist &&
260
+ this.config.requireCredentials?.includes(job.msg.type)
261
+ ) {
262
+ if (state === "failed") {
263
+ // Only terminate if platform is not yet initialized
264
+ // If already initialized, credential failures are non-fatal (wrong session credentials)
265
+ if (!this.config.initialized) {
266
+ this.debug(
267
+ `critical job type ${job.msg.type} failed during initialization, flagging for termination`,
268
+ );
269
+ await this.queue.pause();
270
+ this.config.initialized = false;
271
+ this.flaggedForTermination = true;
272
+ } else {
273
+ this.debug(
274
+ `credential job ${job.msg.type} failed on initialized platform, not flagged for termination`,
275
+ );
276
+ // Platform stays alive - error sent to client via sendToClient above
277
+ }
278
+ } else {
279
+ this.debug("persistent platform initialized");
280
+ await this.queue.resume();
281
+ this.config.initialized = true;
282
+ this.flaggedForTermination = false;
283
+ }
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Sends error message to client and clears all references to this class.
289
+ * @param sessionId
290
+ * @param errorMessage
291
+ */
292
+ private async reportError(sessionId: string, errorMessage: string) {
293
+ const errorObject: ActivityStream = {
294
+ context: this.name,
295
+ type: "error",
296
+ actor: { id: this.actor, type: "unknown" },
297
+ error: errorMessage,
298
+ };
299
+
300
+ // Only attempt to send to client if we have a valid session
301
+ try {
302
+ if (sessionId && this.sessions.has(sessionId)) {
303
+ await this.sendToClient(sessionId, errorObject);
304
+ }
305
+ } catch (err) {
306
+ this.debug(`Failed to send error to client: ${err.message}`);
307
+ }
308
+
309
+ this.sessions.clear();
310
+ await this.shutdown();
311
+ }
312
+
313
+ /**
314
+ * Updates the instance with a new identifier, updating the platformInstances mapping as well.
315
+ * @param identifier
316
+ */
317
+ private updateIdentifier(identifier: string) {
318
+ platformInstances.delete(this.id);
319
+ this.id = identifier;
320
+ platformInstances.set(this.id, this);
321
+ }
322
+
323
+ /**
324
+ * Generates a function tied to a given client session (socket connection), the generated
325
+ * function will be called for each session ID registered, for every platform emit.
326
+ * @param listener
327
+ * @param sessionId
328
+ */
329
+ private callbackFunction(listener: string, sessionId: string) {
330
+ const funcs = {
331
+ close: async (e: object) => {
332
+ this.debug(`close event triggered ${this.id}: ${e}`);
333
+ // Check if process is still connected before attempting error reporting
334
+ if (this.process?.connected && !this.flaggedForTermination) {
335
+ await this.reportError(
336
+ sessionId,
337
+ `Error: session thread closed unexpectedly: ${e}`,
338
+ );
339
+ } else {
340
+ this.debug(
341
+ "Process already disconnected or flagged for termination, skipping error report",
342
+ );
343
+ await this.shutdown();
344
+ }
345
+ },
346
+ message: async ([first, second, third]: MessageFromPlatform) => {
347
+ if (first === "updateActor") {
348
+ // We need to update the key to the store in order to find it in the future.
349
+ this.updateIdentifier(third);
350
+ } else if (first === "error" && typeof second === "string") {
351
+ await this.reportError(sessionId, second);
352
+ } else {
353
+ // treat like a message to clients
354
+ await this.sendToClient(sessionId, second);
355
+ }
356
+ },
357
+ };
358
+ return funcs[listener];
359
+ }
360
+ }