blumenjs 0.2.0 → 0.2.2

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.
@@ -4,6 +4,7 @@ import (
4
4
  "bytes"
5
5
  "encoding/json"
6
6
  "fmt"
7
+ "io"
7
8
  "log"
8
9
  "net"
9
10
  "net/http"
@@ -11,14 +12,23 @@ import (
11
12
  )
12
13
 
13
14
  const (
14
- nodeSSRURL = "http://localhost:4000/render"
15
- nodeDataURL = "http://localhost:4000/data"
15
+ nodeSSRURL = "http://localhost:4000/render"
16
+ nodeStreamURL = "http://localhost:4000/stream-render"
17
+ nodeDataURL = "http://localhost:4000/data"
18
+ nodeAPIURL = "http://localhost:4000/api"
16
19
  )
17
20
 
18
21
  // Global page cache — stores rendered HTML in Go memory for near-instant responses.
19
22
  // Max 500 entries with LRU eviction. A single Go server can cache the entire site.
20
23
  var pageCache = NewPageCache(500)
21
24
 
25
+ // Global WebSocket hub — manages all real-time connections.
26
+ // Each connection runs in its own goroutine for maximum concurrency.
27
+ var wsHub = NewWSHub()
28
+
29
+ // Global SSG server — serves pre-rendered HTML directly from disk.
30
+ var ssgServer = NewSSGServer("dist/static-pages")
31
+
22
32
  // SSRResponse from Node service
23
33
  type SSRResponse struct {
24
34
  HTML string `json:"html"`
@@ -55,6 +65,37 @@ var httpClient = &http.Client{
55
65
  func main() {
56
66
  mux := http.NewServeMux()
57
67
 
68
+ // ── Middleware chain ─────────────────────────────────────
69
+ chain := NewMiddlewareChain()
70
+
71
+ // Redirects & rewrites (processed first, before logging)
72
+ SetupRedirects(chain, "blumen.routes.json")
73
+
74
+ // Global middleware (runs on every request)
75
+ chain.Use(LoggerMiddleware())
76
+
77
+ // CORS middleware for API routes
78
+ chain.UseOn("/api/*", CORSMiddleware(DefaultCORSConfig()))
79
+
80
+ // Security headers (CSP, HSTS, X-Frame-Options, etc.)
81
+ chain.Use(SecurityHeadersMiddleware(DefaultSecurityConfig()))
82
+
83
+ // SSR DoS protection: limit page rendering requests per IP
84
+ // Prevents abuse of the CPU-intensive SSR endpoint
85
+ chain.UseOn("/*", RateLimitMiddleware(200, time.Minute))
86
+
87
+ // Authentication middleware for protected routes
88
+ // Uncomment and configure to enable:
89
+ // chain.UseOn("/dashboard/*", AuthMiddleware(AuthConfig{
90
+ // CookieName: "session",
91
+ // HeaderName: "Authorization",
92
+ // RedirectURL: "/login",
93
+ // ValidateFunc: func(token string) bool {
94
+ // // Add your token validation logic here
95
+ // return token != ""
96
+ // },
97
+ // }))
98
+
58
99
  // Static files with immutable cache headers
59
100
  staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
60
101
  mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
@@ -78,6 +119,49 @@ func main() {
78
119
  }, nil
79
120
  }))
80
121
 
122
+ // API routes: forward all /api/* requests to Node for TypeScript handling
123
+ mux.HandleFunc("/api/", APIProxyHandler())
124
+
125
+ // ── WebSocket endpoint ────────────────────────────────────
126
+ // Configure WebSocket event handlers
127
+ wsHub.OnConnect = func(client *WSClient, r *http.Request) bool {
128
+ // Accept all connections by default.
129
+ // Add authentication logic here (e.g. check JWT in query params).
130
+ return true
131
+ }
132
+
133
+ wsHub.OnMessage = func(client *WSClient, msg WSMessage) *WSMessage {
134
+ // Handle custom message types here.
135
+ // Return a *WSMessage to reply directly to the sender, or nil.
136
+ switch msg.Type {
137
+ case "echo":
138
+ // Example: echo the payload back
139
+ return &WSMessage{Type: "echo", Payload: msg.Payload}
140
+ default:
141
+ return nil
142
+ }
143
+ }
144
+
145
+ // Start the WebSocket hub event loop
146
+ go wsHub.Run()
147
+
148
+ // WebSocket upgrade endpoint
149
+ mux.HandleFunc("/_blumen/ws", WSHandler(wsHub))
150
+
151
+ // WebSocket status endpoint (for monitoring)
152
+ mux.HandleFunc("/_blumen/ws/status", func(w http.ResponseWriter, r *http.Request) {
153
+ w.Header().Set("Content-Type", "application/json")
154
+ status := map[string]interface{}{
155
+ "connections": wsHub.ClientCount(),
156
+ }
157
+ json.NewEncoder(w).Encode(status)
158
+ })
159
+
160
+ // ── Server Actions endpoint ──────────────────────────────
161
+ // Handles POST /_blumen/action with CSRF validation.
162
+ // Forwards action execution to Node SSR server.
163
+ mux.HandleFunc("/_blumen/action", ActionHandler())
164
+
81
165
  // Catch-all: forward every other request to the Node SSR server.
82
166
  // If the page exports getServerProps, Node will fetch the data first.
83
167
  // If a Go DataLoader is registered above, it takes priority.
@@ -99,7 +183,8 @@ func main() {
99
183
 
100
184
  log.Printf("Go server starting on http://localhost:%d", startPort)
101
185
  log.Printf("Page cache initialized (max %d entries)", 500)
102
- if err := http.Serve(listener, mux); err != nil {
186
+ log.Printf("Middleware chain: %d middleware registered", len(chain.entries))
187
+ if err := http.Serve(listener, chain.Then(mux)); err != nil {
103
188
  log.Fatalf("Server error: %v", err)
104
189
  }
105
190
  }
@@ -113,6 +198,13 @@ func PageHandler(loader DataLoader) http.HandlerFunc {
113
198
  return
114
199
  }
115
200
 
201
+ // SSG: serve pre-rendered static pages directly from disk (< 0.5ms)
202
+ // Only for full page requests, not SPA data requests
203
+ if r.Header.Get("X-Blumen-Data") != "1" && ssgServer.HasPage(r.URL.Path) {
204
+ ssgServer.ServePage(w, r)
205
+ return
206
+ }
207
+
116
208
  // Generate cache key from the full URL (path + query string)
117
209
  cacheKey := r.URL.RequestURI()
118
210
 
@@ -144,7 +236,23 @@ func PageHandler(loader DataLoader) http.HandlerFunc {
144
236
  return
145
237
  }
146
238
 
147
- // CACHE MISS: render the page and cache the result
239
+ // CACHE MISS: try streaming SSR first for instant TTFB
240
+ // Streaming bypasses the cache — the HTML is sent directly to the browser.
241
+ // The next request will use renderPage and populate the cache.
242
+ if r.Header.Get("X-Blumen-Data") != "1" {
243
+ if streamRenderPage(w, r, loader) {
244
+ // Successfully streamed — also trigger a background cache fill
245
+ go func() {
246
+ html, revalidate := renderPage(r, loader)
247
+ if html != "" {
248
+ pageCache.Set(cacheKey, html, revalidate)
249
+ }
250
+ }()
251
+ return
252
+ }
253
+ }
254
+
255
+ // Streaming unavailable or SPA data request — fall back to renderPage
148
256
  html, revalidate := renderPage(r, loader)
149
257
  if html == "" {
150
258
  // renderPage already wrote the error response
@@ -240,6 +348,91 @@ func renderPage(r *http.Request, loader DataLoader) (string, int) {
240
348
  return ssrResp.HTML, revalidate
241
349
  }
242
350
 
351
+ // streamRenderPage streams HTML from Node SSR directly to the client.
352
+ // Uses chunked transfer encoding for instant TTFB.
353
+ // Returns true if streaming succeeded, false if caller should fall back.
354
+ func streamRenderPage(w http.ResponseWriter, r *http.Request, loader DataLoader) bool {
355
+ // Check if ResponseWriter supports flushing (required for streaming)
356
+ flusher, ok := w.(http.Flusher)
357
+ if !ok {
358
+ return false
359
+ }
360
+
361
+ var props map[string]interface{}
362
+ var err error
363
+
364
+ if loader != nil {
365
+ props, err = loader(r)
366
+ if err != nil {
367
+ log.Printf("Streaming: loader error: %v", err)
368
+ return false
369
+ }
370
+ } else {
371
+ tsProps, _, tsErr := fetchServerProps(r)
372
+ if tsErr != nil {
373
+ log.Printf("Streaming: getServerProps error: %v", tsErr)
374
+ } else if tsProps != nil {
375
+ props = tsProps
376
+ }
377
+ }
378
+
379
+ ssrReq := SSRRequest{
380
+ Path: r.URL.Path,
381
+ Query: r.URL.Query(),
382
+ Params: map[string]interface{}{
383
+ "url": r.URL.String(),
384
+ "headers": r.Header,
385
+ },
386
+ Data: props,
387
+ }
388
+
389
+ reqBody, err := json.Marshal(ssrReq)
390
+ if err != nil {
391
+ log.Printf("Streaming: marshal error: %v", err)
392
+ return false
393
+ }
394
+
395
+ resp, err := httpClient.Post(nodeStreamURL, "application/json", bytes.NewReader(reqBody))
396
+ if err != nil {
397
+ log.Printf("Streaming: node request error: %v", err)
398
+ return false
399
+ }
400
+ defer resp.Body.Close()
401
+
402
+ if resp.StatusCode != http.StatusOK {
403
+ log.Printf("Streaming: node returned %d", resp.StatusCode)
404
+ return false
405
+ }
406
+
407
+ // Set response headers for streaming
408
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
409
+ w.Header().Set("Transfer-Encoding", "chunked")
410
+ w.Header().Set("X-Content-Type-Options", "nosniff")
411
+ w.Header().Set("X-Frame-Options", "DENY")
412
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
413
+ w.Header().Set("X-Blumen-Cache", "STREAM")
414
+ w.Header().Set("Cache-Control", "no-cache")
415
+ w.WriteHeader(http.StatusOK)
416
+
417
+ // Stream chunks from Node to the browser
418
+ buf := make([]byte, 4096)
419
+ for {
420
+ n, readErr := resp.Body.Read(buf)
421
+ if n > 0 {
422
+ w.Write(buf[:n])
423
+ flusher.Flush()
424
+ }
425
+ if readErr != nil {
426
+ if readErr != io.EOF {
427
+ log.Printf("Streaming: read error: %v", readErr)
428
+ }
429
+ break
430
+ }
431
+ }
432
+
433
+ return true
434
+ }
435
+
243
436
  // fetchServerProps calls the Node SSR /data endpoint to run getServerProps.
244
437
  // Returns nil if the page doesn't export getServerProps.
245
438
  // Also returns the revalidate TTL (0 = no caching from getServerProps).
@@ -341,3 +534,100 @@ func cacheStatusHandler(w http.ResponseWriter, r *http.Request) {
341
534
  http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
342
535
  }
343
536
  }
537
+
538
+ // APINodeRequest is the envelope sent to Node for API route handling.
539
+ type APINodeRequest struct {
540
+ Method string `json:"method"`
541
+ Path string `json:"path"`
542
+ Query map[string][]string `json:"query"`
543
+ Headers map[string]string `json:"headers"`
544
+ Body interface{} `json:"body"`
545
+ }
546
+
547
+ // APINodeResponse is the envelope received from Node after API route handling.
548
+ type APINodeResponse struct {
549
+ Status int `json:"status"`
550
+ Headers map[string]string `json:"headers"`
551
+ Body interface{} `json:"body"`
552
+ }
553
+
554
+ // APIProxyHandler forwards /api/* requests to the Node SSR server.
555
+ // Accepts all HTTP methods (GET, POST, PUT, DELETE, PATCH).
556
+ // Reads the request body, packages it into a JSON envelope, and sends it
557
+ // to Node's /api endpoint. Node matches the route, runs the handler, and
558
+ // returns { status, headers, body } which Go writes back to the client.
559
+ func APIProxyHandler() http.HandlerFunc {
560
+ return func(w http.ResponseWriter, r *http.Request) {
561
+ // Read request body (for POST, PUT, PATCH)
562
+ var requestBody interface{}
563
+ if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch {
564
+ var bodyBuf bytes.Buffer
565
+ bodyBuf.ReadFrom(r.Body)
566
+ defer r.Body.Close()
567
+
568
+ // Try to parse as JSON; if it fails, send as raw string
569
+ var jsonBody interface{}
570
+ if err := json.Unmarshal(bodyBuf.Bytes(), &jsonBody); err == nil {
571
+ requestBody = jsonBody
572
+ } else if bodyBuf.Len() > 0 {
573
+ requestBody = bodyBuf.String()
574
+ }
575
+ }
576
+
577
+ // Forward a subset of headers
578
+ headers := make(map[string]string)
579
+ for _, key := range []string{
580
+ "Authorization", "Cookie", "Content-Type", "Accept",
581
+ "Accept-Language", "User-Agent", "X-Request-ID",
582
+ } {
583
+ if val := r.Header.Get(key); val != "" {
584
+ headers[key] = val
585
+ }
586
+ }
587
+
588
+ apiReq := APINodeRequest{
589
+ Method: r.Method,
590
+ Path: r.URL.Path,
591
+ Query: r.URL.Query(),
592
+ Headers: headers,
593
+ Body: requestBody,
594
+ }
595
+
596
+ reqBody, err := json.Marshal(apiReq)
597
+ if err != nil {
598
+ log.Printf("API proxy: marshal error: %v", err)
599
+ http.Error(w, `{"error":"Internal Server Error"}`, http.StatusInternalServerError)
600
+ return
601
+ }
602
+
603
+ resp, err := httpClient.Post(nodeAPIURL, "application/json", bytes.NewReader(reqBody))
604
+ if err != nil {
605
+ log.Printf("API proxy: node request error: %v", err)
606
+ http.Error(w, `{"error":"API service unavailable"}`, http.StatusBadGateway)
607
+ return
608
+ }
609
+ defer resp.Body.Close()
610
+
611
+ var apiResp APINodeResponse
612
+ if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
613
+ log.Printf("API proxy: decode error: %v", err)
614
+ http.Error(w, `{"error":"Internal Server Error"}`, http.StatusInternalServerError)
615
+ return
616
+ }
617
+
618
+ // Write response headers from Node handler
619
+ for key, val := range apiResp.Headers {
620
+ w.Header().Set(key, val)
621
+ }
622
+
623
+ // Security headers
624
+ w.Header().Set("X-Content-Type-Options", "nosniff")
625
+ w.Header().Set("X-Blumen-Route", "api")
626
+
627
+ // Write status and body
628
+ w.WriteHeader(apiResp.Status)
629
+ if apiResp.Body != nil {
630
+ json.NewEncoder(w).Encode(apiResp.Body)
631
+ }
632
+ }
633
+ }