Improve SSE status and event auth handling

This commit is contained in:
Brandon Zhang
2026-03-27 17:55:28 +08:00
parent f437f6939c
commit 65b29bcf03
6 changed files with 77 additions and 22 deletions

View File

@@ -9,6 +9,13 @@ import (
func bearerAuthMiddleware(requiredToken string) func(http.Handler) http.Handler { func bearerAuthMiddleware(requiredToken string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/events" {
if r.URL.Query().Get("access_token") == requiredToken {
next.ServeHTTP(w, r)
return
}
}
auth := r.Header.Get("Authorization") auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") { if !strings.HasPrefix(auth, "Bearer ") {
writeError(w, http.StatusUnauthorized, "Missing or invalid Authorization header") writeError(w, http.StatusUnauthorized, "Missing or invalid Authorization header")

View File

@@ -2,6 +2,7 @@ package api
import ( import (
"net/http" "net/http"
"time"
"github.com/local-mcp/local-mcp-go/internal/events" "github.com/local-mcp/local-mcp-go/internal/events"
) )
@@ -24,6 +25,8 @@ func handleSSE(broker *events.Broker) http.HandlerFunc {
// Subscribe to event broker // Subscribe to event broker
ch := broker.Subscribe() ch := broker.Subscribe()
defer broker.Unsubscribe(ch) defer broker.Unsubscribe(ch)
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
// Send initial connection event // Send initial connection event
w.Write([]byte("data: {\"type\":\"connected\"}\n\n")) w.Write([]byte("data: {\"type\":\"connected\"}\n\n"))
@@ -38,6 +41,9 @@ func handleSSE(broker *events.Broker) http.HandlerFunc {
} }
w.Write(msg) w.Write(msg)
flusher.Flush() flusher.Flush()
case <-heartbeat.C:
w.Write([]byte(": keepalive\n\n"))
flusher.Flush()
case <-r.Context().Done(): case <-r.Context().Done():
return // client disconnected return // client disconnected
} }

View File

@@ -3,6 +3,8 @@ package api
import ( import (
"net/http" "net/http"
"time" "time"
"github.com/local-mcp/local-mcp-go/internal/models"
) )
func handleHealth() http.HandlerFunc { func handleHealth() http.HandlerFunc {
@@ -16,28 +18,58 @@ func handleHealth() http.HandlerFunc {
func handleStatus(stores Stores) http.HandlerFunc { func handleStatus(stores Stores) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
counts, _ := stores.Instructions.Counts() counts, err := stores.Instructions.Counts()
latest, _ := stores.Agents.Latest() if err != nil {
cfg, _ := stores.Settings.Get() writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp := map[string]any{ cfg, err := stores.Settings.Get()
"uptime_seconds": int(time.Since(serverStartTime).Seconds()), if err != nil {
"queue_pending": counts.PendingCount, writeError(w, http.StatusInternalServerError, err.Error())
"queue_consumed": counts.ConsumedCount, return
"agent_stale_after_seconds": cfg.AgentStaleAfterSeconds, }
latest, err := stores.Agents.Latest()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
agent := map[string]any{
"connected": false,
"last_seen_at": nil,
"last_fetch_at": nil,
"agent_id": nil,
} }
if latest != nil { if latest != nil {
isStale := time.Since(latest.LastSeenAt).Seconds() > float64(cfg.AgentStaleAfterSeconds) connected := time.Since(latest.LastSeenAt).Seconds() <= float64(cfg.AgentStaleAfterSeconds)
resp["agent"] = map[string]any{ agent = map[string]any{
"agent_id": latest.AgentID, "connected": connected,
"last_fetch_at": latest.LastFetchAt.Format(time.RFC3339Nano), "last_seen_at": latest.LastSeenAt.Format(time.RFC3339Nano),
"last_result_type": latest.LastResultType, "last_fetch_at": latest.LastFetchAt.Format(time.RFC3339Nano),
"is_stale": isStale, "agent_id": latest.AgentID,
} }
} }
resp := map[string]any{
"server": map[string]any{
"status": "up",
"started_at": serverStartTime.Format(time.RFC3339Nano),
},
"agent": agent,
"queue": map[string]any{
"pending_count": counts.PendingCount,
"consumed_count": counts.ConsumedCount,
},
"settings": models.Settings{
DefaultWaitSeconds: cfg.DefaultWaitSeconds,
DefaultEmptyResponse: cfg.DefaultEmptyResponse,
AgentStaleAfterSeconds: cfg.AgentStaleAfterSeconds,
},
}
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
} }
} }

View File

@@ -6,7 +6,7 @@
*/ */
import { state } from './state.js'; import { state } from './state.js';
import { api } from './api.js'; import { api, getStoredToken } from './api.js';
let _es = null; let _es = null;
let _reconnectTimer = null; let _reconnectTimer = null;
@@ -19,7 +19,12 @@ export function connectSSE() {
} }
function _connect() { function _connect() {
_es = new EventSource('/api/events'); const token = getStoredToken();
const url = token
? `/api/events?access_token=${encodeURIComponent(token)}`
: '/api/events';
_es = new EventSource(url);
_es.onopen = () => { _es.onopen = () => {
console.debug('[SSE] connected'); console.debug('[SSE] connected');
@@ -43,7 +48,6 @@ function _connect() {
_es.onerror = () => { _es.onerror = () => {
console.warn('[SSE] connection lost reconnecting in', RECONNECT_DELAY_MS, 'ms'); console.warn('[SSE] connection lost reconnecting in', RECONNECT_DELAY_MS, 'ms');
state.set('serverOnline', false);
_reconnecting = true; _reconnecting = true;
state.set('sseReconnecting', true); state.set('sseReconnecting', true);
_es.close(); _es.close();

View File

@@ -9,6 +9,7 @@ const _state = {
status: null, // StatusResponse | null status: null, // StatusResponse | null
config: null, // ConfigResponse | null config: null, // ConfigResponse | null
serverOnline: false, serverOnline: false,
sseReconnecting: false,
}; };
const _listeners = {}; // key -> Set<fn> const _listeners = {}; // key -> Set<fn>

View File

@@ -34,8 +34,10 @@ function updateHeaderLeds(serverOnline, status) {
if (!serverLed || !agentLed) return; if (!serverLed || !agentLed) return;
// Don't overwrite reconnecting state events.js sets that if (state.get('sseReconnecting')) {
if (serverOnline && !state.get('sseReconnecting')) { serverLed.className = 'led led--amber led--pulse';
serverLed.querySelector('.led__label').textContent = 'Reconnecting…';
} else if (serverOnline) {
serverLed.className = 'led led--green led--pulse'; serverLed.className = 'led led--green led--pulse';
serverLed.querySelector('.led__label').textContent = 'Server Online'; serverLed.querySelector('.led__label').textContent = 'Server Online';
} else if (!serverOnline) { } else if (!serverOnline) {
@@ -58,8 +60,11 @@ function renderStatusPanel(status) {
const el = document.getElementById('status-panel-body'); const el = document.getElementById('status-panel-body');
if (!el || !status) return; if (!el || !status) return;
const agent = status.agent; const agent = status.agent ?? {};
const queue = status.queue; const queue = {
pending_count: status.queue?.pending_count ?? 0,
consumed_count: status.queue?.consumed_count ?? 0,
};
el.innerHTML = ` el.innerHTML = `
<div class="stat-row"> <div class="stat-row">