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.
- package/dist/cli/blumen.js +890 -67
- package/dist/cli/commands/build.js +60 -9
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/start.js +1 -1
- package/dist/templates/app/client/entry.tsx +5 -1
- package/dist/templates/app/pages/BlumenStarter.tsx +1 -1
- package/dist/templates/app/shared/DefaultDocument.tsx +67 -8
- package/dist/templates/app/shared/Link.tsx +60 -6
- package/dist/templates/app/shared/RouterContext.tsx +83 -20
- package/dist/templates/app/shared/router.ts +2 -1
- package/dist/templates/go-server/main.go +294 -4
- 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 +467 -3
- package/dist/templates/scripts/generate-routes.ts +457 -17
- package/package.json +21 -7
|
@@ -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
|
|
15
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|