blumenjs 0.1.7 → 0.2.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.
@@ -16,6 +16,7 @@ import React, {
16
16
  useRef,
17
17
  } from "react";
18
18
  import { matchRoute, type RouteDef } from "./router";
19
+ import { BlumenErrorBoundary } from "./ErrorBoundary";
19
20
 
20
21
  // ── Context types ──────────────────────────────────────────────
21
22
 
@@ -169,7 +170,9 @@ export function RouterProvider({
169
170
  <div
170
171
  className={`page-transition ${transitioning ? "page-transition-exit" : "page-transition-active"}`}
171
172
  >
172
- <App Component={PageComponent} pageProps={pageProps} />
173
+ <BlumenErrorBoundary>
174
+ <App Component={PageComponent} pageProps={pageProps} />
175
+ </BlumenErrorBoundary>
173
176
  </div>
174
177
  </RouterContext.Provider>
175
178
  );
@@ -0,0 +1,147 @@
1
+ package main
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "sync"
7
+ "time"
8
+ )
9
+
10
+ // CacheEntry represents a single cached page response.
11
+ type CacheEntry struct {
12
+ HTML string
13
+ ETag string
14
+ CreatedAt time.Time
15
+ Revalidate int // TTL in seconds; 0 = cache forever until evicted
16
+ Stale bool
17
+ }
18
+
19
+ // PageCache is a thread-safe in-memory LRU cache for rendered HTML pages.
20
+ // It uses sync.RWMutex for maximum concurrent read performance —
21
+ // thousands of goroutines can read simultaneously while writes are serialized.
22
+ type PageCache struct {
23
+ mu sync.RWMutex
24
+ entries map[string]*CacheEntry
25
+ order []string // LRU order: most recently used at end
26
+ maxSize int
27
+ }
28
+
29
+ // NewPageCache creates a new cache with the given maximum number of entries.
30
+ func NewPageCache(maxSize int) *PageCache {
31
+ return &PageCache{
32
+ entries: make(map[string]*CacheEntry),
33
+ order: make([]string, 0, maxSize),
34
+ maxSize: maxSize,
35
+ }
36
+ }
37
+
38
+ // Get retrieves a cached page. Returns the entry and whether it was found.
39
+ // Also returns whether the entry is stale (past its revalidate TTL).
40
+ func (c *PageCache) Get(key string) (entry *CacheEntry, found bool, stale bool) {
41
+ c.mu.RLock()
42
+ e, ok := c.entries[key]
43
+ c.mu.RUnlock()
44
+
45
+ if !ok {
46
+ return nil, false, false
47
+ }
48
+
49
+ // Check if the entry has expired
50
+ if e.Revalidate > 0 {
51
+ age := time.Since(e.CreatedAt).Seconds()
52
+ if age > float64(e.Revalidate) {
53
+ return e, true, true // Found but stale
54
+ }
55
+ }
56
+
57
+ // Move to end of LRU order (most recently used)
58
+ c.mu.Lock()
59
+ c.moveToEnd(key)
60
+ c.mu.Unlock()
61
+
62
+ return e, true, false
63
+ }
64
+
65
+ // Set stores a page in the cache with the given revalidation TTL.
66
+ func (c *PageCache) Set(key string, html string, revalidate int) {
67
+ etag := generateETag(html)
68
+
69
+ c.mu.Lock()
70
+ defer c.mu.Unlock()
71
+
72
+ // If entry exists, update it
73
+ if _, exists := c.entries[key]; exists {
74
+ c.entries[key] = &CacheEntry{
75
+ HTML: html,
76
+ ETag: etag,
77
+ CreatedAt: time.Now(),
78
+ Revalidate: revalidate,
79
+ }
80
+ c.moveToEnd(key)
81
+ return
82
+ }
83
+
84
+ // Evict LRU entry if at capacity
85
+ if len(c.entries) >= c.maxSize && len(c.order) > 0 {
86
+ oldest := c.order[0]
87
+ c.order = c.order[1:]
88
+ delete(c.entries, oldest)
89
+ }
90
+
91
+ // Add new entry
92
+ c.entries[key] = &CacheEntry{
93
+ HTML: html,
94
+ ETag: etag,
95
+ CreatedAt: time.Now(),
96
+ Revalidate: revalidate,
97
+ }
98
+ c.order = append(c.order, key)
99
+ }
100
+
101
+ // Invalidate removes a specific key from the cache.
102
+ func (c *PageCache) Invalidate(key string) {
103
+ c.mu.Lock()
104
+ defer c.mu.Unlock()
105
+
106
+ delete(c.entries, key)
107
+ for i, k := range c.order {
108
+ if k == key {
109
+ c.order = append(c.order[:i], c.order[i+1:]...)
110
+ break
111
+ }
112
+ }
113
+ }
114
+
115
+ // Clear removes all entries from the cache.
116
+ func (c *PageCache) Clear() {
117
+ c.mu.Lock()
118
+ defer c.mu.Unlock()
119
+
120
+ c.entries = make(map[string]*CacheEntry)
121
+ c.order = make([]string, 0, c.maxSize)
122
+ }
123
+
124
+ // Size returns the current number of cached entries.
125
+ func (c *PageCache) Size() int {
126
+ c.mu.RLock()
127
+ defer c.mu.RUnlock()
128
+ return len(c.entries)
129
+ }
130
+
131
+ // moveToEnd moves a key to the end of the LRU order (most recently used).
132
+ // Must be called with the write lock held.
133
+ func (c *PageCache) moveToEnd(key string) {
134
+ for i, k := range c.order {
135
+ if k == key {
136
+ c.order = append(c.order[:i], c.order[i+1:]...)
137
+ c.order = append(c.order, key)
138
+ return
139
+ }
140
+ }
141
+ }
142
+
143
+ // generateETag creates an ETag from the HTML content.
144
+ func generateETag(content string) string {
145
+ hash := sha256.Sum256([]byte(content))
146
+ return `"` + hex.EncodeToString(hash[:8]) + `"`
147
+ }
@@ -0,0 +1,200 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "fmt"
8
+ "image"
9
+ "image/jpeg"
10
+ "image/png"
11
+ "log"
12
+ "net/http"
13
+ "os"
14
+ "path/filepath"
15
+ "strconv"
16
+ "strings"
17
+
18
+ "golang.org/x/image/draw"
19
+ )
20
+
21
+ const (
22
+ imageCacheDir = ".blumen/image-cache"
23
+ maxImageWidth = 4096
24
+ defaultQuality = 80
25
+ )
26
+
27
+ func init() {
28
+ // Ensure cache directory exists
29
+ os.MkdirAll(imageCacheDir, 0755)
30
+ }
31
+
32
+ // ImageHandler serves optimized images with on-the-fly resizing and caching.
33
+ // URL: /_blumen/image?src=/static/hero.jpg&w=800&q=80&blur=1
34
+ func ImageHandler() http.HandlerFunc {
35
+ return func(w http.ResponseWriter, r *http.Request) {
36
+ if r.Method != http.MethodGet {
37
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
38
+ return
39
+ }
40
+
41
+ q := r.URL.Query()
42
+ src := q.Get("src")
43
+ widthStr := q.Get("w")
44
+ qualityStr := q.Get("q")
45
+ isBlur := q.Get("blur") == "1"
46
+
47
+ if src == "" {
48
+ http.Error(w, "Missing 'src' parameter", http.StatusBadRequest)
49
+ return
50
+ }
51
+
52
+ // Parse width
53
+ targetWidth := 0
54
+ if widthStr != "" {
55
+ var err error
56
+ targetWidth, err = strconv.Atoi(widthStr)
57
+ if err != nil || targetWidth <= 0 || targetWidth > maxImageWidth {
58
+ http.Error(w, "Invalid 'w' parameter", http.StatusBadRequest)
59
+ return
60
+ }
61
+ }
62
+
63
+ // Parse quality
64
+ quality := defaultQuality
65
+ if qualityStr != "" {
66
+ var err error
67
+ quality, err = strconv.Atoi(qualityStr)
68
+ if err != nil || quality < 1 || quality > 100 {
69
+ quality = defaultQuality
70
+ }
71
+ }
72
+
73
+ // Security: resolve the source path and ensure it's within the project
74
+ cleanSrc := strings.TrimPrefix(src, "/")
75
+ absPath, err := filepath.Abs(cleanSrc)
76
+ if err != nil {
77
+ http.Error(w, "Invalid path", http.StatusBadRequest)
78
+ return
79
+ }
80
+
81
+ // Ensure path is within the project directory
82
+ cwd, _ := os.Getwd()
83
+ if !strings.HasPrefix(absPath, cwd) {
84
+ http.Error(w, "Access denied", http.StatusForbidden)
85
+ return
86
+ }
87
+
88
+ // Check if file exists
89
+ if _, err := os.Stat(absPath); os.IsNotExist(err) {
90
+ http.Error(w, "Image not found", http.StatusNotFound)
91
+ return
92
+ }
93
+
94
+ // Generate cache key
95
+ cacheKey := generateCacheKey(src, targetWidth, quality, isBlur)
96
+ cachePath := filepath.Join(imageCacheDir, cacheKey+".jpg")
97
+
98
+ // Check disk cache
99
+ if cachedData, err := os.ReadFile(cachePath); err == nil {
100
+ serveCachedImage(w, cachedData)
101
+ return
102
+ }
103
+
104
+ // Process the image
105
+ processedData, err := processImage(absPath, targetWidth, quality, isBlur)
106
+ if err != nil {
107
+ log.Printf("Image processing error: %v", err)
108
+ http.ServeFile(w, r, absPath)
109
+ return
110
+ }
111
+
112
+ // Write to disk cache
113
+ os.WriteFile(cachePath, processedData, 0644)
114
+
115
+ // Serve the processed image
116
+ serveCachedImage(w, processedData)
117
+ }
118
+ }
119
+
120
+ func generateCacheKey(src string, width, quality int, blur bool) string {
121
+ data := fmt.Sprintf("%s-%d-%d-%v", src, width, quality, blur)
122
+ hash := sha256.Sum256([]byte(data))
123
+ return hex.EncodeToString(hash[:16])
124
+ }
125
+
126
+ func serveCachedImage(w http.ResponseWriter, data []byte) {
127
+ w.Header().Set("Content-Type", "image/jpeg")
128
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
129
+ w.Header().Set("X-Blumen-Image", "optimized")
130
+ w.Header().Set("Content-Length", strconv.Itoa(len(data)))
131
+ w.WriteHeader(http.StatusOK)
132
+ w.Write(data)
133
+ }
134
+
135
+ func processImage(filePath string, targetWidth, quality int, blur bool) ([]byte, error) {
136
+ file, err := os.Open(filePath)
137
+ if err != nil {
138
+ return nil, fmt.Errorf("open image: %w", err)
139
+ }
140
+ defer file.Close()
141
+
142
+ img, format, err := image.Decode(file)
143
+ if err != nil {
144
+ return nil, fmt.Errorf("decode image (%s): %w", format, err)
145
+ }
146
+
147
+ bounds := img.Bounds()
148
+ origWidth := bounds.Dx()
149
+ origHeight := bounds.Dy()
150
+
151
+ // Calculate target dimensions maintaining aspect ratio
152
+ newWidth := origWidth
153
+ newHeight := origHeight
154
+
155
+ if targetWidth > 0 && targetWidth < origWidth {
156
+ ratio := float64(targetWidth) / float64(origWidth)
157
+ newWidth = targetWidth
158
+ newHeight = int(float64(origHeight) * ratio)
159
+ }
160
+
161
+ // Resize using high-quality bilinear interpolation
162
+ resized := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
163
+ draw.BiLinear.Scale(resized, resized.Bounds(), img, bounds, draw.Over, nil)
164
+
165
+ // For blur placeholders, downscale aggressively
166
+ var finalImg image.Image = resized
167
+ if blur {
168
+ blurWidth := 20
169
+ if newWidth < blurWidth {
170
+ blurWidth = newWidth
171
+ }
172
+ blurHeight := int(float64(newHeight) * (float64(blurWidth) / float64(newWidth)))
173
+ if blurHeight < 1 {
174
+ blurHeight = 1
175
+ }
176
+ blurred := image.NewRGBA(image.Rect(0, 0, blurWidth, blurHeight))
177
+ draw.BiLinear.Scale(blurred, blurred.Bounds(), resized, resized.Bounds(), draw.Over, nil)
178
+ finalImg = blurred
179
+ }
180
+
181
+ // Encode to JPEG
182
+ var buf bytes.Buffer
183
+
184
+ jpegQuality := quality
185
+ if blur {
186
+ jpegQuality = 30
187
+ }
188
+
189
+ err = jpeg.Encode(&buf, finalImg, &jpeg.Options{Quality: jpegQuality})
190
+ if err != nil {
191
+ return nil, fmt.Errorf("encode jpeg: %w", err)
192
+ }
193
+
194
+ return buf.Bytes(), nil
195
+ }
196
+
197
+ // Ensure PNG decoder is registered
198
+ func init() {
199
+ _ = png.Decode
200
+ }
@@ -11,9 +11,14 @@ 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"
15
16
  )
