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.
- package/dist/cli/blumen.js +258 -13
- package/dist/cli/commands/build.js +13 -4
- package/dist/cli/commands/create.js +1 -1
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/fonts.js +232 -0
- package/dist/cli/commands/start.js +1 -1
- package/dist/templates/app/client/entry.tsx +3 -1
- package/dist/templates/app/shared/RouterContext.tsx +4 -1
- package/dist/templates/go-server/cache.go +147 -0
- package/dist/templates/go-server/image.go +200 -0
- package/dist/templates/go-server/main.go +208 -40
- package/dist/templates/node-ssr/server.ts +120 -8
- package/dist/templates/scripts/generate-routes.ts +43 -3
- package/package.json +7 -3
|
@@ -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
|
-
<
|
|
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
|
|
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
|
|
39
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
+
}
|