blumenjs 0.2.0 → 0.2.1

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.
@@ -13,12 +13,20 @@ import (
13
13
  const (
14
14
  nodeSSRURL = "http://localhost:4000/render"
15
15
  nodeDataURL = "http://localhost:4000/data"
16
+ nodeAPIURL = "http://localhost:4000/api"
16
17
  )
17
18
 
18
19
  // Global page cache — stores rendered HTML in Go memory for near-instant responses.
19
20
  // Max 500 entries with LRU eviction. A single Go server can cache the entire site.
20
21
  var pageCache = NewPageCache(500)
21
22
 
23
+ // Global WebSocket hub — manages all real-time connections.
24
+ // Each connection runs in its own goroutine for maximum concurrency.
25
+ var wsHub = NewWSHub()
26
+
27
+ // Global SSG server — serves pre-rendered HTML directly from disk.
28
+ var ssgServer = NewSSGServer("dist/static-pages")
29
+
22
30
  // SSRResponse from Node service
23
31
  type SSRResponse struct {
24
32
  HTML string `json:"html"`
@@ -55,6 +63,37 @@ var httpClient = &http.Client{
55
63
  func main() {
56
64
  mux := http.NewServeMux()
57
65
 
66
+ // ── Middleware chain ─────────────────────────────────────
67
+ chain := NewMiddlewareChain()
68
+
69
+ // Redirects & rewrites (processed first, before logging)
70
+ SetupRedirects(chain, "blumen.routes.json")
71
+
72
+ // Global middleware (runs on every request)
73
+ chain.Use(LoggerMiddleware())
74
+
75
+ // CORS middleware for API routes
76
+ chain.UseOn("/api/*", CORSMiddleware(DefaultCORSConfig()))
77
+
78
+ // Security headers (CSP, HSTS, X-Frame-Options, etc.)
79
+ chain.Use(SecurityHeadersMiddleware(DefaultSecurityConfig()))
80
+
81
+ // SSR DoS protection: limit page rendering requests per IP
82
+ // Prevents abuse of the CPU-intensive SSR endpoint
83
+ chain.UseOn("/*", RateLimitMiddleware(200, time.Minute))
84
+
85
+ // Authentication middleware for protected routes
86
+ // Uncomment and configure to enable:
87
+ // chain.UseOn("/dashboard/*", AuthMiddleware(AuthConfig{
88
+ // CookieName: "session",
89
+ // HeaderName: "Authorization",
90
+ // RedirectURL: "/login",
91
+ // ValidateFunc: func(token string) bool {
92
+ // // Add your token validation logic here
93
+ // return token != ""
94
+ // },
95
+ // }))
96
+
58
97
  // Static files with immutable cache headers
59
98
  staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
60
99
  mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
@@ -78,6 +117,49 @@ func main() {
78
117
  }, nil
79
118
  }))
80
119
 
120
+ // API routes: forward all /api/* requests to Node for TypeScript handling
121
+ mux.HandleFunc("/api/", APIProxyHandler())
122
+
123
+ // ── WebSocket endpoint ────────────────────────────────────
124
+ // Configure WebSocket event handlers
125
+ wsHub.OnConnect = func(client *WSClient, r *http.Request) bool {
126
+ // Accept all connections by default.
127
+ // Add authentication logic here (e.g. check JWT in query params).
128
+ return true
129
+ }
130
+
131
+ wsHub.OnMessage = func(client *WSClient, msg WSMessage) *WSMessage {
132
+ // Handle custom message types here.
133
+ // Return a *WSMessage to reply directly to the sender, or nil.
134
+ switch msg.Type {
135
+ case "echo":
136
+ // Example: echo the payload back
137
+ return &WSMessage{Type: "echo", Payload: msg.Payload}
138
+ default:
139
+ return nil
140
+ }
141
+ }
142
+
143
+ // Start the WebSocket hub event loop
144
+ go wsHub.Run()
145
+
146
+ // WebSocket upgrade endpoint
147
+ mux.HandleFunc("/_blumen/ws", WSHandler(wsHub))
148
+
149
+ // WebSocket status endpoint (for monitoring)
150
+ mux.HandleFunc("/_blumen/ws/status", func(w http.ResponseWriter, r *http.Request) {
151
+ w.Header().Set("Content-Type", "application/json")
152
+ status := map[string]interface{}{
153
+ "connections": wsHub.ClientCount(),
154
+ }
155
+ json.NewEncoder(w).Encode(status)
156
+ })
157
+
158
+ // ── Server Actions endpoint ──────────────────────────────
159
+ // Handles POST /_blumen/action with CSRF validation.
160
+ // Forwards action execution to Node SSR server.
161
+ mux.HandleFunc("/_blumen/action", ActionHandler())
162
+
81
163
  // Catch-all: forward every other request to the Node SSR server.
82
164
  // If the page exports getServerProps, Node will fetch the data first.
83
165
  // If a Go DataLoader is registered above, it takes priority.
@@ -99,7 +181,8 @@ func main() {
99
181
 
100
182
  log.Printf("Go server starting on http://localhost:%d", startPort)
101
183
  log.Printf("Page cache initialized (max %d entries)", 500)
102
- if err := http.Serve(listener, mux); err != nil {
184
+ log.Printf("Middleware chain: %d middleware registered", len(chain.entries))
185
+ if err := http.Serve(listener, chain.Then(mux)); err != nil {
103
186
  log.Fatalf("Server error: %v", err)
104
187
  }
105
188
  }
@@ -113,6 +196,13 @@ func PageHandler(loader DataLoader) http.HandlerFunc {
113
196
  return
114
197
  }
115
198
 
199
+ // SSG: serve pre-rendered static pages directly from disk (< 0.5ms)
200
+ // Only for full page requests, not SPA data requests
201
+ if r.Header.Get("X-Blumen-Data") != "1" && ssgServer.HasPage(r.URL.Path) {
202
+ ssgServer.ServePage(w, r)
203
+ return
204
+ }
205
+
116
206
  // Generate cache key from the full URL (path + query string)
117
207
  cacheKey := r.URL.RequestURI()
118
208
 
@@ -341,3 +431,100 @@ func cacheStatusHandler(w http.ResponseWriter, r *http.Request) {
341
431
  http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
342
432
  }
343
433
  }
434
+
435
+ // APINodeRequest is the envelope sent to Node for API route handling.
436
+ type APINodeRequest struct {
437
+ Method string `json:"method"`
438
+ Path string `json:"path"`
439
+ Query map[string][]string `json:"query"`
440
+ Headers map[string]string `json:"headers"`
441
+ Body interface{} `json:"body"`
442
+ }
443
+
444
+ // APINodeResponse is the envelope received from Node after API route handling.
445
+ type APINodeResponse struct {
446
+ Status int `json:"status"`
447
+ Headers map[string]string `json:"headers"`
448
+ Body interface{} `json:"body"`
449
+ }
450
+
451
+ // APIProxyHandler forwards /api/* requests to the Node SSR server.
452
+ // Accepts all HTTP methods (GET, POST, PUT, DELETE, PATCH).
453
+ // Reads the request body, packages it into a JSON envelope, and sends it
454
+ // to Node's /api endpoint. Node matches the route, runs the handler, and
455
+ // returns { status, headers, body } which Go writes back to the client.
456
+ func APIProxyHandler() http.HandlerFunc {
457
+ return func(w http.ResponseWriter, r *http.Request) {
458
+ // Read request body (for POST, PUT, PATCH)
459
+ var requestBody interface{}
460
+ if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch {
461
+ var bodyBuf bytes.Buffer
462
+ bodyBuf.ReadFrom(r.Body)
463
+ defer r.Body.Close()
464
+
465
+ // Try to parse as JSON; if it fails, send as raw string
466
+ var jsonBody interface{}
467
+ if err := json.Unmarshal(bodyBuf.Bytes(), &jsonBody); err == nil {
468
+ requestBody = jsonBody
469
+ } else if bodyBuf.Len() > 0 {
470
+ requestBody = bodyBuf.String()
471
+ }
472
+ }
473
+
474
+ // Forward a subset of headers
475
+ headers := make(map[string]string)
476
+ for _, key := range []string{
477
+ "Authorization", "Cookie", "Content-Type", "Accept",
478
+ "Accept-Language", "User-Agent", "X-Request-ID",
479
+ } {
480
+ if val := r.Header.Get(key); val != "" {
481
+ headers[key] = val
482
+ }
483
+ }
484
+
485
+ apiReq := APINodeRequest{
486
+ Method: r.Method,
487
+ Path: r.URL.Path,
488
+ Query: r.URL.Query(),
489
+ Headers: headers,
490
+ Body: requestBody,
491
+ }
492
+
493
+ reqBody, err := json.Marshal(apiReq)
494
+ if err != nil {
495
+ log.Printf("API proxy: marshal error: %v", err)
496
+ http.Error(w, `{"error":"Internal Server Error"}`, http.StatusInternalServerError)
497
+ return
498
+ }
499
+
500
+ resp, err := httpClient.Post(nodeAPIURL, "application/json", bytes.NewReader(reqBody))
501
+ if err != nil {
502
+ log.Printf("API proxy: node request error: %v", err)
503
+ http.Error(w, `{"error":"API service unavailable"}`, http.StatusBadGateway)
504
+ return
505
+ }
506
+ defer resp.Body.Close()
507
+
508
+ var apiResp APINodeResponse
509
+ if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
510
+ log.Printf("API proxy: decode error: %v", err)
511
+ http.Error(w, `{"error":"Internal Server Error"}`, http.StatusInternalServerError)
512
+ return
513
+ }
514
+
515
+ // Write response headers from Node handler
516
+ for key, val := range apiResp.Headers {
517
+ w.Header().Set(key, val)
518
+ }
519
+
520
+ // Security headers
521
+ w.Header().Set("X-Content-Type-Options", "nosniff")
522
+ w.Header().Set("X-Blumen-Route", "api")
523
+
524
+ // Write status and body
525
+ w.WriteHeader(apiResp.Status)
526
+ if apiResp.Body != nil {
527
+ json.NewEncoder(w).Encode(apiResp.Body)
528
+ }
529
+ }
530
+ }