Add realtime watcher+SSE transport with tests and lock-retry read path

This commit is contained in:
zenchantlive 2026-02-11 21:05:27 -08:00
parent cc616c1543
commit 3f2ae384f5
15 changed files with 727 additions and 75 deletions

View file

@ -0,0 +1,85 @@
import { canonicalizeWindowsPath } from '../../../lib/pathing';
import { issuesEventBus, SSE_CONNECTED_FRAME, SSE_HEARTBEAT_FRAME, toSseFrame } from '../../../lib/realtime';
import { getIssuesWatchManager } from '../../../lib/watcher';
const encoder = new TextEncoder();
const HEARTBEAT_MS = 15_000;
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const projectRoot = canonicalizeWindowsPath(url.searchParams.get('projectRoot') ?? process.cwd());
try {
getIssuesWatchManager().startWatch(projectRoot);
} catch (error) {
return Response.json(
{
ok: false,
error: {
classification: 'unknown',
message: error instanceof Error ? error.message : 'Failed to initialize watcher.',
},
},
{ status: 500 },
);
}
let cleanup = () => {};
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false;
const write = (payload: string) => {
if (closed) {
return;
}
controller.enqueue(encoder.encode(payload));
};
write(SSE_CONNECTED_FRAME);
const unsubscribe = issuesEventBus.subscribe(
(event) => {
write(toSseFrame(event));
},
{ projectRoot },
);
const heartbeat = setInterval(() => {
write(SSE_HEARTBEAT_FRAME);
}, HEARTBEAT_MS);
const close = () => {
if (closed) {
return;
}
closed = true;
clearInterval(heartbeat);
unsubscribe();
try {
controller.close();
} catch {
// stream already closed
}
};
cleanup = close;
request.signal.addEventListener('abort', close);
},
cancel() {
// Called when client closes EventSource/reader.
// Ensures heartbeat + subscriber cleanup always runs.
cleanup();
return Promise.resolve();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
}