@vira-ui/cli 1.1.2 → 2.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.
Files changed (40) hide show
  1. package/README.md +454 -1029
  2. package/dist/go/appYaml.js +30 -30
  3. package/dist/go/backendEnvExample.js +17 -17
  4. package/dist/go/backendReadme.js +14 -14
  5. package/dist/go/channelHelpers.js +25 -25
  6. package/dist/go/configGo.js +258 -258
  7. package/dist/go/dbGo.js +43 -43
  8. package/dist/go/dbYaml.js +7 -7
  9. package/dist/go/dockerCompose.js +48 -48
  10. package/dist/go/dockerComposeProd.js +78 -78
  11. package/dist/go/dockerfile.js +15 -15
  12. package/dist/go/eventHandlerTemplate.js +22 -22
  13. package/dist/go/eventsAPI.js +411 -411
  14. package/dist/go/goMod.js +16 -16
  15. package/dist/go/kafkaGo.js +67 -67
  16. package/dist/go/kafkaYaml.js +6 -6
  17. package/dist/go/kanbanHandlers.js +216 -216
  18. package/dist/go/mainGo.js +558 -558
  19. package/dist/go/readme.js +27 -27
  20. package/dist/go/redisGo.js +31 -31
  21. package/dist/go/redisYaml.js +4 -4
  22. package/dist/go/registryGo.js +38 -38
  23. package/dist/go/sqlcYaml.js +13 -13
  24. package/dist/go/stateStore.js +115 -115
  25. package/dist/go/typesGo.js +11 -11
  26. package/dist/index.js +472 -24
  27. package/dist/react/envExample.js +3 -3
  28. package/dist/react/envLocal.js +1 -1
  29. package/dist/react/indexCss.js +17 -17
  30. package/dist/react/indexHtml.js +12 -12
  31. package/dist/react/kanbanAppTsx.js +29 -29
  32. package/dist/react/kanbanBoard.js +58 -58
  33. package/dist/react/kanbanCard.js +60 -60
  34. package/dist/react/kanbanColumn.js +62 -62
  35. package/dist/react/kanbanModels.js +32 -32
  36. package/dist/react/mainTsx.js +12 -12
  37. package/dist/react/viteConfig.js +27 -27
  38. package/package.json +47 -45
  39. package/dist/go/useViraState.js +0 -160
  40. 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
  `;