mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
feat(sse): per-client live subscription filter (#86)
* feat(sse): per-client live subscription filter
Lets a connected client narrow its SSE stream to a single session
without forcing an EventSource reconnect. With many open sessions
(N tabs in the UI, all generating output), this cuts terminal-event
SSE traffic by roughly Nx — we only send the actively-rendered
session's bytes instead of all of them.
The existing ?sessions= query filter only worked at connect time;
narrowing or widening it required tearing down the EventSource and
losing in-flight messages. That was acceptable when filters were set
once at page load, but the UI now flips active sessions on every
tab switch.
How it works
============
- Client generates a stable per-page UUID (`_clientId`) once at
CodemanApp construction and includes it on the SSE URL:
GET /api/events?clientId=<uuid>&sessions=<active-id>
- Server records a `clientId -> reply` mapping in addition to the
existing `reply -> sessionFilter` map.
- New endpoint:
POST /api/events/subscribe { clientId, sessions: string[] | null }
updates the in-memory filter for the matching reply. 204 on success,
404 if the client isn't known yet (race on first selectSession after
reconnect — the next reconnect carries the filter via the URL).
- On every selectSession the client fires a fire-and-forget POST. No
reconnect, no re-init, no replay buffer needed.
Behavioural change to broadcast()
=================================
The per-event session filter is removed from `broadcast()`. Previously
that path filtered lifecycle/metadata events (`session:created`,
`session:updated`, `ralph:*`, `hook:*`) by extracting a `sessionId` from
the payload. With per-client narrow filters, that meant a client
subscribed to session A would never see session:created for B and the
sidebar would silently de-sync.
The new contract:
- **Lifecycle/metadata events** (low-volume, UI-correctness critical)
broadcast to all clients regardless of filter.
- **Terminal events** (high-volume, the actual reason for filtering)
apply the filter in `flushSessionTerminalBatch` (already there;
unchanged).
`extractSessionId()` was only used by the old broadcast() filter and
has been removed.
Files
=====
- src/web/sse-stream-manager.ts (+34/-29): add `sseClientsById`,
optional `clientId` arg to addClient/removeClient cleanup, new
`updateClientFilter()`, and the broadcast() change above.
- src/web/server.ts (+22/-3): parse `clientId` on /api/events, pass
to `addClient`, register POST /api/events/subscribe handler.
- src/web/public/app.js (+41/-1): generate `_clientId`, build the
EventSource URL with both clientId + active session, add
`_updateSseSubscription()`, call it on selectSession.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(sse): update operation-lightspeed to match broadcast-all contract
Lifecycle events (session:*, case:*) now reach every connected SSE
client; only session:terminal is gated by the per-client filter.
Updates the four assertions in operation-lightspeed.test.ts that
encoded the old "filter applies to all events" contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: arkon <arkon.85@hotmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
arkon
parent
e87b03b6c2
commit
98966def03
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* Covers:
|
||||
* - SSE subscription filter edge cases (empty params, whitespace, duplicates)
|
||||
* - extractSessionId logic (sessionId vs id field, global events)
|
||||
* - Lifecycle-event broadcast contract (session:*, case:* fan out to all clients;
|
||||
* only session:terminal is filtered by subscription)
|
||||
* - Tab switching: terminal buffer loading, session creation + switch
|
||||
* - Terminal data cap / backpressure recovery
|
||||
* - Lazy teammate terminal lifecycle
|
||||
@@ -254,11 +255,11 @@ describe('Operation Lightspeed', () => {
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// extractSessionId — Event Classification
|
||||
// Lifecycle Event Broadcast — Event Classification
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
describe('extractSessionId via SSE Filtering', () => {
|
||||
it('should route session:updated events by id field', async () => {
|
||||
describe('Lifecycle Event Broadcast Contract', () => {
|
||||
it('should deliver session:updated events to all clients regardless of filter', async () => {
|
||||
// Create two sessions
|
||||
const session1 = await createSession(baseUrl);
|
||||
const session2 = await createSession(baseUrl);
|
||||
@@ -310,21 +311,23 @@ describe('Operation Lightspeed', () => {
|
||||
|
||||
const events = parseSSEEvents(receivedData);
|
||||
|
||||
// Should receive session:updated for session1 only
|
||||
// New contract: session:updated is a lifecycle event that broadcasts to ALL clients.
|
||||
// The subscription filter only applies to session:terminal.
|
||||
const updatedEvents = events.filter((e) => e.event === 'session:updated');
|
||||
const session1Updated = updatedEvents.find((e) => (e.data as any).id === session1);
|
||||
const session2Updated = updatedEvents.find((e) => (e.data as any).id === session2);
|
||||
|
||||
expect(session1Updated).toBeDefined();
|
||||
expect(session2Updated).toBeUndefined();
|
||||
expect(session2Updated).toBeDefined();
|
||||
|
||||
// Cleanup
|
||||
await deleteSession(baseUrl, session1);
|
||||
await deleteSession(baseUrl, session2);
|
||||
});
|
||||
|
||||
it('should filter session:deleted by session ID (sessionId extraction from id field)', async () => {
|
||||
// Tests extractSessionId's fallback path: session:* events use `id` not `sessionId`
|
||||
it('should deliver session:deleted events to all clients regardless of filter', async () => {
|
||||
// New contract: lifecycle events (session:*) broadcast to every connected client;
|
||||
// the per-client filter no longer gates them. Only session:terminal is filtered.
|
||||
const target = await createSession(baseUrl);
|
||||
const other = await createSession(baseUrl);
|
||||
|
||||
@@ -367,13 +370,12 @@ describe('Operation Lightspeed', () => {
|
||||
|
||||
const events = parseSSEEvents(receivedData);
|
||||
|
||||
// Target deletion should arrive (extractSessionId matches `id` field for session:* events)
|
||||
// Both deletions arrive regardless of the per-client filter
|
||||
const targetDeleted = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === target);
|
||||
expect(targetDeleted).toBeDefined();
|
||||
|
||||
// Other deletion should NOT arrive
|
||||
const otherDeleted = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === other);
|
||||
expect(otherDeleted).toBeUndefined();
|
||||
expect(otherDeleted).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -488,13 +490,13 @@ describe('Operation Lightspeed', () => {
|
||||
expect(events.find((e) => e.event === 'init')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle multiple SSE clients with different filters', async () => {
|
||||
it('should fan lifecycle events out to all SSE clients regardless of filter', async () => {
|
||||
const session1 = await createSession(baseUrl);
|
||||
const session2 = await createSession(baseUrl);
|
||||
|
||||
// Client A: subscribes to session1
|
||||
// Client B: subscribes to session2
|
||||
// Client C: no filter (all events)
|
||||
// Client A: subscribes to session1, Client B: subscribes to session2, Client C: no filter.
|
||||
// Under the broadcast contract, all three see every session:deleted event — the filter
|
||||
// only narrows session:terminal traffic.
|
||||
const controllerA = new AbortController();
|
||||
const controllerB = new AbortController();
|
||||
const controllerC = new AbortController();
|
||||
@@ -585,15 +587,13 @@ describe('Operation Lightspeed', () => {
|
||||
const eventsB = parseSSEEvents(dataB);
|
||||
const eventsC = parseSSEEvents(dataC);
|
||||
|
||||
// Client A: sees session1 deleted, not session2
|
||||
// Every client sees both deletions — lifecycle events are not filter-gated.
|
||||
expect(eventsA.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeDefined();
|
||||
expect(eventsA.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeUndefined();
|
||||
expect(eventsA.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeDefined();
|
||||
|
||||
// Client B: sees session2 deleted, not session1
|
||||
expect(eventsB.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeDefined();
|
||||
expect(eventsB.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeDefined();
|
||||
expect(eventsB.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeUndefined();
|
||||
|
||||
// Client C: sees both
|
||||
expect(eventsC.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeDefined();
|
||||
expect(eventsC.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeDefined();
|
||||
});
|
||||
@@ -991,13 +991,13 @@ describe('Operation Lightspeed', () => {
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// extractSessionId — Additional Edge Cases
|
||||
// Lifecycle Event Broadcast — Additional Edge Cases
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
describe('extractSessionId — Edge Cases via SSE', () => {
|
||||
describe('Lifecycle Event Broadcast — Edge Cases via SSE', () => {
|
||||
it('should treat non-session: events with id field as global (not filtered)', async () => {
|
||||
// Events like case:created have an `id` field but aren't session:* events.
|
||||
// extractSessionId should NOT use the `id` field for non-session:* events.
|
||||
// Under the broadcast contract they reach every connected client.
|
||||
const controller = new AbortController();
|
||||
let receivedData = '';
|
||||
|
||||
@@ -1052,8 +1052,9 @@ describe('Operation Lightspeed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should deliver session:created for a newly created session to unfiltered client but not mismatched filter', async () => {
|
||||
// session:created uses `id` field and starts with `session:` — extractSessionId should match it
|
||||
it('should deliver session:created to every client, even those with a mismatched filter', async () => {
|
||||
// Under the broadcast contract, lifecycle events ignore the per-client filter.
|
||||
// A client subscribed only to `existing` still receives `session:created` for `newSession`.
|
||||
const existing = await createSession(baseUrl);
|
||||
|
||||
// Subscribe to existing session only
|
||||
@@ -1091,9 +1092,9 @@ describe('Operation Lightspeed', () => {
|
||||
}
|
||||
|
||||
const events = parseSSEEvents(receivedData);
|
||||
// session:created for newSession should be filtered OUT (id doesn't match our filter)
|
||||
// session:created reaches the filtered client even though its id doesn't match the filter.
|
||||
const createdEvent = events.find((e) => e.event === 'session:created' && (e.data as any).id === newSession);
|
||||
expect(createdEvent).toBeUndefined();
|
||||
expect(createdEvent).toBeDefined();
|
||||
|
||||
await Promise.all([deleteSession(baseUrl, existing), deleteSession(baseUrl, newSession)]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user