diff --git a/CHANGELOG.md b/CHANGELOG.md index ff11114..937a190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Browser feedback no longer goes to a stale session bucket when the widget bundle is cached across MCP restarts (#46). The session ID is now read from the script tag's `?session=` query at runtime instead of being baked into `widget.js` at serve time, so a cached bundle can never carry a stale session. +- `/widget.js` is served with `Cache-Control: no-store` so browsers cannot reuse a previous bundle. +- WebSocket connections that arrive with an unknown session ID are auto-rebound to the live session when exactly one MCP session is registered, or rejected with a `session_invalid` message (and the widget shows a visible reload banner) when the server can't safely guess. +- Pending and ready feedback queues are now persisted to disk (`os.tmpdir()/claude-browser-feedback/.json`) and rehydrated on server boot, so feedback survives crashes and restarts. + +### Added + +- `get_pending_feedback`, `get_connection_status`, and `/status` now surface orphan feedback buckets (feedback filed under a session ID that no MCP session is listening for), and auto-rescue them into the current session when it is the only one registered. + ## [0.6.6] - 2026-04-22 ### Fixed diff --git a/src/server.js b/src/server.js index 8be4028..41fb85f 100644 --- a/src/server.js +++ b/src/server.js @@ -15,6 +15,7 @@ import { fileURLToPath, pathToFileURL } from "url"; import { createRequire } from "module"; import { execFile } from "child_process"; import { deriveSessionId, isValidSessionId, getPendingSummary, detectProjectUrl, formatFeedbackAsContent } from "./utils.js"; +import * as storage from "./storage.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -38,13 +39,22 @@ const connectedClientsBySession = new Map(); // sessionId -> Set let connectedClients = new Set(); // All clients (for total count in /status) let isHttpServerOwner = false; // Track if this instance owns the HTTP server -// Session-partitioned data accessors +// Session-partitioned data accessors. The owner process persists pending+ready +// queues to disk via storage.js so feedback survives crashes and restarts. +function persistSession(sid) { + if (!isHttpServerOwner || !isValidSessionId(sid)) return; + storage.save(sid, { + pending: pendingFeedbackBySession.get(sid) || [], + ready: readyFeedbackBySession.get(sid) || [], + }); +} function getSessionPending(sid) { if (!pendingFeedbackBySession.has(sid)) pendingFeedbackBySession.set(sid, []); return pendingFeedbackBySession.get(sid); } function setSessionPending(sid, arr) { pendingFeedbackBySession.set(sid, arr); + persistSession(sid); } function getSessionReady(sid) { if (!readyFeedbackBySession.has(sid)) readyFeedbackBySession.set(sid, []); @@ -52,6 +62,7 @@ function getSessionReady(sid) { } function setSessionReady(sid, arr) { readyFeedbackBySession.set(sid, arr); + persistSession(sid); } function getSessionResolvers(sid) { if (!feedbackResolversBySession.has(sid)) feedbackResolversBySession.set(sid, []); @@ -62,6 +73,51 @@ function getSessionClients(sid) { return connectedClientsBySession.get(sid); } +// Find feedback buckets that aren't tied to any registered MCP session. These +// are the symptom of issue #46: a stale widget filed feedback under a session +// ID that nobody is listening for. Used to surface (and optionally auto-rescue) +// the data via the MCP tools. +function findOrphanBuckets() { + const orphans = []; + const seen = new Set(); + for (const [sid, items] of pendingFeedbackBySession) { + if (!isValidSessionId(sid)) continue; + if (sessionRegistry.has(sid)) continue; + seen.add(sid); + orphans.push({ + sessionId: sid, + pendingCount: items.length, + readyCount: (readyFeedbackBySession.get(sid) || []).length, + clientCount: (connectedClientsBySession.get(sid) || new Set()).size, + }); + } + for (const [sid, items] of readyFeedbackBySession) { + if (!isValidSessionId(sid)) continue; + if (sessionRegistry.has(sid) || seen.has(sid)) continue; + orphans.push({ + sessionId: sid, + pendingCount: 0, + readyCount: items.length, + clientCount: (connectedClientsBySession.get(sid) || new Set()).size, + }); + } + return orphans.filter(o => o.pendingCount > 0 || o.readyCount > 0); +} + +// Move all pending+ready feedback from an orphan bucket into the target +// session's queues, then drop the orphan. Used when exactly one MCP session +// is registered and auto-rescue is safe. +function migrateOrphanInto(targetSid, orphanSid) { + const oldPending = pendingFeedbackBySession.get(orphanSid) || []; + const oldReady = readyFeedbackBySession.get(orphanSid) || []; + if (oldPending.length) getSessionPending(targetSid).push(...oldPending); + if (oldReady.length) getSessionReady(targetSid).push(...oldReady); + pendingFeedbackBySession.delete(orphanSid); + readyFeedbackBySession.delete(orphanSid); + storage.remove(orphanSid); + persistSession(targetSid); +} + // Helper to parse JSON body from an HTTP request function parseJsonBody(req) { return new Promise((resolve, reject) => { @@ -229,21 +285,22 @@ const httpServer = http.createServer((req, res) => { if (urlObj.pathname === "/widget.js") { const widgetPath = path.join(__dirname, "widget.js"); - const sessionParam = urlObj.searchParams.get('session') || ''; fs.readFile(widgetPath, "utf8", (err, content) => { if (err) { res.writeHead(500); res.end("Error loading widget"); return; } - // Inject runtime values into the widget (including session ID for isolation) - const wsUrl = sessionParam - ? `ws://localhost:${PORT}/ws?session=${sessionParam}` - : `ws://localhost:${PORT}/ws`; + // The session is NOT baked into the bundle. The widget reads it from + // its own