Split static history from live tail, stabilize chat data identities, and reduce scroll-shell churn so old viewport content stops waking on stream deltas. Also defer watcher startup and health polling so idle runtime work better matches actual usage.
267 lines
7.2 KiB
JavaScript
267 lines
7.2 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 registerNotificationRoutes = (app, dependencies) => {
|
|
const {
|
|
uiAuthController,
|
|
ensurePushInitialized,
|
|
ensureGlobalWatcherStarted,
|
|
getOrCreateVapidKeys,
|
|
getUiSessionTokenFromRequest,
|
|
readSettingsFromDiskMigrated,
|
|
writeSettingsToDisk,
|
|
addOrUpdatePushSubscription,
|
|
removePushSubscription,
|
|
updateUiVisibility,
|
|
isUiVisible,
|
|
getSessionActivitySnapshot,
|
|
getSessionStateSnapshot,
|
|
getSessionAttentionSnapshot,
|
|
getSessionState,
|
|
getSessionAttentionState,
|
|
markSessionViewed,
|
|
markSessionUnviewed,
|
|
markUserMessageSent,
|
|
setPushInitialized,
|
|
} = 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/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,
|
|
});
|
|
});
|
|
};
|