nodebb-plugin-integration 0.4.2 → 0.4.4

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.
@@ -0,0 +1,396 @@
1
+ "use strict";
2
+
3
+ const path = require("node:path");
4
+ const { fork } = require("node:child_process");
5
+ const Config = require("../config");
6
+
7
+ const SERVER_PATH = path.join(__dirname, "server.js");
8
+ const CHECK_INTERVAL = 3000;
9
+ const SHUTDOWN_TIMEOUT = 3000;
10
+ const STOP_TIMEOUT = 5000;
11
+
12
+ let child = null;
13
+ let timer = null;
14
+ let lastSignature = null;
15
+ let initialized = false;
16
+ let refreshRequested = false;
17
+ let refreshPromise = null;
18
+ let stopPromise = null;
19
+ let shuttingDown = false;
20
+
21
+ function isPrimaryWorker() {
22
+ try {
23
+ const nconf = nodebb.require("nconf");
24
+ const value = nconf.get("isPrimary");
25
+
26
+ if (value !== undefined && value !== null) {
27
+ return value === true || value === "true";
28
+ }
29
+ } catch (error) {
30
+ // Fall back to the worker environment.
31
+ }
32
+
33
+ const value = process.env.isPrimary;
34
+
35
+ return value === undefined || value === "true";
36
+ }
37
+
38
+ function isExited(processRef) {
39
+ return processRef.exitCode !== null || processRef.signalCode !== null;
40
+ }
41
+
42
+ function waitForExit(processRef, timeout = STOP_TIMEOUT) {
43
+ if (isExited(processRef)) {
44
+ return Promise.resolve();
45
+ }
46
+
47
+ return new Promise((resolve) => {
48
+ let settled = false;
49
+
50
+ const finish = () => {
51
+ if (settled) {
52
+ return;
53
+ }
54
+
55
+ settled = true;
56
+ clearTimeout(timerRef);
57
+ processRef.removeListener("exit", finish);
58
+ processRef.removeListener("close", finish);
59
+ resolve();
60
+ };
61
+
62
+ const timerRef = setTimeout(() => {
63
+ if (!isExited(processRef)) {
64
+ console.warn("[NodeBB Integration] MCP process did not exit; sending SIGKILL");
65
+
66
+ try {
67
+ processRef.kill("SIGKILL");
68
+ } catch (error) {
69
+ console.error(
70
+ "[NodeBB Integration] Failed to kill MCP process:",
71
+ error.message
72
+ );
73
+ }
74
+ }
75
+
76
+ if (isExited(processRef)) {
77
+ finish();
78
+ }
79
+ }, timeout);
80
+
81
+ timerRef.unref();
82
+ processRef.once("exit", finish);
83
+ processRef.once("close", finish);
84
+ });
85
+ }
86
+ function requestShutdown(processRef) {
87
+ return new Promise((resolve) => {
88
+ let settled = false;
89
+
90
+ const finish = () => {
91
+ if (settled) {
92
+ return;
93
+ }
94
+
95
+ settled = true;
96
+ clearTimeout(timerRef);
97
+ processRef.removeListener("message", onMessage);
98
+ processRef.removeListener("exit", finish);
99
+ resolve();
100
+ };
101
+
102
+ const onMessage = (message) => {
103
+ if (message?.type === "shutdown-complete") {
104
+ finish();
105
+ }
106
+ };
107
+
108
+ const timerRef = setTimeout(() => {
109
+ console.warn("[NodeBB Integration] MCP shutdown handshake timed out");
110
+
111
+ if (!isExited(processRef)) {
112
+ try {
113
+ processRef.kill("SIGTERM");
114
+ } catch (error) {
115
+ console.error(
116
+ "[NodeBB Integration] Failed to terminate MCP process:",
117
+ error.message
118
+ );
119
+ }
120
+ }
121
+
122
+ finish();
123
+ }, SHUTDOWN_TIMEOUT);
124
+
125
+ timerRef.unref();
126
+
127
+ processRef.once("message", onMessage);
128
+ processRef.once("exit", finish);
129
+
130
+ if (!processRef.connected || isExited(processRef)) {
131
+ finish();
132
+ return;
133
+ }
134
+
135
+ try {
136
+ processRef.send({ type: "shutdown" }, (error) => {
137
+ if (error) {
138
+ console.error(
139
+ "[NodeBB Integration] Failed to send MCP shutdown:",
140
+ error.message
141
+ );
142
+
143
+ if (!isExited(processRef)) {
144
+ processRef.kill("SIGTERM");
145
+ }
146
+
147
+ finish();
148
+ }
149
+ });
150
+ } catch (error) {
151
+ console.error(
152
+ "[NodeBB Integration] MCP shutdown IPC error:",
153
+ error.message
154
+ );
155
+
156
+ if (!isExited(processRef)) {
157
+ processRef.kill("SIGTERM");
158
+ }
159
+
160
+ finish();
161
+ }
162
+ });
163
+ }
164
+
165
+ async function stopChild() {
166
+ if (stopPromise) {
167
+ return stopPromise;
168
+ }
169
+
170
+ if (!child) {
171
+ lastSignature = null;
172
+ return;
173
+ }
174
+
175
+ const previous = child;
176
+
177
+ child = null;
178
+ lastSignature = null;
179
+
180
+ stopPromise = (async () => {
181
+ if (!isExited(previous)) {
182
+ await requestShutdown(previous);
183
+ }
184
+
185
+ if (!isExited(previous)) {
186
+ try {
187
+ previous.kill("SIGTERM");
188
+ } catch (error) {
189
+ console.error(
190
+ "[NodeBB Integration] Failed to stop MCP process:",
191
+ error.message
192
+ );
193
+ }
194
+ }
195
+
196
+ await waitForExit(previous);
197
+ })();
198
+
199
+ try {
200
+ await stopPromise;
201
+ } finally {
202
+ stopPromise = null;
203
+ }
204
+ }
205
+
206
+ function startChild(config, signature) {
207
+ const nextChild = fork(SERVER_PATH, [], {
208
+ stdio: ["ignore", "inherit", "inherit", "ipc"]
209
+ });
210
+
211
+ child = nextChild;
212
+ lastSignature = signature;
213
+
214
+ nextChild.on("message", (message) => {
215
+ if (child !== nextChild || !message) {
216
+ return;
217
+ }
218
+
219
+ if (message.type === "configured") {
220
+ console.log("[NodeBB Integration] MCP process configured");
221
+ } else if (message.type === "configuration-error") {
222
+ console.error(
223
+ "[NodeBB Integration] MCP configuration failed:",
224
+ message.message
225
+ );
226
+
227
+ lastSignature = null;
228
+ nextChild.kill("SIGTERM");
229
+ }
230
+ });
231
+
232
+ nextChild.on("error", (error) => {
233
+ console.error(
234
+ "[NodeBB Integration] MCP process error:",
235
+ error.message
236
+ );
237
+ });
238
+
239
+ nextChild.on("exit", (code, signal) => {
240
+ if (child === nextChild) {
241
+ child = null;
242
+ lastSignature = null;
243
+ }
244
+
245
+ console.log(
246
+ `[NodeBB Integration] MCP process exited (code=${code}, signal=${signal})`
247
+ );
248
+ });
249
+
250
+ try {
251
+ nextChild.send({
252
+ type: "configure",
253
+ config: {
254
+ nodebbUrl: config.mcpNodebbUrl,
255
+ internalApiToken: config.internalApiToken,
256
+ bearerToken: config.mcpBearerToken,
257
+ host: config.mcpHost,
258
+ port: config.mcpPort
259
+ }
260
+ });
261
+ } catch (error) {
262
+ console.error(
263
+ "[NodeBB Integration] Failed to configure MCP process:",
264
+ error.message
265
+ );
266
+
267
+ lastSignature = null;
268
+ nextChild.kill("SIGTERM");
269
+ }
270
+ }
271
+
272
+ async function syncConfiguration() {
273
+ const config = await Config.get();
274
+
275
+ if (!config.mcpEnabled) {
276
+ if (child) {
277
+ console.log("[NodeBB Integration] MCP disabled");
278
+ await stopChild();
279
+ }
280
+
281
+ return;
282
+ }
283
+
284
+ if (!config.mcpBearerToken || !config.internalApiToken) {
285
+ console.error(
286
+ "[NodeBB Integration] MCP is enabled but required secrets are missing"
287
+ );
288
+
289
+ await stopChild();
290
+ return;
291
+ }
292
+
293
+ const signature = JSON.stringify({
294
+ nodebbUrl: config.mcpNodebbUrl,
295
+ internalApiToken: config.internalApiToken,
296
+ bearerToken: config.mcpBearerToken,
297
+ host: config.mcpHost,
298
+ port: config.mcpPort
299
+ });
300
+
301
+ if (child && child.connected && signature === lastSignature) {
302
+ return;
303
+ }
304
+
305
+ if (child) {
306
+ console.log("[NodeBB Integration] MCP configuration changed");
307
+ await stopChild();
308
+ }
309
+
310
+ console.log("[NodeBB Integration] Starting MCP process");
311
+ startChild(config, signature);
312
+ }
313
+
314
+ function refresh() {
315
+ if (shuttingDown || !isPrimaryWorker()) {
316
+ return Promise.resolve();
317
+ }
318
+
319
+ refreshRequested = true;
320
+
321
+ if (refreshPromise) {
322
+ return refreshPromise;
323
+ }
324
+
325
+ refreshPromise = (async () => {
326
+ try {
327
+ while (refreshRequested && !shuttingDown) {
328
+ refreshRequested = false;
329
+ await syncConfiguration().catch((error) => {
330
+ console.error(
331
+ "[NodeBB Integration] MCP manager error:",
332
+ error.message
333
+ );
334
+ });
335
+ }
336
+ } finally {
337
+ refreshPromise = null;
338
+
339
+ if (refreshRequested && !shuttingDown) {
340
+ refresh().catch((error) => {
341
+ console.error(
342
+ "[NodeBB Integration] MCP refresh failed:",
343
+ error.message
344
+ );
345
+ });
346
+ }
347
+ }
348
+ })();
349
+
350
+ return refreshPromise;
351
+ }
352
+
353
+ function init() {
354
+ if (initialized || !isPrimaryWorker()) {
355
+ return;
356
+ }
357
+
358
+ initialized = true;
359
+ shuttingDown = false;
360
+
361
+ refresh().catch((error) => {
362
+ console.error(
363
+ "[NodeBB Integration] MCP startup failed:",
364
+ error.message
365
+ );
366
+ });
367
+
368
+ timer = setInterval(refresh, CHECK_INTERVAL);
369
+ timer.unref();
370
+
371
+ }
372
+
373
+ async function shutdown() {
374
+ shuttingDown = true;
375
+ refreshRequested = false;
376
+
377
+ if (timer) {
378
+ clearInterval(timer);
379
+ timer = null;
380
+ }
381
+
382
+ if (refreshPromise) {
383
+ await refreshPromise;
384
+ }
385
+
386
+ await stopChild();
387
+
388
+ initialized = false;
389
+ }
390
+
391
+ module.exports = {
392
+ init,
393
+ refresh,
394
+ shutdown,
395
+ isPrimaryWorker
396
+ };