16
17
 
18
+ // Global page cache — stores rendered HTML in Go memory for near-instant responses.
19
+ // Max 500 entries with LRU eviction. A single Go server can cache the entire site.
20
+ var pageCache = NewPageCache(500)
21
+
17
22
  // SSRResponse from Node service
18
23
  type SSRResponse struct {
19
24
  HTML string `json:"html"`
@@ -28,6 +33,21 @@ type SSRRequest struct {
28
33
  Data map[string]interface{} `json:"data,omitempty"`
29
34
  }
30
35
 
36
+ // DataRequest to Node /data endpoint
37
+ type DataRequest struct {
38
+ Path string `json:"path"`
39
+ Query map[string][]string `json:"query"`
40
+ Params map[string]interface{} `json:"params"`
41
+ Headers map[string]string `json:"headers,omitempty"`
42
+ }
43
+
44
+ // DataResponse from Node /data endpoint
45
+ type DataResponse struct {
46
+ Props map[string]interface{} `json:"props"`
47
+ HasServerProps bool `json:"hasServerProps"`
48
+ Revalidate int `json:"revalidate"` // TTL in seconds
49
+ }
50
+
31
51
  var httpClient = &http.Client{
32
52
  Timeout: 10 * time.Second,
33
53
  }
@@ -35,8 +55,20 @@ var httpClient = &http.Client{
35
55
  func main() {
36
56
  mux := http.NewServeMux()
37
57
 
38
- // Static files are served directly — must be registered first
39
- mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
58
+ // Static files with immutable cache headers
59
+ staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
60
+ mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
61
+ // Set aggressive cache headers for static assets
62
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
63
+ w.Header().Set("X-Blumen-Cache", "STATIC")
64
+ staticHandler.ServeHTTP(w, r)
65
+ })
66
+
67
+ // Image optimization endpoint - processes and caches images on the fly
68
+ mux.HandleFunc("/_blumen/image", ImageHandler())
69
+
70
+ // Cache management endpoint (for development/debugging)
71
+ mux.HandleFunc("/_blumen/cache", cacheStatusHandler)
40
72
 
41
73
  // Specific routes with Go loaders for data fetching
42
74
  mux.HandleFunc("/dashboard/settings", PageHandler(func(r *http.Request) (map[string]interface{}, error) {
@@ -46,16 +78,9 @@ func main() {
46
78
  }, nil
47
79
  }))
48
80
 
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
- }))
56
-
57
81
  // Catch-all: forward every other request to the Node SSR server.
