@sockethub/server 5.0.0-alpha.11 → 5.0.0-alpha.12
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/dist/defaults.json +4 -0
- package/dist/index.js +46734 -46237
- package/dist/index.js.map +198 -196
- package/dist/platform.js +17926 -1483
- package/dist/platform.js.map +180 -31
- package/package.json +14 -14
- package/res/sockethub-client.js +17452 -288
- package/res/sockethub-client.min.js +32 -4
- package/src/bootstrap/init.test-helpers.ts +75 -0
- package/src/bootstrap/init.test.ts +70 -106
- package/src/bootstrap/init.ts +45 -27
- package/src/bootstrap/load-platforms.ts +39 -8
- package/src/config.ts +12 -1
- package/src/defaults.json +4 -0
- package/src/index.ts +9 -7
- package/src/janitor.ts +3 -1
- package/src/listener.ts +8 -9
- package/src/middleware/create-activity-object.ts +1 -1
- package/src/middleware/expand-activity-stream.test.data.ts +30 -23
- package/src/middleware/expand-activity-stream.ts +11 -8
- package/src/middleware/store-credentials.test.ts +5 -1
- package/src/middleware/store-credentials.ts +11 -5
- package/src/middleware/validate.test.data.ts +132 -16
- package/src/middleware/validate.test.ts +6 -2
- package/src/middleware/validate.ts +137 -30
- package/src/middleware.ts +26 -22
- package/src/platform-instance.test.ts +41 -6
- package/src/platform-instance.ts +164 -25
- package/src/platform.test.ts +11 -2
- package/src/platform.ts +135 -19
- package/src/process-manager.ts +30 -4
- package/src/rate-limiter.ts +5 -1
- package/src/routes.ts +16 -8
- package/src/sockethub.ts +192 -83
package/src/platform-instance.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type ChildProcess, fork } from "node:child_process";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { type JobDataDecrypted, JobQueue } from "@sockethub/data-layer";
|
|
5
|
+
import { createLogger } from "@sockethub/logger";
|
|
5
6
|
import type {
|
|
6
7
|
ActivityStream,
|
|
7
8
|
CompletedJobHandler,
|
|
@@ -9,9 +10,11 @@ import type {
|
|
|
9
10
|
Logger,
|
|
10
11
|
PlatformConfig,
|
|
11
12
|
} from "@sockethub/schemas";
|
|
13
|
+
import {
|
|
14
|
+
buildCanonicalContext,
|
|
15
|
+
INTERNAL_PLATFORM_CONTEXT_URL,
|
|
16
|
+
} from "@sockethub/schemas";
|
|
12
17
|
import type { Socket } from "socket.io";
|
|
13
|
-
|
|
14
|
-
import { createLogger } from "@sockethub/logger";
|
|
15
18
|
import config from "./config.js";
|
|
16
19
|
import { getSocket } from "./listener.js";
|
|
17
20
|
import { __dirname } from "./util.js";
|
|
@@ -29,19 +32,29 @@ export interface PlatformInstanceParams {
|
|
|
29
32
|
type EnvFormat = {
|
|
30
33
|
LOG_LEVEL?: string;
|
|
31
34
|
REDIS_URL: string;
|
|
35
|
+
SOCKETHUB_PLATFORM_CHILD?: string;
|
|
36
|
+
SOCKETHUB_PLATFORM_HEARTBEAT_INTERVAL_MS?: string;
|
|
37
|
+
SOCKETHUB_PLATFORM_HEARTBEAT_TIMEOUT_MS?: string;
|
|
32
38
|
};
|
|
33
39
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
type MessageFromPlatform =
|
|
41
|
+
| ["updateActor", ActivityStream | undefined, string]
|
|
42
|
+
| ["error", string]
|
|
43
|
+
| ["heartbeat", ActivityStream]
|
|
44
|
+
| [string, ActivityStream, string?];
|
|
39
45
|
|
|
40
46
|
export interface MessageFromParent extends Array<string | unknown> {
|
|
41
47
|
0: string;
|
|
42
48
|
1: unknown;
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
const HEARTBEAT_INTERVAL_MS = Number(
|
|
52
|
+
config.get("platformHeartbeat:intervalMs") ?? 5000,
|
|
53
|
+
);
|
|
54
|
+
const HEARTBEAT_TIMEOUT_MS = Number(
|
|
55
|
+
config.get("platformHeartbeat:timeoutMs") ?? 15000,
|
|
56
|
+
);
|
|
57
|
+
|
|
45
58
|
export default class PlatformInstance {
|
|
46
59
|
id: string;
|
|
47
60
|
flaggedForTermination = false;
|
|
@@ -51,16 +64,24 @@ export default class PlatformInstance {
|
|
|
51
64
|
readonly global: boolean = false;
|
|
52
65
|
readonly completedJobHandlers: Map<string, CompletedJobHandler> = new Map();
|
|
53
66
|
config: PlatformConfig;
|
|
67
|
+
contextUrl?: string;
|
|
54
68
|
private initialized = false;
|
|
55
69
|
readonly name: string;
|
|
56
70
|
process: ChildProcess;
|
|
57
71
|
readonly log: Logger;
|
|
58
72
|
readonly parentId: string;
|
|
59
73
|
readonly sessions: Set<string> = new Set();
|
|
60
|
-
readonly sessionCallbacks:
|
|
61
|
-
close
|
|
62
|
-
|
|
74
|
+
readonly sessionCallbacks: Record<
|
|
75
|
+
"close" | "message",
|
|
76
|
+
Map<string, (...args: Array<unknown>) => void | Promise<void>>
|
|
77
|
+
> = {
|
|
78
|
+
close: new Map(),
|
|
79
|
+
message: new Map(),
|
|
63
80
|
};
|
|
81
|
+
private heartbeatLastSeen = Date.now();
|
|
82
|
+
private heartbeatMonitor?: NodeJS.Timeout;
|
|
83
|
+
private heartbeatListener?: (message: MessageFromPlatform) => void;
|
|
84
|
+
private heartbeatFailureHandled = false;
|
|
64
85
|
private readonly actor?: string;
|
|
65
86
|
|
|
66
87
|
constructor(params: PlatformInstanceParams) {
|
|
@@ -81,9 +102,20 @@ export default class PlatformInstance {
|
|
|
81
102
|
if (process.env.LOG_LEVEL) {
|
|
82
103
|
env.LOG_LEVEL = process.env.LOG_LEVEL;
|
|
83
104
|
}
|
|
105
|
+
const heartbeatInterval = config.get("platformHeartbeat:intervalMs");
|
|
106
|
+
if (typeof heartbeatInterval !== "undefined") {
|
|
107
|
+
env.SOCKETHUB_PLATFORM_HEARTBEAT_INTERVAL_MS =
|
|
108
|
+
String(heartbeatInterval);
|
|
109
|
+
}
|
|
110
|
+
const heartbeatTimeout = config.get("platformHeartbeat:timeoutMs");
|
|
111
|
+
if (typeof heartbeatTimeout !== "undefined") {
|
|
112
|
+
env.SOCKETHUB_PLATFORM_HEARTBEAT_TIMEOUT_MS =
|
|
113
|
+
String(heartbeatTimeout);
|
|
114
|
+
}
|
|
84
115
|
|
|
85
116
|
this.createQueue();
|
|
86
117
|
this.initProcess(this.parentId, this.name, this.id, env);
|
|
118
|
+
this.startHeartbeatMonitor();
|
|
87
119
|
this.createGetSocket();
|
|
88
120
|
}
|
|
89
121
|
|
|
@@ -119,23 +151,31 @@ export default class PlatformInstance {
|
|
|
119
151
|
this.flaggedForTermination = true;
|
|
120
152
|
|
|
121
153
|
try {
|
|
154
|
+
if (this.heartbeatMonitor) {
|
|
155
|
+
clearInterval(this.heartbeatMonitor);
|
|
156
|
+
this.heartbeatMonitor = undefined;
|
|
157
|
+
}
|
|
158
|
+
if (this.heartbeatListener) {
|
|
159
|
+
this.process.removeListener("message", this.heartbeatListener);
|
|
160
|
+
this.heartbeatListener = undefined;
|
|
161
|
+
}
|
|
122
162
|
this.process.removeAllListeners("close");
|
|
123
163
|
this.process.unref();
|
|
124
164
|
this.process.kill();
|
|
125
|
-
} catch (
|
|
165
|
+
} catch (_e) {
|
|
126
166
|
// needs to happen
|
|
127
167
|
}
|
|
128
168
|
|
|
129
169
|
try {
|
|
130
170
|
await this.queue.shutdown();
|
|
131
171
|
this.queue = undefined;
|
|
132
|
-
} catch (
|
|
172
|
+
} catch (_e) {
|
|
133
173
|
// this needs to happen
|
|
134
174
|
}
|
|
135
175
|
|
|
136
176
|
try {
|
|
137
177
|
platformInstances.delete(this.id);
|
|
138
|
-
} catch (
|
|
178
|
+
} catch (_e) {
|
|
139
179
|
// this needs to happen
|
|
140
180
|
}
|
|
141
181
|
}
|
|
@@ -179,7 +219,9 @@ export default class PlatformInstance {
|
|
|
179
219
|
public registerSession(sessionId: string) {
|
|
180
220
|
if (!this.sessions.has(sessionId)) {
|
|
181
221
|
this.sessions.add(sessionId);
|
|
182
|
-
for (const type of Object.keys(this.sessionCallbacks)
|
|
222
|
+
for (const type of Object.keys(this.sessionCallbacks) as Array<
|
|
223
|
+
"close" | "message"
|
|
224
|
+
>) {
|
|
183
225
|
const cb = this.callbackFunction(type, sessionId);
|
|
184
226
|
this.process.on(type, cb);
|
|
185
227
|
this.sessionCallbacks[type].set(sessionId, cb);
|
|
@@ -197,11 +239,11 @@ export default class PlatformInstance {
|
|
|
197
239
|
return this.getSocket(sessionId).then(
|
|
198
240
|
(socket: Socket) => {
|
|
199
241
|
try {
|
|
200
|
-
|
|
201
|
-
// biome-ignore lint/performance/noDelete: <explanation>
|
|
202
|
-
delete msg.sessionSecret;
|
|
242
|
+
this.toExternalPayload(msg as ActivityStream);
|
|
203
243
|
} finally {
|
|
204
|
-
|
|
244
|
+
const contextUrl =
|
|
245
|
+
this.contextUrl ?? INTERNAL_PLATFORM_CONTEXT_URL;
|
|
246
|
+
msg["@context"] = buildCanonicalContext(contextUrl);
|
|
205
247
|
if (
|
|
206
248
|
msg.type === "error" &&
|
|
207
249
|
typeof msg.actor === "undefined" &&
|
|
@@ -213,7 +255,7 @@ export default class PlatformInstance {
|
|
|
213
255
|
socket.emit("message", msg as ActivityStream);
|
|
214
256
|
}
|
|
215
257
|
},
|
|
216
|
-
(err) => this.log.error(`sendToClient ${err}`),
|
|
258
|
+
(err) => this.log.error(`sendToClient ${String(err)}`),
|
|
217
259
|
);
|
|
218
260
|
}
|
|
219
261
|
|
|
@@ -227,6 +269,24 @@ export default class PlatformInstance {
|
|
|
227
269
|
}
|
|
228
270
|
}
|
|
229
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Remove internal-only transport metadata before returning payloads to clients.
|
|
274
|
+
*/
|
|
275
|
+
private toExternalPayload(payload: ActivityStream): ActivityStream {
|
|
276
|
+
const external = payload as InternalActivityStream & {
|
|
277
|
+
context?: unknown;
|
|
278
|
+
};
|
|
279
|
+
delete external.sessionSecret;
|
|
280
|
+
delete external.context;
|
|
281
|
+
if (
|
|
282
|
+
typeof external.platform !== "string" ||
|
|
283
|
+
external.platform.length === 0
|
|
284
|
+
) {
|
|
285
|
+
external.platform = this.name;
|
|
286
|
+
}
|
|
287
|
+
return payload;
|
|
288
|
+
}
|
|
289
|
+
|
|
230
290
|
// handle job results coming in on the queue from platform instances
|
|
231
291
|
private async handleJobResult(
|
|
232
292
|
state: string,
|
|
@@ -248,6 +308,8 @@ export default class PlatformInstance {
|
|
|
248
308
|
payload = job.msg;
|
|
249
309
|
}
|
|
250
310
|
|
|
311
|
+
payload = this.toExternalPayload(payload);
|
|
312
|
+
|
|
251
313
|
// send result to client
|
|
252
314
|
const callback = this.completedJobHandlers.get(job.title);
|
|
253
315
|
if (callback) {
|
|
@@ -300,7 +362,9 @@ export default class PlatformInstance {
|
|
|
300
362
|
*/
|
|
301
363
|
private async reportError(sessionId: string, errorMessage: string) {
|
|
302
364
|
const errorObject: ActivityStream = {
|
|
303
|
-
context:
|
|
365
|
+
"@context": buildCanonicalContext(
|
|
366
|
+
this.contextUrl ?? INTERNAL_PLATFORM_CONTEXT_URL,
|
|
367
|
+
),
|
|
304
368
|
type: "error",
|
|
305
369
|
actor: { id: this.actor, type: "unknown" },
|
|
306
370
|
error: errorMessage,
|
|
@@ -312,7 +376,11 @@ export default class PlatformInstance {
|
|
|
312
376
|
await this.sendToClient(sessionId, errorObject);
|
|
313
377
|
}
|
|
314
378
|
} catch (err) {
|
|
315
|
-
this.log.error(
|
|
379
|
+
this.log.error(
|
|
380
|
+
`Failed to send error to client: ${
|
|
381
|
+
err instanceof Error ? err.message : String(err)
|
|
382
|
+
}`,
|
|
383
|
+
);
|
|
316
384
|
}
|
|
317
385
|
|
|
318
386
|
this.sessions.clear();
|
|
@@ -335,8 +403,11 @@ export default class PlatformInstance {
|
|
|
335
403
|
* @param listener
|
|
336
404
|
* @param sessionId
|
|
337
405
|
*/
|
|
338
|
-
private callbackFunction(listener:
|
|
339
|
-
const funcs
|
|
406
|
+
private callbackFunction(listener: "close" | "message", sessionId: string) {
|
|
407
|
+
const funcs: Record<
|
|
408
|
+
"close" | "message",
|
|
409
|
+
(...args: Array<unknown>) => Promise<void>
|
|
410
|
+
> = {
|
|
340
411
|
close: async (e: object) => {
|
|
341
412
|
this.log.error(`close event triggered ${this.id}: ${e}`);
|
|
342
413
|
// Check if process is still connected before attempting error reporting
|
|
@@ -354,10 +425,33 @@ export default class PlatformInstance {
|
|
|
354
425
|
},
|
|
355
426
|
message: async ([first, second, third]: MessageFromPlatform) => {
|
|
356
427
|
if (first === "updateActor") {
|
|
428
|
+
// Internal control message: platform process is reporting a new actor id.
|
|
357
429
|
// We need to update the key to the store in order to find it in the future.
|
|
358
430
|
this.updateIdentifier(third);
|
|
359
|
-
} else if (first === "error"
|
|
360
|
-
|
|
431
|
+
} else if (first === "error") {
|
|
432
|
+
// Error messages travel over IPC as plain objects; normalize to a string.
|
|
433
|
+
let normalizedError: string;
|
|
434
|
+
if (typeof second === "string") {
|
|
435
|
+
normalizedError = second;
|
|
436
|
+
} else if (
|
|
437
|
+
second &&
|
|
438
|
+
typeof second === "object" &&
|
|
439
|
+
"message" in (second as Record<string, unknown>)
|
|
440
|
+
) {
|
|
441
|
+
normalizedError = String(
|
|
442
|
+
(second as Record<string, unknown>).message,
|
|
443
|
+
);
|
|
444
|
+
} else {
|
|
445
|
+
try {
|
|
446
|
+
normalizedError = JSON.stringify(second);
|
|
447
|
+
} catch {
|
|
448
|
+
normalizedError = String(second);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
await this.reportError(sessionId, normalizedError);
|
|
452
|
+
} else if (first === "heartbeat") {
|
|
453
|
+
// Internal heartbeat signals are handled by the monitor listener only.
|
|
454
|
+
return;
|
|
361
455
|
} else {
|
|
362
456
|
// treat like a message to clients
|
|
363
457
|
await this.sendToClient(sessionId, second);
|
|
@@ -366,4 +460,49 @@ export default class PlatformInstance {
|
|
|
366
460
|
};
|
|
367
461
|
return funcs[listener];
|
|
368
462
|
}
|
|
463
|
+
|
|
464
|
+
private markHeartbeat() {
|
|
465
|
+
this.heartbeatLastSeen = Date.now();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private startHeartbeatMonitor() {
|
|
469
|
+
if (
|
|
470
|
+
!Number.isFinite(HEARTBEAT_INTERVAL_MS) ||
|
|
471
|
+
HEARTBEAT_INTERVAL_MS <= 0 ||
|
|
472
|
+
!Number.isFinite(HEARTBEAT_TIMEOUT_MS) ||
|
|
473
|
+
HEARTBEAT_TIMEOUT_MS <= 0
|
|
474
|
+
) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (!this.process?.on) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
// Track last heartbeat to detect hung platform processes.
|
|
481
|
+
this.heartbeatLastSeen = Date.now();
|
|
482
|
+
this.heartbeatListener = (message: MessageFromPlatform) => {
|
|
483
|
+
if (Array.isArray(message) && message[0] === "heartbeat") {
|
|
484
|
+
this.markHeartbeat();
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
this.process.on("message", this.heartbeatListener);
|
|
488
|
+
this.heartbeatMonitor = setInterval(() => {
|
|
489
|
+
// Avoid double-handling once shutdown starts or a timeout was already handled.
|
|
490
|
+
if (this.flaggedForTermination || this.heartbeatFailureHandled) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (!this.process?.connected) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const elapsed = Date.now() - this.heartbeatLastSeen;
|
|
497
|
+
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
|
|
498
|
+
this.heartbeatFailureHandled = true;
|
|
499
|
+
this.log.error(
|
|
500
|
+
`heartbeat timeout for ${this.id} after ${elapsed}ms`,
|
|
501
|
+
);
|
|
502
|
+
// The child is unresponsive; mark for termination and trigger shutdown.
|
|
503
|
+
this.flaggedForTermination = true;
|
|
504
|
+
void this.shutdown();
|
|
505
|
+
}
|
|
506
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
507
|
+
}
|
|
369
508
|
}
|
package/src/platform.test.ts
CHANGED
|
@@ -42,7 +42,11 @@ describe("platform.ts credential handling", () => {
|
|
|
42
42
|
|
|
43
43
|
validCredentials = {
|
|
44
44
|
type: "credentials",
|
|
45
|
-
context:
|
|
45
|
+
"@context": [
|
|
46
|
+
"https://www.w3.org/ns/activitystreams",
|
|
47
|
+
"https://sockethub.org/ns/context/v1.jsonld",
|
|
48
|
+
"https://sockethub.org/ns/context/platform/xmpp/v1.jsonld",
|
|
49
|
+
],
|
|
46
50
|
actor: {
|
|
47
51
|
id: "testuser@localhost",
|
|
48
52
|
type: "person",
|
|
@@ -75,7 +79,12 @@ describe("platform.ts credential handling", () => {
|
|
|
75
79
|
title: "xmpp-job-1",
|
|
76
80
|
msg: {
|
|
77
81
|
type: "connect",
|
|
78
|
-
context:
|
|
82
|
+
"@context": [
|
|
83
|
+
"https://www.w3.org/ns/activitystreams",
|
|
84
|
+
"https://sockethub.org/ns/context/v1.jsonld",
|
|
85
|
+
"https://sockethub.org/ns/context/platform/xmpp/v1.jsonld",
|
|
86
|
+
],
|
|
87
|
+
platform: "xmpp",
|
|
79
88
|
actor: { id: "testuser@localhost", type: "person" },
|
|
80
89
|
sessionSecret: "secret123",
|
|
81
90
|
},
|
package/src/platform.ts
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
* it and sockethub will start up another process. This ensures memory safety.
|
|
11
11
|
*/
|
|
12
12
|
import { crypto, getPlatformId } from "@sockethub/crypto";
|
|
13
|
+
import type { JobHandler } from "@sockethub/data-layer";
|
|
13
14
|
import {
|
|
14
15
|
CredentialsStore,
|
|
15
16
|
type JobDataDecrypted,
|
|
16
17
|
JobWorker,
|
|
17
18
|
} from "@sockethub/data-layer";
|
|
18
|
-
import type
|
|
19
|
-
import { type Logger, createLogger, setLoggerContext } from "@sockethub/logger";
|
|
19
|
+
import { createLogger, type Logger, setLoggerContext } from "@sockethub/logger";
|
|
20
20
|
import type {
|
|
21
21
|
ActivityStream,
|
|
22
22
|
CredentialsObject,
|
|
@@ -25,6 +25,10 @@ import type {
|
|
|
25
25
|
PlatformInterface,
|
|
26
26
|
PlatformSession,
|
|
27
27
|
} from "@sockethub/schemas";
|
|
28
|
+
import {
|
|
29
|
+
buildCanonicalContext,
|
|
30
|
+
INTERNAL_PLATFORM_CONTEXT_URL,
|
|
31
|
+
} from "@sockethub/schemas";
|
|
28
32
|
import config from "./config";
|
|
29
33
|
|
|
30
34
|
// Simple wrapper function to help with testing
|
|
@@ -53,7 +57,16 @@ async function startPlatformProcess() {
|
|
|
53
57
|
|
|
54
58
|
// conditionally initialize sentry
|
|
55
59
|
let sentry: { readonly reportError: (err: Error) => void } = {
|
|
56
|
-
reportError: (err: Error) => {
|
|
60
|
+
reportError: (err: Error) => {
|
|
61
|
+
logger.debug(
|
|
62
|
+
"Sentry not configured; error not reported to Sentry",
|
|
63
|
+
{
|
|
64
|
+
error: err,
|
|
65
|
+
errorMessage: err?.message ?? String(err),
|
|
66
|
+
errorStack: err?.stack,
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
},
|
|
57
70
|
};
|
|
58
71
|
(async () => {
|
|
59
72
|
if (config.get("sentry:dsn")) {
|
|
@@ -103,16 +116,43 @@ async function startPlatformProcess() {
|
|
|
103
116
|
return p as PlatformInterface;
|
|
104
117
|
})();
|
|
105
118
|
|
|
119
|
+
type PlatformHandlerWithCredentials = (
|
|
120
|
+
msg: ActivityStream,
|
|
121
|
+
credentials: CredentialsObject,
|
|
122
|
+
cb: PlatformCallback,
|
|
123
|
+
) => void;
|
|
124
|
+
type PlatformHandler = (msg: ActivityStream, cb: PlatformCallback) => void;
|
|
125
|
+
function getPlatformHandler(
|
|
126
|
+
instance: PlatformInterface,
|
|
127
|
+
name: string,
|
|
128
|
+
): PlatformHandlerWithCredentials | PlatformHandler | undefined {
|
|
129
|
+
const candidate = (
|
|
130
|
+
instance as PlatformInterface & Record<string, unknown>
|
|
131
|
+
)[name];
|
|
132
|
+
return typeof candidate === "function"
|
|
133
|
+
? (candidate as PlatformHandlerWithCredentials | PlatformHandler)
|
|
134
|
+
: undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const heartbeatIntervalMs = Number(
|
|
138
|
+
config.get("platformHeartbeat:intervalMs") ?? 5000,
|
|
139
|
+
);
|
|
140
|
+
let heartbeatTimer: NodeJS.Timeout | undefined;
|
|
141
|
+
|
|
106
142
|
/**
|
|
107
|
-
* Safely send
|
|
143
|
+
* Safely send message to parent process, handling IPC channel closure
|
|
108
144
|
*/
|
|
109
|
-
function safeProcessSend(message: [string,
|
|
145
|
+
function safeProcessSend(message: [string, unknown]) {
|
|
110
146
|
if (process.send && process.connected) {
|
|
111
147
|
try {
|
|
112
148
|
process.send(message);
|
|
113
149
|
} catch (ipcErr) {
|
|
114
150
|
console.error(
|
|
115
|
-
`Failed to report error via IPC: ${
|
|
151
|
+
`Failed to report error via IPC: ${
|
|
152
|
+
ipcErr instanceof Error
|
|
153
|
+
? ipcErr.message
|
|
154
|
+
: String(ipcErr)
|
|
155
|
+
}`,
|
|
116
156
|
);
|
|
117
157
|
}
|
|
118
158
|
} else {
|
|
@@ -120,6 +160,34 @@ async function startPlatformProcess() {
|
|
|
120
160
|
}
|
|
121
161
|
}
|
|
122
162
|
|
|
163
|
+
function startHeartbeat() {
|
|
164
|
+
if (!Number.isFinite(heartbeatIntervalMs) || heartbeatIntervalMs <= 0) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (heartbeatTimer) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
heartbeatTimer = setInterval(() => {
|
|
171
|
+
safeProcessSend([
|
|
172
|
+
"heartbeat",
|
|
173
|
+
{
|
|
174
|
+
type: "heartbeat",
|
|
175
|
+
"@context": buildCanonicalContext(
|
|
176
|
+
INTERNAL_PLATFORM_CONTEXT_URL,
|
|
177
|
+
),
|
|
178
|
+
actor: {
|
|
179
|
+
id: "sockethub",
|
|
180
|
+
type: "platform",
|
|
181
|
+
},
|
|
182
|
+
object: {
|
|
183
|
+
type: "heartbeat",
|
|
184
|
+
timestamp: Date.now(),
|
|
185
|
+
},
|
|
186
|
+
} as ActivityStream,
|
|
187
|
+
]);
|
|
188
|
+
}, heartbeatIntervalMs);
|
|
189
|
+
}
|
|
190
|
+
|
|
123
191
|
/**
|
|
124
192
|
* Type guard to check if a platform is persistent and has credentialsHash.
|
|
125
193
|
*/
|
|
@@ -157,6 +225,12 @@ async function startPlatformProcess() {
|
|
|
157
225
|
process.exit(1);
|
|
158
226
|
});
|
|
159
227
|
|
|
228
|
+
process.once("exit", () => {
|
|
229
|
+
if (heartbeatTimer) {
|
|
230
|
+
clearInterval(heartbeatTimer);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
160
234
|
/**
|
|
161
235
|
* Incoming messages from the worker to this platform. Data is an array, the first property is the
|
|
162
236
|
* method to call, the rest are params.
|
|
@@ -170,6 +244,7 @@ async function startPlatformProcess() {
|
|
|
170
244
|
parentSecret1 = parentSecret;
|
|
171
245
|
parentSecret2 = parentSecret3;
|
|
172
246
|
await startQueueListener();
|
|
247
|
+
startHeartbeat();
|
|
173
248
|
} else {
|
|
174
249
|
throw new Error("received unknown command from parent thread");
|
|
175
250
|
}
|
|
@@ -202,7 +277,6 @@ async function startPlatformProcess() {
|
|
|
202
277
|
url: redisUrl,
|
|
203
278
|
},
|
|
204
279
|
);
|
|
205
|
-
// biome-ignore lint/performance/noDelete: <explanation>
|
|
206
280
|
delete job.msg.sessionSecret;
|
|
207
281
|
|
|
208
282
|
let jobCallbackCalled = false;
|
|
@@ -223,10 +297,12 @@ async function startPlatformProcess() {
|
|
|
223
297
|
try {
|
|
224
298
|
errMsg = err.toString();
|
|
225
299
|
} catch (err) {
|
|
226
|
-
errMsg = err;
|
|
300
|
+
errMsg = err instanceof Error ? err : String(err);
|
|
227
301
|
}
|
|
228
|
-
|
|
229
|
-
|
|
302
|
+
const errorMessage =
|
|
303
|
+
errMsg instanceof Error ? errMsg.message : errMsg;
|
|
304
|
+
sentry.reportError(new Error(errorMessage));
|
|
305
|
+
reject(new Error(errorMessage));
|
|
230
306
|
} else {
|
|
231
307
|
jobLog.debug(`completed ${job.title} ${job.msg.type}`);
|
|
232
308
|
|
|
@@ -280,7 +356,21 @@ async function startPlatformProcess() {
|
|
|
280
356
|
};
|
|
281
357
|
|
|
282
358
|
// Proceed with platform method call
|
|
283
|
-
|
|
359
|
+
const handler = getPlatformHandler(
|
|
360
|
+
platform,
|
|
361
|
+
job.msg.type,
|
|
362
|
+
);
|
|
363
|
+
if (!handler) {
|
|
364
|
+
doneCallback(
|
|
365
|
+
new Error(
|
|
366
|
+
`platform method ${job.msg.type} not available`,
|
|
367
|
+
),
|
|
368
|
+
null,
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
(handler as PlatformHandlerWithCredentials).call(
|
|
373
|
+
platform,
|
|
284
374
|
job.msg,
|
|
285
375
|
credentials,
|
|
286
376
|
wrappedCallback,
|
|
@@ -288,7 +378,7 @@ async function startPlatformProcess() {
|
|
|
288
378
|
})
|
|
289
379
|
.catch((err) => {
|
|
290
380
|
// Credential store error (invalid/missing credentials)
|
|
291
|
-
jobLog.error(`credential error ${err
|
|
381
|
+
jobLog.error(`credential error ${String(err)}`);
|
|
292
382
|
|
|
293
383
|
/**
|
|
294
384
|
* Critical distinction: handle credential errors differently based on platform state.
|
|
@@ -315,11 +405,20 @@ async function startPlatformProcess() {
|
|
|
315
405
|
*/
|
|
316
406
|
if (platform.isInitialized()) {
|
|
317
407
|
// Platform already running - reject job only, preserve platform instance
|
|
318
|
-
doneCallback(
|
|
408
|
+
doneCallback(
|
|
409
|
+
err instanceof Error
|
|
410
|
+
? err
|
|
411
|
+
: new Error(String(err)),
|
|
412
|
+
null,
|
|
413
|
+
);
|
|
319
414
|
} else {
|
|
320
415
|
// Platform not initialized - terminate platform process
|
|
321
|
-
|
|
322
|
-
|
|
416
|
+
const error =
|
|
417
|
+
err instanceof Error
|
|
418
|
+
? err
|
|
419
|
+
: new Error(String(err));
|
|
420
|
+
sentry.reportError(error);
|
|
421
|
+
reject(error);
|
|
323
422
|
}
|
|
324
423
|
});
|
|
325
424
|
} else if (
|
|
@@ -333,11 +432,28 @@ async function startPlatformProcess() {
|
|
|
333
432
|
);
|
|
334
433
|
} else {
|
|
335
434
|
try {
|
|
336
|
-
|
|
435
|
+
const handler = getPlatformHandler(
|
|
436
|
+
platform,
|
|
437
|
+
job.msg.type,
|
|
438
|
+
);
|
|
439
|
+
if (!handler) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`platform method ${job.msg.type} not available`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
(handler as PlatformHandler).call(
|
|
445
|
+
platform,
|
|
446
|
+
job.msg,
|
|
447
|
+
doneCallback,
|
|
448
|
+
);
|
|
337
449
|
} catch (err) {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
450
|
+
const error =
|
|
451
|
+
err instanceof Error ? err : new Error(String(err));
|
|
452
|
+
jobLog.error(
|
|
453
|
+
`platform call failed ${error.toString()}`,
|
|
454
|
+
);
|
|
455
|
+
sentry.reportError(error);
|
|
456
|
+
reject(error);
|
|
341
457
|
}
|
|
342
458
|
}
|
|
343
459
|
});
|
package/src/process-manager.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { getPlatformId } from "@sockethub/crypto";
|
|
|
2
2
|
|
|
3
3
|
import type { IInitObject } from "./bootstrap/init.js";
|
|
4
4
|
import PlatformInstance, {
|
|
5
|
-
platformInstances,
|
|
6
|
-
type PlatformInstanceParams,
|
|
7
5
|
type MessageFromParent,
|
|
6
|
+
type PlatformInstanceParams,
|
|
7
|
+
platformInstances,
|
|
8
8
|
} from "./platform-instance.js";
|
|
9
9
|
|
|
10
10
|
class ProcessManager {
|
|
@@ -41,6 +41,7 @@ class ProcessManager {
|
|
|
41
41
|
pi = this.ensureProcess(platform);
|
|
42
42
|
}
|
|
43
43
|
pi.config = platformDetails.config;
|
|
44
|
+
pi.contextUrl = platformDetails.contextUrl;
|
|
44
45
|
return pi;
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -74,15 +75,40 @@ class ProcessManager {
|
|
|
74
75
|
actor?: string,
|
|
75
76
|
): PlatformInstance {
|
|
76
77
|
const identifier = getPlatformId(platform, actor);
|
|
78
|
+
const existing = platformInstances.get(identifier);
|
|
77
79
|
const platformInstance =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
existing && this.isProcessAlive(existing)
|
|
81
|
+
? existing
|
|
82
|
+
: this.createPlatformInstance(identifier, platform, actor);
|
|
83
|
+
if (existing && existing !== platformInstance) {
|
|
84
|
+
void existing.shutdown();
|
|
85
|
+
}
|
|
80
86
|
if (sessionId) {
|
|
81
87
|
platformInstance.registerSession(sessionId);
|
|
82
88
|
}
|
|
83
89
|
platformInstances.set(identifier, platformInstance);
|
|
84
90
|
return platformInstance;
|
|
85
91
|
}
|
|
92
|
+
|
|
93
|
+
private isProcessAlive(platformInstance: PlatformInstance): boolean {
|
|
94
|
+
const pid = platformInstance.process?.pid;
|
|
95
|
+
if (!pid) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (platformInstance.process.exitCode !== null) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
process.kill(pid, 0);
|
|
103
|
+
return true;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const err = error as NodeJS.ErrnoException;
|
|
106
|
+
if (err && err.code === "EPERM") {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
86
112
|
}
|
|
87
113
|
|
|
88
114
|
export default ProcessManager;
|