signalk-webhook-bridge 2.0.0
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 +52 -0
- package/LICENSE +674 -0
- package/README.md +358 -0
- package/assets/icon-128.png +0 -0
- package/docs/screenshots/config.png +0 -0
- package/package.json +63 -0
- package/plugin/index.js +481 -0
- package/plugin/paths.js +38 -0
- package/plugin/storage.js +145 -0
- package/plugin/units.js +90 -0
- package/plugin/webhook.js +52 -0
- package/public/540.main.js +2 -0
- package/public/540.main.js.LICENSE.txt +9 -0
- package/public/651.main.js +2 -0
- package/public/651.main.js.LICENSE.txt +9 -0
- package/public/main.js +2 -0
- package/public/main.js.LICENSE.txt +9 -0
- package/public/remoteEntry.js +1 -0
package/plugin/index.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin/index.js
|
|
3
|
+
*
|
|
4
|
+
* Signal K Webhook Bridge.
|
|
5
|
+
*
|
|
6
|
+
* Captures configured Signal K values at a selected interval,
|
|
7
|
+
* stores every snapshot in a persistent SQLite FIFO queue,
|
|
8
|
+
* and delivers queued entries to the configured webhook in order.
|
|
9
|
+
* Adds output-unit labels, retries backlog on startup and after each capture,
|
|
10
|
+
* and exposes path metadata and a configuration schema for the admin interface.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { getAvailablePaths } = require("./paths");
|
|
14
|
+
const { convertValue, getOutputUnitLabel } = require("./units");
|
|
15
|
+
const { sendWebhook } = require("./webhook");
|
|
16
|
+
const storage = require("./storage");
|
|
17
|
+
|
|
18
|
+
module.exports = function (app) {
|
|
19
|
+
const plugin = {};
|
|
20
|
+
|
|
21
|
+
plugin.id = "signalk-webhook-bridge";
|
|
22
|
+
|
|
23
|
+
plugin.name = "Webhook Bridge";
|
|
24
|
+
|
|
25
|
+
plugin.description =
|
|
26
|
+
"Send selected Signal K data paths to an external webhook, with configurable paths, units and offline storage.";
|
|
27
|
+
|
|
28
|
+
let captureTimer = null;
|
|
29
|
+
let currentSettings = null;
|
|
30
|
+
let deliveryRunning = false;
|
|
31
|
+
let stopped = false;
|
|
32
|
+
|
|
33
|
+
let lastCapture = null;
|
|
34
|
+
let lastDelivery = null;
|
|
35
|
+
let lastError = null;
|
|
36
|
+
let deliveryState = "idle";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read configured vessel values, unwrap Signal K value records and convert units.
|
|
40
|
+
* Field names prefer fieldName, then the schema name, then the original path.
|
|
41
|
+
*/
|
|
42
|
+
function buildSnapshot(settings) {
|
|
43
|
+
const payload = {};
|
|
44
|
+
const units = {};
|
|
45
|
+
|
|
46
|
+
const configuredPaths =
|
|
47
|
+
Array.isArray(settings.paths) ? settings.paths : [];
|
|
48
|
+
|
|
49
|
+
configuredPaths.forEach((configuredPath) => {
|
|
50
|
+
if (!configuredPath || !configuredPath.path) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const entry = app.getSelfPath(configuredPath.path);
|
|
55
|
+
|
|
56
|
+
const value =
|
|
57
|
+
(
|
|
58
|
+
entry &&
|
|
59
|
+
typeof entry === "object" &&
|
|
60
|
+
Object.prototype.hasOwnProperty.call(entry, "value")
|
|
61
|
+
) ?
|
|
62
|
+
entry.value
|
|
63
|
+
: entry;
|
|
64
|
+
|
|
65
|
+
const fieldName =
|
|
66
|
+
configuredPath.fieldName ||
|
|
67
|
+
configuredPath.name ||
|
|
68
|
+
configuredPath.path;
|
|
69
|
+
|
|
70
|
+
const outputUnits = configuredPath.units || "native";
|
|
71
|
+
|
|
72
|
+
payload[fieldName] = convertValue(value, outputUnits);
|
|
73
|
+
|
|
74
|
+
/*
|
|
75
|
+
* Report the actual unit sent to the receiving endpoint.
|
|
76
|
+
*
|
|
77
|
+
* If "native" is selected, use the Signal K metadata unit.
|
|
78
|
+
* Otherwise use the configured converted unit.
|
|
79
|
+
*/
|
|
80
|
+
const metadata = app.getMetadata(
|
|
81
|
+
`vessels.self.${configuredPath.path}`,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
units[fieldName] = getOutputUnitLabel(outputUnits, metadata?.units);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
payload.units = units;
|
|
88
|
+
|
|
89
|
+
return payload;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Add a new snapshot to persistent storage.
|
|
94
|
+
*
|
|
95
|
+
* Data is always written to the queue BEFORE any delivery attempt.
|
|
96
|
+
*/
|
|
97
|
+
function captureSnapshot() {
|
|
98
|
+
if (!currentSettings) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const payload = buildSnapshot(currentSettings);
|
|
104
|
+
|
|
105
|
+
const queued = storage.enqueue(payload);
|
|
106
|
+
|
|
107
|
+
lastCapture = queued.createdAt;
|
|
108
|
+
deliveryState = "queued";
|
|
109
|
+
|
|
110
|
+
app.debug(
|
|
111
|
+
`Queued webhook snapshot ${queued.id} at ${queued.createdAt}`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* Try delivery immediately after capturing.
|
|
116
|
+
* If a delivery worker is already active, it continues draining the same queue.
|
|
117
|
+
*/
|
|
118
|
+
processQueue();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
lastError = error.message;
|
|
121
|
+
deliveryState = "error";
|
|
122
|
+
|
|
123
|
+
app.error(`Unable to capture webhook snapshot: ${error.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Deliver queued entries in strict FIFO order.
|
|
129
|
+
*
|
|
130
|
+
* Only one delivery worker may operate at a time.
|
|
131
|
+
*
|
|
132
|
+
* A queued entry is removed only after the webhook confirms
|
|
133
|
+
* successful delivery.
|
|
134
|
+
*
|
|
135
|
+
* On failure the entry remains at the front of the queue and
|
|
136
|
+
* processing stops until another delivery attempt is triggered.
|
|
137
|
+
*/
|
|
138
|
+
async function processQueue() {
|
|
139
|
+
if (deliveryRunning || stopped || !currentSettings) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!currentSettings.webhookUrl) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
deliveryRunning = true;
|
|
148
|
+
deliveryState = "delivering";
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
while (!stopped) {
|
|
152
|
+
const queuedItem = storage.peekOldest();
|
|
153
|
+
|
|
154
|
+
if (!queuedItem) {
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
* Preserve the original capture time when old entries are
|
|
160
|
+
* eventually delivered after an offline period.
|
|
161
|
+
*/
|
|
162
|
+
const outgoingPayload = {
|
|
163
|
+
timestamp: queuedItem.createdAt,
|
|
164
|
+
...queuedItem.payload,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await sendWebhook({
|
|
169
|
+
url: currentSettings.webhookUrl,
|
|
170
|
+
authKey: currentSettings.authKey,
|
|
171
|
+
payload: outgoingPayload,
|
|
172
|
+
app,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
storage.remove(queuedItem.id);
|
|
176
|
+
|
|
177
|
+
lastDelivery = new Date().toISOString();
|
|
178
|
+
lastError = null;
|
|
179
|
+
|
|
180
|
+
app.debug(
|
|
181
|
+
`Delivered queued webhook entry ${queuedItem.id}`,
|
|
182
|
+
);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
/*
|
|
185
|
+
* Leave the oldest entry untouched.
|
|
186
|
+
*
|
|
187
|
+
* This preserves FIFO order and prevents newer entries
|
|
188
|
+
* overtaking an entry that has not been delivered.
|
|
189
|
+
*/
|
|
190
|
+
|
|
191
|
+
lastError = error.message;
|
|
192
|
+
deliveryState = "waiting";
|
|
193
|
+
|
|
194
|
+
app.debug(
|
|
195
|
+
`Webhook delivery unavailable. Queue retained: ${error.message}`,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
app.error(`Error while processing webhook queue: ${error.message}`);
|
|
203
|
+
} finally {
|
|
204
|
+
deliveryRunning = false;
|
|
205
|
+
|
|
206
|
+
if (!stopped && deliveryState !== "waiting") {
|
|
207
|
+
try {
|
|
208
|
+
deliveryState =
|
|
209
|
+
storage.count() > 0 ? "queued" : "connected";
|
|
210
|
+
} catch (error) {
|
|
211
|
+
deliveryState = "error";
|
|
212
|
+
lastError = error.message;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
// Complete deferred cleanup after any in-flight request has settled.
|
|
218
|
+
if (stopped) {
|
|
219
|
+
storage.close();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Open storage, attempt backlog delivery and schedule the first capture after one interval.
|
|
225
|
+
plugin.start = function (settings) {
|
|
226
|
+
app.debug("Starting Signal K Webhook Bridge");
|
|
227
|
+
|
|
228
|
+
currentSettings = settings || {};
|
|
229
|
+
stopped = false;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
storage.openQueue(app);
|
|
233
|
+
|
|
234
|
+
const queuedEntries = storage.count();
|
|
235
|
+
|
|
236
|
+
app.debug(
|
|
237
|
+
`Webhook queue contains ${queuedEntries} entr${
|
|
238
|
+
queuedEntries === 1 ? "y" : "ies"
|
|
239
|
+
}`,
|
|
240
|
+
);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
app.error(`Unable to open webhook queue: ${error.message}`);
|
|
243
|
+
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/*
|
|
248
|
+
* sendFreq is configured in minutes.
|
|
249
|
+
*/
|
|
250
|
+
const sendFreq = Number(currentSettings.sendFreq) || 10;
|
|
251
|
+
const intervalMilliseconds = sendFreq * 60 * 1000;
|
|
252
|
+
|
|
253
|
+
app.debug(
|
|
254
|
+
`Webhook capture interval set to ${sendFreq} minute${
|
|
255
|
+
sendFreq === 1 ? "" : "s"
|
|
256
|
+
}`,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
/*
|
|
260
|
+
* Immediately attempt to clear any backlog left from a previous
|
|
261
|
+
* offline period or server restart.
|
|
262
|
+
*
|
|
263
|
+
* We do NOT capture a new snapshot immediately. New snapshots
|
|
264
|
+
* follow the configured interval.
|
|
265
|
+
*/
|
|
266
|
+
processQueue();
|
|
267
|
+
|
|
268
|
+
captureTimer = setInterval(captureSnapshot, intervalMilliseconds);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Stop future captures; an in-flight delivery finishes before its worker closes storage.
|
|
272
|
+
plugin.stop = function () {
|
|
273
|
+
app.debug("Stopping Signal K Webhook Bridge");
|
|
274
|
+
|
|
275
|
+
stopped = true;
|
|
276
|
+
|
|
277
|
+
if (captureTimer) {
|
|
278
|
+
clearInterval(captureTimer);
|
|
279
|
+
captureTimer = null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
currentSettings = null;
|
|
283
|
+
|
|
284
|
+
/*
|
|
285
|
+
* processQueue may still be awaiting an HTTP request when stop()
|
|
286
|
+
* is called. We therefore avoid closing SQLite underneath an
|
|
287
|
+
* active delivery operation.
|
|
288
|
+
*/
|
|
289
|
+
if (!deliveryRunning) {
|
|
290
|
+
storage.close();
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Return currently available Signal K paths and schema metadata
|
|
296
|
+
* for the custom configuration panel.
|
|
297
|
+
*/
|
|
298
|
+
plugin.registerWithRouter = function (router) {
|
|
299
|
+
router.get("/paths", (req, res) => {
|
|
300
|
+
try {
|
|
301
|
+
const availablePaths = getAvailablePaths(app);
|
|
302
|
+
|
|
303
|
+
const paths = availablePaths.map((path) => {
|
|
304
|
+
const metadata = app.getMetadata(`vessels.self.${path}`);
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
path,
|
|
308
|
+
units: metadata?.units || null,
|
|
309
|
+
displayName: metadata?.displayName || null,
|
|
310
|
+
description: metadata?.description || null,
|
|
311
|
+
displayUnits: metadata?.displayUnits || null,
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
res.status(200).json({
|
|
316
|
+
paths,
|
|
317
|
+
});
|
|
318
|
+
} catch (error) {
|
|
319
|
+
app.error(
|
|
320
|
+
`Unable to retrieve available Signal K paths: ${error.message}`,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
res.status(500).json({
|
|
324
|
+
error: "Unable to retrieve available Signal K paths",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
router.get("/status", (req, res) => {
|
|
329
|
+
try {
|
|
330
|
+
const queueCount = storage.count();
|
|
331
|
+
|
|
332
|
+
res.status(200).json({
|
|
333
|
+
queueCount,
|
|
334
|
+
deliveryState,
|
|
335
|
+
lastCapture,
|
|
336
|
+
lastDelivery,
|
|
337
|
+
lastError,
|
|
338
|
+
});
|
|
339
|
+
} catch (error) {
|
|
340
|
+
app.error(
|
|
341
|
+
`Unable to retrieve webhook status: ${error.message}`,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
res.status(500).json({
|
|
345
|
+
error: "Unable to retrieve webhook status",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
router.post("/retry", async (req, res) => {
|
|
351
|
+
try {
|
|
352
|
+
await processQueue();
|
|
353
|
+
|
|
354
|
+
res.status(200).json({
|
|
355
|
+
success: true,
|
|
356
|
+
queueCount: storage.count(),
|
|
357
|
+
deliveryState,
|
|
358
|
+
lastCapture,
|
|
359
|
+
lastDelivery,
|
|
360
|
+
lastError,
|
|
361
|
+
});
|
|
362
|
+
} catch (error) {
|
|
363
|
+
app.error(`Unable to retry webhook delivery: ${error.message}`);
|
|
364
|
+
|
|
365
|
+
res.status(500).json({
|
|
366
|
+
success: false,
|
|
367
|
+
error: "Unable to retry webhook delivery",
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Describe webhook settings and path mappings for the schema-based configuration form.
|
|
375
|
+
* Path choices are discovered when requested; the custom panel filters units by metadata.
|
|
376
|
+
*/
|
|
377
|
+
plugin.schema = function () {
|
|
378
|
+
const availablePaths = getAvailablePaths(app);
|
|
379
|
+
|
|
380
|
+
app.debug(`Found ${availablePaths.length} available Signal K paths`);
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
type: "object",
|
|
384
|
+
|
|
385
|
+
required: ["sendFreq", "webhookUrl"],
|
|
386
|
+
|
|
387
|
+
properties: {
|
|
388
|
+
webhookUrl: {
|
|
389
|
+
type: "string",
|
|
390
|
+
title: "Webhook URL",
|
|
391
|
+
description:
|
|
392
|
+
"The destination URL that data will be sent to.",
|
|
393
|
+
default: "",
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
authKey: {
|
|
397
|
+
type: "string",
|
|
398
|
+
title: "Authentication Key",
|
|
399
|
+
description:
|
|
400
|
+
"Optional authentication key to include with webhook requests.",
|
|
401
|
+
default: "",
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
sendFreq: {
|
|
405
|
+
type: "number",
|
|
406
|
+
title: "Send Interval",
|
|
407
|
+
description:
|
|
408
|
+
"How often data should be captured and sent, in minutes.",
|
|
409
|
+
default: 10,
|
|
410
|
+
minimum: 1,
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
paths: {
|
|
414
|
+
type: "array",
|
|
415
|
+
title: "Signal K Data Paths",
|
|
416
|
+
description:
|
|
417
|
+
"Add the Signal K values you want to include in each webhook request.",
|
|
418
|
+
|
|
419
|
+
items: {
|
|
420
|
+
type: "object",
|
|
421
|
+
required: ["path"],
|
|
422
|
+
|
|
423
|
+
properties: {
|
|
424
|
+
path: {
|
|
425
|
+
type: "string",
|
|
426
|
+
title: "Signal K Path",
|
|
427
|
+
description:
|
|
428
|
+
"Select one of the Signal K paths currently available on this vessel.",
|
|
429
|
+
enum: availablePaths,
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
name: {
|
|
433
|
+
type: "string",
|
|
434
|
+
title: "Webhook Field Name",
|
|
435
|
+
description:
|
|
436
|
+
"Optional shorter name for this value in the outgoing JSON.",
|
|
437
|
+
default: "",
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
units: {
|
|
441
|
+
type: "string",
|
|
442
|
+
title: "Output Units",
|
|
443
|
+
default: "native",
|
|
444
|
+
|
|
445
|
+
enum: [
|
|
446
|
+
"native",
|
|
447
|
+
"knots",
|
|
448
|
+
"kmh",
|
|
449
|
+
"mph",
|
|
450
|
+
"metres",
|
|
451
|
+
"feet",
|
|
452
|
+
"degrees",
|
|
453
|
+
"celsius",
|
|
454
|
+
"fahrenheit",
|
|
455
|
+
"hpa",
|
|
456
|
+
"mbar",
|
|
457
|
+
],
|
|
458
|
+
|
|
459
|
+
enumNames: [
|
|
460
|
+
"Native Signal K units",
|
|
461
|
+
"Knots",
|
|
462
|
+
"km/h",
|
|
463
|
+
"mph",
|
|
464
|
+
"Metres",
|
|
465
|
+
"Feet",
|
|
466
|
+
"Degrees",
|
|
467
|
+
"Celsius",
|
|
468
|
+
"Fahrenheit",
|
|
469
|
+
"hPa",
|
|
470
|
+
"mbar",
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
return plugin;
|
|
481
|
+
};
|
package/plugin/paths.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin/paths.js
|
|
3
|
+
*
|
|
4
|
+
* Discovers available Signal K stream paths for the API and configuration schema.
|
|
5
|
+
* Checks the server response, removes exact duplicates, trims and sorts path names.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Share path discovery between the HTTP endpoint and configuration schema.
|
|
9
|
+
function getAvailablePaths(app) {
|
|
10
|
+
// A server without this API can still render configuration with an empty list.
|
|
11
|
+
if (
|
|
12
|
+
!app.streambundle ||
|
|
13
|
+
typeof app.streambundle.getAvailablePaths !== "function"
|
|
14
|
+
) {
|
|
15
|
+
app.debug("streambundle.getAvailablePaths() is not available");
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const paths = app.streambundle.getAvailablePaths();
|
|
20
|
+
|
|
21
|
+
// Guard the array operations below against an unexpected server response.
|
|
22
|
+
if (!Array.isArray(paths)) {
|
|
23
|
+
app.debug("getAvailablePaths() did not return an array");
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Remove exact duplicates, keep non-empty strings, trim and sort for display.
|
|
28
|
+
// Deduplication happens before trimming, so whitespace variants can remain.
|
|
29
|
+
return [...new Set(paths)]
|
|
30
|
+
.filter((path) => typeof path === "string")
|
|
31
|
+
.map((path) => path.trim())
|
|
32
|
+
.filter((path) => path.length > 0)
|
|
33
|
+
.sort();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
getAvailablePaths,
|
|
38
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin/storage.js
|
|
3
|
+
*
|
|
4
|
+
* Persists pending webhook payloads in a SQLite queue in the plugin data directory.
|
|
5
|
+
* Provides operations to enqueue, inspect the oldest entry, remove entries and count
|
|
6
|
+
* pending records. The caller handles delivery and removes entries after success.
|
|
7
|
+
* Stores JSON and enqueue timestamps in webhook-queue.sqlite, ordered by record ID.
|
|
8
|
+
* Uses Node's built-in SQLite API with a shared synchronous database connection.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
14
|
+
|
|
15
|
+
// Reuse one connection until close() releases it.
|
|
16
|
+
let db = null;
|
|
17
|
+
|
|
18
|
+
// Open the persistent queue, creating its directory and table on first use.
|
|
19
|
+
function openQueue(app) {
|
|
20
|
+
if (db) {
|
|
21
|
+
return db;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const dataDir = app.getDataDirPath();
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(dataDir)) {
|
|
27
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const dbPath = path.join(dataDir, "webhook-queue.sqlite");
|
|
31
|
+
|
|
32
|
+
db = new DatabaseSync(dbPath);
|
|
33
|
+
|
|
34
|
+
// Enable write-ahead logging and full synchronisation for durable queue writes.
|
|
35
|
+
db.exec(`
|
|
36
|
+
PRAGMA journal_mode = WAL;
|
|
37
|
+
PRAGMA synchronous = FULL;
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS queue (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
created_at TEXT NOT NULL,
|
|
42
|
+
payload TEXT NOT NULL
|
|
43
|
+
);
|
|
44
|
+
`);
|
|
45
|
+
|
|
46
|
+
app.debug(`Webhook queue opened at ${dbPath}`);
|
|
47
|
+
|
|
48
|
+
return db;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Fail explicitly if a queue operation is attempted before opening the database.
|
|
52
|
+
function ensureOpen() {
|
|
53
|
+
if (!db) {
|
|
54
|
+
throw new Error("Webhook queue has not been opened");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Store a JSON payload with its enqueue time and return the new record's identity.
|
|
59
|
+
function enqueue(payload) {
|
|
60
|
+
ensureOpen();
|
|
61
|
+
|
|
62
|
+
const statement = db.prepare(`
|
|
63
|
+
INSERT INTO queue (created_at, payload)
|
|
64
|
+
VALUES (?, ?)
|
|
65
|
+
`);
|
|
66
|
+
|
|
67
|
+
const createdAt = new Date().toISOString();
|
|
68
|
+
const payloadJson = JSON.stringify(payload);
|
|
69
|
+
|
|
70
|
+
const result = statement.run(createdAt, payloadJson);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
id: Number(result.lastInsertRowid),
|
|
74
|
+
createdAt,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Read the earliest inserted entry without deleting it; return null for an empty queue.
|
|
79
|
+
function peekOldest() {
|
|
80
|
+
ensureOpen();
|
|
81
|
+
|
|
82
|
+
const statement = db.prepare(`
|
|
83
|
+
SELECT id, created_at, payload
|
|
84
|
+
FROM queue
|
|
85
|
+
ORDER BY id ASC
|
|
86
|
+
LIMIT 1
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
const row = statement.get();
|
|
90
|
+
|
|
91
|
+
if (!row) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
id: Number(row.id),
|
|
97
|
+
createdAt: row.created_at,
|
|
98
|
+
payload: JSON.parse(row.payload),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Delete a specific entry, normally after the caller confirms successful delivery.
|
|
103
|
+
function remove(id) {
|
|
104
|
+
ensureOpen();
|
|
105
|
+
|
|
106
|
+
const statement = db.prepare(`
|
|
107
|
+
DELETE FROM queue
|
|
108
|
+
WHERE id = ?
|
|
109
|
+
`);
|
|
110
|
+
|
|
111
|
+
statement.run(id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Return the number of entries still waiting in the queue.
|
|
115
|
+
function count() {
|
|
116
|
+
ensureOpen();
|
|
117
|
+
|
|
118
|
+
const statement = db.prepare(`
|
|
119
|
+
SELECT COUNT(*) AS total
|
|
120
|
+
FROM queue
|
|
121
|
+
`);
|
|
122
|
+
|
|
123
|
+
const row = statement.get();
|
|
124
|
+
|
|
125
|
+
return Number(row.total);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Release the database connection; repeated calls are safe and the queue can reopen.
|
|
129
|
+
function close() {
|
|
130
|
+
if (!db) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
db.close();
|
|
135
|
+
db = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
openQueue,
|
|
140
|
+
enqueue,
|
|
141
|
+
peekOldest,
|
|
142
|
+
remove,
|
|
143
|
+
count,
|
|
144
|
+
close,
|
|
145
|
+
};
|