58
- // This decouples Go from knowing about specific React page paths.
82
+ // If the page exports getServerProps, Node will fetch the data first.
83
+ // If a Go DataLoader is registered above, it takes priority.
59
84
  mux.HandleFunc("/", PageHandler(nil))
60
85
 
61
86
  startPort := 3000
@@ -73,6 +98,7 @@ func main() {
73
98
  }
74
99
 
75
100
  log.Printf("Go server starting on http://localhost:%d", startPort)
101
+ log.Printf("Page cache initialized (max %d entries)", 500)
76
102
  if err := http.Serve(listener, mux); err != nil {
77
103
  log.Fatalf("Server error: %v", err)
78
104
  }
@@ -87,33 +113,114 @@ func PageHandler(loader DataLoader) http.HandlerFunc {
87
113
  return
88
114
  }
89
115
 
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)
116
+ // Generate cache key from the full URL (path + query string)
117
+ cacheKey := r.URL.RequestURI()
118
+
119
+ // Check the page cache first
120
+ if entry, found, stale := pageCache.Get(cacheKey); found {
121
+ // Handle ETag conditional request
122
+ if match := r.Header.Get("If-None-Match"); match == entry.ETag {
123
+ w.Header().Set("X-Blumen-Cache", "HIT")
124
+ w.WriteHeader(http.StatusNotModified)
97
125
  return
98
126
  }
99
- }
100
127
 
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{})
128
+ if !stale {
129
+ // CACHE HIT: serve directly from Go memory (sub-millisecond)
130
+ serveCachedPage(w, entry, "HIT")
131
+ return
106
132
  }
