mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-26 15:07:39 +00:00
Notification fix attempt
This commit is contained in:
parent
bfeb94d972
commit
89b30d0d8c
6 changed files with 395 additions and 108 deletions
|
|
@ -41,6 +41,7 @@ import type { GitNotificationHandler } from './components/git-notification-handl
|
|||
import { authClient } from './services/auth-client.js';
|
||||
import { bufferSubscriptionService } from './services/buffer-subscription-service.js';
|
||||
import { getControlEventService } from './services/control-event-service.js';
|
||||
import { notificationEventService } from './services/notification-event-service.js';
|
||||
import { pushNotificationService } from './services/push-notification-service.js';
|
||||
|
||||
const logger = createLogger('app');
|
||||
|
|
@ -461,6 +462,19 @@ export class VibeTunnelApp extends LitElement {
|
|||
|
||||
private async handleAuthSuccess() {
|
||||
logger.log('✅ Authentication successful');
|
||||
|
||||
// If already authenticated (e.g., in no-auth mode), don't re-initialize
|
||||
if (this.isAuthenticated && this.initialLoadComplete) {
|
||||
logger.debug('Already authenticated and initialized, skipping re-initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
// If services are already being initialized, skip
|
||||
if (this.servicesInitialized || this.isAuthenticated) {
|
||||
logger.debug('Services already initialized or being initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isAuthenticated = true;
|
||||
this.currentView = 'list';
|
||||
await this.initializeServices(false); // Initialize services after auth (auth is enabled)
|
||||
|
|
@ -482,23 +496,33 @@ export class VibeTunnelApp extends LitElement {
|
|||
}
|
||||
}
|
||||
|
||||
private async initializeServices(noAuthEnabled = false) {
|
||||
private servicesInitialized = false;
|
||||
|
||||
private async initializeServices(_noAuthEnabled = false) {
|
||||
if (this.servicesInitialized) {
|
||||
logger.debug('Services already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('🚀 Initializing services...');
|
||||
try {
|
||||
// Initialize buffer subscription service for WebSocket connections
|
||||
await bufferSubscriptionService.initialize();
|
||||
|
||||
// Initialize push notification service only if auth is enabled
|
||||
if (!noAuthEnabled) {
|
||||
await pushNotificationService.initialize();
|
||||
} else {
|
||||
logger.log('⏭️ Skipping push notification service initialization (no-auth mode)');
|
||||
}
|
||||
// Initialize push notification service always
|
||||
// It handles its own permission checks and user preferences
|
||||
await pushNotificationService.initialize();
|
||||
|
||||
// Initialize control event service for real-time notifications
|
||||
this.controlEventService = getControlEventService(authClient);
|
||||
this.controlEventService.connect();
|
||||
|
||||
// Initialize notification event service to monitor /api/events SSE connection
|
||||
// This is used by the Mac app for notifications
|
||||
notificationEventService.setAuthClient(authClient);
|
||||
await notificationEventService.connect();
|
||||
|
||||
this.servicesInitialized = true;
|
||||
logger.log('✅ Services initialized successfully');
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize services:', error);
|
||||
|
|
@ -1816,8 +1840,16 @@ export class VibeTunnelApp extends LitElement {
|
|||
.visible=${this.showSettings}
|
||||
.authClient=${authClient}
|
||||
@close=${this.handleCloseSettings}
|
||||
@notifications-enabled=${() => this.showSuccess('Notifications enabled')}
|
||||
@notifications-disabled=${() => this.showSuccess('Notifications disabled')}
|
||||
@notifications-enabled=${async () => {
|
||||
this.showSuccess('Notifications enabled');
|
||||
// Reconnect SSE when notifications are enabled
|
||||
await notificationEventService.connect();
|
||||
}}
|
||||
@notifications-disabled=${() => {
|
||||
this.showSuccess('Notifications disabled');
|
||||
// Disconnect SSE when notifications are disabled
|
||||
notificationEventService.disconnect();
|
||||
}}
|
||||
@success=${(e: CustomEvent) => this.showSuccess(e.detail)}
|
||||
@error=${(e: CustomEvent) => this.showError(e.detail)}
|
||||
></vt-settings>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import { html, LitElement } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import {
|
||||
type PushSubscription,
|
||||
pushNotificationService,
|
||||
} from '../services/push-notification-service.js';
|
||||
import { notificationEventService } from '../services/notification-event-service.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const _logger = createLogger('notification-status');
|
||||
const logger = createLogger('notification-status');
|
||||
|
||||
@customElement('notification-status')
|
||||
export class NotificationStatus extends LitElement {
|
||||
|
|
@ -15,12 +12,9 @@ export class NotificationStatus extends LitElement {
|
|||
return this;
|
||||
}
|
||||
|
||||
@state() private permission: NotificationPermission = 'default';
|
||||
@state() private subscription: PushSubscription | null = null;
|
||||
@state() private isSupported = false;
|
||||
@state() private isSSEConnected = false;
|
||||
|
||||
private permissionChangeUnsubscribe?: () => void;
|
||||
private subscriptionChangeUnsubscribe?: () => void;
|
||||
private connectionStateUnsubscribe?: () => void;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
|
@ -29,35 +23,21 @@ export class NotificationStatus extends LitElement {
|
|||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.permissionChangeUnsubscribe) {
|
||||
this.permissionChangeUnsubscribe();
|
||||
}
|
||||
if (this.subscriptionChangeUnsubscribe) {
|
||||
this.subscriptionChangeUnsubscribe();
|
||||
if (this.connectionStateUnsubscribe) {
|
||||
this.connectionStateUnsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeComponent(): Promise<void> {
|
||||
this.isSupported = pushNotificationService.isSupported();
|
||||
private initializeComponent(): void {
|
||||
// Get initial connection state
|
||||
this.isSSEConnected = notificationEventService.getConnectionStatus();
|
||||
logger.debug('Initial SSE connection status:', this.isSSEConnected);
|
||||
|
||||
if (!this.isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the push notification service to be fully initialized
|
||||
await pushNotificationService.waitForInitialization();
|
||||
|
||||
this.permission = pushNotificationService.getPermission();
|
||||
this.subscription = pushNotificationService.getSubscription();
|
||||
|
||||
// Listen for changes
|
||||
this.permissionChangeUnsubscribe = pushNotificationService.onPermissionChange((permission) => {
|
||||
this.permission = permission;
|
||||
});
|
||||
|
||||
this.subscriptionChangeUnsubscribe = pushNotificationService.onSubscriptionChange(
|
||||
(subscription) => {
|
||||
this.subscription = subscription;
|
||||
// Listen for connection state changes
|
||||
this.connectionStateUnsubscribe = notificationEventService.onConnectionStateChange(
|
||||
(connected) => {
|
||||
logger.log(`SSE connection state changed: ${connected ? 'connected' : 'disconnected'}`);
|
||||
this.isSSEConnected = connected;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -67,27 +47,18 @@ export class NotificationStatus extends LitElement {
|
|||
}
|
||||
|
||||
private getStatusConfig() {
|
||||
// Green when notifications are enabled (permission granted AND subscription active)
|
||||
if (this.permission === 'granted' && this.subscription) {
|
||||
// Green when SSE is connected (Mac app notifications are working)
|
||||
if (this.isSSEConnected) {
|
||||
return {
|
||||
color: 'text-status-success',
|
||||
tooltip: 'Settings (Notifications enabled)',
|
||||
tooltip: 'Settings (Notifications connected)',
|
||||
};
|
||||
}
|
||||
|
||||
// Default color for all other cases (not red anymore)
|
||||
let tooltip = 'Settings (Notifications disabled)';
|
||||
if (!this.isSupported) {
|
||||
tooltip = 'Settings (Notifications not supported)';
|
||||
} else if (this.permission === 'denied') {
|
||||
tooltip = 'Settings (Notifications blocked)';
|
||||
} else if (!this.subscription) {
|
||||
tooltip = 'Settings (Notifications not subscribed)';
|
||||
}
|
||||
|
||||
// Default color when SSE is not connected
|
||||
return {
|
||||
color: 'text-muted',
|
||||
tooltip,
|
||||
tooltip: 'Settings (Notifications disconnected)',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
295
web/src/client/services/notification-event-service.ts
Normal file
295
web/src/client/services/notification-event-service.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import { createLogger } from '../utils/logger.js';
|
||||
import type { AuthClient } from './auth-client.js';
|
||||
import { pushNotificationService } from './push-notification-service.js';
|
||||
|
||||
const logger = createLogger('notification-event-service');
|
||||
|
||||
type ConnectionStateHandler = (connected: boolean) => void;
|
||||
type EventHandler = (data: unknown) => void;
|
||||
|
||||
export class NotificationEventService {
|
||||
private eventSource: EventSource | null = null;
|
||||
private isConnected = false;
|
||||
private connectionStateHandlers: Set<ConnectionStateHandler> = new Set();
|
||||
private eventListeners: Map<string, Set<EventHandler>> = new Map();
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectDelay = 1000; // Start with 1 second
|
||||
private maxReconnectDelay = 30000; // Max 30 seconds
|
||||
private shouldReconnect = true;
|
||||
private isConnecting = false;
|
||||
|
||||
constructor(private authClient?: AuthClient) {}
|
||||
|
||||
/**
|
||||
* Connect to the notification event stream
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.eventSource || this.isConnecting) {
|
||||
logger.debug('Already connected or connecting to notification event stream');
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip notification check in no-auth mode - always connect
|
||||
const isNoAuth = !this.authClient || !this.authClient.getAuthHeader().Authorization;
|
||||
|
||||
if (!isNoAuth) {
|
||||
// Check if notifications are enabled before connecting
|
||||
try {
|
||||
logger.debug('Checking notification preferences...');
|
||||
await pushNotificationService.waitForInitialization();
|
||||
const preferences = await pushNotificationService.loadPreferences();
|
||||
logger.debug('Loaded notification preferences:', preferences);
|
||||
if (!preferences.enabled) {
|
||||
logger.debug('Notifications are disabled, not connecting to SSE');
|
||||
this.isConnecting = false;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Could not check notification preferences:', error);
|
||||
// Continue anyway - let the user enable notifications later
|
||||
}
|
||||
} else {
|
||||
logger.debug('No-auth mode - connecting to SSE without checking preferences');
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
logger.log('Connecting to notification event stream...');
|
||||
|
||||
let url = '/api/events';
|
||||
|
||||
// EventSource doesn't support custom headers in browsers
|
||||
// In no-auth mode, we don't need to add a token
|
||||
if (!isNoAuth && this.authClient) {
|
||||
const authHeader = this.authClient.getAuthHeader();
|
||||
if (authHeader.Authorization?.startsWith('Bearer ')) {
|
||||
const token = authHeader.Authorization.substring(7);
|
||||
url = `${url}?token=${encodeURIComponent(token)}`;
|
||||
logger.debug('Added auth token to EventSource URL');
|
||||
}
|
||||
} else {
|
||||
logger.debug('No auth mode - connecting without token');
|
||||
}
|
||||
|
||||
this.eventSource = new EventSource(url);
|
||||
|
||||
// Add readyState logging
|
||||
logger.log(
|
||||
`EventSource created with URL: ${url}, readyState: ${this.eventSource.readyState} (0=CONNECTING, 1=OPEN, 2=CLOSED)`
|
||||
);
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
logger.log('✅ SSE onopen event fired - connection established');
|
||||
this.isConnected = true;
|
||||
this.isConnecting = false;
|
||||
this.reconnectDelay = 1000; // Reset delay on successful connection
|
||||
this.notifyConnectionState(true);
|
||||
};
|
||||
|
||||
// Add multiple timeouts to track connection state
|
||||
setTimeout(() => {
|
||||
logger.log(
|
||||
`SSE state after 100ms: readyState=${this.eventSource?.readyState}, isConnected=${this.isConnected}`
|
||||
);
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
logger.log(
|
||||
`SSE state after 500ms: readyState=${this.eventSource?.readyState}, isConnected=${this.isConnected}`
|
||||
);
|
||||
}, 500);
|
||||
|
||||
setTimeout(() => {
|
||||
logger.log(
|
||||
`SSE state after 1s: readyState=${this.eventSource?.readyState}, isConnected=${this.isConnected}`
|
||||
);
|
||||
// If we're connected but onopen didn't fire, manually set connected state
|
||||
if (this.eventSource?.readyState === EventSource.OPEN && !this.isConnected) {
|
||||
logger.warn('⚠️ SSE is OPEN but onopen never fired - manually setting connected state');
|
||||
this.isConnected = true;
|
||||
this.isConnecting = false;
|
||||
this.notifyConnectionState(true);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
this.eventSource.onmessage = (event) => {
|
||||
logger.log('📨 Received SSE message:', event.data);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
logger.log('Parsed notification event:', data);
|
||||
|
||||
// If we receive the initial "connected" event, mark as connected
|
||||
if (data.type === 'connected') {
|
||||
logger.log('✅ Received connected event from SSE');
|
||||
if (!this.isConnected) {
|
||||
this.isConnected = true;
|
||||
this.isConnecting = false;
|
||||
this.notifyConnectionState(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify listeners for this event type
|
||||
if (data.type) {
|
||||
this.notify(data.type, data);
|
||||
}
|
||||
} catch (_error) {
|
||||
logger.log('Received non-JSON event:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = (error) => {
|
||||
// EventSource error events don't contain much information
|
||||
// Check readyState to understand what happened
|
||||
const readyState = this.eventSource?.readyState;
|
||||
const currentUrl = this.eventSource?.url || 'unknown';
|
||||
|
||||
if (readyState === EventSource.CONNECTING) {
|
||||
logger.warn(
|
||||
`⚠️ SSE connection failed while connecting to ${currentUrl} (likely auth or CORS issue)`
|
||||
);
|
||||
} else if (readyState === EventSource.OPEN) {
|
||||
logger.warn('⚠️ SSE connection error while open (network issue)');
|
||||
} else if (readyState === EventSource.CLOSED) {
|
||||
logger.debug('SSE connection closed');
|
||||
}
|
||||
|
||||
logger.error('❌ Notification event stream error:', error);
|
||||
logger.log(
|
||||
`EventSource readyState on error: ${readyState} (0=CONNECTING, 1=OPEN, 2=CLOSED), URL: ${currentUrl}`
|
||||
);
|
||||
|
||||
this.isConnected = false;
|
||||
this.isConnecting = false;
|
||||
this.notifyConnectionState(false);
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the event stream
|
||||
*/
|
||||
disconnect(): void {
|
||||
logger.log('Disconnecting from notification event stream');
|
||||
this.shouldReconnect = false;
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
this.isConnected = false;
|
||||
this.notifyConnectionState(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconnection attempt with exponential backoff
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer || !this.shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Scheduling reconnect in ${this.reconnectDelay}ms...`);
|
||||
|
||||
// Clean up existing connection
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (this.shouldReconnect) {
|
||||
this.connect();
|
||||
// Exponential backoff with max delay
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
||||
}
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current connection status
|
||||
*/
|
||||
getConnectionStatus(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for connection state changes
|
||||
*/
|
||||
onConnectionStateChange(handler: ConnectionStateHandler): () => void {
|
||||
this.connectionStateHandlers.add(handler);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.connectionStateHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for a specific event type
|
||||
*/
|
||||
on(eventType: string, handler: EventHandler): () => void {
|
||||
if (!this.eventListeners.has(eventType)) {
|
||||
this.eventListeners.set(eventType, new Set());
|
||||
}
|
||||
this.eventListeners.get(eventType)?.add(handler);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.off(eventType, handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a handler for a specific event type
|
||||
*/
|
||||
off(eventType: string, handler: EventHandler): void {
|
||||
this.eventListeners.get(eventType)?.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all handlers of a specific event type
|
||||
*/
|
||||
private notify(eventType: string, data: unknown): void {
|
||||
this.eventListeners.get(eventType)?.forEach((handler) => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
logger.error(`Error in event handler for ${eventType}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all handlers of connection state change
|
||||
*/
|
||||
private notifyConnectionState(connected: boolean): void {
|
||||
this.connectionStateHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler(connected);
|
||||
} catch (error) {
|
||||
logger.error('Error in connection state handler:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the auth client (for when it becomes available later)
|
||||
*/
|
||||
setAuthClient(authClient: AuthClient): void {
|
||||
this.authClient = authClient;
|
||||
// Don't reconnect if we already have an event source
|
||||
// The EventSource API doesn't require auth headers in browsers
|
||||
// as it uses cookies for authentication
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const notificationEventService = new NotificationEventService();
|
||||
|
|
@ -4,6 +4,7 @@ import type { NotificationPreferences } from '../../types/config.js';
|
|||
import { DEFAULT_NOTIFICATION_PREFERENCES } from '../../types/config.js';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { authClient } from './auth-client';
|
||||
import { notificationEventService } from './notification-event-service';
|
||||
import { serverConfigService } from './server-config-service';
|
||||
|
||||
// Re-export types for components
|
||||
|
|
@ -368,57 +369,45 @@ export class PushNotificationService {
|
|||
async testNotification(): Promise<void> {
|
||||
logger.log('🔔 Testing notification system...');
|
||||
|
||||
try {
|
||||
// Set up SSE listener for test notifications before sending the request
|
||||
const eventSource = new EventSource('/api/events');
|
||||
let receivedNotification = false;
|
||||
if (!this.serviceWorkerRegistration) {
|
||||
throw new Error('Service worker not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Promise that resolves when we receive the test notification
|
||||
const notificationPromise = new Promise<void>((resolve) => {
|
||||
let receivedNotification = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!receivedNotification) {
|
||||
logger.warn('⏱️ Timeout waiting for SSE test notification');
|
||||
eventSource.close();
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
}, 5000); // 5 second timeout
|
||||
|
||||
eventSource.addEventListener('test-notification', async (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
logger.log('📨 Received test notification via SSE:', data);
|
||||
receivedNotification = true;
|
||||
clearTimeout(timeout);
|
||||
const unsubscribe = notificationEventService.on('test-notification', async (data: any) => {
|
||||
logger.log('📨 Received test notification via SSE:', data);
|
||||
receivedNotification = true;
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
|
||||
// Show notification if we have permission
|
||||
if (this.serviceWorkerRegistration && this.getPermission() === 'granted') {
|
||||
await this.serviceWorkerRegistration.showNotification(
|
||||
data.title || 'VibeTunnel Test',
|
||||
{
|
||||
body: data.body || 'Test notification received via SSE!',
|
||||
icon: '/apple-touch-icon.png',
|
||||
badge: '/favicon-32.png',
|
||||
tag: 'vibetunnel-test-sse',
|
||||
requireInteraction: false,
|
||||
}
|
||||
);
|
||||
logger.log('✅ Displayed SSE test notification');
|
||||
}
|
||||
|
||||
eventSource.close();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle SSE test notification:', error);
|
||||
eventSource.close();
|
||||
resolve();
|
||||
// Show notification if we have permission
|
||||
if (this.serviceWorkerRegistration && this.getPermission() === 'granted') {
|
||||
await this.serviceWorkerRegistration.showNotification(
|
||||
data.title || 'VibeTunnel Test',
|
||||
{
|
||||
body: data.body || 'Test notification received via SSE!',
|
||||
icon: '/apple-touch-icon.png',
|
||||
badge: '/favicon-32.png',
|
||||
tag: 'vibetunnel-test-sse',
|
||||
requireInteraction: false,
|
||||
}
|
||||
);
|
||||
logger.log('✅ Displayed SSE test notification');
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
logger.error('SSE connection error');
|
||||
eventSource.close();
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Send the test notification request to server
|
||||
|
|
@ -427,13 +416,13 @@ export class PushNotificationService {
|
|||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authClient.getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
logger.error('❌ Server test notification failed:', error);
|
||||
eventSource.close();
|
||||
throw new Error(error.error || 'Failed to send test notification');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
|
|||
|
||||
// SSE endpoint for event streaming
|
||||
router.get('/events', (req: Request, res: Response) => {
|
||||
logger.info('📡 SSE connection attempt received');
|
||||
logger.debug('Client connected to event stream');
|
||||
|
||||
// Set headers for SSE
|
||||
|
|
@ -20,6 +21,7 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
|
|||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable proxy buffering
|
||||
|
||||
// Event ID counter
|
||||
let eventId = 0;
|
||||
|
|
@ -27,7 +29,6 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
|
|||
let keepAlive: NodeJS.Timeout;
|
||||
|
||||
// Forward-declare event handlers for cleanup
|
||||
// biome-ignore lint/style/useConst: These are assigned later in the code
|
||||
let onNotification: (event: ServerEvent) => void;
|
||||
|
||||
// Cleanup function to remove event listeners
|
||||
|
|
@ -42,7 +43,7 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
|
|||
|
||||
// Send initial connection event as default message event
|
||||
try {
|
||||
res.write('data: {"type": "connected"}\n\n');
|
||||
res.write('event: connected\ndata: {"type": "connected"}\n\n');
|
||||
} catch (error) {
|
||||
logger.debug('Failed to send initial connection event:', error);
|
||||
return;
|
||||
|
|
@ -69,16 +70,15 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
|
|||
logger.info('🧪 Forwarding test notification through SSE:', event);
|
||||
}
|
||||
|
||||
// Send as default message event (not named event) for compatibility with Mac EventSource
|
||||
// The event type is already included in the data payload
|
||||
const sseMessage = `id: ${++eventId}\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
|
||||
try {
|
||||
const sseMessage = `id: ${++eventId}\nevent: ${
|
||||
event.type
|
||||
}\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
res.write(sseMessage);
|
||||
logger.debug(`✅ SSE event written: ${event.type}`);
|
||||
} catch (error) {
|
||||
logger.debug('Failed to write SSE event:', error);
|
||||
cleanup();
|
||||
logger.error('Failed to write SSE event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ describe('VibeTunnelSocketClient', () => {
|
|||
const client = new VibeTunnelSocketClient(socketPath);
|
||||
const statusHandler = vi.fn();
|
||||
|
||||
client.on('status', statusHandler);
|
||||
client.on('STATUS_UPDATE', statusHandler);
|
||||
await client.connect();
|
||||
|
||||
// Send status from server
|
||||
|
|
@ -274,7 +274,7 @@ describe('VibeTunnelSocketClient', () => {
|
|||
const client = new VibeTunnelSocketClient(socketPath);
|
||||
const errorHandler = vi.fn();
|
||||
|
||||
client.on('serverError', errorHandler);
|
||||
client.on('ERROR', errorHandler);
|
||||
await client.connect();
|
||||
|
||||
// Send error from server
|
||||
|
|
@ -320,7 +320,7 @@ describe('VibeTunnelSocketClient', () => {
|
|||
const client = new VibeTunnelSocketClient(socketPath);
|
||||
const statusHandler = vi.fn();
|
||||
|
||||
client.on('status', statusHandler);
|
||||
client.on('STATUS_UPDATE', statusHandler);
|
||||
await client.connect();
|
||||
|
||||
// Send multiple messages at once
|
||||
|
|
@ -343,7 +343,7 @@ describe('VibeTunnelSocketClient', () => {
|
|||
const client = new VibeTunnelSocketClient(socketPath);
|
||||
const statusHandler = vi.fn();
|
||||
|
||||
client.on('status', statusHandler);
|
||||
client.on('STATUS_UPDATE', statusHandler);
|
||||
await client.connect();
|
||||
|
||||
// Create a message and split it
|
||||
|
|
|
|||
Loading…
Reference in a new issue