9fa091c150
Introduce a desktop.task.dispatch topic exchange with per-client routing keys. Tenant-api publishes a task_available frame keyed by target client ID when a publish job is created; every instance binds its own transient queue and forwards to the matching WebSocket (/api/desktop/dispatch) it owns. Desktop-client now prefers this push channel and only falls back to HTTP /lease polling when the socket is down. Also drop admin-web monitoring-plugin remnants and scope the publish modal account list to publish platforms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
3.6 KiB
Go
142 lines
3.6 KiB
Go
package transport
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
|
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
|
)
|
|
|
|
const (
|
|
dispatchWriteWait = 10 * time.Second
|
|
dispatchPongWait = 60 * time.Second
|
|
dispatchPingPeriod = 25 * time.Second
|
|
dispatchReadLimitBytes = 2048
|
|
dispatchCloseGracePause = 500 * time.Millisecond
|
|
)
|
|
|
|
type DesktopDispatchHandler struct {
|
|
app *bootstrap.App
|
|
upgrader websocket.Upgrader
|
|
}
|
|
|
|
func NewDesktopDispatchHandler(a *bootstrap.App) *DesktopDispatchHandler {
|
|
return &DesktopDispatchHandler{
|
|
app: a,
|
|
upgrader: websocket.Upgrader{
|
|
ReadBufferSize: 4096,
|
|
WriteBufferSize: 4096,
|
|
CheckOrigin: func(_ *http.Request) bool {
|
|
// Origin enforcement happens at the gateway / LB layer; this
|
|
// endpoint is already gated by the bearer token middleware.
|
|
return true
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Dispatch upgrades the HTTP request into a WebSocket and streams dispatch
|
|
// events for the current desktop client. The contract intentionally mirrors
|
|
// the existing SSE events endpoint: connect with a bearer token, receive JSON
|
|
// frames whose shape matches DesktopDispatchEvent, and reconnect on drop.
|
|
func (h *DesktopDispatchHandler) Dispatch(c *gin.Context) {
|
|
client := MustDesktopClient(c.Request.Context())
|
|
hub := h.app.DesktopDispatch
|
|
if hub == nil {
|
|
c.AbortWithStatus(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
conn, err := h.upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
if h.app.Logger != nil {
|
|
h.app.Logger.Warn("desktop dispatch upgrade failed",
|
|
zap.String("client_id", client.ID.String()),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
subscriber, cancel := hub.Subscribe(client.ID.String())
|
|
defer cancel()
|
|
|
|
conn.SetReadLimit(dispatchReadLimitBytes)
|
|
_ = conn.SetReadDeadline(time.Now().Add(dispatchPongWait))
|
|
conn.SetPongHandler(func(string) error {
|
|
_ = conn.SetReadDeadline(time.Now().Add(dispatchPongWait))
|
|
return nil
|
|
})
|
|
|
|
// Reader goroutine: drains control frames and signals disconnect. We do
|
|
// not expect application data from the client over this socket.
|
|
readerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(readerDone)
|
|
for {
|
|
if _, _, err := conn.ReadMessage(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
connected := stream.DesktopDispatchEvent{
|
|
Type: "connected",
|
|
TargetClientID: client.ID.String(),
|
|
WorkspaceID: client.WorkspaceID,
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
if err := writeDispatchFrame(conn, connected); err != nil {
|
|
_ = conn.Close()
|
|
return
|
|
}
|
|
|
|
pingTicker := time.NewTicker(dispatchPingPeriod)
|
|
defer pingTicker.Stop()
|
|
|
|
defer func() {
|
|
_ = conn.SetWriteDeadline(time.Now().Add(dispatchWriteWait))
|
|
_ = conn.WriteMessage(websocket.CloseMessage,
|
|
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
|
time.Sleep(dispatchCloseGracePause)
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
outbound := subscriber.Outbound()
|
|
for {
|
|
select {
|
|
case <-c.Request.Context().Done():
|
|
return
|
|
case <-readerDone:
|
|
return
|
|
case event, ok := <-outbound:
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := writeDispatchFrame(conn, event); err != nil {
|
|
return
|
|
}
|
|
case <-pingTicker.C:
|
|
_ = conn.SetWriteDeadline(time.Now().Add(dispatchWriteWait))
|
|
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeDispatchFrame(conn *websocket.Conn, event stream.DesktopDispatchEvent) error {
|
|
body, err := json.Marshal(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_ = conn.SetWriteDeadline(time.Now().Add(dispatchWriteWait))
|
|
return conn.WriteMessage(websocket.TextMessage, body)
|
|
}
|