107
- json.NewEncoder(w).Encode(props)
133
+
134
+ // STALE: serve stale content immediately, revalidate in background
135
+ serveCachedPage(w, entry, "STALE")
136
+
137
+ // Background revalidation (fire and forget)
138
+ go func() {
139
+ html, revalidate := renderPage(r, loader)
140
+ if html != "" {
141
+ pageCache.Set(cacheKey, html, revalidate)
142
+ }
143
+ }()
144
+ return
145
+ }
146
+
147
+ // CACHE MISS: render the page and cache the result
148
+ html, revalidate := renderPage(r, loader)
149
+ if html == "" {
150
+ // renderPage already wrote the error response
108
151
  return
109
152
  }
110
153
 
111
- handleRouteWithProps(w, r, props)
154
+ // Cache the rendered page
155
+ pageCache.Set(cacheKey, html, revalidate)
156
+
157
+ // Serve the fresh response
158
+ entry, _, _ := pageCache.Get(cacheKey)
159
+ if entry != nil {
160
+ serveCachedPage(w, entry, "MISS")
161
+ } else {
162
+ // Fallback: serve directly without cache metadata
163
+ writeHTMLResponse(w, html, "MISS", "")
164
+ }
165
+ }
166
+ }
167
+
168
+ // serveCachedPage writes a cached page response with proper headers.
169
+ func serveCachedPage(w http.ResponseWriter, entry *CacheEntry, cacheStatus string) {
170
+ writeHTMLResponse(w, entry.HTML, cacheStatus, entry.ETag)
171
+ }
172
+
173
+ // writeHTMLResponse writes an HTML response with cache and security headers.
174
+ func writeHTMLResponse(w http.ResponseWriter, html string, cacheStatus string, etag string) {
175
+ // Security headers
176
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
177
+ w.Header().Set("X-Content-Type-Options", "nosniff")
178
+ w.Header().Set("X-Frame-Options", "DENY")
179
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
180
+
181
+ // Cache headers
182
+ w.Header().Set("X-Blumen-Cache", cacheStatus)
183
+ if etag != "" {
184
+ w.Header().Set("ETag", etag)
112
185
  }
186
+ // Tell browsers and CDNs they can cache for 10s, serve stale for 60s while revalidating
187
+ w.Header().Set("Cache-Control", "public, s-maxage=10, stale-while-revalidate=60")
188
+
189
+ w.WriteHeader(http.StatusOK)
190
+ w.Write([]byte(html))
113
191
  }
