blumenjs 0.1.7 → 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.
@@ -11,9 +11,22 @@ import (
11
11
  )
12
12
 
13
13
  const (
14
- nodeSSRURL = "http://localhost:4000/render"
14
+ nodeSSRURL = "http://localhost:4000/render"
15
+ nodeDataURL = "http://localhost:4000/data"
16
+ nodeAPIURL = "http://localhost:4000/api"
15
17
  )
16
18
 
19
+ // Global page cache — stores rendered HTML in Go memory for near-instant responses.
20
+ // Max 500 entries with LRU eviction. A single Go server can cache the entire site.
21
+ var pageCache = NewPageCache(500)
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
+
17
30
  // SSRResponse from Node service
18
31
  type SSRResponse struct {
19
32
  HTML string `json:"html"`
@@ -28,6 +41,21 @@ type SSRRequest struct {
28
41
  Data map[string]interface{} `json:"data,omitempty"`
29
42
  }
30
43
 
44
+ // DataRequest to Node /data endpoint
45
+ type DataRequest struct {
46
+ Path string `json:"path"`
47
+ Query map[string][]string `json:"query"`
48
+ Params map[string]interface{} `json:"params"`
49
+ Headers map[string]string `json:"headers,omitempty"`
50
+ }
51
+
52
+ // DataResponse from Node /data endpoint
53
+ type DataResponse struct {
54
+ Props map[string]interface{} `json:"props"`
55
+ HasServerProps bool `json:"hasServerProps"`
56
+ Revalidate int `json:"revalidate"` // TTL in seconds
57
+ }
58
+
31
59
  var httpClient = &http.Client{
32
60
  Timeout: 10 * time.Second,
33
61
  }
@@ -35,8 +63,51 @@ var httpClient = &http.Client{
35
63
  func main() {
36
64
  mux := http.NewServeMux()
37
65
 
38
- // Static files are served directly — must be registered first
39
- mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
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
+
97
+ // Static files with immutable cache headers
98
+ staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
99
+ mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
100
+ // Set aggressive cache headers for static assets
101
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
102
+ w.Header().Set("X-Blumen-Cache", "STATIC")
103
+ staticHandler.ServeHTTP(w, r)
104
+ })
105
+
106
+ // Image optimization endpoint - processes and caches images on the fly
107
+ mux.HandleFunc("/_blumen/image", ImageHandler())
108
+
109
+ // Cache management endpoint (for development/debugging)
110
+ mux.HandleFunc("/_blumen/cache", cacheStatusHandler)
40
111
 
41
112
  // Specific routes with Go loaders for data fetching
42
113
  mux.HandleFunc("/dashboard/settings", PageHandler(func(r *http.Request) (map[string]interface{}, error) {
@@ -46,16 +117,52 @@ func main() {
46
117
  }, nil
47
118
  }))
48
119
 
49
- mux.HandleFunc("/users/{id}", PageHandler(func(r *http.Request) (map[string]interface{}, error) {
50
- id := r.PathValue("id")
51
- return map[string]interface{}{
52
- "userId": id,
53
- "userProfile": "Profile data for user " + id + " fetched from Go db",
54
- }, nil
55
- }))
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())
56
162
 
57
163
  // Catch-all: forward every other request to the Node SSR server.
58
- // This decouples Go from knowing about specific React page paths.
164
+ // If the page exports getServerProps, Node will fetch the data first.
165
+ // If a Go DataLoader is registered above, it takes priority.
59
166
  mux.HandleFunc("/", PageHandler(nil))
60
167
 
61
168
  startPort := 3000
@@ -73,7 +180,9 @@ func main() {
73
180
  }
74
181
 
75
182
  log.Printf("Go server starting on http://localhost:%d", startPort)
76
- if err := http.Serve(listener, mux); err != nil {
183
+ log.Printf("Page cache initialized (max %d entries)", 500)
184
+ log.Printf("Middleware chain: %d middleware registered", len(chain.entries))
185
+ if err := http.Serve(listener, chain.Then(mux)); err != nil {
77
186
  log.Fatalf("Server error: %v", err)
78
187
  }
79
188
  }
@@ -87,33 +196,121 @@ func PageHandler(loader DataLoader) http.HandlerFunc {
87
196
  return
88
197
  }
89
198
 
