@getdashfy/server 0.1.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 +10 -0
- package/LICENSE +661 -0
- package/README.md +768 -0
- package/dist/index.cjs +1235 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +422 -0
- package/dist/index.d.ts +422 -0
- package/dist/index.js +1225 -0
- package/dist/index.js.map +1 -0
- package/package.json +82 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
import path, { extname } from 'path';
|
|
2
|
+
import cors from '@fastify/cors';
|
|
3
|
+
import fastifyStatic from '@fastify/static';
|
|
4
|
+
import { getErrorMessage } from '@getdashfy/utils';
|
|
5
|
+
import { watch } from 'chokidar';
|
|
6
|
+
import Fastify from 'fastify';
|
|
7
|
+
import { Server } from 'socket.io';
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
import { request as request$1 } from 'undici';
|
|
10
|
+
import { readFile } from 'fs/promises';
|
|
11
|
+
import { parse } from 'yaml';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import pino from 'pino';
|
|
14
|
+
|
|
15
|
+
// src/dashfy.ts
|
|
16
|
+
|
|
17
|
+
// ../types/dist/index.js
|
|
18
|
+
var WebSocketEvent = /* @__PURE__ */ ((WebSocketEvent2) => {
|
|
19
|
+
WebSocketEvent2["CONNECT"] = "connect";
|
|
20
|
+
WebSocketEvent2["DISCONNECT"] = "disconnect";
|
|
21
|
+
WebSocketEvent2["ERROR"] = "error";
|
|
22
|
+
WebSocketEvent2["CONFIGURATION"] = "configuration";
|
|
23
|
+
WebSocketEvent2["RECONNECT"] = "reconnect";
|
|
24
|
+
WebSocketEvent2["RECONNECT_ATTEMPT"] = "reconnect_attempt";
|
|
25
|
+
WebSocketEvent2["RECONNECT_ERROR"] = "reconnect_error";
|
|
26
|
+
WebSocketEvent2["RECONNECT_FAILED"] = "reconnect_failed";
|
|
27
|
+
WebSocketEvent2["API_SUBSCRIPTION"] = "api.subscription";
|
|
28
|
+
WebSocketEvent2["API_UNSUBSCRIPTION"] = "api.unsubscription";
|
|
29
|
+
WebSocketEvent2["API_DATA"] = "api.data";
|
|
30
|
+
WebSocketEvent2["API_ERROR"] = "api.error";
|
|
31
|
+
return WebSocketEvent2;
|
|
32
|
+
})(WebSocketEvent || {});
|
|
33
|
+
|
|
34
|
+
// src/constants.ts
|
|
35
|
+
var DEFAULT_PORT = 5001;
|
|
36
|
+
var DEFAULT_HOST = "0.0.0.0";
|
|
37
|
+
var DEFAULT_POLL_INTERVAL = 15e3;
|
|
38
|
+
var DEFAULT_PUSH_INTERVAL = 2e3;
|
|
39
|
+
var SOCKET_PING_TIMEOUT = 6e4;
|
|
40
|
+
var SOCKET_PING_INTERVAL = 25e3;
|
|
41
|
+
var DEFAULT_REQUEST_TIMEOUT = 1e4;
|
|
42
|
+
function createPushIntervalFactory(logger, prefix) {
|
|
43
|
+
return (options = {}) => {
|
|
44
|
+
const { interval = DEFAULT_PUSH_INTERVAL } = options;
|
|
45
|
+
const intervals = /* @__PURE__ */ new Map();
|
|
46
|
+
const logKey = (key) => prefix ? `${prefix}.${key}` : key;
|
|
47
|
+
return (key, callback, fetchFn) => {
|
|
48
|
+
const stop = () => {
|
|
49
|
+
const existing = intervals.get(key);
|
|
50
|
+
if (!existing) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
clearInterval(existing);
|
|
54
|
+
intervals.delete(key);
|
|
55
|
+
logger.info(`[${logKey(key)}] Stopped push interval`);
|
|
56
|
+
};
|
|
57
|
+
if (intervals.has(key)) {
|
|
58
|
+
return stop;
|
|
59
|
+
}
|
|
60
|
+
const pushData = async () => {
|
|
61
|
+
try {
|
|
62
|
+
const data = await fetchFn();
|
|
63
|
+
callback(data);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
logger.error(`[${logKey(key)}] Error: ${getErrorMessage(error)}`);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
void pushData();
|
|
69
|
+
const timer = setInterval(() => {
|
|
70
|
+
void pushData();
|
|
71
|
+
}, interval);
|
|
72
|
+
intervals.set(key, timer);
|
|
73
|
+
logger.info(`[${logKey(key)}] Started push interval (${interval}ms)`);
|
|
74
|
+
return stop;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async function request(options) {
|
|
79
|
+
const { url, method = "GET", headers = {}, body, timeout = DEFAULT_REQUEST_TIMEOUT } = options;
|
|
80
|
+
const requestHeaders = {
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
Accept: "application/json",
|
|
83
|
+
...headers
|
|
84
|
+
};
|
|
85
|
+
const response = await request$1(url, {
|
|
86
|
+
method,
|
|
87
|
+
headers: requestHeaders,
|
|
88
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
89
|
+
headersTimeout: timeout,
|
|
90
|
+
bodyTimeout: timeout
|
|
91
|
+
});
|
|
92
|
+
if (response.statusCode >= 400) {
|
|
93
|
+
const errorBody = await response.body.text().catch(() => "");
|
|
94
|
+
throw new Error(`HTTP ${response.statusCode}: ${errorBody || "Request failed"}`);
|
|
95
|
+
}
|
|
96
|
+
return response.body.json();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/bus.ts
|
|
100
|
+
var Bus = class extends EventEmitter {
|
|
101
|
+
logger;
|
|
102
|
+
pollInterval;
|
|
103
|
+
apis = /* @__PURE__ */ new Map();
|
|
104
|
+
clients = /* @__PURE__ */ new Map();
|
|
105
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
106
|
+
/**
|
|
107
|
+
* Creates a new Bus instance.
|
|
108
|
+
*
|
|
109
|
+
* @param options - Bus options
|
|
110
|
+
* @param options.logger - Logger instance
|
|
111
|
+
* @param options.pollInterval - Poll interval in milliseconds (default: 15_000)
|
|
112
|
+
*
|
|
113
|
+
* @remarks
|
|
114
|
+
* Changing `pollInterval` after construction only affects subscriptions created afterwards;
|
|
115
|
+
* timers for already-active poll subscriptions keep their original interval until recreated.
|
|
116
|
+
*/
|
|
117
|
+
constructor(options) {
|
|
118
|
+
super();
|
|
119
|
+
this.logger = options.logger;
|
|
120
|
+
this.pollInterval = options.pollInterval ?? DEFAULT_POLL_INTERVAL;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Register a new API with the bus.
|
|
124
|
+
*
|
|
125
|
+
* An API is an object composed of various methods that can be called by clients.
|
|
126
|
+
* APIs can operate in two modes:
|
|
127
|
+
* - `poll`: Methods are called periodically at a fixed interval
|
|
128
|
+
* - `push`: Methods push data to clients when available
|
|
129
|
+
*
|
|
130
|
+
* @param id - Unique identifier for the API
|
|
131
|
+
* @param apiRegistration - Function that returns an object with API methods
|
|
132
|
+
* @param mode - API mode: 'poll' (default) or 'push'
|
|
133
|
+
* @throws {Error} If the API mode is not 'poll' or 'push'
|
|
134
|
+
* @throws {Error} If an API with the same ID is already registered
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```ts
|
|
138
|
+
* bus.registerApi('weather', ({ logger, request }) => ({
|
|
139
|
+
* getCurrentWeather: async () => {
|
|
140
|
+
* return request({ url: 'https://api.weather.com/current' })
|
|
141
|
+
* }
|
|
142
|
+
* }), 'poll')
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
registerApi(id, apiRegistration, mode = "poll") {
|
|
146
|
+
if (mode !== "poll" && mode !== "push") {
|
|
147
|
+
const errorMessage = `Invalid API mode: ${String(mode)}. Must be 'poll' or 'push'`;
|
|
148
|
+
this.logger.error(errorMessage);
|
|
149
|
+
throw new Error(errorMessage);
|
|
150
|
+
}
|
|
151
|
+
if (this.apis.has(id)) {
|
|
152
|
+
const errorMessage = `API '${id}' is already registered`;
|
|
153
|
+
this.logger.error(errorMessage);
|
|
154
|
+
throw new Error(errorMessage);
|
|
155
|
+
}
|
|
156
|
+
const logger = this.logger.child({ api: id });
|
|
157
|
+
const methods = apiRegistration({
|
|
158
|
+
logger,
|
|
159
|
+
request,
|
|
160
|
+
createPushInterval: createPushIntervalFactory(logger, id)
|
|
161
|
+
});
|
|
162
|
+
this.apis.set(id, { mode, methods });
|
|
163
|
+
this.logger.info(`Registered API '${id}' (mode: ${mode})`);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Get all registered API identifiers.
|
|
167
|
+
*
|
|
168
|
+
* Returns an array of all API IDs that have been registered with the bus,
|
|
169
|
+
* sorted alphabetically. Useful for debugging, introspection, or displaying
|
|
170
|
+
* available APIs.
|
|
171
|
+
*
|
|
172
|
+
* @returns Array of registered API IDs in alphabetical order
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```ts
|
|
176
|
+
* const apis = bus.listApis()
|
|
177
|
+
* console.log(apis) // ['github', 'news', 'weather']
|
|
178
|
+
*
|
|
179
|
+
* // Check if a specific API is registered
|
|
180
|
+
* if (bus.listApis().includes('github')) {
|
|
181
|
+
* console.log('GitHub API is available')
|
|
182
|
+
* }
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
listApis() {
|
|
186
|
+
return Array.from(this.apis.keys()).sort((a, b) => a.localeCompare(b));
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Register a new client connection with the bus.
|
|
190
|
+
*
|
|
191
|
+
* This should be called when a client establishes a WebSocket connection. The client
|
|
192
|
+
* is tracked with its socket, an empty subscriptions set, and a connection timestamp.
|
|
193
|
+
* Once registered, the client can subscribe to API methods.
|
|
194
|
+
*
|
|
195
|
+
* @param socket - Socket.IO socket instance representing the connected client
|
|
196
|
+
* @throws {Error} If a client with the same socket ID is already registered
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```ts
|
|
200
|
+
* // In your Socket.IO connection handler
|
|
201
|
+
* io.on('connection', (socket) => {
|
|
202
|
+
* bus.addClient(socket)
|
|
203
|
+
*
|
|
204
|
+
* socket.on('disconnect', () => {
|
|
205
|
+
* bus.removeClient(socket.id)
|
|
206
|
+
* })
|
|
207
|
+
* })
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
addClient(socket) {
|
|
211
|
+
if (this.clients.has(socket.id)) {
|
|
212
|
+
const errorMessage = `Client with id ${socket.id} already registered`;
|
|
213
|
+
this.logger.error(errorMessage);
|
|
214
|
+
throw new Error(errorMessage);
|
|
215
|
+
}
|
|
216
|
+
this.clients.set(socket.id, {
|
|
217
|
+
socket,
|
|
218
|
+
subscriptions: /* @__PURE__ */ new Set(),
|
|
219
|
+
connectedAt: /* @__PURE__ */ new Date()
|
|
220
|
+
});
|
|
221
|
+
this.logger.info(`Client with id ${socket.id} connected`);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Remove a client and clean up all associated resources.
|
|
225
|
+
*
|
|
226
|
+
* This method removes the client from all subscriptions it was part of. If a subscription
|
|
227
|
+
* has no remaining clients after removal, the subscription is cleaned up and any polling
|
|
228
|
+
* timers are cleared. This should be called when a client disconnects to prevent memory leaks.
|
|
229
|
+
*
|
|
230
|
+
* @param clientId - The unique identifier of the client to remove (socket.id)
|
|
231
|
+
* @throws {Error} If the client is not found
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```ts
|
|
235
|
+
* // In your Socket.IO disconnect handler
|
|
236
|
+
* socket.on('disconnect', () => {
|
|
237
|
+
* bus.removeClient(socket.id)
|
|
238
|
+
* console.log(`Client ${socket.id} removed`)
|
|
239
|
+
* })
|
|
240
|
+
* ```
|
|
241
|
+
*/
|
|
242
|
+
removeClient(clientId) {
|
|
243
|
+
const clientInfo = this.clients.get(clientId);
|
|
244
|
+
if (!clientInfo) {
|
|
245
|
+
const errorMessage = `Client with id ${clientId} not found`;
|
|
246
|
+
this.logger.error(errorMessage);
|
|
247
|
+
throw new Error(errorMessage);
|
|
248
|
+
}
|
|
249
|
+
for (const subscriptionId of clientInfo.subscriptions) {
|
|
250
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
251
|
+
if (!subscription) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
subscription.clients.delete(clientId);
|
|
255
|
+
if (subscription.clients.size === 0) {
|
|
256
|
+
this.teardownSubscription(subscriptionId);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
this.clients.delete(clientId);
|
|
260
|
+
this.logger.info(`Client with id ${clientId} disconnected`);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Get the current number of connected clients.
|
|
264
|
+
*
|
|
265
|
+
* Returns the real-time count of active client connections. Useful for monitoring,
|
|
266
|
+
* health checks, or displaying connection statistics.
|
|
267
|
+
*
|
|
268
|
+
* @returns Number of currently connected clients
|
|
269
|
+
*
|
|
270
|
+
* @example
|
|
271
|
+
* ```ts
|
|
272
|
+
* // Display connection stats
|
|
273
|
+
* const count = bus.clientCount()
|
|
274
|
+
* console.log(`${count} client(s) connected`)
|
|
275
|
+
*
|
|
276
|
+
* // Health check endpoint
|
|
277
|
+
* app.get('/health', (req, res) => {
|
|
278
|
+
* res.json({
|
|
279
|
+
* status: 'ok',
|
|
280
|
+
* clients: bus.clientCount()
|
|
281
|
+
* })
|
|
282
|
+
* })
|
|
283
|
+
* ```
|
|
284
|
+
*/
|
|
285
|
+
clientCount() {
|
|
286
|
+
return this.clients.size;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Subscribe a client to an API method call.
|
|
290
|
+
*
|
|
291
|
+
* This creates or reuses a subscription for the specified API method and adds the client
|
|
292
|
+
* to receive updates. For poll mode APIs, this starts periodic polling. For push mode APIs,
|
|
293
|
+
* this sets up the push producer. If cached data exists, it's immediately sent to the client.
|
|
294
|
+
*
|
|
295
|
+
* Routing is resolved from `subscription.api` (the registered API) and `subscription.endpoint`
|
|
296
|
+
* (the method on that API). The `subscription.id` is used as the key for deduping subscriptions,
|
|
297
|
+
* caching, and client messages; by convention it is `${api}.${endpoint}` (e.g. 'github.repos'),
|
|
298
|
+
* but this format is not enforced.
|
|
299
|
+
*
|
|
300
|
+
* @param clientId - The unique identifier of the connected client
|
|
301
|
+
* @param subscription - Subscription details including `id`, `api`, `endpoint`, and optional `params`
|
|
302
|
+
* @throws {Error} If the API ID (`subscription.api`) is empty or undefined
|
|
303
|
+
* @throws {Error} If the endpoint (`subscription.endpoint`) is empty or undefined
|
|
304
|
+
* @throws {Error} If the API is not registered
|
|
305
|
+
* @throws {Error} If the API method does not exist or is not a function
|
|
306
|
+
*
|
|
307
|
+
* @remarks
|
|
308
|
+
* If the client is not registered, the call returns silently (logging a warning) instead of
|
|
309
|
+
* throwing. If a subscription with the same `id` already exists, the incoming `params` are
|
|
310
|
+
* ignored: the subscription keeps the `params` supplied by its first subscriber.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```ts
|
|
314
|
+
* // Subscribe to a GitHub API method
|
|
315
|
+
* bus.subscribe('client-123', {
|
|
316
|
+
* id: 'github.repos',
|
|
317
|
+
* api: 'github',
|
|
318
|
+
* endpoint: 'repos',
|
|
319
|
+
* params: { user: 'octocat' }
|
|
320
|
+
* })
|
|
321
|
+
*
|
|
322
|
+
* // Subscribe without parameters
|
|
323
|
+
* bus.subscribe('client-456', {
|
|
324
|
+
* id: 'weather.current',
|
|
325
|
+
* api: 'weather',
|
|
326
|
+
* endpoint: 'current'
|
|
327
|
+
* })
|
|
328
|
+
* ```
|
|
329
|
+
*/
|
|
330
|
+
subscribe(clientId, subscription) {
|
|
331
|
+
const clientInfo = this.clients.get(clientId);
|
|
332
|
+
if (!clientInfo) {
|
|
333
|
+
this.logger.warn(`Cannot subscribe: client with id ${clientId} not found`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const subscriptionId = subscription.id;
|
|
337
|
+
const apiId = subscription.api;
|
|
338
|
+
const methodName = subscription.endpoint;
|
|
339
|
+
if (!apiId) {
|
|
340
|
+
const errorMessage = `Invalid API ID in subscription: ${subscriptionId}`;
|
|
341
|
+
this.logger.error(errorMessage);
|
|
342
|
+
throw new Error(errorMessage);
|
|
343
|
+
}
|
|
344
|
+
if (!methodName) {
|
|
345
|
+
const errorMessage = `Invalid endpoint in subscription: ${subscriptionId}`;
|
|
346
|
+
this.logger.error(errorMessage);
|
|
347
|
+
throw new Error(errorMessage);
|
|
348
|
+
}
|
|
349
|
+
const api = this.apis.get(apiId);
|
|
350
|
+
if (!api) {
|
|
351
|
+
const errorMessage = `API not found: ${apiId}`;
|
|
352
|
+
this.logger.error(errorMessage);
|
|
353
|
+
throw new Error(errorMessage);
|
|
354
|
+
}
|
|
355
|
+
const method = api.methods[methodName];
|
|
356
|
+
if (!method || typeof method !== "function") {
|
|
357
|
+
const errorMessage = `API method not found: ${apiId}.${methodName}`;
|
|
358
|
+
this.logger.error(errorMessage);
|
|
359
|
+
throw new Error(errorMessage);
|
|
360
|
+
}
|
|
361
|
+
if (!this.subscriptions.has(subscriptionId)) {
|
|
362
|
+
this.subscriptions.set(subscriptionId, {
|
|
363
|
+
id: subscriptionId,
|
|
364
|
+
api: apiId,
|
|
365
|
+
endpoint: methodName,
|
|
366
|
+
params: subscription.params,
|
|
367
|
+
clients: /* @__PURE__ */ new Set()
|
|
368
|
+
});
|
|
369
|
+
this.logger.info(`Created subscription ${subscriptionId}`);
|
|
370
|
+
if (api.mode === "poll") {
|
|
371
|
+
void this.processApiCall(subscriptionId, method, subscription.params);
|
|
372
|
+
const timer = setInterval(() => {
|
|
373
|
+
void this.processApiCall(subscriptionId, method, subscription.params);
|
|
374
|
+
}, this.pollInterval);
|
|
375
|
+
const sub2 = this.subscriptions.get(subscriptionId);
|
|
376
|
+
if (sub2) {
|
|
377
|
+
sub2.timer = timer;
|
|
378
|
+
}
|
|
379
|
+
this.logger.info(
|
|
380
|
+
`Started polling for subscription ${subscriptionId} every ${this.pollInterval}ms`
|
|
381
|
+
);
|
|
382
|
+
} else if (api.mode === "push") {
|
|
383
|
+
const producerResult = method((data) => {
|
|
384
|
+
const message = { id: subscriptionId, data };
|
|
385
|
+
const sub2 = this.subscriptions.get(subscriptionId);
|
|
386
|
+
if (sub2) {
|
|
387
|
+
sub2.cached = message;
|
|
388
|
+
}
|
|
389
|
+
this.send(subscriptionId, message, WebSocketEvent.API_DATA);
|
|
390
|
+
}, subscription.params);
|
|
391
|
+
Promise.resolve(producerResult).then((result) => {
|
|
392
|
+
if (typeof result !== "function") {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const dispose = result;
|
|
396
|
+
const sub2 = this.subscriptions.get(subscriptionId);
|
|
397
|
+
if (sub2) {
|
|
398
|
+
sub2.pushDispose = dispose;
|
|
399
|
+
} else {
|
|
400
|
+
dispose();
|
|
401
|
+
}
|
|
402
|
+
}).catch((error) => {
|
|
403
|
+
this.logger.error(
|
|
404
|
+
`Push producer for ${subscriptionId} failed: ${getErrorMessage(error)}`
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
this.logger.info(`Created push producer for subscription ${subscriptionId}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const sub = this.subscriptions.get(subscriptionId);
|
|
411
|
+
if (!sub) {
|
|
412
|
+
this.logger.warn(`Cannot subscribe: subscription ${subscriptionId} not found`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
sub.clients.add(clientId);
|
|
416
|
+
clientInfo.subscriptions.add(subscriptionId);
|
|
417
|
+
if (sub.cached) {
|
|
418
|
+
this.logger.info(
|
|
419
|
+
`Sending cached data to client with id ${clientId} for subscription ${subscriptionId}`
|
|
420
|
+
);
|
|
421
|
+
clientInfo.socket.emit(WebSocketEvent.API_DATA, sub.cached);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Unsubscribe a client from an API method subscription.
|
|
426
|
+
*
|
|
427
|
+
* This removes the client from the specified subscription. If this was the last client
|
|
428
|
+
* subscribed to this API method, the subscription is cleaned up entirely and any polling
|
|
429
|
+
* timers are cleared to prevent unnecessary API calls and memory leaks.
|
|
430
|
+
*
|
|
431
|
+
* If the client or subscription doesn't exist, the method returns silently without error.
|
|
432
|
+
* This is safe to call multiple times or for non-existent subscriptions.
|
|
433
|
+
*
|
|
434
|
+
* @param clientId - The unique identifier of the client to unsubscribe
|
|
435
|
+
* @param subscriptionId - The subscription ID to unsubscribe from (matches `subscription.id`, conventionally 'api.method')
|
|
436
|
+
*
|
|
437
|
+
* @example
|
|
438
|
+
* ```ts
|
|
439
|
+
* // Unsubscribe from a specific API
|
|
440
|
+
* socket.on('unsubscribe', (subscriptionId) => {
|
|
441
|
+
* bus.unsubscribe(socket.id, subscriptionId)
|
|
442
|
+
* })
|
|
443
|
+
*
|
|
444
|
+
* // Unsubscribe when widget is removed
|
|
445
|
+
* bus.unsubscribe('client-123', 'github.repos')
|
|
446
|
+
* ```
|
|
447
|
+
*/
|
|
448
|
+
unsubscribe(clientId, subscriptionId) {
|
|
449
|
+
const clientInfo = this.clients.get(clientId);
|
|
450
|
+
if (!clientInfo) {
|
|
451
|
+
this.logger.warn(`Cannot unsubscribe: client with id ${clientId} not found`);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
455
|
+
if (!subscription) {
|
|
456
|
+
this.logger.warn(`Cannot unsubscribe: subscription ${subscriptionId} not found`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
subscription.clients.delete(clientId);
|
|
460
|
+
clientInfo.subscriptions.delete(subscriptionId);
|
|
461
|
+
if (subscription.clients.size === 0) {
|
|
462
|
+
this.teardownSubscription(subscriptionId);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Get detailed information about all active subscriptions.
|
|
467
|
+
*
|
|
468
|
+
* Returns an array of subscription metadata including subscription IDs, client counts,
|
|
469
|
+
* cache status, and timer status. Useful for monitoring, debugging, and displaying
|
|
470
|
+
* real-time statistics about API subscriptions.
|
|
471
|
+
*
|
|
472
|
+
* @returns Array of subscription info objects, each containing:
|
|
473
|
+
* - `id`: Subscription identifier (conventionally 'api.method')
|
|
474
|
+
* - `clientCount`: Number of clients subscribed to this API method
|
|
475
|
+
* - `hasCachedData`: Whether cached data is available for immediate delivery
|
|
476
|
+
* - `hasTimer`: Whether a polling timer is active (poll mode only)
|
|
477
|
+
*
|
|
478
|
+
* @remarks
|
|
479
|
+
* Subscriptions are torn down automatically when their last client leaves, so a
|
|
480
|
+
* `clientCount` of `0` should not appear during normal operation.
|
|
481
|
+
*
|
|
482
|
+
* @example
|
|
483
|
+
* ```ts
|
|
484
|
+
* // Get all subscription stats
|
|
485
|
+
* const subs = bus.getSubscriptionsInfo()
|
|
486
|
+
* console.log(subs)
|
|
487
|
+
* // [
|
|
488
|
+
* // {
|
|
489
|
+
* // id: 'github.repos',
|
|
490
|
+
* // clientCount: 3,
|
|
491
|
+
* // hasCachedData: true,
|
|
492
|
+
* // hasTimer: true
|
|
493
|
+
* // },
|
|
494
|
+
* // {
|
|
495
|
+
* // id: 'weather.current',
|
|
496
|
+
* // clientCount: 1,
|
|
497
|
+
* // hasCachedData: false,
|
|
498
|
+
* // hasTimer: false
|
|
499
|
+
* // }
|
|
500
|
+
* // ]
|
|
501
|
+
*
|
|
502
|
+
* // Debug endpoint for monitoring
|
|
503
|
+
* app.get('/debug/subscriptions', (req, res) => {
|
|
504
|
+
* res.json({
|
|
505
|
+
* total: bus.getSubscriptionsInfo().length,
|
|
506
|
+
* subscriptions: bus.getSubscriptionsInfo()
|
|
507
|
+
* })
|
|
508
|
+
* })
|
|
509
|
+
* ```
|
|
510
|
+
*/
|
|
511
|
+
getSubscriptionsInfo() {
|
|
512
|
+
return Array.from(this.subscriptions.entries()).map(([id, sub]) => ({
|
|
513
|
+
id,
|
|
514
|
+
clientCount: sub.clients.size,
|
|
515
|
+
hasCachedData: !!sub.cached,
|
|
516
|
+
hasTimer: !!sub.timer
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Execute an API method call and distribute results to subscribed clients.
|
|
521
|
+
*
|
|
522
|
+
* This internal method is called either periodically (for poll mode) or on-demand (for push mode).
|
|
523
|
+
* It executes the API method with the provided parameters, caches the result, and sends the data
|
|
524
|
+
* to all clients subscribed to this API method. If the API call fails, an error message is sent
|
|
525
|
+
* instead and logged.
|
|
526
|
+
*
|
|
527
|
+
* @param subscriptionId - The subscription identifier (conventionally 'api.method')
|
|
528
|
+
* @param method - The API method function to execute
|
|
529
|
+
* @param params - Optional parameters to pass to the API method
|
|
530
|
+
*
|
|
531
|
+
* @remarks
|
|
532
|
+
* This method handles:
|
|
533
|
+
* - Executing the API method with error handling
|
|
534
|
+
* - Caching successful responses for immediate delivery to new subscribers
|
|
535
|
+
* - Broadcasting data to all subscribed clients via 'api.data' event
|
|
536
|
+
* - Broadcasting errors to all subscribed clients via 'api.error' event
|
|
537
|
+
* - Logging execution details for debugging
|
|
538
|
+
*
|
|
539
|
+
* @internal
|
|
540
|
+
*/
|
|
541
|
+
async processApiCall(subscriptionId, method, params) {
|
|
542
|
+
try {
|
|
543
|
+
this.logger.debug(`Calling subscription ${subscriptionId}`);
|
|
544
|
+
const data = await method(params);
|
|
545
|
+
const message = { id: subscriptionId, data };
|
|
546
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
547
|
+
if (subscription) {
|
|
548
|
+
this.logger.info(`Caching response for subscription ${subscriptionId}`);
|
|
549
|
+
subscription.cached = message;
|
|
550
|
+
}
|
|
551
|
+
this.send(subscriptionId, message, WebSocketEvent.API_DATA);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
const errorMessage = {
|
|
554
|
+
id: subscriptionId,
|
|
555
|
+
error: {
|
|
556
|
+
message: getErrorMessage(error)
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
this.logger.error({ err: error, subscriptionId }, "Subscription execution error");
|
|
560
|
+
this.send(subscriptionId, errorMessage, WebSocketEvent.API_ERROR);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Broadcast data to all clients subscribed to a specific API method.
|
|
565
|
+
*
|
|
566
|
+
* This internal method iterates through all clients subscribed to the given subscription
|
|
567
|
+
* and emits the data via Socket.IO. It safely handles cases where clients may have
|
|
568
|
+
* disconnected but haven't been cleaned up yet.
|
|
569
|
+
*
|
|
570
|
+
* @param subscriptionId - The subscription identifier (conventionally 'api.method')
|
|
571
|
+
* @param data - The data payload to send (API response or error message)
|
|
572
|
+
* @param type - The message type: 'api.data' for successful responses, 'api.error' for errors
|
|
573
|
+
*
|
|
574
|
+
* @remarks
|
|
575
|
+
* This method:
|
|
576
|
+
* - Returns silently if the subscription doesn't exist (already cleaned up)
|
|
577
|
+
* - Skips clients that no longer exist (race condition handling)
|
|
578
|
+
* - Uses Socket.IO's emit to send data to each client
|
|
579
|
+
*
|
|
580
|
+
* @internal
|
|
581
|
+
*/
|
|
582
|
+
send(subscriptionId, data, type) {
|
|
583
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
584
|
+
if (!subscription) {
|
|
585
|
+
this.logger.warn(`Cannot send data to subscription ${subscriptionId}: not found`);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
for (const clientId of subscription.clients) {
|
|
589
|
+
const clientInfo = this.clients.get(clientId);
|
|
590
|
+
if (clientInfo) {
|
|
591
|
+
clientInfo.socket.emit(type, data);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Tear down a subscription that has no clients remaining.
|
|
597
|
+
*
|
|
598
|
+
* Clears any poll-mode timer and invokes the push-mode disposer (if either is set),
|
|
599
|
+
* then removes the subscription entry. Safe to call when the subscription is missing.
|
|
600
|
+
*
|
|
601
|
+
* @param subscriptionId - The subscription identifier (conventionally 'api.method')
|
|
602
|
+
*
|
|
603
|
+
* @internal
|
|
604
|
+
*/
|
|
605
|
+
teardownSubscription(subscriptionId) {
|
|
606
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
607
|
+
if (!subscription) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (subscription.timer) {
|
|
611
|
+
clearInterval(subscription.timer);
|
|
612
|
+
}
|
|
613
|
+
if (subscription.pushDispose) {
|
|
614
|
+
try {
|
|
615
|
+
subscription.pushDispose();
|
|
616
|
+
} catch (error) {
|
|
617
|
+
this.logger.error(`Push dispose for ${subscriptionId} failed: ${getErrorMessage(error)}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
this.subscriptions.delete(subscriptionId);
|
|
621
|
+
this.logger.info(`Removed subscription ${subscriptionId} (no clients)`);
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
var WidgetConfigSchema = z.object({
|
|
625
|
+
extension: z.string(),
|
|
626
|
+
widget: z.string(),
|
|
627
|
+
title: z.string().optional(),
|
|
628
|
+
columns: z.number().positive(),
|
|
629
|
+
rows: z.number().positive(),
|
|
630
|
+
x: z.number().nonnegative(),
|
|
631
|
+
y: z.number().nonnegative()
|
|
632
|
+
}).passthrough();
|
|
633
|
+
var DashboardConfigSchema = z.object({
|
|
634
|
+
name: z.string().optional(),
|
|
635
|
+
title: z.string().optional(),
|
|
636
|
+
columns: z.number().positive(),
|
|
637
|
+
rows: z.number().positive(),
|
|
638
|
+
widgets: z.array(WidgetConfigSchema)
|
|
639
|
+
});
|
|
640
|
+
var DashfyConfigSchema = z.object({
|
|
641
|
+
port: z.number().positive().default(DEFAULT_PORT),
|
|
642
|
+
host: z.string().default(DEFAULT_HOST),
|
|
643
|
+
baseDir: z.string().optional(),
|
|
644
|
+
theme: z.string().optional(),
|
|
645
|
+
rotationDuration: z.number().positive().optional(),
|
|
646
|
+
dashboards: z.array(DashboardConfigSchema).min(1),
|
|
647
|
+
apis: z.object({
|
|
648
|
+
pollInterval: z.number().positive().optional()
|
|
649
|
+
}).passthrough().optional()
|
|
650
|
+
});
|
|
651
|
+
var ConfigError = class extends Error {
|
|
652
|
+
constructor(message) {
|
|
653
|
+
super(message);
|
|
654
|
+
this.name = "ConfigError";
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
function detectConfigFormat(filePath) {
|
|
658
|
+
const extension = extname(filePath).toLowerCase();
|
|
659
|
+
switch (extension) {
|
|
660
|
+
case ".json":
|
|
661
|
+
return "json" /* JSON */;
|
|
662
|
+
case ".yml":
|
|
663
|
+
case ".yaml":
|
|
664
|
+
return "yaml" /* YAML */;
|
|
665
|
+
default:
|
|
666
|
+
throw new ConfigError(
|
|
667
|
+
`Unsupported configuration file format: ${extension}. Supported formats: .json, .yml, .yaml`
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function parseConfigContent(content, format) {
|
|
672
|
+
try {
|
|
673
|
+
switch (format) {
|
|
674
|
+
case "json" /* JSON */:
|
|
675
|
+
return JSON.parse(content);
|
|
676
|
+
case "yaml" /* YAML */:
|
|
677
|
+
return parse(content);
|
|
678
|
+
/* v8 ignore next 3 */
|
|
679
|
+
default:
|
|
680
|
+
throw new Error(`Unsupported format: ${format}`);
|
|
681
|
+
}
|
|
682
|
+
} catch (error) {
|
|
683
|
+
throw new ConfigError(
|
|
684
|
+
`Failed to parse ${format.toUpperCase()} content: ${getErrorMessage(error)}`
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
async function loadConfig(path2) {
|
|
689
|
+
try {
|
|
690
|
+
const format = detectConfigFormat(path2);
|
|
691
|
+
const content = await readFile(path2, "utf-8");
|
|
692
|
+
const rawConfig = parseConfigContent(content, format);
|
|
693
|
+
const config = DashfyConfigSchema.parse(rawConfig);
|
|
694
|
+
return config;
|
|
695
|
+
} catch (error) {
|
|
696
|
+
if (error instanceof z.ZodError) {
|
|
697
|
+
const issues = error.issues.map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`).join("\n");
|
|
698
|
+
throw new ConfigError(`Configuration validation failed:
|
|
699
|
+
${issues}`);
|
|
700
|
+
}
|
|
701
|
+
if (error instanceof ConfigError) {
|
|
702
|
+
throw error;
|
|
703
|
+
}
|
|
704
|
+
throw new Error(`Failed to load configuration from "${path2}": ${getErrorMessage(error)}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
function createLogger(options) {
|
|
708
|
+
const isDevelopment = process.env.NODE_ENV !== "production";
|
|
709
|
+
return pino({
|
|
710
|
+
level: process.env.LOG_LEVEL ?? (isDevelopment ? "info" : "warn"),
|
|
711
|
+
transport: isDevelopment ? {
|
|
712
|
+
target: "pino-pretty",
|
|
713
|
+
options: {
|
|
714
|
+
colorize: true,
|
|
715
|
+
translateTime: "HH:MM:ss",
|
|
716
|
+
ignore: "pid,hostname",
|
|
717
|
+
singleLine: false
|
|
718
|
+
}
|
|
719
|
+
} : void 0,
|
|
720
|
+
...options
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/dashfy.ts
|
|
725
|
+
var Dashfy = class {
|
|
726
|
+
app;
|
|
727
|
+
io;
|
|
728
|
+
bus;
|
|
729
|
+
config;
|
|
730
|
+
logger;
|
|
731
|
+
configPath;
|
|
732
|
+
/**
|
|
733
|
+
* Creates a new Dashfy instance.
|
|
734
|
+
*
|
|
735
|
+
* @param options - Dashfy options
|
|
736
|
+
* @param options.logger - Logger instance
|
|
737
|
+
* @param options.app - Existing Fastify instance. When provided,
|
|
738
|
+
* Dashfy will use this instance instead of creating a new one,
|
|
739
|
+
* allowing integration with existing Fastify middleware and routes.
|
|
740
|
+
*
|
|
741
|
+
* @example
|
|
742
|
+
* ```ts
|
|
743
|
+
* // Integrate Dashfy with existing Fastify app
|
|
744
|
+
* import Fastify from 'fastify'
|
|
745
|
+
* import { Dashfy } from '@getdashfy/server'
|
|
746
|
+
*
|
|
747
|
+
* const app = Fastify()
|
|
748
|
+
*
|
|
749
|
+
* // Add your custom routes
|
|
750
|
+
* app.get('/custom-api', async (request, reply) => {
|
|
751
|
+
* return { message: 'Custom endpoint' }
|
|
752
|
+
* })
|
|
753
|
+
*
|
|
754
|
+
* // Initialize Dashfy with existing app
|
|
755
|
+
* const dashfy = new Dashfy({ app })
|
|
756
|
+
* await dashfy.configureFromFile('./dashfy.config.json')
|
|
757
|
+
* await dashfy.start()
|
|
758
|
+
* ```
|
|
759
|
+
*/
|
|
760
|
+
constructor(options = {}) {
|
|
761
|
+
this.logger = options.logger ?? createLogger();
|
|
762
|
+
if (options.app) {
|
|
763
|
+
this.app = options.app;
|
|
764
|
+
} else {
|
|
765
|
+
this.app = Fastify({
|
|
766
|
+
logger: options.logger ? false : true,
|
|
767
|
+
disableRequestLogging: true
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
this.bus = new Bus({
|
|
771
|
+
logger: this.logger.child({ component: "bus" })
|
|
772
|
+
});
|
|
773
|
+
this.registerCoreApi();
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Configures the Dashfy server with the provided configuration object.
|
|
777
|
+
*
|
|
778
|
+
* This method accepts a configuration object and applies it to the server instance.
|
|
779
|
+
* If a global poll interval is specified in the configuration, it will be applied
|
|
780
|
+
* to the message bus for all poll-mode APIs.
|
|
781
|
+
*
|
|
782
|
+
* @param config - The Dashfy configuration object containing server settings,
|
|
783
|
+
* dashboards, and optional API configuration
|
|
784
|
+
*
|
|
785
|
+
* @remarks
|
|
786
|
+
* This method should be called before {@link start}. For loading configuration
|
|
787
|
+
* from a JSON or YAML file, use {@link configureFromFile} instead.
|
|
788
|
+
*
|
|
789
|
+
* @example
|
|
790
|
+
* ```ts
|
|
791
|
+
* const dashfy = new Dashfy();
|
|
792
|
+
*
|
|
793
|
+
* // Configure with a configuration object
|
|
794
|
+
* dashfy.configure({
|
|
795
|
+
* port: 5001,
|
|
796
|
+
* host: 'localhost',
|
|
797
|
+
* dashboards: [
|
|
798
|
+
* {
|
|
799
|
+
* id: 'main',
|
|
800
|
+
* title: 'Main Dashboard',
|
|
801
|
+
* widgets: []
|
|
802
|
+
* }
|
|
803
|
+
* ],
|
|
804
|
+
* apis: {
|
|
805
|
+
* pollInterval: 30000 // 30 seconds
|
|
806
|
+
* }
|
|
807
|
+
* });
|
|
808
|
+
*
|
|
809
|
+
* await dashfy.start();
|
|
810
|
+
* ```
|
|
811
|
+
*/
|
|
812
|
+
configure(config) {
|
|
813
|
+
this.config = config;
|
|
814
|
+
if (config.apis?.pollInterval) {
|
|
815
|
+
this.bus.pollInterval = config.apis.pollInterval;
|
|
816
|
+
this.logger.info(`Set global poll interval to ${this.bus.pollInterval}ms`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Loads and applies configuration from a file (JSON or YAML).
|
|
821
|
+
*
|
|
822
|
+
* This method reads a configuration file (supports .json, .yml, .yaml formats),
|
|
823
|
+
* validates it against the schema, and applies it to the server instance. It also
|
|
824
|
+
* optionally watches the file for changes and automatically reloads the configuration
|
|
825
|
+
* when the file is modified. When changes are detected, all connected clients are
|
|
826
|
+
* notified via Socket.IO.
|
|
827
|
+
*
|
|
828
|
+
* @param configPath - Path to the configuration file (absolute or relative).
|
|
829
|
+
* Supports .json, .yml, .yaml extensions
|
|
830
|
+
* @param watchConfig - Whether to watch the configuration file for changes.
|
|
831
|
+
* When `true`, the configuration will be automatically reloaded
|
|
832
|
+
* on file changes. Defaults to `true`
|
|
833
|
+
*
|
|
834
|
+
* @throws {Error} If the configuration file cannot be read or fails validation
|
|
835
|
+
*
|
|
836
|
+
* @remarks
|
|
837
|
+
* This method should be called before {@link start}. If you already have a
|
|
838
|
+
* configuration object, use {@link configure} instead.
|
|
839
|
+
*
|
|
840
|
+
* When the configuration is reloaded due to file changes:
|
|
841
|
+
* - The new configuration is validated before applying
|
|
842
|
+
* - Connected clients receive the updated configuration (excluding sensitive API data)
|
|
843
|
+
* - If validation fails, the old configuration remains active
|
|
844
|
+
*
|
|
845
|
+
* @example
|
|
846
|
+
* ```ts
|
|
847
|
+
* const dashfy = new Dashfy();
|
|
848
|
+
*
|
|
849
|
+
* // Load configuration from JSON file with auto-reload
|
|
850
|
+
* await dashfy.configureFromFile('./dashfy.config.json');
|
|
851
|
+
*
|
|
852
|
+
* // Load configuration from YAML file with auto-reload
|
|
853
|
+
* await dashfy.configureFromFile('./dashfy.config.yaml');
|
|
854
|
+
*
|
|
855
|
+
* // Load without watching for changes (production)
|
|
856
|
+
* await dashfy.configureFromFile('./dashfy.config.yaml', false);
|
|
857
|
+
*
|
|
858
|
+
* await dashfy.start();
|
|
859
|
+
* ```
|
|
860
|
+
*/
|
|
861
|
+
async configureFromFile(configPath, watchConfig = true) {
|
|
862
|
+
this.configPath = configPath;
|
|
863
|
+
const config = await loadConfig(configPath);
|
|
864
|
+
this.configure(config);
|
|
865
|
+
if (watchConfig) {
|
|
866
|
+
const watcher = watch(configPath);
|
|
867
|
+
watcher.on("change", async () => {
|
|
868
|
+
this.logger.info("Configuration file changed, reloading...");
|
|
869
|
+
try {
|
|
870
|
+
const newConfig = await loadConfig(configPath);
|
|
871
|
+
this.configure(newConfig);
|
|
872
|
+
if (this.io) {
|
|
873
|
+
this.io.emit("configuration", this.getPublicConfig(newConfig));
|
|
874
|
+
}
|
|
875
|
+
this.logger.info("Configuration reloaded successfully");
|
|
876
|
+
} catch (error) {
|
|
877
|
+
this.logger.error({ err: error }, "Failed to reload configuration");
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
this.logger.info(`Watching configuration file: ${configPath}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Registers an API with the message bus for data retrieval.
|
|
885
|
+
*
|
|
886
|
+
* This method allows you to register custom APIs that widgets can subscribe to
|
|
887
|
+
* for fetching data. APIs can operate in either poll mode (periodic fetching)
|
|
888
|
+
* or push mode (real-time updates via callbacks).
|
|
889
|
+
*
|
|
890
|
+
* @param id - Unique identifier for the API (e.g., 'github', 'weather', 'analytics')
|
|
891
|
+
* @param api - Factory function that returns an object containing API methods.
|
|
892
|
+
* Each method should be async and return data that widgets can consume
|
|
893
|
+
* @param mode - The API mode: `poll` (default) for periodic polling,
|
|
894
|
+
* or `push` for real-time push-based updates
|
|
895
|
+
*
|
|
896
|
+
* @throws {Error} If an API with the same ID is already registered
|
|
897
|
+
* @throws {Error} If the API mode is invalid
|
|
898
|
+
*
|
|
899
|
+
* @remarks
|
|
900
|
+
* - In **poll mode**, the bus automatically calls API methods at regular intervals
|
|
901
|
+
* - In **push mode**, API methods receive a callback to push data when available
|
|
902
|
+
* - APIs should be registered before calling {@link start}
|
|
903
|
+
* - The core `dashfy` API is automatically registered and provides system information
|
|
904
|
+
*
|
|
905
|
+
* @example
|
|
906
|
+
* ```ts
|
|
907
|
+
* const dashfy = new Dashfy();
|
|
908
|
+
*
|
|
909
|
+
* // Register a poll-mode API (default)
|
|
910
|
+
* dashfy.registerApi('github', () => ({
|
|
911
|
+
* async stars(params: { owner: string; repo: string }) {
|
|
912
|
+
* const response = await fetch(
|
|
913
|
+
* `https://api.github.com/repos/${params.owner}/${params.repo}`
|
|
914
|
+
* );
|
|
915
|
+
* const data = await response.json();
|
|
916
|
+
* return { stars: data.stargazers_count };
|
|
917
|
+
* }
|
|
918
|
+
* }));
|
|
919
|
+
*
|
|
920
|
+
* // Register a push-mode API for real-time updates
|
|
921
|
+
* dashfy.registerApi('metrics', () => ({
|
|
922
|
+
* async cpuUsage(callback: (data: unknown) => void) {
|
|
923
|
+
* const interval = setInterval(() => {
|
|
924
|
+
* const usage = process.cpuUsage();
|
|
925
|
+
* callback({ cpu: usage });
|
|
926
|
+
* }, 1000);
|
|
927
|
+
*
|
|
928
|
+
* return () => clearInterval(interval);
|
|
929
|
+
* }
|
|
930
|
+
* }), 'push');
|
|
931
|
+
* ```
|
|
932
|
+
*/
|
|
933
|
+
registerApi(id, api, mode = "poll") {
|
|
934
|
+
this.bus.registerApi(id, api, mode);
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Starts the Dashfy server and begins listening for connections.
|
|
938
|
+
*
|
|
939
|
+
* This method initializes and starts all server components including the HTTP server,
|
|
940
|
+
* WebSocket connections, static file serving, and API endpoints. It must be called
|
|
941
|
+
* after configuration is loaded via {@link configure} or {@link configureFromFile}.
|
|
942
|
+
*
|
|
943
|
+
* @throws {Error} If configuration has not been set before calling this method
|
|
944
|
+
*
|
|
945
|
+
* @remarks
|
|
946
|
+
* **What this method does:**
|
|
947
|
+
* 1. Validates that configuration is loaded
|
|
948
|
+
* 2. Sets up CORS for cross-origin requests
|
|
949
|
+
* 3. Serves static files from the `build` directory (if available)
|
|
950
|
+
* 4. Registers HTTP endpoints:
|
|
951
|
+
* - `GET /config` - Returns public configuration (excludes sensitive API data)
|
|
952
|
+
* - `GET /health` - Health check endpoint with uptime and status
|
|
953
|
+
* - `GET /api/info` - Returns registered APIs and connection info
|
|
954
|
+
* 5. Initializes Socket.IO for real-time WebSocket communication
|
|
955
|
+
* 6. Sets up Socket.IO event handlers:
|
|
956
|
+
* - `connection` - Client connects, receives configuration
|
|
957
|
+
* - `api.subscription` - Widget subscribes to data
|
|
958
|
+
* - `api.unsubscription` - Widget unsubscribes from data
|
|
959
|
+
* - `disconnect` - Client disconnects, cleanup performed
|
|
960
|
+
* - `error` - Socket error handling
|
|
961
|
+
* 7. Starts listening on configured host and port
|
|
962
|
+
*
|
|
963
|
+
* **Port and Host Resolution:**
|
|
964
|
+
* - Port: `process.env.PORT` → `config.port` → `5001` (default)
|
|
965
|
+
* - Host: `process.env.HOST` → `config.host` → `0.0.0.0` (default)
|
|
966
|
+
*
|
|
967
|
+
* @example
|
|
968
|
+
* ```ts
|
|
969
|
+
* const dashfy = new Dashfy();
|
|
970
|
+
*
|
|
971
|
+
* // Configure the server
|
|
972
|
+
* await dashfy.configureFromFile('./dashfy.config.json');
|
|
973
|
+
*
|
|
974
|
+
* // Register custom APIs
|
|
975
|
+
* dashfy.registerApi('github', () => ({
|
|
976
|
+
* async stars(params: { owner: string; repo: string }) {
|
|
977
|
+
* // ... fetch GitHub stars
|
|
978
|
+
* }
|
|
979
|
+
* }));
|
|
980
|
+
*
|
|
981
|
+
* // Start the server
|
|
982
|
+
* await dashfy.start();
|
|
983
|
+
* // Server is now running and accepting connections
|
|
984
|
+
* ```
|
|
985
|
+
*
|
|
986
|
+
* @example
|
|
987
|
+
* ```ts
|
|
988
|
+
* // With environment variables
|
|
989
|
+
* process.env.PORT = '3000';
|
|
990
|
+
* process.env.HOST = 'localhost';
|
|
991
|
+
*
|
|
992
|
+
* const dashfy = new Dashfy();
|
|
993
|
+
* await dashfy.configureFromFile('./dashfy.config.json');
|
|
994
|
+
* await dashfy.start();
|
|
995
|
+
* // Server running on http://localhost:3000
|
|
996
|
+
* ```
|
|
997
|
+
*/
|
|
998
|
+
async start() {
|
|
999
|
+
if (!this.config) {
|
|
1000
|
+
const errorMessage = "Configuration required. Call configure() or configureFromFile() before starting.";
|
|
1001
|
+
this.logger.error(errorMessage);
|
|
1002
|
+
throw new Error(errorMessage);
|
|
1003
|
+
}
|
|
1004
|
+
await this.app.register(cors, {
|
|
1005
|
+
origin: true,
|
|
1006
|
+
credentials: true
|
|
1007
|
+
});
|
|
1008
|
+
const baseDir = this.config.baseDir ?? process.cwd();
|
|
1009
|
+
const staticPath = path.join(baseDir, "build");
|
|
1010
|
+
try {
|
|
1011
|
+
await this.app.register(fastifyStatic, {
|
|
1012
|
+
root: staticPath,
|
|
1013
|
+
prefix: "/"
|
|
1014
|
+
});
|
|
1015
|
+
this.logger.info(`Serving static files from ${staticPath}`);
|
|
1016
|
+
} catch (_error) {
|
|
1017
|
+
this.logger.warn("Static files directory not found, skipping static serve");
|
|
1018
|
+
}
|
|
1019
|
+
this.app.get("/config", () => {
|
|
1020
|
+
return this.getPublicConfig(this.config);
|
|
1021
|
+
});
|
|
1022
|
+
this.app.get("/health", () => ({
|
|
1023
|
+
status: "ok",
|
|
1024
|
+
uptime: process.uptime(),
|
|
1025
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1026
|
+
}));
|
|
1027
|
+
this.app.get("/api/info", () => ({
|
|
1028
|
+
apis: this.bus.listApis(),
|
|
1029
|
+
clientCount: this.bus.clientCount(),
|
|
1030
|
+
subscriptions: this.bus.getSubscriptionsInfo()
|
|
1031
|
+
}));
|
|
1032
|
+
this.io = new Server(this.app.server, {
|
|
1033
|
+
cors: {
|
|
1034
|
+
origin: "*",
|
|
1035
|
+
credentials: true
|
|
1036
|
+
},
|
|
1037
|
+
transports: ["websocket", "polling"],
|
|
1038
|
+
pingTimeout: SOCKET_PING_TIMEOUT,
|
|
1039
|
+
pingInterval: SOCKET_PING_INTERVAL
|
|
1040
|
+
});
|
|
1041
|
+
this.io.on("error", (error) => {
|
|
1042
|
+
this.logger.error({ err: error }, "Socket.IO server error");
|
|
1043
|
+
});
|
|
1044
|
+
this.io.on("connection", (socket) => {
|
|
1045
|
+
this.logger.info(`Socket connected: ${socket.id}`);
|
|
1046
|
+
this.bus.addClient(socket);
|
|
1047
|
+
if (this.config) {
|
|
1048
|
+
socket.emit("configuration", this.getPublicConfig(this.config));
|
|
1049
|
+
}
|
|
1050
|
+
socket.on(WebSocketEvent.API_SUBSCRIPTION, (subscription) => {
|
|
1051
|
+
this.logger.debug(`Subscription request: ${subscription.id} from ${socket.id}`);
|
|
1052
|
+
try {
|
|
1053
|
+
this.bus.subscribe(socket.id, subscription);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
this.logger.error({ err: error }, "Subscription error");
|
|
1056
|
+
socket.emit(WebSocketEvent.API_ERROR, {
|
|
1057
|
+
id: subscription.id,
|
|
1058
|
+
error: {
|
|
1059
|
+
message: getErrorMessage(error)
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
socket.on(WebSocketEvent.API_UNSUBSCRIPTION, (subscription) => {
|
|
1065
|
+
this.logger.debug(`Unsubscription request: ${subscription.id} from ${socket.id}`);
|
|
1066
|
+
this.bus.unsubscribe(socket.id, subscription.id);
|
|
1067
|
+
});
|
|
1068
|
+
socket.on(WebSocketEvent.DISCONNECT, (reason) => {
|
|
1069
|
+
this.logger.info(`Socket disconnected: ${socket.id} (${reason})`);
|
|
1070
|
+
this.bus.removeClient(socket.id);
|
|
1071
|
+
});
|
|
1072
|
+
socket.on(WebSocketEvent.ERROR, (error) => {
|
|
1073
|
+
this.logger.error({ err: error, socketId: socket.id }, "Socket error");
|
|
1074
|
+
});
|
|
1075
|
+
});
|
|
1076
|
+
const port = process.env.PORT ? Number(process.env.PORT) : this.config.port ?? DEFAULT_PORT;
|
|
1077
|
+
const host = process.env.HOST ?? this.config.host ?? DEFAULT_HOST;
|
|
1078
|
+
await this.app.listen({ port, host });
|
|
1079
|
+
this.logger.info(`\u{1F680} Dashfy server started`);
|
|
1080
|
+
this.logger.info(` - HTTP: http://${host}:${port}`);
|
|
1081
|
+
this.logger.info(` - WebSocket: ws://${host}:${port}`);
|
|
1082
|
+
this.logger.info(` - Registered APIs: ${this.bus.listApis().join(", ")}`);
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Gracefully stops the Dashfy server and cleans up all resources.
|
|
1086
|
+
*
|
|
1087
|
+
* This method performs a graceful shutdown of the server, closing all active
|
|
1088
|
+
* connections and releasing resources. It closes the Socket.IO server (if initialized)
|
|
1089
|
+
* and the underlying Fastify HTTP server.
|
|
1090
|
+
*
|
|
1091
|
+
* @remarks
|
|
1092
|
+
* **Shutdown process:**
|
|
1093
|
+
* 1. Logs shutdown initiation
|
|
1094
|
+
* 2. Closes Socket.IO server (disconnects all WebSocket clients)
|
|
1095
|
+
* 3. Closes Fastify HTTP server (stops accepting new connections)
|
|
1096
|
+
* 4. Waits for all pending requests to complete
|
|
1097
|
+
* 5. Logs shutdown completion
|
|
1098
|
+
*
|
|
1099
|
+
* This method is safe to call even if the server was never started or is already stopped.
|
|
1100
|
+
* All connected clients will be disconnected, and their subscriptions will be cleaned up
|
|
1101
|
+
* automatically by the bus.
|
|
1102
|
+
*
|
|
1103
|
+
* @example
|
|
1104
|
+
* ```ts
|
|
1105
|
+
* const dashfy = new Dashfy();
|
|
1106
|
+
* await dashfy.configureFromFile('./dashfy.config.json');
|
|
1107
|
+
* await dashfy.start();
|
|
1108
|
+
*
|
|
1109
|
+
* // Later, gracefully shut down the server
|
|
1110
|
+
* await dashfy.stop();
|
|
1111
|
+
* ```
|
|
1112
|
+
*
|
|
1113
|
+
* @example
|
|
1114
|
+
* ```ts
|
|
1115
|
+
* // Graceful shutdown on process signals
|
|
1116
|
+
* const dashfy = new Dashfy();
|
|
1117
|
+
* await dashfy.configureFromFile('./dashfy.config.json');
|
|
1118
|
+
* await dashfy.start();
|
|
1119
|
+
*
|
|
1120
|
+
* process.on('SIGTERM', async () => {
|
|
1121
|
+
* console.log('SIGTERM received, shutting down gracefully...');
|
|
1122
|
+
* await dashfy.stop();
|
|
1123
|
+
* process.exit(0);
|
|
1124
|
+
* });
|
|
1125
|
+
*
|
|
1126
|
+
* process.on('SIGINT', async () => {
|
|
1127
|
+
* console.log('SIGINT received, shutting down gracefully...');
|
|
1128
|
+
* await dashfy.stop();
|
|
1129
|
+
* process.exit(0);
|
|
1130
|
+
* });
|
|
1131
|
+
* ```
|
|
1132
|
+
*/
|
|
1133
|
+
async stop() {
|
|
1134
|
+
this.logger.info("Stopping Dashfy server...");
|
|
1135
|
+
if (this.io) {
|
|
1136
|
+
await new Promise((resolve, reject) => {
|
|
1137
|
+
void this.io.close((error) => {
|
|
1138
|
+
if (error) {
|
|
1139
|
+
reject(error);
|
|
1140
|
+
} else {
|
|
1141
|
+
resolve();
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
await this.app.close();
|
|
1147
|
+
this.logger.info("Server stopped");
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Returns a sanitized configuration object without sensitive API data.
|
|
1151
|
+
*
|
|
1152
|
+
* Removes the `apis` property from the configuration before sending it to clients,
|
|
1153
|
+
* as it may contain sensitive server-side settings like polling intervals or API keys.
|
|
1154
|
+
*
|
|
1155
|
+
* @param config - The full Dashfy configuration object
|
|
1156
|
+
* @returns Configuration object without the `apis` property
|
|
1157
|
+
*
|
|
1158
|
+
* @internal
|
|
1159
|
+
*/
|
|
1160
|
+
getPublicConfig(config) {
|
|
1161
|
+
const { apis, ...publicConfig } = config;
|
|
1162
|
+
return publicConfig;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Registers the core `dashfy` API that provides system introspection and monitoring.
|
|
1166
|
+
*
|
|
1167
|
+
* This internal method is automatically called during construction to register
|
|
1168
|
+
* a built-in API that exposes server health, statistics, and debugging information.
|
|
1169
|
+
* The `dashfy` API is available to all widgets for monitoring server state.
|
|
1170
|
+
*
|
|
1171
|
+
* @remarks
|
|
1172
|
+
* **Available methods:**
|
|
1173
|
+
* - `inspector()` - Returns comprehensive system information including:
|
|
1174
|
+
* - `apis`: List of all registered API identifiers
|
|
1175
|
+
* - `clientCount`: Number of currently connected WebSocket clients
|
|
1176
|
+
* - `subscriptions`: Detailed information about all active subscriptions
|
|
1177
|
+
* - `uptime`: Server process uptime in seconds
|
|
1178
|
+
* - `version`: Dashfy server version (from package.json)
|
|
1179
|
+
* - `nodeVersion`: Node.js runtime version
|
|
1180
|
+
*
|
|
1181
|
+
* This API is useful for:
|
|
1182
|
+
* - Building admin/monitoring dashboards
|
|
1183
|
+
* - Debugging subscription and connection issues
|
|
1184
|
+
* - Health checks and system monitoring
|
|
1185
|
+
* - Displaying server statistics to users
|
|
1186
|
+
*
|
|
1187
|
+
* @example
|
|
1188
|
+
* ```ts
|
|
1189
|
+
* // Widgets can subscribe to the dashfy API
|
|
1190
|
+
* // In a widget configuration:
|
|
1191
|
+
* {
|
|
1192
|
+
* api: 'dashfy',
|
|
1193
|
+
* method: 'inspector'
|
|
1194
|
+
* }
|
|
1195
|
+
*
|
|
1196
|
+
* // This returns:
|
|
1197
|
+
* {
|
|
1198
|
+
* apis: ['dashfy', 'github', 'weather'],
|
|
1199
|
+
* clientCount: 5,
|
|
1200
|
+
* subscriptions: [...],
|
|
1201
|
+
* uptime: 3600.5,
|
|
1202
|
+
* version: '0.1.0',
|
|
1203
|
+
* nodeVersion: 'v20.10.0'
|
|
1204
|
+
* }
|
|
1205
|
+
* ```
|
|
1206
|
+
*
|
|
1207
|
+
* @internal
|
|
1208
|
+
*/
|
|
1209
|
+
registerCoreApi() {
|
|
1210
|
+
this.registerApi("dashfy", () => ({
|
|
1211
|
+
inspector: async () => Promise.resolve({
|
|
1212
|
+
apis: this.bus.listApis(),
|
|
1213
|
+
clientCount: this.bus.clientCount(),
|
|
1214
|
+
subscriptions: this.bus.getSubscriptionsInfo(),
|
|
1215
|
+
uptime: process.uptime(),
|
|
1216
|
+
version: process.env.npm_package_version ?? "unknown",
|
|
1217
|
+
nodeVersion: process.version
|
|
1218
|
+
})
|
|
1219
|
+
}));
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
export { Dashfy };
|
|
1224
|
+
//# sourceMappingURL=index.js.map
|
|
1225
|
+
//# sourceMappingURL=index.js.map
|