feat(desktop): push publish tasks via AMQP topic dispatch WebSocket

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>
This commit is contained in:
2026-04-20 19:46:59 +08:00
parent 7abac1e9c4
commit 9fa091c150
20 changed files with 1083 additions and 177 deletions
+8 -2
View File
@@ -45,8 +45,9 @@ type App struct {
VectorStore retrieval.VectorStore
ObjectStorage objectstorage.Client
AuditLogs *auditlog.AsyncWriter
GenerationStreams *stream.GenerationHub
GenerationStreams *stream.GenerationHub
DesktopTaskStreams *stream.DesktopTaskHub
DesktopDispatch *stream.DesktopDispatchHub
Cache cache.Cache
KolProfiles repository.KolProfileRepository
KolPackages repository.KolPackageRepository
@@ -111,6 +112,10 @@ func New(configPath string) (*App, error) {
auditLogs := auditlog.NewAsyncWriter(pool, logger)
generationStreams := stream.NewGenerationHub(mqClient)
desktopTaskStreams := stream.NewDesktopTaskHub(mqClient)
desktopDispatchBindings := []string{
cfg.RabbitMQ.DesktopDispatchPublishPrefix + ".*",
}
desktopDispatch := stream.NewDesktopDispatchHub(mqClient, logger, desktopDispatchBindings)
appCache := cache.New(cfg.Cache.Driver, rdb)
kolProfiles := repository.NewKolProfileRepository(pool)
kolPackages := repository.NewKolPackageRepository(pool)
@@ -170,8 +175,9 @@ func New(configPath string) (*App, error) {
VectorStore: vectorStore,
ObjectStorage: objectStorageClient,
AuditLogs: auditLogs,
GenerationStreams: generationStreams,
GenerationStreams: generationStreams,
DesktopTaskStreams: desktopTaskStreams,
DesktopDispatch: desktopDispatch,
Cache: appCache,
KolProfiles: kolProfiles,
KolPackages: kolPackages,
+12
View File
@@ -78,6 +78,9 @@ type RabbitMQConfig struct {
GenerationDLQRouteKey string `mapstructure:"generation_dlq_routing_key"`
GenerationEventExchange string `mapstructure:"generation_event_exchange"`
DesktopTaskEventExchange string `mapstructure:"desktop_task_event_exchange"`
DesktopDispatchExchange string `mapstructure:"desktop_dispatch_exchange"`
DesktopDispatchPublishPrefix string `mapstructure:"desktop_dispatch_publish_prefix"`
DesktopDispatchMonitorPrefix string `mapstructure:"desktop_dispatch_monitor_prefix"`
TemplateAssistExchange string `mapstructure:"template_assist_exchange"`
TemplateAssistRoutingKey string `mapstructure:"template_assist_routing_key"`
TemplateAssistQueue string `mapstructure:"template_assist_queue"`
@@ -475,6 +478,15 @@ func normalizeRabbitMQConfig(cfg *RabbitMQConfig) {
if strings.TrimSpace(cfg.DesktopTaskEventExchange) == "" {
cfg.DesktopTaskEventExchange = "desktop.task.event"
}
if strings.TrimSpace(cfg.DesktopDispatchExchange) == "" {
cfg.DesktopDispatchExchange = "desktop.task.dispatch"
}
if strings.TrimSpace(cfg.DesktopDispatchPublishPrefix) == "" {
cfg.DesktopDispatchPublishPrefix = "publish"
}
if strings.TrimSpace(cfg.DesktopDispatchMonitorPrefix) == "" {
cfg.DesktopDispatchMonitorPrefix = "monitor"
}
if strings.TrimSpace(cfg.TemplateAssistExchange) == "" {
cfg.TemplateAssistExchange = "template.assist"
}
@@ -46,6 +46,16 @@ func New(_ context.Context, cfg config.RabbitMQConfig) (*Client, error) {
return client, nil
}
// Config returns a copy of the effective rabbitmq configuration. Services that
// need to derive routing keys (e.g. desktop dispatch) consult this instead of
// hard-coding string constants.
func (c *Client) Config() config.RabbitMQConfig {
if c == nil {
return config.RabbitMQConfig{}
}
return c.cfg
}
func (c *Client) Close() error {
if c == nil {
return nil
@@ -102,6 +112,91 @@ func (c *Client) PublishDesktopTaskEvent(ctx context.Context, body []byte) error
return c.publishFanout(ctx, c.cfg.DesktopTaskEventExchange, body)
}
func (c *Client) PublishDesktopDispatch(ctx context.Context, routingKey string, body []byte) error {
return c.publish(ctx, c.cfg.DesktopDispatchExchange, routingKey, body)
}
// ConsumeDesktopDispatch declares a transient per-process queue bound to the
// desktop dispatch topic exchange using bindingKeys, and begins consuming from
// it. Every tenant-api instance gets its own queue so every instance sees every
// dispatch message; the hub inside the instance then filters by target client
// presence. Messages are auto-ack (transient delivery is OK: if the instance
// dies, the client reconnects and the server re-drives the task via the
// existing lease/recovery path).
func (c *Client) ConsumeDesktopDispatch(consumerName string, bindingKeys []string) (<-chan amqp.Delivery, *amqp.Channel, error) {
if len(bindingKeys) == 0 {
return nil, nil, fmt.Errorf("consume rabbitmq desktop dispatch: bindingKeys is required")
}
for attempt := 0; attempt < 2; attempt++ {
conn, ch, err := c.openChannel()
if err != nil {
return nil, nil, err
}
queue, err := ch.QueueDeclare(
"",
false,
true,
true,
false,
nil,
)
if err != nil {
_ = ch.Close()
c.invalidateConnection(conn)
if attempt == 1 {
return nil, nil, fmt.Errorf("declare rabbitmq desktop dispatch queue: %w", err)
}
continue
}
bindFailed := false
for _, key := range bindingKeys {
if err := ch.QueueBind(
queue.Name,
key,
c.cfg.DesktopDispatchExchange,
false,
nil,
); err != nil {
_ = ch.Close()
c.invalidateConnection(conn)
if attempt == 1 {
return nil, nil, fmt.Errorf("bind rabbitmq desktop dispatch queue: %w", err)
}
bindFailed = true
break
}
}
if bindFailed {
continue
}
deliveries, err := ch.Consume(
queue.Name,
consumerName,
true,
true,
false,
false,
nil,
)
if err != nil {
_ = ch.Close()
c.invalidateConnection(conn)
if attempt == 1 {
return nil, nil, fmt.Errorf("consume rabbitmq desktop dispatch queue: %w", err)
}
continue
}
return deliveries, ch, nil
}
return nil, nil, fmt.Errorf("consume rabbitmq desktop dispatch queue: retry exhausted")
}
func (c *Client) PublishTemplateAssistTask(ctx context.Context, body []byte) error {
return c.publish(ctx, c.cfg.TemplateAssistExchange, c.cfg.TemplateAssistRoutingKey, body)
}
@@ -578,6 +673,18 @@ func (c *Client) ensureTopology(conn *amqp.Connection) error {
return fmt.Errorf("declare rabbitmq exchange %s: %w", c.cfg.DesktopTaskEventExchange, err)
}
if err := ch.ExchangeDeclare(
c.cfg.DesktopDispatchExchange,
"topic",
true,
false,
false,
false,
nil,
); err != nil {
return fmt.Errorf("declare rabbitmq exchange %s: %w", c.cfg.DesktopDispatchExchange, err)
}
if err := c.ensureWorkQueue(
ch,
c.cfg.TemplateAssistExchange,
@@ -0,0 +1,274 @@
package stream
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
amqp "github.com/rabbitmq/amqp091-go"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
)
// DesktopDispatchEvent is the payload pushed to a desktop client over the
// dispatch WebSocket. The shape intentionally mirrors DesktopTaskEvent so that
// the existing SSE handler on the desktop client can treat both channels the
// same way and dedupe by task_id.
type DesktopDispatchEvent struct {
Type string `json:"type"`
TaskID string `json:"task_id"`
JobID string `json:"job_id,omitempty"`
WorkspaceID int64 `json:"workspace_id"`
TargetAccountID string `json:"target_account_id,omitempty"`
TargetClientID string `json:"target_client_id"`
Platform string `json:"platform,omitempty"`
Title *string `json:"title,omitempty"`
BusinessDate *string `json:"business_date,omitempty"`
SchedulerGroupKey *string `json:"scheduler_group_key,omitempty"`
QuestionText *string `json:"question_text,omitempty"`
Status string `json:"status"`
Kind string `json:"kind"`
Priority int `json:"priority,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// DispatchSubscriber represents a live WebSocket connection bound to a single
// desktop client id. A client may have multiple subscribers (e.g. during a
// reconnect race), so the hub stores them as a set per client id.
type DispatchSubscriber struct {
id uint64
out chan DesktopDispatchEvent
closed chan struct{}
}
// Outbound returns the channel the handler goroutine should read from to write
// frames to the WebSocket. The channel is closed when the subscriber is
// unregistered; readers should range over it.
func (s *DispatchSubscriber) Outbound() <-chan DesktopDispatchEvent {
return s.out
}
type dispatchClientStream struct {
subscribers map[uint64]*DispatchSubscriber
}
// DesktopDispatchHub delivers per-client dispatch messages pulled from the
// desktop.task.dispatch topic exchange. Every tenant-api instance owns its own
// autodelete queue bound to the exchange, so every instance sees every message
// and routes it to the subscribers it currently owns. Clients not connected to
// this instance are simply filtered out; the client reconnects via the load
// balancer and lands on whichever instance currently holds its WebSocket.
type DesktopDispatchHub struct {
mu sync.RWMutex
clients map[string]*dispatchClientStream
rabbitMQ *rabbitmq.Client
logger *zap.Logger
instanceID string
consumerName string
bindingKeys []string
nextSubID uint64
}
// NewDesktopDispatchHub wires the hub to the shared rabbitmq client. The
// supplied binding keys decide which dispatch routing keys this instance
// receives; initially that is just `publish.*`, and the monitor slice will be
// added when the monitor chain is migrated.
func NewDesktopDispatchHub(rabbitMQClient *rabbitmq.Client, logger *zap.Logger, bindingKeys []string) *DesktopDispatchHub {
instanceID := fmt.Sprintf("%d-%d", os.Getpid(), time.Now().UnixNano())
keys := make([]string, 0, len(bindingKeys))
for _, key := range bindingKeys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
keys = append(keys, trimmed)
}
if len(keys) == 0 {
keys = []string{"publish.*"}
}
return &DesktopDispatchHub{
clients: make(map[string]*dispatchClientStream),
rabbitMQ: rabbitMQClient,
logger: logger,
instanceID: instanceID,
consumerName: "desktop-dispatch-" + instanceID,
bindingKeys: keys,
}
}
// Run starts the background AMQP consumer. It is safe to call Run on a hub
// without a rabbitmq client — the hub simply behaves as an in-process broker
// used in tests, useful for unit-level wiring.
func (h *DesktopDispatchHub) Run(ctx context.Context) {
if h == nil || h.rabbitMQ == nil {
return
}
go h.run(ctx)
}
// Subscribe registers a new WebSocket consumer for the given client id. The
// returned cancel closes the outbound channel and removes the subscriber; the
// WebSocket handler must call it on disconnect.
func (h *DesktopDispatchHub) Subscribe(clientID string) (*DispatchSubscriber, func()) {
if h == nil || clientID == "" {
return nil, func() {}
}
h.mu.Lock()
stream, ok := h.clients[clientID]
if !ok {
stream = &dispatchClientStream{subscribers: make(map[uint64]*DispatchSubscriber)}
h.clients[clientID] = stream
}
h.nextSubID++
sub := &DispatchSubscriber{
id: h.nextSubID,
out: make(chan DesktopDispatchEvent, 32),
closed: make(chan struct{}),
}
stream.subscribers[sub.id] = sub
h.mu.Unlock()
cancel := func() {
h.mu.Lock()
stream, ok := h.clients[clientID]
if !ok {
h.mu.Unlock()
return
}
existing, present := stream.subscribers[sub.id]
if !present {
h.mu.Unlock()
return
}
delete(stream.subscribers, sub.id)
if len(stream.subscribers) == 0 {
delete(h.clients, clientID)
}
h.mu.Unlock()
select {
case <-existing.closed:
// already closed
default:
close(existing.closed)
close(existing.out)
}
}
return sub, cancel
}
// Publish delivers the event to every subscriber currently registered for the
// event's target client id on this instance. Events for clients that are not
// on this instance are dropped silently, by design.
func (h *DesktopDispatchHub) Publish(event DesktopDispatchEvent) {
if h == nil || event.TargetClientID == "" {
return
}
h.mu.RLock()
stream, ok := h.clients[event.TargetClientID]
if !ok {
h.mu.RUnlock()
return
}
subs := make([]*DispatchSubscriber, 0, len(stream.subscribers))
for _, sub := range stream.subscribers {
subs = append(subs, sub)
}
h.mu.RUnlock()
for _, sub := range subs {
select {
case <-sub.closed:
continue
case sub.out <- event:
default:
// Subscriber's outbound channel is full: the reader has fallen
// behind. We drop the message rather than block the dispatch
// consumer — the client will resync via the lease API on
// reconnect.
if h.logger != nil {
h.logger.Warn("desktop dispatch subscriber slow, dropping event",
zap.String("client_id", event.TargetClientID),
zap.String("task_id", event.TaskID),
)
}
}
}
}
// ActiveClientIDs returns the set of client ids currently connected on this
// instance. Intended for diagnostics and the /api/health/ready endpoint.
func (h *DesktopDispatchHub) ActiveClientIDs() []string {
if h == nil {
return nil
}
h.mu.RLock()
defer h.mu.RUnlock()
out := make([]string, 0, len(h.clients))
for id := range h.clients {
out = append(out, id)
}
return out
}
func (h *DesktopDispatchHub) run(ctx context.Context) {
for {
deliveries, ch, err := h.rabbitMQ.ConsumeDesktopDispatch(h.consumerName, h.bindingKeys)
if err != nil {
if h.logger != nil {
h.logger.Warn("desktop dispatch consume failed, retrying",
zap.Error(err),
)
}
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
continue
}
}
closed := false
for !closed {
select {
case <-ctx.Done():
_ = ch.Close()
return
case delivery, ok := <-deliveries:
if !ok {
closed = true
continue
}
h.handleDelivery(delivery)
}
}
_ = ch.Close()
}
}
func (h *DesktopDispatchHub) handleDelivery(delivery amqp.Delivery) {
var event DesktopDispatchEvent
if err := json.Unmarshal(delivery.Body, &event); err != nil {
if h.logger != nil {
h.logger.Warn("desktop dispatch event decode failed",
zap.Error(err),
zap.String("routing_key", delivery.RoutingKey),
)
}
return
}
if event.TargetClientID == "" {
// Fall back to the routing key: `{prefix}.{client_id}` -> client_id.
if idx := strings.LastIndex(delivery.RoutingKey, "."); idx >= 0 && idx < len(delivery.RoutingKey)-1 {
event.TargetClientID = delivery.RoutingKey[idx+1:]
}
}
h.Publish(event)
}
@@ -0,0 +1,135 @@
package stream
import (
"testing"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
func TestDispatchHub_PublishRoutesToLocalSubscriber(t *testing.T) {
t.Parallel()
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
sub, cancel := hub.Subscribe("client-a")
defer cancel()
event := DesktopDispatchEvent{
Type: "task_available",
TaskID: "task-1",
TargetClientID: "client-a",
Kind: "publish",
Status: "queued",
}
hub.Publish(event)
select {
case got := <-sub.Outbound():
if got.TaskID != "task-1" {
t.Fatalf("unexpected task id: %s", got.TaskID)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("expected to receive event within 200ms")
}
}
func TestDispatchHub_DropsEventsForUnknownClient(t *testing.T) {
t.Parallel()
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
// Subscribe for a different client, publish for another one.
_, cancel := hub.Subscribe("client-b")
defer cancel()
hub.Publish(DesktopDispatchEvent{
Type: "task_available",
TargetClientID: "client-a",
})
ids := hub.ActiveClientIDs()
if len(ids) != 1 || ids[0] != "client-b" {
t.Fatalf("expected only client-b subscribed, got %v", ids)
}
}
func TestDispatchHub_CancelRemovesSubscriber(t *testing.T) {
t.Parallel()
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
sub, cancel := hub.Subscribe("client-a")
cancel()
// Subsequent publish must not panic even though the subscriber is gone;
// channel is closed, reads must yield zero value with !ok.
hub.Publish(DesktopDispatchEvent{TargetClientID: "client-a"})
select {
case _, ok := <-sub.Outbound():
if ok {
t.Fatal("expected outbound channel to be closed after cancel")
}
case <-time.After(50 * time.Millisecond):
t.Fatal("expected closed channel to yield immediately")
}
if ids := hub.ActiveClientIDs(); len(ids) != 0 {
t.Fatalf("expected no subscribers after cancel, got %v", ids)
}
}
func TestDispatchHub_MultipleSubscribersSameClient(t *testing.T) {
t.Parallel()
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
subA, cancelA := hub.Subscribe("client-a")
defer cancelA()
subB, cancelB := hub.Subscribe("client-a")
defer cancelB()
hub.Publish(DesktopDispatchEvent{
Type: "task_available",
TaskID: "task-1",
TargetClientID: "client-a",
})
for i, ch := range []<-chan DesktopDispatchEvent{subA.Outbound(), subB.Outbound()} {
select {
case got := <-ch:
if got.TaskID != "task-1" {
t.Fatalf("subscriber %d got unexpected task id: %s", i, got.TaskID)
}
case <-time.After(200 * time.Millisecond):
t.Fatalf("subscriber %d did not receive event", i)
}
}
}
func TestDispatchHub_HandleDeliveryFallsBackToRoutingKey(t *testing.T) {
t.Parallel()
hub := NewDesktopDispatchHub(nil, nil, []string{"publish.*"})
sub, cancel := hub.Subscribe("client-c")
defer cancel()
// Body intentionally omits target_client_id; the hub should recover it
// from the routing key "<prefix>.<client_id>".
delivery := amqp.Delivery{
RoutingKey: "publish.client-c",
Body: []byte(`{"type":"task_available","task_id":"task-9","kind":"publish","status":"queued"}`),
}
hub.handleDelivery(delivery)
select {
case got := <-sub.Outbound():
if got.TargetClientID != "client-c" {
t.Fatalf("expected fallback to set target client id, got %q", got.TargetClientID)
}
if got.TaskID != "task-9" {
t.Fatalf("unexpected task id: %s", got.TaskID)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("expected fallback-routed event")
}
}
@@ -2,7 +2,9 @@ package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -16,6 +18,7 @@ import (
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/shared/stream"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
@@ -232,6 +235,23 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
}); publishErr != nil {
s.logWarn("desktop task available event publish failed", publishErr, zap.String("task_id", task.DesktopID.String()))
}
s.publishDispatchAvailable(task, stream.DesktopDispatchEvent{
Type: "task_available",
TaskID: task.DesktopID.String(),
JobID: task.JobID.String(),
WorkspaceID: task.WorkspaceID,
TargetAccountID: task.TargetAccountID.String(),
TargetClientID: task.TargetClientID.String(),
Platform: task.Platform,
Title: title,
BusinessDate: businessDate,
SchedulerGroupKey: schedulerGroupKey,
QuestionText: questionText,
Status: task.Status,
Kind: task.Kind,
UpdatedAt: task.UpdatedAt,
})
}
return &CreatePublishJobResponse{
@@ -351,6 +371,43 @@ func resolveRetryArticleIDFromPayload(payload map[string]any) (int64, bool) {
return 0, false
}
// publishDispatchAvailable pushes a per-client dispatch frame onto the topic
// exchange. Every tenant-api instance consumes the exchange and forwards the
// frame to the WebSocket owning the target client id. This is the primary
// signal the desktop client reacts to; the fanout event above is kept only so
// that other UIs (admin-web) see the same task lifecycle.
func (s *PublishJobService) publishDispatchAvailable(task *repository.DesktopTask, event stream.DesktopDispatchEvent) {
if s == nil || s.messaging == nil || task == nil {
return
}
cfg := s.messaging.Config()
prefix := strings.TrimSpace(cfg.DesktopDispatchPublishPrefix)
if prefix == "" {
prefix = "publish"
}
targetClientID := task.TargetClientID.String()
if targetClientID == "" {
return
}
routingKey := fmt.Sprintf("%s.%s", prefix, targetClientID)
body, err := json.Marshal(event)
if err != nil {
s.logWarn("desktop dispatch event encode failed", err,
zap.String("task_id", task.DesktopID.String()),
)
return
}
if err := s.messaging.PublishDesktopDispatch(context.Background(), routingKey, body); err != nil {
s.logWarn("desktop dispatch publish failed", err,
zap.String("task_id", task.DesktopID.String()),
zap.String("routing_key", routingKey),
)
}
}
func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Field) {
if s.logger == nil || err == nil {
return
@@ -0,0 +1,141 @@
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)
}
@@ -29,6 +29,7 @@ func RegisterRoutes(app *bootstrap.App) {
desktopAccountHandler := NewDesktopAccountHandler(app)
desktopTaskHandler := NewDesktopTaskHandler(app)
desktopEventsHandler := NewDesktopEventsHandler(app)
desktopDispatchHandler := NewDesktopDispatchHandler(app)
desktopContentHandler := NewDesktopContentHandler(app)
publishJobHandler := NewPublishJobHandler(app)
protected.POST("/desktop/clients/register", desktopClientHandler.Register)
@@ -40,6 +41,7 @@ func RegisterRoutes(app *bootstrap.App) {
desktopAuth.POST("/clients/offline", desktopClientHandler.Offline)
desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke)
desktopAuth.GET("/events", desktopEventsHandler.Stream)
desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch)
desktopAuth.GET("/accounts", desktopAccountHandler.List)
desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article)
desktopAuth.GET("/publish-tasks", desktopTaskHandler.ListPublish)