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