ntfy-mcp-server 1.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/LICENSE +201 -0
- package/README.md +423 -0
- package/dist/config/index.d.ts +23 -0
- package/dist/config/index.js +111 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +108 -0
- package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.d.ts +2 -0
- package/dist/mcp-server/resources/ntfyResource/getNtfyTopic.js +111 -0
- package/dist/mcp-server/resources/ntfyResource/index.d.ts +12 -0
- package/dist/mcp-server/resources/ntfyResource/index.js +72 -0
- package/dist/mcp-server/resources/ntfyResource/types.d.ts +27 -0
- package/dist/mcp-server/resources/ntfyResource/types.js +8 -0
- package/dist/mcp-server/server.d.ts +40 -0
- package/dist/mcp-server/server.js +245 -0
- package/dist/mcp-server/tools/ntfyTool/index.d.ts +11 -0
- package/dist/mcp-server/tools/ntfyTool/index.js +110 -0
- package/dist/mcp-server/tools/ntfyTool/ntfyMessage.d.ts +9 -0
- package/dist/mcp-server/tools/ntfyTool/ntfyMessage.js +289 -0
- package/dist/mcp-server/tools/ntfyTool/types.d.ts +252 -0
- package/dist/mcp-server/tools/ntfyTool/types.js +144 -0
- package/dist/mcp-server/utils/registrationHelper.d.ts +48 -0
- package/dist/mcp-server/utils/registrationHelper.js +63 -0
- package/dist/services/ntfy/constants.d.ts +37 -0
- package/dist/services/ntfy/constants.js +37 -0
- package/dist/services/ntfy/errors.d.ts +79 -0
- package/dist/services/ntfy/errors.js +134 -0
- package/dist/services/ntfy/index.d.ts +33 -0
- package/dist/services/ntfy/index.js +56 -0
- package/dist/services/ntfy/publisher.d.ts +66 -0
- package/dist/services/ntfy/publisher.js +229 -0
- package/dist/services/ntfy/subscriber.d.ts +81 -0
- package/dist/services/ntfy/subscriber.js +502 -0
- package/dist/services/ntfy/types.d.ts +161 -0
- package/dist/services/ntfy/types.js +4 -0
- package/dist/services/ntfy/utils.d.ts +85 -0
- package/dist/services/ntfy/utils.js +410 -0
- package/dist/types-global/errors.d.ts +35 -0
- package/dist/types-global/errors.js +39 -0
- package/dist/types-global/mcp.d.ts +30 -0
- package/dist/types-global/mcp.js +25 -0
- package/dist/types-global/tool.d.ts +61 -0
- package/dist/types-global/tool.js +99 -0
- package/dist/utils/errorHandler.d.ts +98 -0
- package/dist/utils/errorHandler.js +271 -0
- package/dist/utils/idGenerator.d.ts +94 -0
- package/dist/utils/idGenerator.js +149 -0
- package/dist/utils/index.d.ts +13 -0
- package/dist/utils/index.js +16 -0
- package/dist/utils/logger.d.ts +36 -0
- package/dist/utils/logger.js +92 -0
- package/dist/utils/rateLimiter.d.ts +115 -0
- package/dist/utils/rateLimiter.js +180 -0
- package/dist/utils/requestContext.d.ts +68 -0
- package/dist/utils/requestContext.js +91 -0
- package/dist/utils/sanitization.d.ts +224 -0
- package/dist/utils/sanitization.js +367 -0
- package/dist/utils/security.d.ts +26 -0
- package/dist/utils/security.js +27 -0
- package/package.json +47 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ntfy subscriber implementation
|
|
3
|
+
*/
|
|
4
|
+
import { BaseErrorCode } from '../../types-global/errors.js';
|
|
5
|
+
import { ErrorHandler } from '../../utils/errorHandler.js';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
import { sanitizeInput, sanitizeInputForLogging } from '../../utils/sanitization.js';
|
|
8
|
+
import { createRequestContext } from '../../utils/requestContext.js';
|
|
9
|
+
import { idGenerator } from '../../utils/idGenerator.js';
|
|
10
|
+
import { DEFAULT_REQUEST_TIMEOUT, DEFAULT_SUBSCRIPTION_OPTIONS, ERROR_MESSAGES, KEEPALIVE_TIMEOUT, MAX_RECONNECT_ATTEMPTS, RECONNECT_DELAY, SUBSCRIPTION_ENDPOINTS } from './constants.js';
|
|
11
|
+
import { NtfyConnectionError, NtfyError, NtfyInvalidTopicError, NtfyParseError, NtfySubscriptionClosedError, NtfyTimeoutError, ntfyErrorMapper } from './errors.js';
|
|
12
|
+
import { buildSubscriptionUrlSync, createAbortControllerWithTimeout, createRequestHeadersSync, validateTopicSync, parseJsonMessageSync } from './utils.js';
|
|
13
|
+
/**
|
|
14
|
+
* NtfySubscriber class for subscribing to ntfy topics
|
|
15
|
+
*/
|
|
16
|
+
export class NtfySubscriber {
|
|
17
|
+
/**
|
|
18
|
+
* Creates a new NtfySubscriber instance
|
|
19
|
+
* @param handlers Event handlers for the subscription
|
|
20
|
+
*/
|
|
21
|
+
constructor(handlers = {}) {
|
|
22
|
+
this.handlers = handlers;
|
|
23
|
+
this.connectionActive = false;
|
|
24
|
+
this.lastKeepaliveTime = 0;
|
|
25
|
+
this.reconnectAttempts = 0;
|
|
26
|
+
this.subscriberId = idGenerator.generateRandomString(8);
|
|
27
|
+
// Create logger with subscriber context
|
|
28
|
+
this.logger = logger.createChildLogger({
|
|
29
|
+
module: 'NtfySubscriber',
|
|
30
|
+
subscriberId: this.subscriberId,
|
|
31
|
+
subscriptionTime: new Date().toISOString()
|
|
32
|
+
});
|
|
33
|
+
this.logger.debug('NtfySubscriber instance created');
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe to a ntfy topic
|
|
37
|
+
* @param topic Topic to subscribe to (can be comma-separated for multiple topics)
|
|
38
|
+
* @param options Subscription options
|
|
39
|
+
* @returns Promise that resolves when the subscription is established
|
|
40
|
+
* @throws NtfyInvalidTopicError if the topic name is invalid
|
|
41
|
+
* @throws NtfyConnectionError if the connection fails
|
|
42
|
+
*/
|
|
43
|
+
async subscribe(topic, options = {}) {
|
|
44
|
+
return ErrorHandler.tryCatch(async () => {
|
|
45
|
+
// Create a request context for tracking this operation
|
|
46
|
+
const requestCtx = createRequestContext({
|
|
47
|
+
operation: 'subscribe',
|
|
48
|
+
topic,
|
|
49
|
+
subscriberId: this.subscriberId,
|
|
50
|
+
options: sanitizeInputForLogging(options)
|
|
51
|
+
});
|
|
52
|
+
// Validate topic
|
|
53
|
+
if (!validateTopicSync(topic)) {
|
|
54
|
+
this.logger.error('Invalid topic name', {
|
|
55
|
+
topic,
|
|
56
|
+
requestId: requestCtx.requestId
|
|
57
|
+
});
|
|
58
|
+
throw new NtfyInvalidTopicError(ERROR_MESSAGES.INVALID_TOPIC, topic);
|
|
59
|
+
}
|
|
60
|
+
// Store current topic for reconnect logic
|
|
61
|
+
this.currentTopic = topic;
|
|
62
|
+
// Log the subscription attempt
|
|
63
|
+
this.logger.info('Subscribing to topic', {
|
|
64
|
+
topic,
|
|
65
|
+
options: sanitizeInputForLogging(options),
|
|
66
|
+
requestId: requestCtx.requestId
|
|
67
|
+
});
|
|
68
|
+
// Merge options with defaults
|
|
69
|
+
const mergedOptions = { ...DEFAULT_SUBSCRIPTION_OPTIONS, ...options };
|
|
70
|
+
// Close any existing subscription
|
|
71
|
+
this.unsubscribe();
|
|
72
|
+
// Reset reconnect attempts
|
|
73
|
+
this.reconnectAttempts = 0;
|
|
74
|
+
// Start subscription
|
|
75
|
+
await this.startSubscription(topic, 'json', mergedOptions);
|
|
76
|
+
// Start keepalive check if this is a persistent connection
|
|
77
|
+
if (!mergedOptions.poll) {
|
|
78
|
+
this.startKeepaliveCheck();
|
|
79
|
+
}
|
|
80
|
+
this.logger.info('Successfully subscribed to topic', {
|
|
81
|
+
topic,
|
|
82
|
+
requestId: requestCtx.requestId
|
|
83
|
+
});
|
|
84
|
+
}, {
|
|
85
|
+
operation: 'subscribe',
|
|
86
|
+
context: {
|
|
87
|
+
topic,
|
|
88
|
+
subscriberId: this.subscriberId
|
|
89
|
+
},
|
|
90
|
+
input: sanitizeInputForLogging(options),
|
|
91
|
+
errorCode: BaseErrorCode.SERVICE_UNAVAILABLE,
|
|
92
|
+
errorMapper: ntfyErrorMapper,
|
|
93
|
+
rethrow: true
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Unsubscribe from the current topic
|
|
98
|
+
*/
|
|
99
|
+
unsubscribe() {
|
|
100
|
+
const requestCtx = createRequestContext({
|
|
101
|
+
operation: 'unsubscribe',
|
|
102
|
+
subscriberId: this.subscriberId,
|
|
103
|
+
topic: this.currentTopic
|
|
104
|
+
});
|
|
105
|
+
this.logger.debug('Unsubscribing from topic', {
|
|
106
|
+
requestId: requestCtx.requestId,
|
|
107
|
+
topic: this.currentTopic
|
|
108
|
+
});
|
|
109
|
+
this.stopKeepaliveCheck();
|
|
110
|
+
if (this.abortController) {
|
|
111
|
+
this.abortController.abort();
|
|
112
|
+
this.abortController = undefined;
|
|
113
|
+
}
|
|
114
|
+
if (this.cleanupFn) {
|
|
115
|
+
this.cleanupFn();
|
|
116
|
+
this.cleanupFn = undefined;
|
|
117
|
+
}
|
|
118
|
+
this.connectionActive = false;
|
|
119
|
+
this.logger.info('Unsubscribed from topic', {
|
|
120
|
+
requestId: requestCtx.requestId,
|
|
121
|
+
topic: this.currentTopic
|
|
122
|
+
});
|
|
123
|
+
// Clear current topic
|
|
124
|
+
this.currentTopic = undefined;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Start a subscription to a topic
|
|
128
|
+
* @param topic Topic to subscribe to
|
|
129
|
+
* @param format Format to subscribe in (json, sse, raw, ws)
|
|
130
|
+
* @param options Subscription options
|
|
131
|
+
*/
|
|
132
|
+
async startSubscription(topic, format, options) {
|
|
133
|
+
const requestCtx = createRequestContext({
|
|
134
|
+
operation: 'startSubscription',
|
|
135
|
+
subscriberId: this.subscriberId,
|
|
136
|
+
topic,
|
|
137
|
+
format
|
|
138
|
+
});
|
|
139
|
+
const sanitizedTopic = sanitizeInput.string(topic);
|
|
140
|
+
this.logger.debug('Starting subscription', {
|
|
141
|
+
topic: sanitizedTopic,
|
|
142
|
+
format,
|
|
143
|
+
requestId: requestCtx.requestId
|
|
144
|
+
});
|
|
145
|
+
const url = buildSubscriptionUrlSync(topic, SUBSCRIPTION_ENDPOINTS[format], options);
|
|
146
|
+
const headers = createRequestHeadersSync(options);
|
|
147
|
+
// Create abort controller with timeout
|
|
148
|
+
const { controller, cleanup } = createAbortControllerWithTimeout(DEFAULT_REQUEST_TIMEOUT);
|
|
149
|
+
this.abortController = controller;
|
|
150
|
+
this.cleanupFn = cleanup;
|
|
151
|
+
try {
|
|
152
|
+
// Make the request
|
|
153
|
+
this.logger.debug('Sending subscription request', {
|
|
154
|
+
url,
|
|
155
|
+
requestId: requestCtx.requestId
|
|
156
|
+
});
|
|
157
|
+
const response = await fetch(url, {
|
|
158
|
+
method: 'GET',
|
|
159
|
+
headers,
|
|
160
|
+
signal: controller.signal,
|
|
161
|
+
});
|
|
162
|
+
// Check response status
|
|
163
|
+
if (!response.ok) {
|
|
164
|
+
this.logger.error('HTTP error from ntfy server', {
|
|
165
|
+
status: response.status,
|
|
166
|
+
statusText: response.statusText,
|
|
167
|
+
url,
|
|
168
|
+
requestId: requestCtx.requestId
|
|
169
|
+
});
|
|
170
|
+
throw new NtfyConnectionError(`HTTP Error: ${response.status} ${response.statusText}`, url);
|
|
171
|
+
}
|
|
172
|
+
// Set connection as active
|
|
173
|
+
this.connectionActive = true;
|
|
174
|
+
this.logger.debug('Connection established', {
|
|
175
|
+
requestId: requestCtx.requestId
|
|
176
|
+
});
|
|
177
|
+
// Get response body as reader
|
|
178
|
+
const reader = response.body?.getReader();
|
|
179
|
+
if (!reader) {
|
|
180
|
+
this.logger.error('No response body available', {
|
|
181
|
+
url,
|
|
182
|
+
requestId: requestCtx.requestId
|
|
183
|
+
});
|
|
184
|
+
throw new NtfyConnectionError('No response body available', url);
|
|
185
|
+
}
|
|
186
|
+
// Process the stream
|
|
187
|
+
await this.processJsonStream(reader, requestCtx.requestId);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
// Clean up and rethrow
|
|
191
|
+
this.logger.error('Error starting subscription', {
|
|
192
|
+
error: error instanceof Error ? error.message : String(error),
|
|
193
|
+
topic: sanitizedTopic,
|
|
194
|
+
url,
|
|
195
|
+
requestId: requestCtx.requestId
|
|
196
|
+
});
|
|
197
|
+
this.cleanupFn();
|
|
198
|
+
this.cleanupFn = undefined;
|
|
199
|
+
this.abortController = undefined;
|
|
200
|
+
this.connectionActive = false;
|
|
201
|
+
// Attempt reconnect if appropriate
|
|
202
|
+
if (!options.poll &&
|
|
203
|
+
!(error instanceof NtfySubscriptionClosedError) &&
|
|
204
|
+
this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
205
|
+
this.scheduleReconnect(topic, format, options);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Process a JSON stream from ntfy
|
|
214
|
+
* @param reader ReadableStreamDefaultReader to read from
|
|
215
|
+
* @param requestId Request ID for logging
|
|
216
|
+
*/
|
|
217
|
+
async processJsonStream(reader, requestId) {
|
|
218
|
+
const decoder = new TextDecoder();
|
|
219
|
+
let buffer = '';
|
|
220
|
+
this.logger.debug('Starting to process JSON stream', { requestId });
|
|
221
|
+
while (this.connectionActive) {
|
|
222
|
+
try {
|
|
223
|
+
const { done, value } = await reader.read();
|
|
224
|
+
if (done) {
|
|
225
|
+
// Stream has ended
|
|
226
|
+
this.logger.info('Stream ended normally', { requestId });
|
|
227
|
+
this.connectionActive = false;
|
|
228
|
+
if (this.handlers.onClose) {
|
|
229
|
+
try {
|
|
230
|
+
this.handlers.onClose();
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
this.logger.error('Error in onClose handler', {
|
|
234
|
+
error: error instanceof Error ? error.message : String(error),
|
|
235
|
+
requestId
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
// Decode the chunk and add to buffer
|
|
242
|
+
buffer += decoder.decode(value, { stream: true });
|
|
243
|
+
// Process any complete lines in the buffer
|
|
244
|
+
const lines = buffer.split('\n');
|
|
245
|
+
buffer = lines.pop() || ''; // Keep the last incomplete line in the buffer
|
|
246
|
+
// Process each complete line
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
if (line.trim()) {
|
|
249
|
+
try {
|
|
250
|
+
const message = parseJsonMessageSync(line);
|
|
251
|
+
this.handleMessage(message, requestId);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
this.handleParseError(error, line, requestId);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
// Handle read errors
|
|
261
|
+
this.connectionActive = false;
|
|
262
|
+
this.logger.error('Error reading from stream', {
|
|
263
|
+
error: error instanceof Error ? error.message : String(error),
|
|
264
|
+
errorType: error instanceof Error ? error.name : typeof error,
|
|
265
|
+
requestId
|
|
266
|
+
});
|
|
267
|
+
// Handle various error types more specifically
|
|
268
|
+
if (error instanceof Error) {
|
|
269
|
+
// AbortError - intentional close
|
|
270
|
+
if (error.name === 'AbortError') {
|
|
271
|
+
throw new NtfySubscriptionClosedError('Subscription aborted');
|
|
272
|
+
}
|
|
273
|
+
// Network errors
|
|
274
|
+
if (error.name === 'NetworkError' ||
|
|
275
|
+
error.name === 'TypeError' ||
|
|
276
|
+
error.message.includes('network') ||
|
|
277
|
+
error.message.includes('connection')) {
|
|
278
|
+
const connectionError = new NtfyConnectionError(`Network error during stream processing: ${error.message}`);
|
|
279
|
+
// Add additional context to the error details
|
|
280
|
+
connectionError.details = {
|
|
281
|
+
originalError: error.name,
|
|
282
|
+
originalMessage: error.message
|
|
283
|
+
};
|
|
284
|
+
throw connectionError;
|
|
285
|
+
}
|
|
286
|
+
// Timeout errors
|
|
287
|
+
if (error.name === 'TimeoutError' ||
|
|
288
|
+
error.message.includes('timeout') ||
|
|
289
|
+
error.message.includes('timed out')) {
|
|
290
|
+
throw new NtfyTimeoutError(`Stream reading timed out: ${error.message}`, DEFAULT_REQUEST_TIMEOUT);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Default case - generic connection error
|
|
294
|
+
throw new NtfyConnectionError(`Error reading from stream: ${error instanceof Error ? error.message : String(error)}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Handle a message from ntfy
|
|
300
|
+
* @param message Message from ntfy
|
|
301
|
+
* @param requestId Request ID for logging
|
|
302
|
+
*/
|
|
303
|
+
handleMessage(message, requestId) {
|
|
304
|
+
// Update last keepalive time for any message
|
|
305
|
+
this.lastKeepaliveTime = Date.now();
|
|
306
|
+
// Log message receipt at debug level
|
|
307
|
+
this.logger.debug('Received message', {
|
|
308
|
+
messageId: message.id,
|
|
309
|
+
eventType: message.event,
|
|
310
|
+
topic: message.topic,
|
|
311
|
+
requestId
|
|
312
|
+
});
|
|
313
|
+
// Call the appropriate handler based on message type
|
|
314
|
+
try {
|
|
315
|
+
switch (message.event) {
|
|
316
|
+
case 'message':
|
|
317
|
+
if (this.handlers.onMessage) {
|
|
318
|
+
const notificationMessage = message;
|
|
319
|
+
this.logger.debug('Processing notification message', {
|
|
320
|
+
messageId: notificationMessage.id,
|
|
321
|
+
hasTitle: !!notificationMessage.title,
|
|
322
|
+
requestId
|
|
323
|
+
});
|
|
324
|
+
this.handlers.onMessage(notificationMessage);
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
case 'open':
|
|
328
|
+
if (this.handlers.onOpen) {
|
|
329
|
+
this.logger.debug('Processing open message', { requestId });
|
|
330
|
+
this.handlers.onOpen(message);
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
case 'keepalive':
|
|
334
|
+
if (this.handlers.onKeepalive) {
|
|
335
|
+
this.logger.debug('Processing keepalive message', { requestId });
|
|
336
|
+
this.handlers.onKeepalive(message);
|
|
337
|
+
}
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
// Always call onAnyMessage if it exists
|
|
341
|
+
if (this.handlers.onAnyMessage) {
|
|
342
|
+
this.handlers.onAnyMessage(message);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
this.logger.error('Error in message handler', {
|
|
347
|
+
error: error instanceof Error ? error.message : String(error),
|
|
348
|
+
messageType: message.event,
|
|
349
|
+
messageId: message.id,
|
|
350
|
+
requestId
|
|
351
|
+
});
|
|
352
|
+
// Don't rethrow to avoid breaking the stream processing
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Handle a parse error
|
|
357
|
+
* @param error Error that occurred
|
|
358
|
+
* @param rawData Raw data that caused the error
|
|
359
|
+
* @param requestId Request ID for logging
|
|
360
|
+
*/
|
|
361
|
+
handleParseError(error, rawData, requestId) {
|
|
362
|
+
this.logger.error('Failed to parse message', {
|
|
363
|
+
error: error instanceof Error ? error.message : String(error),
|
|
364
|
+
rawData: rawData.length > 100 ? `${rawData.substring(0, 100)}...` : rawData,
|
|
365
|
+
requestId
|
|
366
|
+
});
|
|
367
|
+
if (this.handlers.onError) {
|
|
368
|
+
try {
|
|
369
|
+
if (error instanceof NtfyParseError) {
|
|
370
|
+
this.handlers.onError(error);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
const parsedError = new NtfyParseError(`Failed to parse message: ${error instanceof Error ? error.message : String(error)}`, rawData);
|
|
374
|
+
this.handlers.onError(parsedError);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch (handlerError) {
|
|
378
|
+
this.logger.error('Error in error handler', {
|
|
379
|
+
error: handlerError instanceof Error ? handlerError.message : String(handlerError),
|
|
380
|
+
requestId
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Handle a subscription error
|
|
387
|
+
* @param error Error that occurred
|
|
388
|
+
* @param requestId Request ID for logging
|
|
389
|
+
*/
|
|
390
|
+
handleSubscriptionError(error, requestId) {
|
|
391
|
+
this.logger.error('Subscription error', {
|
|
392
|
+
error: error instanceof Error ? error.message : String(error),
|
|
393
|
+
errorType: error instanceof Error ? error.name : 'Unknown',
|
|
394
|
+
requestId
|
|
395
|
+
});
|
|
396
|
+
if (this.handlers.onError) {
|
|
397
|
+
try {
|
|
398
|
+
if (error instanceof NtfyError) {
|
|
399
|
+
this.handlers.onError(error);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
this.handlers.onError(new NtfyConnectionError(`Subscription error: ${error instanceof Error ? error.message : String(error)}`));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
catch (handlerError) {
|
|
406
|
+
this.logger.error('Error in error handler', {
|
|
407
|
+
error: handlerError instanceof Error ? handlerError.message : String(handlerError),
|
|
408
|
+
requestId
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Start the keepalive check interval
|
|
415
|
+
*/
|
|
416
|
+
startKeepaliveCheck() {
|
|
417
|
+
const requestCtx = createRequestContext({
|
|
418
|
+
operation: 'startKeepaliveCheck',
|
|
419
|
+
subscriberId: this.subscriberId,
|
|
420
|
+
topic: this.currentTopic
|
|
421
|
+
});
|
|
422
|
+
this.logger.debug('Starting keepalive check', {
|
|
423
|
+
timeout: KEEPALIVE_TIMEOUT,
|
|
424
|
+
checkInterval: KEEPALIVE_TIMEOUT / 2,
|
|
425
|
+
requestId: requestCtx.requestId
|
|
426
|
+
});
|
|
427
|
+
this.stopKeepaliveCheck();
|
|
428
|
+
this.lastKeepaliveTime = Date.now();
|
|
429
|
+
this.keepaliveCheckInterval = setInterval(() => {
|
|
430
|
+
const now = Date.now();
|
|
431
|
+
const elapsed = now - this.lastKeepaliveTime;
|
|
432
|
+
this.logger.debug('Keepalive check', {
|
|
433
|
+
elapsed,
|
|
434
|
+
threshold: KEEPALIVE_TIMEOUT,
|
|
435
|
+
requestId: requestCtx.requestId
|
|
436
|
+
});
|
|
437
|
+
if (elapsed > KEEPALIVE_TIMEOUT && this.connectionActive) {
|
|
438
|
+
// Connection has timed out
|
|
439
|
+
this.logger.warn('Keepalive timeout detected', {
|
|
440
|
+
elapsed,
|
|
441
|
+
threshold: KEEPALIVE_TIMEOUT,
|
|
442
|
+
requestId: requestCtx.requestId
|
|
443
|
+
});
|
|
444
|
+
this.handleSubscriptionError(new NtfyTimeoutError('Keepalive timeout', KEEPALIVE_TIMEOUT), requestCtx.requestId);
|
|
445
|
+
this.unsubscribe();
|
|
446
|
+
}
|
|
447
|
+
}, KEEPALIVE_TIMEOUT / 2);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Stop the keepalive check interval
|
|
451
|
+
*/
|
|
452
|
+
stopKeepaliveCheck() {
|
|
453
|
+
if (this.keepaliveCheckInterval) {
|
|
454
|
+
this.logger.debug('Stopping keepalive check');
|
|
455
|
+
clearInterval(this.keepaliveCheckInterval);
|
|
456
|
+
this.keepaliveCheckInterval = undefined;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Schedule a reconnection attempt
|
|
461
|
+
* @param topic Topic to reconnect to
|
|
462
|
+
* @param format Format to reconnect with
|
|
463
|
+
* @param options Subscription options
|
|
464
|
+
*/
|
|
465
|
+
scheduleReconnect(topic, format, options) {
|
|
466
|
+
const requestCtx = createRequestContext({
|
|
467
|
+
operation: 'scheduleReconnect',
|
|
468
|
+
subscriberId: this.subscriberId,
|
|
469
|
+
topic,
|
|
470
|
+
format
|
|
471
|
+
});
|
|
472
|
+
this.reconnectAttempts++;
|
|
473
|
+
// Add jitter to prevent thundering herd problem
|
|
474
|
+
// and cap at a maximum delay of 30 seconds
|
|
475
|
+
const MAX_BACKOFF_DELAY = 30000; // 30 seconds
|
|
476
|
+
const baseDelay = RECONNECT_DELAY * this.reconnectAttempts;
|
|
477
|
+
const jitter = Math.floor(Math.random() * 1000); // Add up to 1 second of jitter
|
|
478
|
+
const delay = Math.min(baseDelay + jitter, MAX_BACKOFF_DELAY);
|
|
479
|
+
this.logger.info('Scheduling reconnection attempt', {
|
|
480
|
+
topic,
|
|
481
|
+
attemptNumber: this.reconnectAttempts,
|
|
482
|
+
maxAttempts: MAX_RECONNECT_ATTEMPTS,
|
|
483
|
+
baseDelay: baseDelay,
|
|
484
|
+
jitter: jitter,
|
|
485
|
+
actualDelay: delay,
|
|
486
|
+
maxBackoff: MAX_BACKOFF_DELAY,
|
|
487
|
+
requestId: requestCtx.requestId
|
|
488
|
+
});
|
|
489
|
+
setTimeout(() => {
|
|
490
|
+
if (!this.connectionActive) {
|
|
491
|
+
this.logger.info('Attempting reconnection', {
|
|
492
|
+
topic,
|
|
493
|
+
attemptNumber: this.reconnectAttempts,
|
|
494
|
+
requestId: requestCtx.requestId
|
|
495
|
+
});
|
|
496
|
+
this.startSubscription(topic, format, options).catch((error) => {
|
|
497
|
+
this.handleSubscriptionError(error, requestCtx.requestId);
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}, delay);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the ntfy service
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Message event types from ntfy
|
|
6
|
+
*/
|
|
7
|
+
export type NtfyEventType = 'open' | 'message' | 'keepalive' | 'poll_request';
|
|
8
|
+
/**
|
|
9
|
+
* Message priority levels
|
|
10
|
+
* - 1: min priority
|
|
11
|
+
* - 2: low priority
|
|
12
|
+
* - 3: default priority
|
|
13
|
+
* - 4: high priority
|
|
14
|
+
* - 5: max priority
|
|
15
|
+
*/
|
|
16
|
+
export type NtfyPriority = 1 | 2 | 3 | 4 | 5;
|
|
17
|
+
/**
|
|
18
|
+
* Subscription format types
|
|
19
|
+
*/
|
|
20
|
+
export type NtfySubscriptionFormat = 'json' | 'sse' | 'raw' | 'ws';
|
|
21
|
+
/**
|
|
22
|
+
* Attachment information
|
|
23
|
+
*/
|
|
24
|
+
export interface NtfyAttachment {
|
|
25
|
+
/** Name of the attachment */
|
|
26
|
+
name: string;
|
|
27
|
+
/** URL of the attachment */
|
|
28
|
+
url: string;
|
|
29
|
+
/** Mime type of the attachment (only defined if attachment was uploaded to ntfy server) */
|
|
30
|
+
type?: string;
|
|
31
|
+
/** Size of the attachment in bytes (only defined if attachment was uploaded to ntfy server) */
|
|
32
|
+
size?: number;
|
|
33
|
+
/** Attachment expiry date as Unix time stamp (only defined if attachment was uploaded to ntfy server) */
|
|
34
|
+
expires?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Action button definition
|
|
38
|
+
*/
|
|
39
|
+
export interface NtfyAction {
|
|
40
|
+
/** Action identifier */
|
|
41
|
+
id: string;
|
|
42
|
+
/** Label for the action button */
|
|
43
|
+
label: string;
|
|
44
|
+
/** Action type (e.g., view, broadcast, http) */
|
|
45
|
+
action: string;
|
|
46
|
+
/** URL or data for the action */
|
|
47
|
+
url?: string;
|
|
48
|
+
/** HTTP method for http actions */
|
|
49
|
+
method?: string;
|
|
50
|
+
/** Additional headers for http actions */
|
|
51
|
+
headers?: Record<string, string>;
|
|
52
|
+
/** Body for http actions */
|
|
53
|
+
body?: string;
|
|
54
|
+
/** Clear notification after action */
|
|
55
|
+
clear?: boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Base message interface with common fields for all message types
|
|
59
|
+
*/
|
|
60
|
+
export interface NtfyBaseMessage {
|
|
61
|
+
/** Randomly chosen message identifier */
|
|
62
|
+
id: string;
|
|
63
|
+
/** Message date time, as Unix time stamp */
|
|
64
|
+
time: number;
|
|
65
|
+
/** Message type */
|
|
66
|
+
event: NtfyEventType;
|
|
67
|
+
/** Comma-separated list of topics the message is associated with */
|
|
68
|
+
topic: string;
|
|
69
|
+
/** Unix time stamp indicating when the message will be deleted */
|
|
70
|
+
expires?: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Regular notification message
|
|
74
|
+
*/
|
|
75
|
+
export interface NtfyNotificationMessage extends NtfyBaseMessage {
|
|
76
|
+
event: 'message';
|
|
77
|
+
/** Message body */
|
|
78
|
+
message: string;
|
|
79
|
+
/** Message title */
|
|
80
|
+
title?: string;
|
|
81
|
+
/** List of tags that may or not map to emojis */
|
|
82
|
+
tags?: string[];
|
|
83
|
+
/** Message priority with 1=min, 3=default and 5=max */
|
|
84
|
+
priority?: NtfyPriority;
|
|
85
|
+
/** Website opened when notification is clicked */
|
|
86
|
+
click?: string;
|
|
87
|
+
/** Action buttons that can be displayed in the notification */
|
|
88
|
+
actions?: NtfyAction[];
|
|
89
|
+
/** Details about an attachment */
|
|
90
|
+
attachment?: NtfyAttachment;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Connection open message
|
|
94
|
+
*/
|
|
95
|
+
export interface NtfyOpenMessage extends NtfyBaseMessage {
|
|
96
|
+
event: 'open';
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Keepalive message to maintain connection
|
|
100
|
+
*/
|
|
101
|
+
export interface NtfyKeepaliveMessage extends NtfyBaseMessage {
|
|
102
|
+
event: 'keepalive';
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Poll request message
|
|
106
|
+
*/
|
|
107
|
+
export interface NtfyPollRequestMessage extends NtfyBaseMessage {
|
|
108
|
+
event: 'poll_request';
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Union of all message types
|
|
112
|
+
*/
|
|
113
|
+
export type NtfyMessage = NtfyNotificationMessage | NtfyOpenMessage | NtfyKeepaliveMessage | NtfyPollRequestMessage;
|
|
114
|
+
/**
|
|
115
|
+
* Options for subscribing to ntfy topics
|
|
116
|
+
*/
|
|
117
|
+
export interface NtfySubscriptionOptions {
|
|
118
|
+
/** Whether to poll for messages instead of maintaining a connection */
|
|
119
|
+
poll?: boolean;
|
|
120
|
+
/** Return cached messages since timestamp, duration or message ID */
|
|
121
|
+
since?: string | number;
|
|
122
|
+
/** Include scheduled/delayed messages */
|
|
123
|
+
scheduled?: boolean;
|
|
124
|
+
/** Filter by message ID */
|
|
125
|
+
id?: string;
|
|
126
|
+
/** Filter by message content */
|
|
127
|
+
message?: string;
|
|
128
|
+
/** Filter by title */
|
|
129
|
+
title?: string;
|
|
130
|
+
/** Filter by priority (comma-separated list) */
|
|
131
|
+
priority?: string;
|
|
132
|
+
/** Filter by tags (comma-separated list) */
|
|
133
|
+
tags?: string;
|
|
134
|
+
/** Base URL for the ntfy server */
|
|
135
|
+
baseUrl?: string;
|
|
136
|
+
/** Authentication token or credentials */
|
|
137
|
+
auth?: string;
|
|
138
|
+
/** Basic auth username */
|
|
139
|
+
username?: string;
|
|
140
|
+
/** Basic auth password */
|
|
141
|
+
password?: string;
|
|
142
|
+
/** Additional headers to include in requests */
|
|
143
|
+
headers?: Record<string, string>;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Subscription event handlers
|
|
147
|
+
*/
|
|
148
|
+
export interface NtfySubscriptionHandlers {
|
|
149
|
+
/** Called when a message is received */
|
|
150
|
+
onMessage?: (message: NtfyNotificationMessage) => void;
|
|
151
|
+
/** Called when the connection is opened */
|
|
152
|
+
onOpen?: (message: NtfyOpenMessage) => void;
|
|
153
|
+
/** Called when a keepalive message is received */
|
|
154
|
+
onKeepalive?: (message: NtfyKeepaliveMessage) => void;
|
|
155
|
+
/** Called when an error occurs */
|
|
156
|
+
onError?: (error: Error) => void;
|
|
157
|
+
/** Called when the connection is closed */
|
|
158
|
+
onClose?: () => void;
|
|
159
|
+
/** Called when any message is received (regardless of type) */
|
|
160
|
+
onAnyMessage?: (message: NtfyMessage) => void;
|
|
161
|
+
}
|