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.
- package/dist/cli/blumen.js +270 -15
- package/dist/cli/commands/build.js +25 -6
- 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/pages/BlumenStarter.tsx +1 -1
- package/dist/templates/app/shared/DefaultDocument.tsx +49 -4
- package/dist/templates/app/shared/Link.tsx +60 -6
- package/dist/templates/app/shared/RouterContext.tsx +81 -18
- package/dist/templates/app/shared/router.ts +2 -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 +394 -39
- package/dist/templates/go-server/middleware.go +546 -0
- package/dist/templates/go-server/websocket.go +430 -0
- package/dist/templates/node-ssr/server.ts +364 -8
- package/dist/templates/scripts/generate-routes.ts +355 -7
- package/package.json +12 -6
|
@@ -11,9 +11,22 @@ import (
|
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
const (
|
|
14
|
-
nodeSSRURL
|
|
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
|
-
//
|
|
39
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
if
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
+
}
|