@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.
- package/LICENSE +165 -0
- package/README.md +130 -0
- package/bin/sockethub +4 -0
- package/dist/defaults.json +36 -0
- package/dist/index.js +166465 -0
- package/dist/index.js.map +1877 -0
- package/dist/platform.js +103625 -0
- package/dist/platform.js.map +1435 -0
- package/package.json +100 -0
- package/res/socket.io.js +4908 -0
- package/res/sockethub-client.js +631 -0
- package/res/sockethub-client.min.js +19 -0
- package/src/bootstrap/init.d.ts +21 -0
- package/src/bootstrap/init.test.ts +211 -0
- package/src/bootstrap/init.ts +160 -0
- package/src/bootstrap/load-platforms.ts +151 -0
- package/src/config.test.ts +33 -0
- package/src/config.ts +98 -0
- package/src/defaults.json +36 -0
- package/src/index.ts +68 -0
- package/src/janitor.test.ts +211 -0
- package/src/janitor.ts +157 -0
- package/src/listener.ts +173 -0
- package/src/middleware/create-activity-object.test.ts +30 -0
- package/src/middleware/create-activity-object.ts +22 -0
- package/src/middleware/expand-activity-stream.test.data.ts +351 -0
- package/src/middleware/expand-activity-stream.test.ts +77 -0
- package/src/middleware/expand-activity-stream.ts +37 -0
- package/src/middleware/store-credentials.test.ts +85 -0
- package/src/middleware/store-credentials.ts +16 -0
- package/src/middleware/validate.test.data.ts +259 -0
- package/src/middleware/validate.test.ts +44 -0
- package/src/middleware/validate.ts +73 -0
- package/src/middleware.test.ts +184 -0
- package/src/middleware.ts +71 -0
- package/src/platform-instance.test.ts +531 -0
- package/src/platform-instance.ts +360 -0
- package/src/platform.test.ts +375 -0
- package/src/platform.ts +358 -0
- package/src/process-manager.ts +88 -0
- package/src/routes.test.ts +54 -0
- package/src/routes.ts +61 -0
- package/src/sentry.test.ts +106 -0
- package/src/sentry.ts +19 -0
- package/src/sockethub.ts +198 -0
- package/src/util.ts +5 -0
package/src/platform.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This runs as a stand-alone separate process that handles:
|
|
3
|
+
* 1. Starting up an instance of a given platform
|
|
4
|
+
* 2. Connecting to the redis job queue
|
|
5
|
+
* 3. Sending the platform jobs
|
|
6
|
+
* 4. Handling the result by putting it in the outgoing queue for
|
|
7
|
+
* sockethub core to send back to the client.
|
|
8
|
+
*
|
|
9
|
+
* If an exception is thrown by the platform, this process will die along with
|
|
10
|
+
* it and sockethub will start up another process. This ensures memory safety.
|
|
11
|
+
*/
|
|
12
|
+
import { crypto, getPlatformId } from "@sockethub/crypto";
|
|
13
|
+
import {
|
|
14
|
+
CredentialsStore,
|
|
15
|
+
type JobDataDecrypted,
|
|
16
|
+
JobWorker,
|
|
17
|
+
} from "@sockethub/data-layer";
|
|
18
|
+
import type { JobHandler } from "@sockethub/data-layer";
|
|
19
|
+
import type {
|
|
20
|
+
ActivityStream,
|
|
21
|
+
CredentialsObject,
|
|
22
|
+
PersistentPlatformInterface,
|
|
23
|
+
PlatformCallback,
|
|
24
|
+
PlatformInterface,
|
|
25
|
+
PlatformSession,
|
|
26
|
+
} from "@sockethub/schemas";
|
|
27
|
+
import debug from "debug";
|
|
28
|
+
import config from "./config";
|
|
29
|
+
|
|
30
|
+
// command-line params
|
|
31
|
+
const parentId = process.argv[2];
|
|
32
|
+
const platformName = process.argv[3];
|
|
33
|
+
let identifier = process.argv[4];
|
|
34
|
+
const redisUrl = process.env.REDIS_URL;
|
|
35
|
+
|
|
36
|
+
const loggerPrefix = `sockethub:platform:${platformName}:${identifier}`;
|
|
37
|
+
let logger = debug(loggerPrefix);
|
|
38
|
+
|
|
39
|
+
// conditionally initialize sentry
|
|
40
|
+
let sentry: { readonly reportError: (err: Error) => void } = {
|
|
41
|
+
reportError: (err: Error) => {},
|
|
42
|
+
};
|
|
43
|
+
(async () => {
|
|
44
|
+
if (config.get("sentry:dsn")) {
|
|
45
|
+
logger("initializing sentry");
|
|
46
|
+
sentry = await import("./sentry");
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
|
|
50
|
+
let jobWorker: JobWorker;
|
|
51
|
+
let jobWorkerStarted = false;
|
|
52
|
+
let parentSecret1: string;
|
|
53
|
+
let parentSecret2: string;
|
|
54
|
+
|
|
55
|
+
logger(`platform handler initializing for ${platformName} ${identifier}`);
|
|
56
|
+
|
|
57
|
+
interface SecretInterface {
|
|
58
|
+
parentSecret1: string;
|
|
59
|
+
parentSecret2: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface SecretFromParent extends Array<string | SecretInterface> {
|
|
63
|
+
0: string;
|
|
64
|
+
1: SecretInterface;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Initialize platform module
|
|
69
|
+
*/
|
|
70
|
+
const platformSession: PlatformSession = {
|
|
71
|
+
debug: debug(`sockethub:platform:${platformName}:${identifier}`),
|
|
72
|
+
sendToClient: getSendFunction("message"),
|
|
73
|
+
updateActor: updateActor,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const platform: PlatformInterface = await (async () => {
|
|
77
|
+
const PlatformModule = await import(`@sockethub/platform-${platformName}`);
|
|
78
|
+
const p = new PlatformModule.default(platformSession) as PlatformInterface;
|
|
79
|
+
logger(`platform handler loaded for ${platformName} ${identifier}`);
|
|
80
|
+
return p as PlatformInterface;
|
|
81
|
+
})();
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Safely send error message to parent process, handling IPC channel closure
|
|
85
|
+
*/
|
|
86
|
+
function safeProcessSend(message: [string, string]) {
|
|
87
|
+
if (process.send && process.connected) {
|
|
88
|
+
try {
|
|
89
|
+
process.send(message);
|
|
90
|
+
} catch (ipcErr) {
|
|
91
|
+
console.error(`Failed to report error via IPC: ${ipcErr.message}`);
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
console.error("Cannot report error: IPC channel not available");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Type guard to check if a platform is persistent and has credentialsHash.
|
|
100
|
+
*/
|
|
101
|
+
function isPersistentPlatform(
|
|
102
|
+
platform: PlatformInterface,
|
|
103
|
+
): platform is PersistentPlatformInterface {
|
|
104
|
+
return platform.config.persist === true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handle any uncaught errors from the platform by alerting the worker and shutting down.
|
|
109
|
+
*/
|
|
110
|
+
process.once("uncaughtException", (err: Error) => {
|
|
111
|
+
console.log("EXCEPTION IN PLATFORM");
|
|
112
|
+
sentry.reportError(err);
|
|
113
|
+
console.log("error:\n", err.stack);
|
|
114
|
+
safeProcessSend(["error", err.toString()]);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
process.once("unhandledRejection", (err: Error) => {
|
|
119
|
+
console.log("EXCEPTION IN PLATFORM");
|
|
120
|
+
sentry.reportError(err);
|
|
121
|
+
console.log("error:\n", err.stack);
|
|
122
|
+
safeProcessSend(["error", err.toString()]);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* In the case of a parent disconnect, terminate child process.
|
|
128
|
+
*/
|
|
129
|
+
// Detect parent death via IPC disconnect
|
|
130
|
+
process.on("disconnect", () => {
|
|
131
|
+
console.log(`Parent disconnected. Child ${process.pid} exiting.`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Incoming messages from the worker to this platform. Data is an array, the first property is the
|
|
137
|
+
* method to call, the rest are params.
|
|
138
|
+
*/
|
|
139
|
+
process.on("message", async (data: SecretFromParent) => {
|
|
140
|
+
if (data[0] === "secrets") {
|
|
141
|
+
const { parentSecret2: parentSecret3, parentSecret1: parentSecret } =
|
|
142
|
+
data[1];
|
|
143
|
+
parentSecret1 = parentSecret;
|
|
144
|
+
parentSecret2 = parentSecret3;
|
|
145
|
+
await startQueueListener();
|
|
146
|
+
} else {
|
|
147
|
+
throw new Error("received unknown command from parent thread");
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Returns a function used to handle completed jobs from the platform code (the `done` callback).
|
|
153
|
+
*/
|
|
154
|
+
function getJobHandler(): JobHandler {
|
|
155
|
+
return async (
|
|
156
|
+
job: JobDataDecrypted,
|
|
157
|
+
): Promise<string | undefined | ActivityStream> => {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const jobLog = debug(`${loggerPrefix}:${job.sessionId}`);
|
|
160
|
+
jobLog(`received ${job.title} ${job.msg.type}`);
|
|
161
|
+
const credentialStore = new CredentialsStore(
|
|
162
|
+
parentId,
|
|
163
|
+
job.sessionId,
|
|
164
|
+
parentSecret1 + job.msg.sessionSecret,
|
|
165
|
+
{
|
|
166
|
+
url: redisUrl,
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
// biome-ignore lint/performance/noDelete: <explanation>
|
|
170
|
+
delete job.msg.sessionSecret;
|
|
171
|
+
|
|
172
|
+
let jobCallbackCalled = false;
|
|
173
|
+
const doneCallback: PlatformCallback = (
|
|
174
|
+
err: Error | null,
|
|
175
|
+
result: null | ActivityStream,
|
|
176
|
+
): void => {
|
|
177
|
+
if (jobCallbackCalled) {
|
|
178
|
+
resolve(null);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
jobCallbackCalled = true;
|
|
182
|
+
if (err) {
|
|
183
|
+
jobLog(`failed ${job.title} ${job.msg.type}`);
|
|
184
|
+
let errMsg: string | Error;
|
|
185
|
+
// some error objects (e.g. TimeoutError) don't interpolate correctly
|
|
186
|
+
// to being human-readable, so we have to do this little dance
|
|
187
|
+
try {
|
|
188
|
+
errMsg = err.toString();
|
|
189
|
+
} catch (err) {
|
|
190
|
+
errMsg = err;
|
|
191
|
+
}
|
|
192
|
+
sentry.reportError(new Error(errMsg as string));
|
|
193
|
+
reject(new Error(errMsg as string));
|
|
194
|
+
} else {
|
|
195
|
+
jobLog(`completed ${job.title} ${job.msg.type}`);
|
|
196
|
+
resolve(result);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (platform.config.requireCredentials?.includes(job.msg.type)) {
|
|
201
|
+
// This method requires credentials and should be called even if the platform is not
|
|
202
|
+
// yet initialized, because they need to authenticate before they are initialized.
|
|
203
|
+
|
|
204
|
+
// Get credentialsHash for validation.
|
|
205
|
+
// For persistent platforms: undefined (or empty string) initially, then we set to hash after first
|
|
206
|
+
// successful call.
|
|
207
|
+
// For stateless platforms: always undefined (no validation, credentials used once per request)
|
|
208
|
+
// CredentialsStore skips validation when credentialsHash is falsy (undefined or empty string)
|
|
209
|
+
const credentialsHash = isPersistentPlatform(platform)
|
|
210
|
+
? platform.credentialsHash
|
|
211
|
+
: undefined;
|
|
212
|
+
|
|
213
|
+
credentialStore
|
|
214
|
+
.get(job.msg.actor.id, credentialsHash)
|
|
215
|
+
.then((credentials) => {
|
|
216
|
+
// Create wrapper callback that updates credentialsHash after successful call
|
|
217
|
+
const wrappedCallback: PlatformCallback = (
|
|
218
|
+
err: Error | null,
|
|
219
|
+
result: null | ActivityStream,
|
|
220
|
+
): void => {
|
|
221
|
+
if (!err && isPersistentPlatform(platform)) {
|
|
222
|
+
// Update credentialsHash after successful platform call.
|
|
223
|
+
// Only persistent platforms track credential state across requests.
|
|
224
|
+
platform.credentialsHash = crypto.objectHash(
|
|
225
|
+
credentials.object,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
doneCallback(err, result);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Proceed with platform method call
|
|
232
|
+
platform[job.msg.type](
|
|
233
|
+
job.msg,
|
|
234
|
+
credentials,
|
|
235
|
+
wrappedCallback,
|
|
236
|
+
);
|
|
237
|
+
})
|
|
238
|
+
.catch((err) => {
|
|
239
|
+
// Credential store error (invalid/missing credentials)
|
|
240
|
+
jobLog(`credential error ${err.toString()}`);
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Critical distinction: handle credential errors differently based on platform state.
|
|
244
|
+
*
|
|
245
|
+
* For INITIALIZED platforms (already running):
|
|
246
|
+
* - Reject ONLY this job via doneCallback(err, null)
|
|
247
|
+
* - Keep the platform process running
|
|
248
|
+
* - Why: Platform instances can be shared by multiple clients (sessions).
|
|
249
|
+
* Terminating on credential error would crash the platform for ALL users,
|
|
250
|
+
* including those with valid credentials. This would create a DoS vector
|
|
251
|
+
* where one user's mistake (browser refresh with wrong creds, mistyped
|
|
252
|
+
* password, expired token) would break the service for everyone sharing
|
|
253
|
+
* that platform instance.
|
|
254
|
+
* - The failing client receives an error message, while other clients
|
|
255
|
+
* continue operating normally.
|
|
256
|
+
*
|
|
257
|
+
* For UNINITIALIZED platforms (not yet started):
|
|
258
|
+
* - Terminate the platform process via reject(err)
|
|
259
|
+
* - Why: If the initial connection fails due to invalid credentials, there's
|
|
260
|
+
* no valid session to preserve. The platform instance was created specifically
|
|
261
|
+
* for this connection attempt and has no other users. Terminating allows
|
|
262
|
+
* proper cleanup and a fresh start on the next attempt.
|
|
263
|
+
* - Error is reported to Sentry for monitoring authentication issues.
|
|
264
|
+
*/
|
|
265
|
+
if (platform.config.initialized) {
|
|
266
|
+
// Platform already running - reject job only, preserve platform instance
|
|
267
|
+
doneCallback(err, null);
|
|
268
|
+
} else {
|
|
269
|
+
// Platform not initialized - terminate platform process
|
|
270
|
+
sentry.reportError(err);
|
|
271
|
+
reject(err);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
} else if (
|
|
275
|
+
platform.config.persist &&
|
|
276
|
+
!platform.config.initialized
|
|
277
|
+
) {
|
|
278
|
+
reject(
|
|
279
|
+
new Error(
|
|
280
|
+
`${job.msg.type} called on uninitialized platform`,
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
} else {
|
|
284
|
+
try {
|
|
285
|
+
platform[job.msg.type](job.msg, doneCallback);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
jobLog(`platform call failed ${err.toString()}`);
|
|
288
|
+
sentry.reportError(err);
|
|
289
|
+
reject(err);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get a function which sends a message to the parent thread (PlatformInstance). The platform
|
|
298
|
+
* can call that function to send messages back to the client.
|
|
299
|
+
* @param command string containing the type of command to be sent. 'message' or 'close'
|
|
300
|
+
*/
|
|
301
|
+
function getSendFunction(command: string) {
|
|
302
|
+
return (msg: ActivityStream, special?: string) => {
|
|
303
|
+
if (platform.config.persist) {
|
|
304
|
+
process.send([command, msg, special]);
|
|
305
|
+
} else {
|
|
306
|
+
logger(
|
|
307
|
+
"sendToClient called on non-persistent platform, rejecting.",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* When a user changes its actor name, the channel identifier changes, we need to ensure that
|
|
315
|
+
* both the queue thread (listening on the channel for jobs) and the logging object are updated.
|
|
316
|
+
* @param credentials
|
|
317
|
+
*/
|
|
318
|
+
async function updateActor(credentials: CredentialsObject): Promise<void> {
|
|
319
|
+
identifier = getPlatformId(platformName, credentials.actor.id);
|
|
320
|
+
logger(
|
|
321
|
+
`platform actor updated to ${credentials.actor.id} identifier ${identifier}`,
|
|
322
|
+
);
|
|
323
|
+
logger = debug(`sockethub:platform:${identifier}`);
|
|
324
|
+
|
|
325
|
+
// Update credentialsHash for persistent platforms (tracks actor-specific state)
|
|
326
|
+
if (isPersistentPlatform(platform)) {
|
|
327
|
+
platform.credentialsHash = crypto.objectHash(credentials.object);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
platform.debug = debug(`sockethub:platform:${platformName}:${identifier}`);
|
|
331
|
+
process.send(["updateActor", undefined, identifier]);
|
|
332
|
+
await startQueueListener(true);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Starts listening on the queue for incoming jobs
|
|
337
|
+
* @param refresh boolean if the param is true, we re-init the `queue.process`
|
|
338
|
+
* (used when identifier changes)
|
|
339
|
+
*/
|
|
340
|
+
async function startQueueListener(refresh = false) {
|
|
341
|
+
if (jobWorkerStarted) {
|
|
342
|
+
if (refresh) {
|
|
343
|
+
await jobWorker.shutdown();
|
|
344
|
+
} else {
|
|
345
|
+
logger("start queue called multiple times, skipping");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
jobWorker = new JobWorker(
|
|
350
|
+
parentId,
|
|
351
|
+
identifier,
|
|
352
|
+
parentSecret1 + parentSecret2,
|
|
353
|
+
{ url: redisUrl },
|
|
354
|
+
);
|
|
355
|
+
logger("listening on the queue for incoming jobs");
|
|
356
|
+
jobWorker.onJob(getJobHandler());
|
|
357
|
+
jobWorkerStarted = true;
|
|
358
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getPlatformId } from "@sockethub/crypto";
|
|
2
|
+
|
|
3
|
+
import type { IInitObject } from "./bootstrap/init.js";
|
|
4
|
+
import PlatformInstance, {
|
|
5
|
+
platformInstances,
|
|
6
|
+
type PlatformInstanceParams,
|
|
7
|
+
type MessageFromParent,
|
|
8
|
+
} from "./platform-instance.js";
|
|
9
|
+
|
|
10
|
+
class ProcessManager {
|
|
11
|
+
private readonly parentId: string;
|
|
12
|
+
private readonly parentSecret1: string;
|
|
13
|
+
private readonly parentSecret2: string;
|
|
14
|
+
private init: IInitObject;
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
parentId: string,
|
|
18
|
+
parentSecret1: string,
|
|
19
|
+
parentSecret2: string,
|
|
20
|
+
init: IInitObject,
|
|
21
|
+
) {
|
|
22
|
+
this.parentId = parentId;
|
|
23
|
+
this.parentSecret1 = parentSecret1;
|
|
24
|
+
this.parentSecret2 = parentSecret2;
|
|
25
|
+
this.init = init;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get(
|
|
29
|
+
platform: string,
|
|
30
|
+
actorId: string,
|
|
31
|
+
sessionId?: string,
|
|
32
|
+
): PlatformInstance {
|
|
33
|
+
const platformDetails = this.init.platforms.get(platform);
|
|
34
|
+
let pi: PlatformInstance;
|
|
35
|
+
|
|
36
|
+
if (platformDetails.config.persist) {
|
|
37
|
+
// ensure process is started - one for each actor
|
|
38
|
+
pi = this.ensureProcess(platform, sessionId, actorId);
|
|
39
|
+
} else {
|
|
40
|
+
// ensure process is started - one for all jobs
|
|
41
|
+
pi = this.ensureProcess(platform);
|
|
42
|
+
}
|
|
43
|
+
pi.config = platformDetails.config;
|
|
44
|
+
return pi;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private createPlatformInstance(
|
|
48
|
+
identifier: string,
|
|
49
|
+
platform: string,
|
|
50
|
+
actor?: string,
|
|
51
|
+
): PlatformInstance {
|
|
52
|
+
const secrets: MessageFromParent = [
|
|
53
|
+
"secrets",
|
|
54
|
+
{
|
|
55
|
+
parentSecret1: this.parentSecret1,
|
|
56
|
+
parentSecret2: this.parentSecret2,
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
const platformInstanceConfig: PlatformInstanceParams = {
|
|
60
|
+
identifier: identifier,
|
|
61
|
+
platform: platform,
|
|
62
|
+
parentId: this.parentId,
|
|
63
|
+
actor: actor,
|
|
64
|
+
};
|
|
65
|
+
const platformInstance = new PlatformInstance(platformInstanceConfig);
|
|
66
|
+
platformInstance.initQueue(this.parentSecret1 + this.parentSecret2);
|
|
67
|
+
platformInstance.process.send(secrets);
|
|
68
|
+
return platformInstance;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private ensureProcess(
|
|
72
|
+
platform: string,
|
|
73
|
+
sessionId?: string,
|
|
74
|
+
actor?: string,
|
|
75
|
+
): PlatformInstance {
|
|
76
|
+
const identifier = getPlatformId(platform, actor);
|
|
77
|
+
const platformInstance =
|
|
78
|
+
platformInstances.get(identifier) ||
|
|
79
|
+
this.createPlatformInstance(identifier, platform, actor);
|
|
80
|
+
if (sessionId) {
|
|
81
|
+
platformInstance.registerSession(sessionId);
|
|
82
|
+
}
|
|
83
|
+
platformInstances.set(identifier, platformInstance);
|
|
84
|
+
return platformInstance;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default ProcessManager;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import * as sinon from "sinon";
|
|
4
|
+
|
|
5
|
+
import routes, { basePaths, type IRoutePaths } from "./routes.js";
|
|
6
|
+
|
|
7
|
+
describe("routes/base", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
sinon.restore();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("can find each of the base files it serves", () => {
|
|
13
|
+
Object.values(basePaths).forEach((fwd: string) => {
|
|
14
|
+
try {
|
|
15
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
16
|
+
expect(existsSync(fwd)).toBeTrue();
|
|
17
|
+
} catch (e) {
|
|
18
|
+
throw new Error(`Unable to resolve path ${fwd}`);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("adds base routes", () => {
|
|
24
|
+
const app = {
|
|
25
|
+
get: sinon.spy(),
|
|
26
|
+
};
|
|
27
|
+
routes.setup(app);
|
|
28
|
+
sinon.assert.callCount(app.get, Object.keys(basePaths).length);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles calls to base routes as expected", () => {
|
|
32
|
+
const routeHandlers: any = {};
|
|
33
|
+
const app = {
|
|
34
|
+
get: (path: string | number, route: any) => {
|
|
35
|
+
routeHandlers[path] = route;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
routes.setup(app);
|
|
39
|
+
|
|
40
|
+
function verifyPathRoutes(pathMap: IRoutePaths) {
|
|
41
|
+
Object.keys(pathMap).forEach((path) => {
|
|
42
|
+
const res = {
|
|
43
|
+
setHeader: sinon.spy(),
|
|
44
|
+
sendFile: sinon.spy(),
|
|
45
|
+
};
|
|
46
|
+
expect(pathMap[path].endsWith(".ejs")).toBeFalse();
|
|
47
|
+
routeHandlers[path]({ url: path }, res);
|
|
48
|
+
sinon.assert.called(res.setHeader);
|
|
49
|
+
sinon.assert.calledWith(res.sendFile, pathMap[path]);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
verifyPathRoutes(basePaths);
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import debug from "debug";
|
|
3
|
+
|
|
4
|
+
import { __dirname } from "./util.js";
|
|
5
|
+
|
|
6
|
+
const logger = debug("sockethub:server:routes");
|
|
7
|
+
|
|
8
|
+
export interface IRoutePaths {
|
|
9
|
+
[key: string]: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const basePaths: IRoutePaths = {
|
|
13
|
+
"/sockethub-client.js": path.resolve(
|
|
14
|
+
__dirname,
|
|
15
|
+
"..",
|
|
16
|
+
"res",
|
|
17
|
+
"sockethub-client.js",
|
|
18
|
+
),
|
|
19
|
+
"/sockethub-client.min.js": path.resolve(
|
|
20
|
+
__dirname,
|
|
21
|
+
"..",
|
|
22
|
+
"res",
|
|
23
|
+
"sockethub-client.min.js",
|
|
24
|
+
),
|
|
25
|
+
"/socket.io.js": path.resolve(__dirname, "..", "res", "socket.io.js"),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function prepFileRoutes(pathMap) {
|
|
29
|
+
const _routes = [];
|
|
30
|
+
for (const key of Object.keys(pathMap)) {
|
|
31
|
+
_routes.push({
|
|
32
|
+
meta: {
|
|
33
|
+
method: "GET",
|
|
34
|
+
path: key,
|
|
35
|
+
},
|
|
36
|
+
route: (req, res) => {
|
|
37
|
+
logger(`serving resource ${req.url}`);
|
|
38
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
39
|
+
res.sendFile(pathMap[req.url]);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return _routes;
|
|
44
|
+
}
|
|
45
|
+
const baseRoutes = prepFileRoutes(basePaths);
|
|
46
|
+
|
|
47
|
+
function addRoute(app) {
|
|
48
|
+
return (route) => {
|
|
49
|
+
app[route.meta.method.toLowerCase()](route.meta.path, route.route);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Setup
|
|
55
|
+
*/
|
|
56
|
+
const routes = {
|
|
57
|
+
setup: (app: unknown) => {
|
|
58
|
+
baseRoutes.forEach(addRoute(app));
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
export default routes;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
// Mock config to avoid loading real configuration
|
|
5
|
+
const mockConfig = {
|
|
6
|
+
get: mock((key: string) => {
|
|
7
|
+
if (key === "sentry:dsn") {
|
|
8
|
+
return "https://ffc702eb2f3b24d9e06ca20e1ef1fd09@o4508859714895872.ingest.de.sentry.io/4508859718369360";
|
|
9
|
+
}
|
|
10
|
+
if (key === "sentry") {
|
|
11
|
+
return {
|
|
12
|
+
dsn: "https://ffc702eb2f3b24d9e06ca20e1ef1fd09@o4508859714895872.ingest.de.sentry.io/4508859718369360",
|
|
13
|
+
environment: "test",
|
|
14
|
+
traceSampleRate: 1.0
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
})
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("Sentry Integration Bug - Issue #918", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
// Clear any existing Sentry instances
|
|
24
|
+
delete require.cache[require.resolve("./sentry.js")];
|
|
25
|
+
mockConfig.get.mockClear();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should reproduce 'A Proxy's target should be an Object' error", async () => {
|
|
29
|
+
// This test reproduces the bug by initializing Sentry with Bun server instrumentation
|
|
30
|
+
// The error occurs when Sentry's Bun integration tries to instrument server options
|
|
31
|
+
let sentryError: Error | null = null;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// Create a proper mock that implements the config interface
|
|
35
|
+
const configPath = require.resolve("./config.js");
|
|
36
|
+
|
|
37
|
+
// Clear the config from cache first
|
|
38
|
+
delete require.cache[configPath];
|
|
39
|
+
|
|
40
|
+
// Mock the config module before importing sentry
|
|
41
|
+
require.cache[configPath] = {
|
|
42
|
+
exports: {
|
|
43
|
+
default: mockConfig,
|
|
44
|
+
__esModule: true
|
|
45
|
+
},
|
|
46
|
+
loaded: true,
|
|
47
|
+
children: [],
|
|
48
|
+
parent: null,
|
|
49
|
+
filename: configPath,
|
|
50
|
+
id: configPath,
|
|
51
|
+
paths: [],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Import Sentry module which triggers initialization
|
|
55
|
+
const sentryPath = require.resolve("./sentry.js");
|
|
56
|
+
delete require.cache[sentryPath]; // Clear sentry from cache
|
|
57
|
+
await import("./sentry.js");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
sentryError = error as Error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// With the upgrade to @sentry/bun 10.15.0, this should now work without the Proxy error
|
|
63
|
+
// If we're still on the old version, we'd expect the Proxy error
|
|
64
|
+
// If we're on the new version, Sentry should initialize successfully
|
|
65
|
+
if (sentryError) {
|
|
66
|
+
// If there's still an error, it should NOT be the Proxy error (since we upgraded)
|
|
67
|
+
expect(sentryError.message).not.toContain("A Proxy's 'target' should be an Object");
|
|
68
|
+
} else {
|
|
69
|
+
// Success - the upgrade fixed the issue
|
|
70
|
+
expect(sentryError).toBeNull();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should verify the Sentry version has been upgraded to fix the bug", () => {
|
|
75
|
+
// This test documents that the problematic version has been fixed
|
|
76
|
+
const packageJson = require("../package.json");
|
|
77
|
+
const currentVersion = packageJson.dependencies["@sentry/bun"];
|
|
78
|
+
|
|
79
|
+
// Should now be version 10.x or higher (which fixes the Proxy bug)
|
|
80
|
+
expect(currentVersion).toMatch(/^(\^?10\.|^10\.|latest)/);
|
|
81
|
+
|
|
82
|
+
// Document the bug details for reference
|
|
83
|
+
const bugInfo = {
|
|
84
|
+
brokenVersion: "9.5.0",
|
|
85
|
+
fixedVersion: "10.15.0",
|
|
86
|
+
bugDescription: "A Proxy's 'target' should be an Object",
|
|
87
|
+
location: "instrumentBunServeOptions in @sentry/bun"
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
expect(bugInfo.brokenVersion).toBe("9.5.0");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("Sentry Integration Workaround", () => {
|
|
95
|
+
it("should suggest disabling Sentry DSN as temporary workaround", () => {
|
|
96
|
+
// Document the workaround: set sentry.dsn to empty string or remove it
|
|
97
|
+
const workaroundConfig = {
|
|
98
|
+
sentry: {
|
|
99
|
+
dsn: "", // Empty DSN disables Sentry
|
|
100
|
+
environment: "production"
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
expect(workaroundConfig.sentry.dsn).toBe("");
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/sentry.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is only imported if sentry reporting is enabled in the Sockethub config.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as Sentry from "@sentry/bun";
|
|
6
|
+
import debug from "debug";
|
|
7
|
+
import config from "./config";
|
|
8
|
+
|
|
9
|
+
const logger = debug("sockethub:sentry");
|
|
10
|
+
if (!config.get("sentry:dsn")) {
|
|
11
|
+
throw new Error("Sentry attempted initialization with no DSN provided");
|
|
12
|
+
}
|
|
13
|
+
logger("initialized");
|
|
14
|
+
Sentry.init(config.get("sentry"));
|
|
15
|
+
|
|
16
|
+
export function reportError(err: Error): void {
|
|
17
|
+
logger("reporting error");
|
|
18
|
+
Sentry.captureException(err);
|
|
19
|
+
}
|