90
- var props map[string]interface{}
91
- var err error
92
- if loader != nil {
93
- props, err = loader(r)
94
- if err != nil {
95
- log.Printf("Loader error: %v", err)
96
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
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
+
206
+ // Generate cache key from the full URL (path + query string)
207
+ cacheKey := r.URL.RequestURI()
208
+
209
+ // Check the page cache first
210
+ if entry, found, stale := pageCache.Get(cacheKey); found {
211
+ // Handle ETag conditional request
212
+ if match := r.Header.Get("If-None-Match"); match == entry.ETag {
213
+ w.Header().Set("X-Blumen-Cache", "HIT")
214
+ w.WriteHeader(http.StatusNotModified)
97
215
  return
98
216
  }
99
- }
100
217
 
101
- // If this is a SPA data fetch request, return JSON props
102
- if r.Header.Get("X-Blumen-Data") == "1" || r.URL.Query().Get("_data") == "1" {
103
- w.Header().Set("Content-Type", "application/json")
104
- if props == nil {
105
- props = make(map[string]interface{})
218
+ if !stale {
219
+ // CACHE HIT: serve directly from Go memory (sub-millisecond)
220
+ serveCachedPage(w, entry, "HIT")
221
+ return
106
222
  }
107
- json.NewEncoder(w).Encode(props)
223
+
224
+ // STALE: serve stale content immediately, revalidate in background
225
+ serveCachedPage(w, entry, "STALE")
226
+
227
+ // Background revalidation (fire and forget)
228
+ go func() {
229
+ html, revalidate := renderPage(r, loader)
230
+ if html != "" {
231
+ pageCache.Set(cacheKey, html, revalidate)
232
+ }
233
+ }()
108
234
  return
109
235
  }
110
236
 
111
- handleRouteWithProps(w, r, props)
237
+ // CACHE MISS: render the page and cache the result
238
+ html, revalidate := renderPage(r, loader)
239
+ if html == "" {
240
+ // renderPage already wrote the error response
241
+ return
242
+ }
243
+
244
+ // Cache the rendered page
245
+ pageCache.Set(cacheKey, html, revalidate)
246
+
247
+ // Serve the fresh response
248
+ entry, _, _ := pageCache.Get(cacheKey)
249
+ if entry != nil {
250
+ serveCachedPage(w, entry, "MISS")
251
+ } else {
252
+ // Fallback: serve directly without cache metadata
253
+ writeHTMLResponse(w, html, "MISS", "")
254
+ }
255
+ }
256
+ }
257
+
258
+ // serveCachedPage writes a cached page response with proper headers.
259
+ func serveCachedPage(w http.ResponseWriter, entry *CacheEntry, cacheStatus string) {
260
+ writeHTMLResponse(w, entry.HTML, cacheStatus, entry.ETag)
261
+ }
262
+
263
+ // writeHTMLResponse writes an HTML response with cache and security headers.
264
+ func writeHTMLResponse(w http.ResponseWriter, html string, cacheStatus string, etag string) {
265
+ // Security headers
266
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
267
+ w.Header().Set("X-Content-Type-Options", "nosniff")
268
+ w.Header().Set("X-Frame-Options", "DENY")
269
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
270
+
271
+ // Cache headers
272
+ w.Header().Set("X-Blumen-Cache", cacheStatus)
273
+ if etag != "" {
274
+ w.Header().Set("ETag", etag)
112
275
  }
276
+ // Tell browsers and CDNs they can cache for 10s, serve stale for 60s while revalidating
277
+ w.Header().Set("Cache-Control", "public, s-maxage=10, stale-while-revalidate=60")
278
+
279
+ w.WriteHeader(http.StatusOK)
280
+ w.Write([]byte(html))
113
281
  }
114
282
 
115
- func handleRouteWithProps(w http.ResponseWriter, r *http.Request, props map[string]interface{}) {
116
- // Prepare SSR request
283
+ // renderPage performs the full SSR pipeline: fetch data + render HTML.
284
+ // Returns the HTML string and the revalidation TTL in seconds.
285
+ func renderPage(r *http.Request, loader DataLoader) (string, int) {
286
+ var props map[string]interface{}
287
+ var err error
288
+ revalidate := 0
289
+
290
+ if loader != nil {
291
+ // Use Go DataLoader (takes priority over getServerProps)
292
+ props, err = loader(r)
293
+ if err != nil {
294
+ log.Printf("Loader error: %v", err)
295
+ return "", 0
296
+ }
297
+ } else {
298
+ // No Go DataLoader - check if the page has getServerProps
299
+ tsProps, tsRevalidate, tsErr := fetchServerProps(r)
300
+ if tsErr != nil {
301
+ log.Printf("getServerProps error: %v", tsErr)
302
+ // Non-fatal: render the page without server props
303
+ } else if tsProps != nil {
304
+ props = tsProps
305
+ revalidate = tsRevalidate
306
+ }
307
+ }
308
+
309
+ // If this is a SPA data fetch request, return JSON props (don't cache)
310
+ // This check shouldn't happen in renderPage since it's called from the handler,
311
+ // but the handler already handles this case before reaching here.
312
+
313
+ // Call Node SSR to render the HTML
117
314
  ssrReq := SSRRequest{
118
315
  Path: r.URL.Path,
119
316
  Query: r.URL.Query(),
@@ -124,24 +321,64 @@ func handleRouteWithProps(w http.ResponseWriter, r *http.Request, props map[stri
124
321
  Data: props,
125
322
  }
126
323
 
127
- // Call Node SSR service
128
324
  ssrResp, err := callNodeSSR(ssrReq)
129
325
  if err != nil {
130
326
  log.Printf("SSR error: %v", err)
131
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
132
- return
327
+ return "", 0
133
328
  }
134
329
 
330
+ return ssrResp.HTML, revalidate
331
+ }
135
332
 
333
+ // fetchServerProps calls the Node SSR /data endpoint to run getServerProps.
334
+ // Returns nil if the page doesn't export getServerProps.
335
+ // Also returns the revalidate TTL (0 = no caching from getServerProps).
336
+ func fetchServerProps(r *http.Request) (map[string]interface{}, int, error) {
337
+ // Build a subset of headers to forward
338
+ headers := make(map[string]string)
339
+ for _, key := range []string{"Authorization", "Cookie", "Accept-Language", "User-Agent"} {
340
+ if val := r.Header.Get(key); val != "" {
341
+ headers[key] = val
342
+ }
343
+ }
136
344
 
137
- // Security headers
138
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
139
- w.Header().Set("X-Content-Type-Options", "nosniff")
140
- w.Header().Set("X-Frame-Options", "DENY")
141
- w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
345
+ dataReq := DataRequest{
346
+ Path: r.URL.Path,
347
+ Query: r.URL.Query(),
348
+ Params: map[string]interface{}{},
349
+ Headers: headers,
350
+ }
142
351
 
143
- w.WriteHeader(http.StatusOK)
144
- w.Write([]byte(ssrResp.HTML))
352
+ reqBody, err := json.Marshal(dataReq)
353
+ if err != nil {
354
+ return nil, 0, fmt.Errorf("marshal data request: %w", err)
355
+ }
356
+
357
+ resp, err := httpClient.Post(
358
+ nodeDataURL,
359
+ "application/json",
360
+ bytes.NewReader(reqBody),
361
+ )
362
+ if err != nil {
363
+ return nil, 0, fmt.Errorf("http post /data: %w", err)
364
+ }
365
+ defer resp.Body.Close()
366
+
367
+ if resp.StatusCode != http.StatusOK {
368
+ return nil, 0, fmt.Errorf("node /data returned %d", resp.StatusCode)
369
+ }
370
+
371
+ var dataResp DataResponse
372
+ if err := json.NewDecoder(resp.Body).Decode(&dataResp); err != nil {
373
+ return nil, 0, fmt.Errorf("decode data response: %w", err)
374
+ }
375
+
376
+ if !dataResp.HasServerProps {
377
+ // Page doesn't export getServerProps, skip
378
+ return nil, 0, nil
379
+ }
380
+
381
+ return dataResp.Props, dataResp.Revalidate, nil
145
382
  }
146
383
 
147
384
  func callNodeSSR(req SSRRequest) (*SSRResponse, error) {
@@ -172,4 +409,122 @@ func callNodeSSR(req SSRRequest) (*SSRResponse, error) {
172
409
  return &ssrResp, nil
173
410
  }
174
411
 
412
+ // cacheStatusHandler provides cache introspection for debugging.
413
+ // GET /_blumen/cache — returns current cache size and status
414
+ // DELETE /_blumen/cache — clears the entire cache
415
+ func cacheStatusHandler(w http.ResponseWriter, r *http.Request) {
416
+ switch r.Method {
417
+ case http.MethodGet:
418
+ w.Header().Set("Content-Type", "application/json")
419
+ json.NewEncoder(w).Encode(map[string]interface{}{
420
+ "size": pageCache.Size(),
421
+ "maxSize": 500,
422
+ "status": "active",
423
+ })
424
+ case http.MethodDelete:
425
+ pageCache.Clear()
426
+ w.Header().Set("Content-Type", "application/json")
427
+ json.NewEncoder(w).Encode(map[string]interface{}{
428
+ "message": "Cache cleared",
429
+ })
430
+ default:
431
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
432
+ }
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()
175
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
+ }