ntfy-mcp-server
Version:
An MCP (Model Context Protocol) server designed to interact with the ntfy push notification service. It enables LLMs and AI agents to send notifications to your devices with extensive customization options.
503 lines (502 loc) • 20.8 kB
JavaScript
/**
* Ntfy subscriber implementation
*/
import { BaseErrorCode } from '../../types-global/errors.js';
import { ErrorHandler } from '../../utils/errorHandler.js';
import { logger } from '../../utils/logger.js';
import { sanitizeInput, sanitizeInputForLogging } from '../../utils/sanitization.js';
import { createRequestContext } from '../../utils/requestContext.js';
import { idGenerator } from '../../utils/idGenerator.js';
import { DEFAULT_REQUEST_TIMEOUT, DEFAULT_SUBSCRIPTION_OPTIONS, ERROR_MESSAGES, KEEPALIVE_TIMEOUT, MAX_RECONNECT_ATTEMPTS, RECONNECT_DELAY, SUBSCRIPTION_ENDPOINTS } from './constants.js';
import { NtfyConnectionError, NtfyError, NtfyInvalidTopicError, NtfyParseError, NtfySubscriptionClosedError, NtfyTimeoutError, ntfyErrorMapper } from './errors.js';
import { buildSubscriptionUrlSync, createAbortControllerWithTimeout, createRequestHeadersSync, validateTopicSync, parseJsonMessageSync } from './utils.js';
/**
* NtfySubscriber class for subscribing to ntfy topics
*/
export class NtfySubscriber {
/**
* Creates a new NtfySubscriber instance
* @param handlers Event handlers for the subscription
*/
constructor(handlers = {}) {
this.handlers = handlers;
this.connectionActive = false;
this.lastKeepaliveTime = 0;
this.reconnectAttempts = 0;
this.subscriberId = idGenerator.generateRandomString(8);
// Create logger with subscriber context
this.logger = logger.createChildLogger({
module: 'NtfySubscriber',
subscriberId: this.subscriberId,
subscriptionTime: new Date().toISOString()
});
this.logger.debug('NtfySubscriber instance created');
}
/**
* Subscribe to a ntfy topic
* @param topic Topic to subscribe to (can be comma-separated for multiple topics)
* @param options Subscription options
* @returns Promise that resolves when the subscription is established
* @throws NtfyInvalidTopicError if the topic name is invalid
* @throws NtfyConnectionError if the connection fails
*/
async subscribe(topic, options = {}) {
return ErrorHandler.tryCatch(async () => {
// Create a request context for tracking this operation
const requestCtx = createRequestContext({
operation: 'subscribe',
topic,
subscriberId: this.subscriberId,
options: sanitizeInputForLogging(options)
});
// Validate topic
if (!validateTopicSync(topic)) {
this.logger.error('Invalid topic name', {
topic,
requestId: requestCtx.requestId
});
throw new NtfyInvalidTopicError(ERROR_MESSAGES.INVALID_TOPIC, topic);
}
// Store current topic for reconnect logic
this.currentTopic = topic;
// Log the subscription attempt
this.logger.info('Subscribing to topic', {
topic,
options: sanitizeInputForLogging(options),
requestId: requestCtx.requestId
});
// Merge options with defaults
const mergedOptions = { ...DEFAULT_SUBSCRIPTION_OPTIONS, ...options };
// Close any existing subscription
this.unsubscribe();
// Reset reconnect attempts
this.reconnectAttempts = 0;
// Start subscription
await this.startSubscription(topic, 'json', mergedOptions);
// Start keepalive check if this is a persistent connection
if (!mergedOptions.poll) {
this.startKeepaliveCheck();
}
this.logger.info('Successfully subscribed to topic', {
topic,
requestId: requestCtx.requestId
});
}, {
operation: 'subscribe',
context: {
topic,
subscriberId: this.subscriberId
},
input: sanitizeInputForLogging(options),
errorCode: BaseErrorCode.SERVICE_UNAVAILABLE,
errorMapper: ntfyErrorMapper,
rethrow: true
});
}
/**
* Unsubscribe from the current topic
*/
unsubscribe() {
const requestCtx = createRequestContext({
operation: 'unsubscribe',
subscriberId: this.subscriberId,
topic: this.currentTopic
});
this.logger.debug('Unsubscribing from topic', {
requestId: requestCtx.requestId,
topic: this.currentTopic
});
this.stopKeepaliveCheck();
if (this.abortController) {
this.abortController.abort();
this.abortController = undefined;
}
if (this.cleanupFn) {
this.cleanupFn();
this.cleanupFn = undefined;
}
this.connectionActive = false;
this.logger.info('Unsubscribed from topic', {
requestId: requestCtx.requestId,
topic: this.currentTopic
});
// Clear current topic
this.currentTopic = undefined;
}
/**
* Start a subscription to a topic
* @param topic Topic to subscribe to
* @param format Format to subscribe in (json, sse, raw, ws)
* @param options Subscription options
*/
async startSubscription(topic, format, options) {
const requestCtx = createRequestContext({
operation: 'startSubscription',
subscriberId: this.subscriberId,
topic,
format
});
const sanitizedTopic = sanitizeInput.string(topic);
this.logger.debug('Starting subscription', {
topic: sanitizedTopic,
format,
requestId: requestCtx.requestId
});
const url = buildSubscriptionUrlSync(topic, SUBSCRIPTION_ENDPOINTS[format], options);
const headers = createRequestHeadersSync(options);
// Create abort controller with timeout
const { controller, cleanup } = createAbortControllerWithTimeout(DEFAULT_REQUEST_TIMEOUT);
this.abortController = controller;
this.cleanupFn = cleanup;
try {
// Make the request
this.logger.debug('Sending subscription request', {
url,
requestId: requestCtx.requestId
});
const response = await fetch(url, {
method: 'GET',
headers,
signal: controller.signal,
});
// Check response status
if (!response.ok) {
this.logger.error('HTTP error from ntfy server', {
status: response.status,
statusText: response.statusText,
url,
requestId: requestCtx.requestId
});
throw new NtfyConnectionError(`HTTP Error: ${response.status} ${response.statusText}`, url);
}
// Set connection as active
this.connectionActive = true;
this.logger.debug('Connection established', {
requestId: requestCtx.requestId
});
// Get response body as reader
const reader = response.body?.getReader();
if (!reader) {
this.logger.error('No response body available', {
url,
requestId: requestCtx.requestId
});
throw new NtfyConnectionError('No response body available', url);
}
// Process the stream
await this.processJsonStream(reader, requestCtx.requestId);
}
catch (error) {
// Clean up and rethrow
this.logger.error('Error starting subscription', {
error: error instanceof Error ? error.message : String(error),
topic: sanitizedTopic,
url,
requestId: requestCtx.requestId
});
this.cleanupFn();
this.cleanupFn = undefined;
this.abortController = undefined;
this.connectionActive = false;
// Attempt reconnect if appropriate
if (!options.poll &&
!(error instanceof NtfySubscriptionClosedError) &&
this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
this.scheduleReconnect(topic, format, options);
}
else {
throw error;
}
}
}
/**
* Process a JSON stream from ntfy
* @param reader ReadableStreamDefaultReader to read from
* @param requestId Request ID for logging
*/
async processJsonStream(reader, requestId) {
const decoder = new TextDecoder();
let buffer = '';
this.logger.debug('Starting to process JSON stream', { requestId });
while (this.connectionActive) {
try {
const { done, value } = await reader.read();
if (done) {
// Stream has ended
this.logger.info('Stream ended normally', { requestId });
this.connectionActive = false;
if (this.handlers.onClose) {
try {
this.handlers.onClose();
}
catch (error) {
this.logger.error('Error in onClose handler', {
error: error instanceof Error ? error.message : String(error),
requestId
});
}
}
break;
}
// Decode the chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Process any complete lines in the buffer
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep the last incomplete line in the buffer
// Process each complete line
for (const line of lines) {
if (line.trim()) {
try {
const message = parseJsonMessageSync(line);
this.handleMessage(message, requestId);
}
catch (error) {
this.handleParseError(error, line, requestId);
}
}
}
}
catch (error) {
// Handle read errors
this.connectionActive = false;
this.logger.error('Error reading from stream', {
error: error instanceof Error ? error.message : String(error),
errorType: error instanceof Error ? error.name : typeof error,
requestId
});
// Handle various error types more specifically
if (error instanceof Error) {
// AbortError - intentional close
if (error.name === 'AbortError') {
throw new NtfySubscriptionClosedError('Subscription aborted');
}
// Network errors
if (error.name === 'NetworkError' ||
error.name === 'TypeError' ||
error.message.includes('network') ||
error.message.includes('connection')) {
const connectionError = new NtfyConnectionError(`Network error during stream processing: ${error.message}`);
// Add additional context to the error details
connectionError.details = {
originalError: error.name,
originalMessage: error.message
};
throw connectionError;
}
// Timeout errors
if (error.name === 'TimeoutError' ||
error.message.includes('timeout') ||
error.message.includes('timed out')) {
throw new NtfyTimeoutError(`Stream reading timed out: ${error.message}`, DEFAULT_REQUEST_TIMEOUT);
}
}
// Default case - generic connection error
throw new NtfyConnectionError(`Error reading from stream: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
/**
* Handle a message from ntfy
* @param message Message from ntfy
* @param requestId Request ID for logging
*/
handleMessage(message, requestId) {
// Update last keepalive time for any message
this.lastKeepaliveTime = Date.now();
// Log message receipt at debug level
this.logger.debug('Received message', {
messageId: message.id,
eventType: message.event,
topic: message.topic,
requestId
});
// Call the appropriate handler based on message type
try {
switch (message.event) {
case 'message':
if (this.handlers.onMessage) {
const notificationMessage = message;
this.logger.debug('Processing notification message', {
messageId: notificationMessage.id,
hasTitle: !!notificationMessage.title,
requestId
});
this.handlers.onMessage(notificationMessage);
}
break;
case 'open':
if (this.handlers.onOpen) {
this.logger.debug('Processing open message', { requestId });
this.handlers.onOpen(message);
}
break;
case 'keepalive':
if (this.handlers.onKeepalive) {
this.logger.debug('Processing keepalive message', { requestId });
this.handlers.onKeepalive(message);
}
break;
}
// Always call onAnyMessage if it exists
if (this.handlers.onAnyMessage) {
this.handlers.onAnyMessage(message);
}
}
catch (error) {
this.logger.error('Error in message handler', {
error: error instanceof Error ? error.message : String(error),
messageType: message.event,
messageId: message.id,
requestId
});
// Don't rethrow to avoid breaking the stream processing
}
}
/**
* Handle a parse error
* @param error Error that occurred
* @param rawData Raw data that caused the error
* @param requestId Request ID for logging
*/
handleParseError(error, rawData, requestId) {
this.logger.error('Failed to parse message', {
error: error instanceof Error ? error.message : String(error),
rawData: rawData.length > 100 ? `${rawData.substring(0, 100)}...` : rawData,
requestId
});
if (this.handlers.onError) {
try {
if (error instanceof NtfyParseError) {
this.handlers.onError(error);
}
else {
const parsedError = new NtfyParseError(`Failed to parse message: ${error instanceof Error ? error.message : String(error)}`, rawData);
this.handlers.onError(parsedError);
}
}
catch (handlerError) {
this.logger.error('Error in error handler', {
error: handlerError instanceof Error ? handlerError.message : String(handlerError),
requestId
});
}
}
}
/**
* Handle a subscription error
* @param error Error that occurred
* @param requestId Request ID for logging
*/
handleSubscriptionError(error, requestId) {
this.logger.error('Subscription error', {
error: error instanceof Error ? error.message : String(error),
errorType: error instanceof Error ? error.name : 'Unknown',
requestId
});
if (this.handlers.onError) {
try {
if (error instanceof NtfyError) {
this.handlers.onError(error);
}
else {
this.handlers.onError(new NtfyConnectionError(`Subscription error: ${error instanceof Error ? error.message : String(error)}`));
}
}
catch (handlerError) {
this.logger.error('Error in error handler', {
error: handlerError instanceof Error ? handlerError.message : String(handlerError),
requestId
});
}
}
}
/**
* Start the keepalive check interval
*/
startKeepaliveCheck() {
const requestCtx = createRequestContext({
operation: 'startKeepaliveCheck',
subscriberId: this.subscriberId,
topic: this.currentTopic
});
this.logger.debug('Starting keepalive check', {
timeout: KEEPALIVE_TIMEOUT,
checkInterval: KEEPALIVE_TIMEOUT / 2,
requestId: requestCtx.requestId
});
this.stopKeepaliveCheck();
this.lastKeepaliveTime = Date.now();
this.keepaliveCheckInterval = setInterval(() => {
const now = Date.now();
const elapsed = now - this.lastKeepaliveTime;
this.logger.debug('Keepalive check', {
elapsed,
threshold: KEEPALIVE_TIMEOUT,
requestId: requestCtx.requestId
});
if (elapsed > KEEPALIVE_TIMEOUT && this.connectionActive) {
// Connection has timed out
this.logger.warn('Keepalive timeout detected', {
elapsed,
threshold: KEEPALIVE_TIMEOUT,
requestId: requestCtx.requestId
});
this.handleSubscriptionError(new NtfyTimeoutError('Keepalive timeout', KEEPALIVE_TIMEOUT), requestCtx.requestId);
this.unsubscribe();
}
}, KEEPALIVE_TIMEOUT / 2);
}
/**
* Stop the keepalive check interval
*/
stopKeepaliveCheck() {
if (this.keepaliveCheckInterval) {
this.logger.debug('Stopping keepalive check');
clearInterval(this.keepaliveCheckInterval);
this.keepaliveCheckInterval = undefined;
}
}
/**
* Schedule a reconnection attempt
* @param topic Topic to reconnect to
* @param format Format to reconnect with
* @param options Subscription options
*/
scheduleReconnect(topic, format, options) {
const requestCtx = createRequestContext({
operation: 'scheduleReconnect',
subscriberId: this.subscriberId,
topic,
format
});
this.reconnectAttempts++;
// Add jitter to prevent thundering herd problem
// and cap at a maximum delay of 30 seconds
const MAX_BACKOFF_DELAY = 30000; // 30 seconds
const baseDelay = RECONNECT_DELAY * this.reconnectAttempts;
const jitter = Math.floor(Math.random() * 1000); // Add up to 1 second of jitter
const delay = Math.min(baseDelay + jitter, MAX_BACKOFF_DELAY);
this.logger.info('Scheduling reconnection attempt', {
topic,
attemptNumber: this.reconnectAttempts,
maxAttempts: MAX_RECONNECT_ATTEMPTS,
baseDelay: baseDelay,
jitter: jitter,
actualDelay: delay,
maxBackoff: MAX_BACKOFF_DELAY,
requestId: requestCtx.requestId
});
setTimeout(() => {
if (!this.connectionActive) {
this.logger.info('Attempting reconnection', {
topic,
attemptNumber: this.reconnectAttempts,
requestId: requestCtx.requestId
});
this.startSubscription(topic, format, options).catch((error) => {
this.handleSubscriptionError(error, requestCtx.requestId);
});
}
}, delay);
}
}