114
192
 
115
- func handleRouteWithProps(w http.ResponseWriter, r *http.Request, props map[string]interface{}) {
116
- // Prepare SSR request
193
+ // renderPage performs the full SSR pipeline: fetch data + render HTML.
194
+ // Returns the HTML string and the revalidation TTL in seconds.
195
+ func renderPage(r *http.Request, loader DataLoader) (string, int) {
196
+ var props map[string]interface{}
197
+ var err error
198
+ revalidate := 0
199
+
200
+ if loader != nil {
201
+ // Use Go DataLoader (takes priority over getServerProps)
202
+ props, err = loader(r)
203
+ if err != nil {
204
+ log.Printf("Loader error: %v", err)
205
+ return "", 0
206
+ }
207
+ } else {
208
+ // No Go DataLoader - check if the page has getServerProps
209
+ tsProps, tsRevalidate, tsErr := fetchServerProps(r)
210
+ if tsErr != nil {
211
+ log.Printf("getServerProps error: %v", tsErr)
212
+ // Non-fatal: render the page without server props
213
+ } else if tsProps != nil {
214
+ props = tsProps
215
+ revalidate = tsRevalidate
216
+ }
217
+ }
218
+
219
+ // If this is a SPA data fetch request, return JSON props (don't cache)
220
+ // This check shouldn't happen in renderPage since it's called from the handler,
221
+ // but the handler already handles this case before reaching here.
222
+
223
+ // Call Node SSR to render the HTML
117
224
  ssrReq := SSRRequest{
118
225
  Path: r.URL.Path,
119
226
  Query: r.URL.Query(),
@@ -124,24 +231,64 @@ func handleRouteWithProps(w http.ResponseWriter, r *http.Request, props map[stri
124
231
  Data: props,
125
232
  }
126
233
 
127
- // Call Node SSR service
128
234
  ssrResp, err := callNodeSSR(ssrReq)
129
235
  if err != nil {
130
236
  log.Printf("SSR error: %v", err)
131
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
132
- return
237
+ return "", 0
133
238
  }
134
239
 
240
+ return ssrResp.HTML, revalidate
241
+ }
135
242
 
243
+ // fetchServerProps calls the Node SSR /data endpoint to run getServerProps.
244
+ // Returns nil if the page doesn't export getServerProps.
245
+ // Also returns the revalidate TTL (0 = no caching from getServerProps).
246
+ func fetchServerProps(r *http.Request) (map[string]interface{}, int, error) {
247
+ // Build a subset of headers to forward
248
+ headers := make(map[string]string)
249
+ for _, key := range []string{"Authorization", "Cookie", "Accept-Language", "User-Agent"} {
250
+ if val := r.Header.Get(key); val != "" {
251
+ headers[key] = val
252
+ }
253
+ }
136
254
 
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")
255
+ dataReq := DataRequest{
256
+ Path: r.URL.Path,
257
+ Query: r.URL.Query(),
258
+ Params: map[string]interface{}{},
259
+ Headers: headers,
260
+ }
142
261
 
143
- w.WriteHeader(http.StatusOK)
144
- w.Write([]byte(ssrResp.HTML))
262
+ reqBody, err := json.Marshal(dataReq)
263
+ if err != nil {
264
+ return nil, 0, fmt.Errorf("marshal data request: %w", err)
265
+ }
266
+
267
+ resp, err := httpClient.Post(
268
+ nodeDataURL,
269
+ "application/json",
270
+ bytes.NewReader(reqBody),
271
+ )
272
+ if err != nil {
273
+ return nil, 0, fmt.Errorf("http post /data: %w", err)
274
+ }
275
+ defer resp.Body.Close()
276
+
277
+ if resp.StatusCode != http.StatusOK {
278
+ return nil, 0, fmt.Errorf("node /data returned %d", resp.StatusCode)
279
+ }
280
+
281
+ var dataResp DataResponse
282
+ if err := json.NewDecoder(resp.Body).Decode(&dataResp); err != nil {
283
+ return nil, 0, fmt.Errorf("decode data response: %w", err)
284
+ }
285
+
286
+ if !dataResp.HasServerProps {
287
+ // Page doesn't export getServerProps, skip
288
+ return nil, 0, nil
289
+ }
290
+
291
+ return dataResp.Props, dataResp.Revalidate, nil
145
292
  }
146
293
 
147
294
  func callNodeSSR(req SSRRequest) (*SSRResponse, error) {
@@ -172,4 +319,25 @@ func callNodeSSR(req SSRRequest) (*SSRResponse, error) {
172
319
  return &ssrResp, nil
173
320
  }
174
321
 
175
-
322
+ // cacheStatusHandler provides cache introspection for debugging.
323
+ // GET /_blumen/cache — returns current cache size and status
324
+ // DELETE /_blumen/cache — clears the entire cache
325
+ func cacheStatusHandler(w http.ResponseWriter, r *http.Request) {
326
+ switch r.Method {
327
+ case http.MethodGet:
328
+ w.Header().Set("Content-Type", "application/json")
329
+ json.NewEncoder(w).Encode(map[string]interface{}{
330
+ "size": pageCache.Size(),
331
+ "maxSize": 500,
332
+ "status": "active",
333
+ })
334
+ case http.MethodDelete:
335
+ pageCache.Clear()
336
+ w.Header().Set("Content-Type", "application/json")
337
+ json.NewEncoder(w).Encode(map[string]interface{}{
338
+ "message": "Cache cleared",
339
+ })
340
+ default:
341
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
342
+ }
343
+ }