Files
openchamber/packages/web/server/lib/notifications/routes.js
T
284d72bef2 Keep notification SSE stream alive behind proxies (#1516)
* fix: keep notification SSE stream alive

* Fix PR comments

* fix: cover notification stream error cleanup

---------

Co-authored-by: Konstantin Zolin <zolin_ka@vk.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-06-11 19:48:06 +03:00

353 lines
9.5 KiB
JavaScript

const parsePushSubscribeBody = (body) => {
if (!body || typeof body !== 'object') return null;
const endpoint = body.endpoint;
const keys = body.keys;
const p256dh = keys?.p256dh;
const auth = keys?.auth;
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
if (typeof p256dh !== 'string' || p256dh.trim().length === 0) return null;
if (typeof auth !== 'string' || auth.trim().length === 0) return null;
return {
endpoint: endpoint.trim(),
keys: { p256dh: p256dh.trim(), auth: auth.trim() },
};
};
const parsePushUnsubscribeBody = (body) => {
if (!body || typeof body !== 'object') return null;
const endpoint = body.endpoint;
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
return { endpoint: endpoint.trim() };
};
export const NOTIFICATION_SSE_HEARTBEAT_INTERVAL_MS = 20_000;
export const registerNotificationRoutes = (app, dependencies) => {
const {
uiAuthController,
ensurePushInitialized,
ensureGlobalWatcherStarted,
getOrCreateVapidKeys,
getUiSessionTokenFromRequest,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
updateUiVisibility,
isUiVisible,
getUiNotificationClients,
writeSseEvent,
getSessionActivitySnapshot,
getSessionStateSnapshot,
getSessionAttentionSnapshot,
getSessionState,
getSessionAttentionState,
markSessionViewed,
markSessionUnviewed,
markUserMessageSent,
setPushInitialized,
setAutoAcceptSession,
} = dependencies;
const ensureSessionWatcher = async () => {
if (typeof ensureGlobalWatcherStarted !== 'function') {
return;
}
try {
await ensureGlobalWatcherStarted();
} catch (error) {
console.warn('[OpenCodeWatcher] lazy start failed:', error?.message ?? error);
}
};
app.get('/api/push/vapid-public-key', async (_req, res) => {
try {
await ensurePushInitialized();
const keys = await getOrCreateVapidKeys();
res.json({ publicKey: keys.publicKey });
} catch (error) {
console.warn('[Push] Failed to load VAPID key:', error);
res.status(500).json({ error: 'Failed to load push key' });
}
});
app.post('/api/push/subscribe', async (req, res) => {
await ensurePushInitialized();
await ensureSessionWatcher();
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const parsed = parsePushSubscribeBody(req.body);
if (!parsed) {
return res.status(400).json({ error: 'Invalid body' });
}
const { endpoint, keys } = parsed;
const origin = typeof req.body?.origin === 'string' ? req.body.origin.trim() : '';
if (origin.startsWith('http://') || origin.startsWith('https://')) {
try {
const settings = await readSettingsFromDiskMigrated();
if (typeof settings?.publicOrigin !== 'string' || settings.publicOrigin.trim().length === 0) {
await writeSettingsToDisk({
...settings,
publicOrigin: origin,
});
setPushInitialized(false);
}
} catch {
}
}
await addOrUpdatePushSubscription(
uiToken,
{
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
},
req.headers['user-agent']
);
return res.json({ ok: true });
});
app.delete('/api/push/subscribe', async (req, res) => {
await ensurePushInitialized();
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const parsed = parsePushUnsubscribeBody(req.body);
if (!parsed) {
return res.status(400).json({ error: 'Invalid body' });
}
await removePushSubscription(uiToken, parsed.endpoint);
return res.json({ ok: true });
});
app.post('/api/push/visibility', async (req, res) => {
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const visible = req.body && typeof req.body === 'object' ? req.body.visible : null;
updateUiVisibility(uiToken, visible === true);
return res.json({ ok: true });
});
app.get('/api/push/visibility', (req, res) => {
const uiToken = getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
return res.json({
ok: true,
visible: isUiVisible(uiToken),
});
});
app.get('/api/notifications/stream', async (req, res) => {
await ensureSessionWatcher();
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return;
}
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
const clients = getUiNotificationClients();
clients.add(res);
let closed = false;
let heartbeatTimer = null;
const cleanup = () => {
if (closed) {
return;
}
closed = true;
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
clients.delete(res);
};
req.on('close', cleanup);
res.on('error', cleanup);
const flushSse = () => {
res.flush?.();
};
heartbeatTimer = setInterval(() => {
if (closed || res.writableEnded || res.destroyed) {
cleanup();
return;
}
try {
res.write(':heartbeat\n\n');
flushSse();
} catch {
cleanup();
}
}, NOTIFICATION_SSE_HEARTBEAT_INTERVAL_MS);
try {
writeSseEvent(res, {
type: 'openchamber:notification-stream-ready',
properties: { uiToken },
});
flushSse();
} catch {
cleanup();
}
});
app.get('/api/session-activity', (_req, res) => {
void ensureSessionWatcher();
res.json(getSessionActivitySnapshot());
});
app.get('/api/sessions/snapshot', async (_req, res) => {
await ensureSessionWatcher();
res.json({
statusSessions: getSessionStateSnapshot(),
attentionSessions: getSessionAttentionSnapshot(),
serverTime: Date.now(),
});
});
app.get('/api/sessions/status', async (_req, res) => {
await ensureSessionWatcher();
const snapshot = getSessionStateSnapshot();
res.json({
sessions: snapshot,
serverTime: Date.now(),
});
});
app.get('/api/sessions/:id/status', async (req, res) => {
await ensureSessionWatcher();
const sessionId = req.params.id;
const state = getSessionState(sessionId);
if (!state) {
return res.status(404).json({
error: 'Session not found or no state available',
sessionId,
});
}
return res.json({
sessionId,
...state,
});
});
app.get('/api/sessions/attention', async (_req, res) => {
await ensureSessionWatcher();
const snapshot = getSessionAttentionSnapshot();
res.json({
sessions: snapshot,
serverTime: Date.now(),
});
});
app.get('/api/sessions/:id/attention', async (req, res) => {
await ensureSessionWatcher();
const sessionId = req.params.id;
const state = getSessionAttentionState(sessionId);
if (!state) {
return res.status(404).json({
error: 'Session not found or no attention state available',
sessionId,
});
}
return res.json({
sessionId,
...state,
});
});
app.post('/api/sessions/:id/view', (req, res) => {
const sessionId = req.params.id;
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
markSessionViewed(sessionId, clientId);
return res.json({
success: true,
sessionId,
viewed: true,
});
});
app.post('/api/sessions/:id/unview', (req, res) => {
const sessionId = req.params.id;
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
markSessionUnviewed(sessionId, clientId);
return res.json({
success: true,
sessionId,
viewed: false,
});
});
app.post('/api/sessions/:id/message-sent', (req, res) => {
const sessionId = req.params.id;
markUserMessageSent(sessionId);
return res.json({
success: true,
sessionId,
messageSent: true,
});
});
// Mirror client-side Permission Auto-Accept state to the server so it can
// suppress permission notifications at the source (the 500ms debounce race
// otherwise leaks notifications for auto-accepted permissions).
app.post('/api/notifications/auto-accept', (req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
const enabled = body.enabled === true;
if (!sessionId) {
return res.status(400).json({ error: 'sessionId required' });
}
if (typeof setAutoAcceptSession === 'function') {
setAutoAcceptSession(sessionId, enabled);
}
return res.json({ success: true, sessionId, enabled });
});
};