@vira-ui/cli 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +454 -1029
- package/dist/go/appYaml.js +30 -30
- package/dist/go/backendEnvExample.js +17 -17
- package/dist/go/backendReadme.js +14 -14
- package/dist/go/channelHelpers.js +25 -25
- package/dist/go/configGo.js +258 -258
- package/dist/go/dbGo.js +43 -43
- package/dist/go/dbYaml.js +7 -7
- package/dist/go/dockerCompose.js +48 -48
- package/dist/go/dockerComposeProd.js +78 -78
- package/dist/go/dockerfile.js +15 -15
- package/dist/go/eventHandlerTemplate.js +22 -22
- package/dist/go/eventsAPI.js +411 -411
- package/dist/go/goMod.js +16 -16
- package/dist/go/kafkaGo.js +67 -67
- package/dist/go/kafkaYaml.js +6 -6
- package/dist/go/kanbanHandlers.js +216 -216
- package/dist/go/mainGo.js +558 -558
- package/dist/go/readme.js +27 -27
- package/dist/go/redisGo.js +31 -31
- package/dist/go/redisYaml.js +4 -4
- package/dist/go/registryGo.js +38 -38
- package/dist/go/sqlcYaml.js +13 -13
- package/dist/go/stateStore.js +115 -115
- package/dist/go/typesGo.js +11 -11
- package/dist/index.js +472 -24
- package/dist/react/envExample.js +3 -3
- package/dist/react/envLocal.js +1 -1
- package/dist/react/indexCss.js +17 -17
- package/dist/react/indexHtml.js +12 -12
- package/dist/react/kanbanAppTsx.js +29 -29
- package/dist/react/kanbanBoard.js +58 -58
- package/dist/react/kanbanCard.js +60 -60
- package/dist/react/kanbanColumn.js +62 -62
- package/dist/react/kanbanModels.js +32 -32
- package/dist/react/mainTsx.js +12 -12
- package/dist/react/viteConfig.js +27 -27
- package/package.json +47 -45
- package/dist/go/useViraState.js +0 -160
- package/dist/go/useViraStream.js +0 -167
package/dist/go/mainGo.js
CHANGED
|
@@ -1,562 +1,562 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.mainGo = void 0;
|
|
4
|
-
exports.mainGo = `package main
|
|
5
|
-
|
|
6
|
-
import (
|
|
7
|
-
"context"
|
|
8
|
-
"encoding/json"
|
|
9
|
-
"errors"
|
|
10
|
-
"fmt"
|
|
11
|
-
"net/http"
|
|
12
|
-
"os/signal"
|
|
13
|
-
"strconv"
|
|
14
|
-
"strings"
|
|
15
|
-
"sync"
|
|
16
|
-
"syscall"
|
|
17
|
-
"time"
|
|
18
|
-
|
|
19
|
-
"github.com/go-chi/chi/v5"
|
|
20
|
-
"github.com/go-chi/chi/v5/middleware"
|
|
21
|
-
"github.com/go-chi/cors"
|
|
22
|
-
"github.com/google/uuid"
|
|
23
|
-
"github.com/gorilla/websocket"
|
|
24
|
-
"github.com/rs/zerolog"
|
|
25
|
-
|
|
26
|
-
"vira-engine-backend/internal/cache"
|
|
27
|
-
"vira-engine-backend/internal/config"
|
|
28
|
-
"vira-engine-backend/internal/db"
|
|
29
|
-
"vira-engine-backend/internal/events"
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
type ctxKey string
|
|
33
|
-
|
|
34
|
-
const (
|
|
35
|
-
ctxReqID ctxKey = "reqID"
|
|
36
|
-
ctxUserID ctxKey = "userID"
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
type healthResponse struct {
|
|
40
|
-
Status string \`json:"status"\`
|
|
41
|
-
Time string \`json:"time"\`
|
|
42
|
-
DB string \`json:"db"\`
|
|
43
|
-
Redis string \`json:"redis"\`
|
|
44
|
-
Kafka string \`json:"kafka"\`
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// wsHub wraps events.Hub with WebSocket connection management.
|
|
48
|
-
type wsHub struct {
|
|
49
|
-
*events.Hub
|
|
50
|
-
mu sync.Mutex
|
|
51
|
-
clients map[*websocket.Conn]bool
|
|
52
|
-
subs map[string]map[*websocket.Conn]bool
|
|
53
|
-
sessions map[*websocket.Conn]string
|
|
54
|
-
sessionSubs map[string][]string // session -> channels (for persist/reconnect)
|
|
55
|
-
connMu map[*websocket.Conn]*sync.Mutex
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
func newWSHub(eventHub *events.Hub) *wsHub {
|
|
59
|
-
return &wsHub{
|
|
60
|
-
Hub: eventHub,
|
|
61
|
-
clients: make(map[*websocket.Conn]bool),
|
|
62
|
-
subs: make(map[string]map[*websocket.Conn]bool),
|
|
63
|
-
sessions: make(map[*websocket.Conn]string),
|
|
64
|
-
sessionSubs: make(map[string][]string),
|
|
65
|
-
connMu: make(map[*websocket.Conn]*sync.Mutex),
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
func (h *wsHub) add(c *websocket.Conn) {
|
|
70
|
-
h.mu.Lock()
|
|
71
|
-
h.clients[c] = true
|
|
72
|
-
h.connMu[c] = &sync.Mutex{}
|
|
73
|
-
h.mu.Unlock()
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
func (h *wsHub) remove(c *websocket.Conn) {
|
|
77
|
-
h.mu.Lock()
|
|
78
|
-
delete(h.clients, c)
|
|
79
|
-
for ch, set := range h.subs {
|
|
80
|
-
delete(set, c)
|
|
81
|
-
if len(set) == 0 {
|
|
82
|
-
delete(h.subs, ch)
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
delete(h.sessions, c)
|
|
86
|
-
delete(h.connMu, c)
|
|
87
|
-
h.mu.Unlock()
|
|
88
|
-
_ = c.Close()
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// write serializes writes per connection to avoid concurrent writers.
|
|
92
|
-
func (h *wsHub) write(c *websocket.Conn, msgType int, data []byte) error {
|
|
93
|
-
h.mu.Lock()
|
|
94
|
-
mu := h.connMu[c]
|
|
95
|
-
h.mu.Unlock()
|
|
96
|
-
if mu == nil {
|
|
97
|
-
return errors.New("conn mutex missing")
|
|
98
|
-
}
|
|
99
|
-
mu.Lock()
|
|
100
|
-
defer mu.Unlock()
|
|
101
|
-
return c.WriteMessage(msgType, data)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
func (h *wsHub) setSession(c *websocket.Conn, session string) {
|
|
105
|
-
h.mu.Lock()
|
|
106
|
-
h.sessions[c] = session
|
|
107
|
-
// Restore previous subscriptions if session exists
|
|
108
|
-
if subs, ok := h.sessionSubs[session]; ok {
|
|
109
|
-
for _, ch := range subs {
|
|
110
|
-
if _, exists := h.subs[ch]; !exists {
|
|
111
|
-
h.subs[ch] = make(map[*websocket.Conn]bool)
|
|
112
|
-
}
|
|
113
|
-
h.subs[ch][c] = true
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
h.mu.Unlock()
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
func (h *wsHub) subscribe(c *websocket.Conn, channel string) {
|
|
120
|
-
h.mu.Lock()
|
|
121
|
-
if _, ok := h.subs[channel]; !ok {
|
|
122
|
-
h.subs[channel] = make(map[*websocket.Conn]bool)
|
|
123
|
-
}
|
|
124
|
-
h.subs[channel][c] = true
|
|
125
|
-
session := h.sessions[c]
|
|
126
|
-
if session != "" {
|
|
127
|
-
// Persist subscription for session
|
|
128
|
-
subs := h.sessionSubs[session]
|
|
129
|
-
found := false
|
|
130
|
-
for _, ch := range subs {
|
|
131
|
-
if ch == channel {
|
|
132
|
-
found = true
|
|
133
|
-
break
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
if !found {
|
|
137
|
-
h.sessionSubs[session] = append(subs, channel)
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
h.mu.Unlock()
|
|
141
|
-
|
|
142
|
-
// Send snapshot if available
|
|
143
|
-
if snap, version, ok := h.Snapshot(channel); ok {
|
|
144
|
-
msg := events.WSMessage{
|
|
145
|
-
Type: "update",
|
|
146
|
-
Channel: channel,
|
|
147
|
-
Data: snap,
|
|
148
|
-
VersionNo: version,
|
|
149
|
-
Ts: time.Now().UnixMilli(),
|
|
150
|
-
}
|
|
151
|
-
raw, _ := json.Marshal(msg)
|
|
152
|
-
_ = h.write(c, websocket.TextMessage, raw)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
func (h *wsHub) unsubscribe(c *websocket.Conn, channel string) {
|
|
157
|
-
h.mu.Lock()
|
|
158
|
-
if set, ok := h.subs[channel]; ok {
|
|
159
|
-
delete(set, c)
|
|
160
|
-
if len(set) == 0 {
|
|
161
|
-
delete(h.subs, channel)
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
h.mu.Unlock()
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
func (h *wsHub) broadcast(channel string, raw json.RawMessage) {
|
|
168
|
-
h.mu.Lock()
|
|
169
|
-
targets := h.subs[channel]
|
|
170
|
-
h.mu.Unlock()
|
|
171
|
-
|
|
172
|
-
if len(targets) == 0 {
|
|
173
|
-
return
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
for c := range targets {
|
|
177
|
-
if err := h.write(c, websocket.TextMessage, raw); err != nil {
|
|
178
|
-
_ = c.Close()
|
|
179
|
-
h.remove(c)
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
func handleDemoEvent(ctx context.Context, hub events.EventEmitter, conn *websocket.Conn, msg events.WSMessage) {
|
|
185
|
-
var payload map[string]any
|
|
186
|
-
if len(msg.Data) > 0 {
|
|
187
|
-
_ = json.Unmarshal(msg.Data, &payload)
|
|
188
|
-
}
|
|
189
|
-
ch := "demo"
|
|
190
|
-
if v, ok := payload["channel"].(string); ok && v != "" {
|
|
191
|
-
ch = v
|
|
192
|
-
}
|
|
193
|
-
hub.Update(ch, payload)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
func init() {
|
|
197
|
-
events.Register("demo.echo", handleDemoEvent)
|
|
198
|
-
// Kanban handlers are auto-registered via registry_kanban.go (generated by CLI)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
var upgrader = websocket.Upgrader{
|
|
202
|
-
CheckOrigin: func(r *http.Request) bool { return true },
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
func main() {
|
|
206
|
-
cfg := config.Load("config/app.yaml")
|
|
207
|
-
logger := config.NewLogger(cfg)
|
|
208
|
-
eventHub := events.NewHub()
|
|
209
|
-
hub := newWSHub(eventHub)
|
|
210
|
-
// Connect Hub's broadcast to wsHub's broadcast
|
|
211
|
-
events.SetBroadcaster(hub.broadcast)
|
|
212
|
-
// Apply state settings
|
|
213
|
-
if strings.ToLower(cfg.State.DiffMode) == "patch" {
|
|
214
|
-
eventHub.SetDiffMode(events.DiffModePatch)
|
|
215
|
-
}
|
|
216
|
-
if cfg.State.MaxHistory > 0 {
|
|
217
|
-
eventHub.SetHistoryLimit(cfg.State.MaxHistory)
|
|
218
|
-
}
|
|
219
|
-
eventHub.ApplyRegistry() // Apply auto-registered handlers
|
|
220
|
-
|
|
221
|
-
r := chi.NewRouter()
|
|
222
|
-
r.Use(middleware.RequestID)
|
|
223
|
-
r.Use(middleware.RealIP)
|
|
224
|
-
r.Use(middleware.Recoverer)
|
|
225
|
-
r.Use(cors.Handler(cors.Options{
|
|
226
|
-
AllowedOrigins: []string{"*"},
|
|
227
|
-
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
228
|
-
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-User-ID", "X-Requested-With"},
|
|
229
|
-
ExposedHeaders: []string{"Link"},
|
|
230
|
-
AllowCredentials: false,
|
|
231
|
-
MaxAge: 300,
|
|
232
|
-
}))
|
|
233
|
-
r.Use(requestContext(logger))
|
|
234
|
-
r.Use(httpLogger(logger))
|
|
235
|
-
|
|
236
|
-
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
237
|
-
defer stop()
|
|
238
|
-
|
|
239
|
-
pool, err := db.NewPool(ctx, cfg, logger)
|
|
240
|
-
if err != nil {
|
|
241
|
-
logger.Fatal().Err(err).Msg("failed to init db pool")
|
|
242
|
-
}
|
|
243
|
-
defer pool.Close()
|
|
244
|
-
|
|
245
|
-
redisClient, err := cache.NewRedisClient(ctx, cfg, logger)
|
|
246
|
-
if err != nil {
|
|
247
|
-
logger.Fatal().Err(err).Msg("failed to init redis client")
|
|
248
|
-
}
|
|
249
|
-
defer redisClient.Close()
|
|
250
|
-
|
|
251
|
-
// Configure persistence
|
|
252
|
-
if strings.ToLower(cfg.State.Persist) == "redis" {
|
|
253
|
-
eventHub.SetStore(events.NewRedisStore(redisClient))
|
|
254
|
-
logger.Info().Msg("state persistence enabled: redis")
|
|
255
|
-
}
|
|
256
|
-
if cfg.State.TTLSec > 0 {
|
|
257
|
-
eventHub.SetTTL(cfg.State.TTLSec)
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
kafkaClient, err := events.NewKafka(cfg, logger)
|
|
261
|
-
if err != nil {
|
|
262
|
-
logger.Fatal().Err(err).Msg("failed to init kafka client")
|
|
263
|
-
}
|
|
264
|
-
defer kafkaClient.Close()
|
|
265
|
-
|
|
266
|
-
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
267
|
-
dbStatus := "ok"
|
|
268
|
-
if err := pool.Ping(r.Context()); err != nil {
|
|
269
|
-
dbStatus = fmt.Sprintf("error: %v", err)
|
|
270
|
-
}
|
|
271
|
-
redisStatus := "ok"
|
|
272
|
-
if _, err := redisClient.Ping(r.Context()).Result(); err != nil {
|
|
273
|
-
redisStatus = fmt.Sprintf("error: %v", err)
|
|
274
|
-
}
|
|
275
|
-
kafkaStatus := "ok"
|
|
276
|
-
if err := kafkaClient.Ping(r.Context()); err != nil {
|
|
277
|
-
kafkaStatus = fmt.Sprintf("error: %v", err)
|
|
278
|
-
}
|
|
279
|
-
respondJSON(w, http.StatusOK, healthResponse{
|
|
280
|
-
Status: "ok",
|
|
281
|
-
Time: time.Now().UTC().Format(time.RFC3339),
|
|
282
|
-
DB: dbStatus,
|
|
283
|
-
Redis: redisStatus,
|
|
284
|
-
Kafka: kafkaStatus,
|
|
285
|
-
})
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
// Debug: current state snapshot
|
|
289
|
-
r.Get("/api/state/{channel}", func(w http.ResponseWriter, r *http.Request) {
|
|
290
|
-
ch := chi.URLParam(r, "channel")
|
|
291
|
-
if ch == "" {
|
|
292
|
-
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "channel required"})
|
|
293
|
-
return
|
|
294
|
-
}
|
|
295
|
-
snap, ver, ok := eventHub.Snapshot(ch)
|
|
296
|
-
if !ok {
|
|
297
|
-
respondJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
298
|
-
return
|
|
299
|
-
}
|
|
300
|
-
respondJSON(w, http.StatusOK, map[string]any{
|
|
301
|
-
"channel": ch,
|
|
302
|
-
"versionNo": ver,
|
|
303
|
-
"data": json.RawMessage(snap),
|
|
304
|
-
})
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
// Debug: replay from version
|
|
308
|
-
r.Get("/api/replay/{channel}", func(w http.ResponseWriter, r *http.Request) {
|
|
309
|
-
ch := chi.URLParam(r, "channel")
|
|
310
|
-
if ch == "" {
|
|
311
|
-
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "channel required"})
|
|
312
|
-
return
|
|
313
|
-
}
|
|
314
|
-
fromStr := r.URL.Query().Get("from")
|
|
315
|
-
var from int64 = 0
|
|
316
|
-
if fromStr != "" {
|
|
317
|
-
if v, err := strconv.ParseInt(fromStr, 10, 64); err == nil {
|
|
318
|
-
from = v
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
snapshots := eventHub.Replay(ch, from)
|
|
322
|
-
respondJSON(w, http.StatusOK, map[string]any{
|
|
323
|
-
"channel": ch,
|
|
324
|
-
"from": from,
|
|
325
|
-
"snapshots": snapshots,
|
|
326
|
-
})
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
|
|
330
|
-
conn, err := upgrader.Upgrade(w, r, nil)
|
|
331
|
-
if err != nil {
|
|
332
|
-
logger.Error().Err(err).Msg("upgrade ws")
|
|
333
|
-
return
|
|
334
|
-
}
|
|
335
|
-
hub.add(conn)
|
|
336
|
-
logger.Info().Msg("ws connected")
|
|
337
|
-
|
|
338
|
-
go func(c *websocket.Conn) {
|
|
339
|
-
pingInterval := 15 * time.Second
|
|
340
|
-
ping := time.NewTicker(pingInterval)
|
|
341
|
-
defer ping.Stop()
|
|
342
|
-
defer hub.remove(c)
|
|
343
|
-
|
|
344
|
-
// Handle ping in separate goroutine
|
|
345
|
-
go func() {
|
|
346
|
-
for range ping.C {
|
|
347
|
-
if err := hub.write(c, websocket.TextMessage, []byte(fmt.Sprintf(\`{"type":"ping","ts":%d}\`, time.Now().UnixMilli()))); err != nil {
|
|
348
|
-
return
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}()
|
|
352
|
-
|
|
353
|
-
// Read messages in main goroutine
|
|
354
|
-
for {
|
|
355
|
-
_, msg, err := c.ReadMessage()
|
|
356
|
-
if err != nil {
|
|
357
|
-
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
358
|
-
logger.Warn().Err(err).Msg("ws read error unexpected")
|
|
359
|
-
} else {
|
|
360
|
-
logger.Info().Err(err).Msg("ws read closed")
|
|
361
|
-
}
|
|
362
|
-
return
|
|
363
|
-
}
|
|
364
|
-
var m events.WSMessage
|
|
365
|
-
if err := json.Unmarshal(msg, &m); err != nil {
|
|
366
|
-
logger.Warn().Err(err).Msg("ws decode")
|
|
367
|
-
continue
|
|
368
|
-
}
|
|
369
|
-
switch m.Type {
|
|
370
|
-
case "sub":
|
|
371
|
-
for _, ch := range m.Channels {
|
|
372
|
-
if ch == "" {
|
|
373
|
-
continue
|
|
374
|
-
}
|
|
375
|
-
hub.subscribe(c, ch)
|
|
376
|
-
}
|
|
377
|
-
ack, _ := json.Marshal(events.WSMessage{Type: "sub_ack", Channels: m.Channels})
|
|
378
|
-
_ = hub.write(c, websocket.TextMessage, ack)
|
|
379
|
-
case "unsub":
|
|
380
|
-
for _, ch := range m.Channels {
|
|
381
|
-
if ch == "" {
|
|
382
|
-
continue
|
|
383
|
-
}
|
|
384
|
-
hub.unsubscribe(c, ch)
|
|
385
|
-
}
|
|
386
|
-
ack, _ := json.Marshal(events.WSMessage{Type: "unsub_ack", Channels: m.Channels})
|
|
387
|
-
_ = hub.write(c, websocket.TextMessage, ack)
|
|
388
|
-
case "handshake":
|
|
389
|
-
// Validate protocol version
|
|
390
|
-
if m.Version != "" && m.Version != events.ProtocolVersion() {
|
|
391
|
-
errMsg, _ := json.Marshal(events.WSMessage{
|
|
392
|
-
Type: "error",
|
|
393
|
-
Code: "version_mismatch",
|
|
394
|
-
Message: fmt.Sprintf("Protocol version mismatch: client=%s, server=%s", m.Version, events.ProtocolVersion()),
|
|
395
|
-
Retry: false,
|
|
396
|
-
})
|
|
397
|
-
_ = hub.write(c, websocket.TextMessage, errMsg)
|
|
398
|
-
c.Close()
|
|
399
|
-
return
|
|
400
|
-
}
|
|
401
|
-
// Auth token check
|
|
402
|
-
if cfg.Auth.Token != "" && m.Auth != cfg.Auth.Token {
|
|
403
|
-
errMsg, _ := json.Marshal(events.WSMessage{
|
|
404
|
-
Type: "error",
|
|
405
|
-
Code: "unauthorized",
|
|
406
|
-
Message: "Invalid auth token",
|
|
407
|
-
Retry: false,
|
|
408
|
-
})
|
|
409
|
-
_ = hub.write(c, websocket.TextMessage, errMsg)
|
|
410
|
-
c.Close()
|
|
411
|
-
return
|
|
412
|
-
}
|
|
413
|
-
session := m.Session
|
|
414
|
-
if session == "" {
|
|
415
|
-
session = uuid.NewString()
|
|
416
|
-
}
|
|
417
|
-
hub.setSession(c, session)
|
|
418
|
-
ack, _ := json.Marshal(events.WSMessage{
|
|
419
|
-
Type: "ack",
|
|
420
|
-
Session: session,
|
|
421
|
-
Interval: pingInterval.Milliseconds(),
|
|
422
|
-
Version: events.ProtocolVersion(),
|
|
423
|
-
Ts: time.Now().UnixMilli(),
|
|
424
|
-
})
|
|
425
|
-
_ = hub.write(c, websocket.TextMessage, ack)
|
|
426
|
-
case "event":
|
|
427
|
-
if m.Name != "" {
|
|
428
|
-
// Check idempotency (msgId dedup)
|
|
429
|
-
if m.MsgID != "" && eventHub.CheckMsgID(m.MsgID) {
|
|
430
|
-
// Duplicate message, ignore
|
|
431
|
-
continue
|
|
432
|
-
}
|
|
433
|
-
if handler, ok := eventHub.Get(m.Name); ok {
|
|
434
|
-
handler(context.Background(), eventHub, c, m)
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
case "update":
|
|
438
|
-
if m.Channel != "" {
|
|
439
|
-
// Check idempotency
|
|
440
|
-
if m.MsgID != "" && eventHub.CheckMsgID(m.MsgID) {
|
|
441
|
-
continue
|
|
442
|
-
}
|
|
443
|
-
var payload any
|
|
444
|
-
if len(m.Data) > 0 {
|
|
445
|
-
_ = json.Unmarshal(m.Data, &payload)
|
|
446
|
-
}
|
|
447
|
-
eventHub.Update(m.Channel, payload)
|
|
448
|
-
}
|
|
449
|
-
case "diff":
|
|
450
|
-
if m.Channel != "" {
|
|
451
|
-
// Check idempotency
|
|
452
|
-
if m.MsgID != "" && eventHub.CheckMsgID(m.MsgID) {
|
|
453
|
-
continue
|
|
454
|
-
}
|
|
455
|
-
var patch any
|
|
456
|
-
if len(m.Patch) > 0 {
|
|
457
|
-
_ = json.Unmarshal(m.Patch, &patch)
|
|
458
|
-
}
|
|
459
|
-
eventHub.Diff(m.Channel, patch)
|
|
460
|
-
}
|
|
461
|
-
case "ping":
|
|
462
|
-
pong, _ := json.Marshal(events.WSMessage{Type: "pong", Ts: time.Now().UnixMilli()})
|
|
463
|
-
if err := hub.write(c, websocket.TextMessage, pong); err != nil {
|
|
464
|
-
logger.Warn().Err(err).Msg("ws write pong error")
|
|
465
|
-
return
|
|
466
|
-
}
|
|
467
|
-
case "pong":
|
|
468
|
-
// Client acknowledged ping, connection is healthy
|
|
469
|
-
default:
|
|
470
|
-
// ignore unknown
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}(conn)
|
|
474
|
-
})
|
|
475
|
-
|
|
476
|
-
r.Post("/api/demo", func(w http.ResponseWriter, r *http.Request) {
|
|
477
|
-
var payload map[string]any
|
|
478
|
-
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
479
|
-
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
|
480
|
-
return
|
|
481
|
-
}
|
|
482
|
-
ch := "demo"
|
|
483
|
-
if v, ok := payload["channel"].(string); ok && v != "" {
|
|
484
|
-
ch = v
|
|
485
|
-
}
|
|
486
|
-
eventHub.Emit(ch, payload)
|
|
487
|
-
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
488
|
-
})
|
|
489
|
-
|
|
490
|
-
srv := &http.Server{
|
|
491
|
-
Addr: httpAddr(cfg.HTTP.Port),
|
|
492
|
-
Handler: r,
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
logger.Info().Str("addr", srv.Addr).Msg("Vira Engine stub API listening")
|
|
496
|
-
go func() {
|
|
497
|
-
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
498
|
-
logger.Fatal().Err(err).Msg("server error")
|
|
499
|
-
}
|
|
500
|
-
}()
|
|
501
|
-
|
|
502
|
-
<-ctx.Done()
|
|
503
|
-
logger.Info().Msg("shutting down...")
|
|
504
|
-
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
505
|
-
defer cancel()
|
|
506
|
-
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
507
|
-
logger.Fatal().Err(err).Msg("server shutdown error")
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
func requestContext(logger zerolog.Logger) func(next http.Handler) http.Handler {
|
|
512
|
-
return func(next http.Handler) http.Handler {
|
|
513
|
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
514
|
-
reqID := middleware.GetReqID(r.Context())
|
|
515
|
-
userID := r.Header.Get("X-User-ID")
|
|
516
|
-
|
|
517
|
-
ctx := context.WithValue(r.Context(), ctxReqID, reqID)
|
|
518
|
-
if userID != "" {
|
|
519
|
-
ctx = context.WithValue(ctx, ctxUserID, userID)
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
next.ServeHTTP(w, r.WithContext(ctx))
|
|
523
|
-
})
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
func httpLogger(logger zerolog.Logger) func(next http.Handler) http.Handler {
|
|
528
|
-
return func(next http.Handler) http.Handler {
|
|
529
|
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
530
|
-
started := time.Now()
|
|
531
|
-
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
532
|
-
|
|
533
|
-
next.ServeHTTP(ww, r)
|
|
534
|
-
|
|
535
|
-
evt := logger.Info()
|
|
536
|
-
if reqID, ok := r.Context().Value(ctxReqID).(string); ok && reqID != "" {
|
|
537
|
-
evt = evt.Str("reqID", reqID)
|
|
538
|
-
}
|
|
539
|
-
if userID, ok := r.Context().Value(ctxUserID).(string); ok && userID != "" {
|
|
540
|
-
evt = evt.Str("userID", userID)
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
evt.
|
|
544
|
-
Str("method", r.Method).
|
|
545
|
-
Str("path", r.URL.Path).
|
|
546
|
-
Int("status", ww.Status()).
|
|
547
|
-
Dur("latency", time.Since(started)).
|
|
548
|
-
Msg("http_request")
|
|
549
|
-
})
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
func respondJSON(w http.ResponseWriter, status int, payload any) {
|
|
554
|
-
w.Header().Set("Content-Type", "application/json")
|
|
555
|
-
w.WriteHeader(status)
|
|
556
|
-
_ = json.NewEncoder(w).Encode(payload)
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
func httpAddr(port int) string {
|
|
560
|
-
return ":" + strconv.Itoa(port)
|
|
561
|
-
}
|
|
4
|
+
exports.mainGo = `package main
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"context"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"errors"
|
|
10
|
+
"fmt"
|
|
11
|
+
"net/http"
|
|
12
|
+
"os/signal"
|
|
13
|
+
"strconv"
|
|
14
|
+
"strings"
|
|
15
|
+
"sync"
|
|
16
|
+
"syscall"
|
|
17
|
+
"time"
|
|
18
|
+
|
|
19
|
+
"github.com/go-chi/chi/v5"
|
|
20
|
+
"github.com/go-chi/chi/v5/middleware"
|
|
21
|
+
"github.com/go-chi/cors"
|
|
22
|
+
"github.com/google/uuid"
|
|
23
|
+
"github.com/gorilla/websocket"
|
|
24
|
+
"github.com/rs/zerolog"
|
|
25
|
+
|
|
26
|
+
"vira-engine-backend/internal/cache"
|
|
27
|
+
"vira-engine-backend/internal/config"
|
|
28
|
+
"vira-engine-backend/internal/db"
|
|
29
|
+
"vira-engine-backend/internal/events"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
type ctxKey string
|
|
33
|
+
|
|
34
|
+
const (
|
|
35
|
+
ctxReqID ctxKey = "reqID"
|
|
36
|
+
ctxUserID ctxKey = "userID"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
type healthResponse struct {
|
|
40
|
+
Status string \`json:"status"\`
|
|
41
|
+
Time string \`json:"time"\`
|
|
42
|
+
DB string \`json:"db"\`
|
|
43
|
+
Redis string \`json:"redis"\`
|
|
44
|
+
Kafka string \`json:"kafka"\`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// wsHub wraps events.Hub with WebSocket connection management.
|
|
48
|
+
type wsHub struct {
|
|
49
|
+
*events.Hub
|
|
50
|
+
mu sync.Mutex
|
|
51
|
+
clients map[*websocket.Conn]bool
|
|
52
|
+
subs map[string]map[*websocket.Conn]bool
|
|
53
|
+
sessions map[*websocket.Conn]string
|
|
54
|
+
sessionSubs map[string][]string // session -> channels (for persist/reconnect)
|
|
55
|
+
connMu map[*websocket.Conn]*sync.Mutex
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func newWSHub(eventHub *events.Hub) *wsHub {
|
|
59
|
+
return &wsHub{
|
|
60
|
+
Hub: eventHub,
|
|
61
|
+
clients: make(map[*websocket.Conn]bool),
|
|
62
|
+
subs: make(map[string]map[*websocket.Conn]bool),
|
|
63
|
+
sessions: make(map[*websocket.Conn]string),
|
|
64
|
+
sessionSubs: make(map[string][]string),
|
|
65
|
+
connMu: make(map[*websocket.Conn]*sync.Mutex),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func (h *wsHub) add(c *websocket.Conn) {
|
|
70
|
+
h.mu.Lock()
|
|
71
|
+
h.clients[c] = true
|
|
72
|
+
h.connMu[c] = &sync.Mutex{}
|
|
73
|
+
h.mu.Unlock()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func (h *wsHub) remove(c *websocket.Conn) {
|
|
77
|
+
h.mu.Lock()
|
|
78
|
+
delete(h.clients, c)
|
|
79
|
+
for ch, set := range h.subs {
|
|
80
|
+
delete(set, c)
|
|
81
|
+
if len(set) == 0 {
|
|
82
|
+
delete(h.subs, ch)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
delete(h.sessions, c)
|
|
86
|
+
delete(h.connMu, c)
|
|
87
|
+
h.mu.Unlock()
|
|
88
|
+
_ = c.Close()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// write serializes writes per connection to avoid concurrent writers.
|
|
92
|
+
func (h *wsHub) write(c *websocket.Conn, msgType int, data []byte) error {
|
|
93
|
+
h.mu.Lock()
|
|
94
|
+
mu := h.connMu[c]
|
|
95
|
+
h.mu.Unlock()
|
|
96
|
+
if mu == nil {
|
|
97
|
+
return errors.New("conn mutex missing")
|
|
98
|
+
}
|
|
99
|
+
mu.Lock()
|
|
100
|
+
defer mu.Unlock()
|
|
101
|
+
return c.WriteMessage(msgType, data)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func (h *wsHub) setSession(c *websocket.Conn, session string) {
|
|
105
|
+
h.mu.Lock()
|
|
106
|
+
h.sessions[c] = session
|
|
107
|
+
// Restore previous subscriptions if session exists
|
|
108
|
+
if subs, ok := h.sessionSubs[session]; ok {
|
|
109
|
+
for _, ch := range subs {
|
|
110
|
+
if _, exists := h.subs[ch]; !exists {
|
|
111
|
+
h.subs[ch] = make(map[*websocket.Conn]bool)
|
|
112
|
+
}
|
|
113
|
+
h.subs[ch][c] = true
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
h.mu.Unlock()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func (h *wsHub) subscribe(c *websocket.Conn, channel string) {
|
|
120
|
+
h.mu.Lock()
|
|
121
|
+
if _, ok := h.subs[channel]; !ok {
|
|
122
|
+
h.subs[channel] = make(map[*websocket.Conn]bool)
|
|
123
|
+
}
|
|
124
|
+
h.subs[channel][c] = true
|
|
125
|
+
session := h.sessions[c]
|
|
126
|
+
if session != "" {
|
|
127
|
+
// Persist subscription for session
|
|
128
|
+
subs := h.sessionSubs[session]
|
|
129
|
+
found := false
|
|
130
|
+
for _, ch := range subs {
|
|
131
|
+
if ch == channel {
|
|
132
|
+
found = true
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if !found {
|
|
137
|
+
h.sessionSubs[session] = append(subs, channel)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
h.mu.Unlock()
|
|
141
|
+
|
|
142
|
+
// Send snapshot if available
|
|
143
|
+
if snap, version, ok := h.Snapshot(channel); ok {
|
|
144
|
+
msg := events.WSMessage{
|
|
145
|
+
Type: "update",
|
|
146
|
+
Channel: channel,
|
|
147
|
+
Data: snap,
|
|
148
|
+
VersionNo: version,
|
|
149
|
+
Ts: time.Now().UnixMilli(),
|
|
150
|
+
}
|
|
151
|
+
raw, _ := json.Marshal(msg)
|
|
152
|
+
_ = h.write(c, websocket.TextMessage, raw)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func (h *wsHub) unsubscribe(c *websocket.Conn, channel string) {
|
|
157
|
+
h.mu.Lock()
|
|
158
|
+
if set, ok := h.subs[channel]; ok {
|
|
159
|
+
delete(set, c)
|
|
160
|
+
if len(set) == 0 {
|
|
161
|
+
delete(h.subs, channel)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
h.mu.Unlock()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func (h *wsHub) broadcast(channel string, raw json.RawMessage) {
|
|
168
|
+
h.mu.Lock()
|
|
169
|
+
targets := h.subs[channel]
|
|
170
|
+
h.mu.Unlock()
|
|
171
|
+
|
|
172
|
+
if len(targets) == 0 {
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for c := range targets {
|
|
177
|
+
if err := h.write(c, websocket.TextMessage, raw); err != nil {
|
|
178
|
+
_ = c.Close()
|
|
179
|
+
h.remove(c)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
func handleDemoEvent(ctx context.Context, hub events.EventEmitter, conn *websocket.Conn, msg events.WSMessage) {
|
|
185
|
+
var payload map[string]any
|
|
186
|
+
if len(msg.Data) > 0 {
|
|
187
|
+
_ = json.Unmarshal(msg.Data, &payload)
|
|
188
|
+
}
|
|
189
|
+
ch := "demo"
|
|
190
|
+
if v, ok := payload["channel"].(string); ok && v != "" {
|
|
191
|
+
ch = v
|
|
192
|
+
}
|
|
193
|
+
hub.Update(ch, payload)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
func init() {
|
|
197
|
+
events.Register("demo.echo", handleDemoEvent)
|
|
198
|
+
// Kanban handlers are auto-registered via registry_kanban.go (generated by CLI)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
var upgrader = websocket.Upgrader{
|
|
202
|
+
CheckOrigin: func(r *http.Request) bool { return true },
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func main() {
|
|
206
|
+
cfg := config.Load("config/app.yaml")
|
|
207
|
+
logger := config.NewLogger(cfg)
|
|
208
|
+
eventHub := events.NewHub()
|
|
209
|
+
hub := newWSHub(eventHub)
|
|
210
|
+
// Connect Hub's broadcast to wsHub's broadcast
|
|
211
|
+
events.SetBroadcaster(hub.broadcast)
|
|
212
|
+
// Apply state settings
|
|
213
|
+
if strings.ToLower(cfg.State.DiffMode) == "patch" {
|
|
214
|
+
eventHub.SetDiffMode(events.DiffModePatch)
|
|
215
|
+
}
|
|
216
|
+
if cfg.State.MaxHistory > 0 {
|
|
217
|
+
eventHub.SetHistoryLimit(cfg.State.MaxHistory)
|
|
218
|
+
}
|
|
219
|
+
eventHub.ApplyRegistry() // Apply auto-registered handlers
|
|
220
|
+
|
|
221
|
+
r := chi.NewRouter()
|
|
222
|
+
r.Use(middleware.RequestID)
|
|
223
|
+
r.Use(middleware.RealIP)
|
|
224
|
+
r.Use(middleware.Recoverer)
|
|
225
|
+
r.Use(cors.Handler(cors.Options{
|
|
226
|
+
AllowedOrigins: []string{"*"},
|
|
227
|
+
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
228
|
+
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-User-ID", "X-Requested-With"},
|
|
229
|
+
ExposedHeaders: []string{"Link"},
|
|
230
|
+
AllowCredentials: false,
|
|
231
|
+
MaxAge: 300,
|
|
232
|
+
}))
|
|
233
|
+
r.Use(requestContext(logger))
|
|
234
|
+
r.Use(httpLogger(logger))
|
|
235
|
+
|
|
236
|
+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
237
|
+
defer stop()
|
|
238
|
+
|
|
239
|
+
pool, err := db.NewPool(ctx, cfg, logger)
|
|
240
|
+
if err != nil {
|
|
241
|
+
logger.Fatal().Err(err).Msg("failed to init db pool")
|
|
242
|
+
}
|
|
243
|
+
defer pool.Close()
|
|
244
|
+
|
|
245
|
+
redisClient, err := cache.NewRedisClient(ctx, cfg, logger)
|
|
246
|
+
if err != nil {
|
|
247
|
+
logger.Fatal().Err(err).Msg("failed to init redis client")
|
|
248
|
+
}
|
|
249
|
+
defer redisClient.Close()
|
|
250
|
+
|
|
251
|
+
// Configure persistence
|
|
252
|
+
if strings.ToLower(cfg.State.Persist) == "redis" {
|
|
253
|
+
eventHub.SetStore(events.NewRedisStore(redisClient))
|
|
254
|
+
logger.Info().Msg("state persistence enabled: redis")
|
|
255
|
+
}
|
|
256
|
+
if cfg.State.TTLSec > 0 {
|
|
257
|
+
eventHub.SetTTL(cfg.State.TTLSec)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
kafkaClient, err := events.NewKafka(cfg, logger)
|
|
261
|
+
if err != nil {
|
|
262
|
+
logger.Fatal().Err(err).Msg("failed to init kafka client")
|
|
263
|
+
}
|
|
264
|
+
defer kafkaClient.Close()
|
|
265
|
+
|
|
266
|
+
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
267
|
+
dbStatus := "ok"
|
|
268
|
+
if err := pool.Ping(r.Context()); err != nil {
|
|
269
|
+
dbStatus = fmt.Sprintf("error: %v", err)
|
|
270
|
+
}
|
|
271
|
+
redisStatus := "ok"
|
|
272
|
+
if _, err := redisClient.Ping(r.Context()).Result(); err != nil {
|
|
273
|
+
redisStatus = fmt.Sprintf("error: %v", err)
|
|
274
|
+
}
|
|
275
|
+
kafkaStatus := "ok"
|
|
276
|
+
if err := kafkaClient.Ping(r.Context()); err != nil {
|
|
277
|
+
kafkaStatus = fmt.Sprintf("error: %v", err)
|
|
278
|
+
}
|
|
279
|
+
respondJSON(w, http.StatusOK, healthResponse{
|
|
280
|
+
Status: "ok",
|
|
281
|
+
Time: time.Now().UTC().Format(time.RFC3339),
|
|
282
|
+
DB: dbStatus,
|
|
283
|
+
Redis: redisStatus,
|
|
284
|
+
Kafka: kafkaStatus,
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// Debug: current state snapshot
|
|
289
|
+
r.Get("/api/state/{channel}", func(w http.ResponseWriter, r *http.Request) {
|
|
290
|
+
ch := chi.URLParam(r, "channel")
|
|
291
|
+
if ch == "" {
|
|
292
|
+
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "channel required"})
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
snap, ver, ok := eventHub.Snapshot(ch)
|
|
296
|
+
if !ok {
|
|
297
|
+
respondJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
respondJSON(w, http.StatusOK, map[string]any{
|
|
301
|
+
"channel": ch,
|
|
302
|
+
"versionNo": ver,
|
|
303
|
+
"data": json.RawMessage(snap),
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// Debug: replay from version
|
|
308
|
+
r.Get("/api/replay/{channel}", func(w http.ResponseWriter, r *http.Request) {
|
|
309
|
+
ch := chi.URLParam(r, "channel")
|
|
310
|
+
if ch == "" {
|
|
311
|
+
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "channel required"})
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
fromStr := r.URL.Query().Get("from")
|
|
315
|
+
var from int64 = 0
|
|
316
|
+
if fromStr != "" {
|
|
317
|
+
if v, err := strconv.ParseInt(fromStr, 10, 64); err == nil {
|
|
318
|
+
from = v
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
snapshots := eventHub.Replay(ch, from)
|
|
322
|
+
respondJSON(w, http.StatusOK, map[string]any{
|
|
323
|
+
"channel": ch,
|
|
324
|
+
"from": from,
|
|
325
|
+
"snapshots": snapshots,
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
|
|
330
|
+
conn, err := upgrader.Upgrade(w, r, nil)
|
|
331
|
+
if err != nil {
|
|
332
|
+
logger.Error().Err(err).Msg("upgrade ws")
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
hub.add(conn)
|
|
336
|
+
logger.Info().Msg("ws connected")
|
|
337
|
+
|
|
338
|
+
go func(c *websocket.Conn) {
|
|
339
|
+
pingInterval := 15 * time.Second
|
|
340
|
+
ping := time.NewTicker(pingInterval)
|
|
341
|
+
defer ping.Stop()
|
|
342
|
+
defer hub.remove(c)
|
|
343
|
+
|
|
344
|
+
// Handle ping in separate goroutine
|
|
345
|
+
go func() {
|
|
346
|
+
for range ping.C {
|
|
347
|
+
if err := hub.write(c, websocket.TextMessage, []byte(fmt.Sprintf(\`{"type":"ping","ts":%d}\`, time.Now().UnixMilli()))); err != nil {
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}()
|
|
352
|
+
|
|
353
|
+
// Read messages in main goroutine
|
|
354
|
+
for {
|
|
355
|
+
_, msg, err := c.ReadMessage()
|
|
356
|
+
if err != nil {
|
|
357
|
+
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
358
|
+
logger.Warn().Err(err).Msg("ws read error unexpected")
|
|
359
|
+
} else {
|
|
360
|
+
logger.Info().Err(err).Msg("ws read closed")
|
|
361
|
+
}
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
var m events.WSMessage
|
|
365
|
+
if err := json.Unmarshal(msg, &m); err != nil {
|
|
366
|
+
logger.Warn().Err(err).Msg("ws decode")
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
switch m.Type {
|
|
370
|
+
case "sub":
|
|
371
|
+
for _, ch := range m.Channels {
|
|
372
|
+
if ch == "" {
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
hub.subscribe(c, ch)
|
|
376
|
+
}
|
|
377
|
+
ack, _ := json.Marshal(events.WSMessage{Type: "sub_ack", Channels: m.Channels})
|
|
378
|
+
_ = hub.write(c, websocket.TextMessage, ack)
|
|
379
|
+
case "unsub":
|
|
380
|
+
for _, ch := range m.Channels {
|
|
381
|
+
if ch == "" {
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
hub.unsubscribe(c, ch)
|
|
385
|
+
}
|
|
386
|
+
ack, _ := json.Marshal(events.WSMessage{Type: "unsub_ack", Channels: m.Channels})
|
|
387
|
+
_ = hub.write(c, websocket.TextMessage, ack)
|
|
388
|
+
case "handshake":
|
|
389
|
+
// Validate protocol version
|
|
390
|
+
if m.Version != "" && m.Version != events.ProtocolVersion() {
|
|
391
|
+
errMsg, _ := json.Marshal(events.WSMessage{
|
|
392
|
+
Type: "error",
|
|
393
|
+
Code: "version_mismatch",
|
|
394
|
+
Message: fmt.Sprintf("Protocol version mismatch: client=%s, server=%s", m.Version, events.ProtocolVersion()),
|
|
395
|
+
Retry: false,
|
|
396
|
+
})
|
|
397
|
+
_ = hub.write(c, websocket.TextMessage, errMsg)
|
|
398
|
+
c.Close()
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
// Auth token check
|
|
402
|
+
if cfg.Auth.Token != "" && m.Auth != cfg.Auth.Token {
|
|
403
|
+
errMsg, _ := json.Marshal(events.WSMessage{
|
|
404
|
+
Type: "error",
|
|
405
|
+
Code: "unauthorized",
|
|
406
|
+
Message: "Invalid auth token",
|
|
407
|
+
Retry: false,
|
|
408
|
+
})
|
|
409
|
+
_ = hub.write(c, websocket.TextMessage, errMsg)
|
|
410
|
+
c.Close()
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
session := m.Session
|
|
414
|
+
if session == "" {
|
|
415
|
+
session = uuid.NewString()
|
|
416
|
+
}
|
|
417
|
+
hub.setSession(c, session)
|
|
418
|
+
ack, _ := json.Marshal(events.WSMessage{
|
|
419
|
+
Type: "ack",
|
|
420
|
+
Session: session,
|
|
421
|
+
Interval: pingInterval.Milliseconds(),
|
|
422
|
+
Version: events.ProtocolVersion(),
|
|
423
|
+
Ts: time.Now().UnixMilli(),
|
|
424
|
+
})
|
|
425
|
+
_ = hub.write(c, websocket.TextMessage, ack)
|
|
426
|
+
case "event":
|
|
427
|
+
if m.Name != "" {
|
|
428
|
+
// Check idempotency (msgId dedup)
|
|
429
|
+
if m.MsgID != "" && eventHub.CheckMsgID(m.MsgID) {
|
|
430
|
+
// Duplicate message, ignore
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
if handler, ok := eventHub.Get(m.Name); ok {
|
|
434
|
+
handler(context.Background(), eventHub, c, m)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
case "update":
|
|
438
|
+
if m.Channel != "" {
|
|
439
|
+
// Check idempotency
|
|
440
|
+
if m.MsgID != "" && eventHub.CheckMsgID(m.MsgID) {
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
var payload any
|
|
444
|
+
if len(m.Data) > 0 {
|
|
445
|
+
_ = json.Unmarshal(m.Data, &payload)
|
|
446
|
+
}
|
|
447
|
+
eventHub.Update(m.Channel, payload)
|
|
448
|
+
}
|
|
449
|
+
case "diff":
|
|
450
|
+
if m.Channel != "" {
|
|
451
|
+
// Check idempotency
|
|
452
|
+
if m.MsgID != "" && eventHub.CheckMsgID(m.MsgID) {
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
var patch any
|
|
456
|
+
if len(m.Patch) > 0 {
|
|
457
|
+
_ = json.Unmarshal(m.Patch, &patch)
|
|
458
|
+
}
|
|
459
|
+
eventHub.Diff(m.Channel, patch)
|
|
460
|
+
}
|
|
461
|
+
case "ping":
|
|
462
|
+
pong, _ := json.Marshal(events.WSMessage{Type: "pong", Ts: time.Now().UnixMilli()})
|
|
463
|
+
if err := hub.write(c, websocket.TextMessage, pong); err != nil {
|
|
464
|
+
logger.Warn().Err(err).Msg("ws write pong error")
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
case "pong":
|
|
468
|
+
// Client acknowledged ping, connection is healthy
|
|
469
|
+
default:
|
|
470
|
+
// ignore unknown
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}(conn)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
r.Post("/api/demo", func(w http.ResponseWriter, r *http.Request) {
|
|
477
|
+
var payload map[string]any
|
|
478
|
+
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
479
|
+
respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
ch := "demo"
|
|
483
|
+
if v, ok := payload["channel"].(string); ok && v != "" {
|
|
484
|
+
ch = v
|
|
485
|
+
}
|
|
486
|
+
eventHub.Emit(ch, payload)
|
|
487
|
+
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
srv := &http.Server{
|
|
491
|
+
Addr: httpAddr(cfg.HTTP.Port),
|
|
492
|
+
Handler: r,
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
logger.Info().Str("addr", srv.Addr).Msg("Vira Engine stub API listening")
|
|
496
|
+
go func() {
|
|
497
|
+
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
498
|
+
logger.Fatal().Err(err).Msg("server error")
|
|
499
|
+
}
|
|
500
|
+
}()
|
|
501
|
+
|
|
502
|
+
<-ctx.Done()
|
|
503
|
+
logger.Info().Msg("shutting down...")
|
|
504
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
505
|
+
defer cancel()
|
|
506
|
+
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
507
|
+
logger.Fatal().Err(err).Msg("server shutdown error")
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
func requestContext(logger zerolog.Logger) func(next http.Handler) http.Handler {
|
|
512
|
+
return func(next http.Handler) http.Handler {
|
|
513
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
514
|
+
reqID := middleware.GetReqID(r.Context())
|
|
515
|
+
userID := r.Header.Get("X-User-ID")
|
|
516
|
+
|
|
517
|
+
ctx := context.WithValue(r.Context(), ctxReqID, reqID)
|
|
518
|
+
if userID != "" {
|
|
519
|
+
ctx = context.WithValue(ctx, ctxUserID, userID)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
next.ServeHTTP(w, r.WithContext(ctx))
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
func httpLogger(logger zerolog.Logger) func(next http.Handler) http.Handler {
|
|
528
|
+
return func(next http.Handler) http.Handler {
|
|
529
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
530
|
+
started := time.Now()
|
|
531
|
+
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
532
|
+
|
|
533
|
+
next.ServeHTTP(ww, r)
|
|
534
|
+
|
|
535
|
+
evt := logger.Info()
|
|
536
|
+
if reqID, ok := r.Context().Value(ctxReqID).(string); ok && reqID != "" {
|
|
537
|
+
evt = evt.Str("reqID", reqID)
|
|
538
|
+
}
|
|
539
|
+
if userID, ok := r.Context().Value(ctxUserID).(string); ok && userID != "" {
|
|
540
|
+
evt = evt.Str("userID", userID)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
evt.
|
|
544
|
+
Str("method", r.Method).
|
|
545
|
+
Str("path", r.URL.Path).
|
|
546
|
+
Int("status", ww.Status()).
|
|
547
|
+
Dur("latency", time.Since(started)).
|
|
548
|
+
Msg("http_request")
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
func respondJSON(w http.ResponseWriter, status int, payload any) {
|
|
554
|
+
w.Header().Set("Content-Type", "application/json")
|
|
555
|
+
w.WriteHeader(status)
|
|
556
|
+
_ = json.NewEncoder(w).Encode(payload)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
func httpAddr(port int) string {
|
|
560
|
+
return ":" + strconv.Itoa(port)
|
|
561
|
+
}
|
|
562
562
|
`;
|