blumenjs 0.2.1 → 0.2.3

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.
@@ -14,6 +14,7 @@ import React, {
14
14
  useCallback,
15
15
  useEffect,
16
16
  useRef,
17
+ Suspense,
17
18
  } from "react";
18
19
  import { matchRoute, type RouteDef } from "./router";
19
20
  import { BlumenErrorBoundary } from "./ErrorBoundary";
@@ -230,7 +231,9 @@ export function RouterProvider({
230
231
  className={`page-transition ${transitioning ? "page-transition-exit" : "page-transition-active"}`}
231
232
  >
232
233
  <BlumenErrorBoundary>
233
- <App Component={PageComponent} pageProps={pageProps} />
234
+ <Suspense fallback={LoadingComp ? <LoadingComp /> : <DefaultLoading />}>
235
+ <App Component={PageComponent} pageProps={pageProps} />
236
+ </Suspense>
234
237
  </BlumenErrorBoundary>
235
238
  </div>
236
239
  )}
@@ -0,0 +1,147 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io"
7
+ "log"
8
+ "net/http"
9
+ "strings"
10
+ "time"
11
+ )
12
+
13
+ // ─── Server Actions Handler ────────────────────────────────────
14
+ // Handles POST /_blumen/action requests from client components.
15
+ // Validates CSRF tokens, then forwards to the Node SSR server
16
+ // for execution.
17
+
18
+ // actionRequest is the JSON body from the client.
19
+ type actionRequest struct {
20
+ Action string `json:"action"`
21
+ Input interface{} `json:"input"`
22
+ }
23
+
24
+ // actionResponse is the response from the Node SSR server.
25
+ type actionResponse struct {
26
+ Success bool `json:"success"`
27
+ Data interface{} `json:"data,omitempty"`
28
+ Error string `json:"error,omitempty"`
29
+ }
30
+
31
+ // ActionHandler creates the HTTP handler for server actions.
32
+ func ActionHandler() http.HandlerFunc {
33
+ return func(w http.ResponseWriter, r *http.Request) {
34
+ // Only POST is allowed
35
+ if r.Method != http.MethodPost {
36
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
37
+ return
38
+ }
39
+
40
+ // ── CSRF Validation (double-submit cookie pattern) ────
41
+ // The client sends a token in the X-CSRF-Token header
42
+ // and also sets it as a _blumen_csrf cookie.
43
+ // We validate that they match.
44
+ headerToken := r.Header.Get("X-CSRF-Token")
45
+ if headerToken == "" {
46
+ w.Header().Set("Content-Type", "application/json")
47
+ w.WriteHeader(http.StatusForbidden)
48
+ json.NewEncoder(w).Encode(actionResponse{
49
+ Success: false,
50
+ Error: "Missing CSRF token",
51
+ })
52
+ return
53
+ }
54
+
55
+ // Get CSRF cookie
56
+ cookieToken := ""
57
+ if cookie, err := r.Cookie("_blumen_csrf"); err == nil {
58
+ cookieToken = cookie.Value
59
+ }
60
+
61
+ // Validate: header token must match cookie token
62
+ if cookieToken == "" || headerToken != cookieToken {
63
+ w.Header().Set("Content-Type", "application/json")
64
+ w.WriteHeader(http.StatusForbidden)
65
+ json.NewEncoder(w).Encode(actionResponse{
66
+ Success: false,
67
+ Error: "Invalid CSRF token",
68
+ })
69
+ return
70
+ }
71
+
72
+ // ── Parse request body ────────────────────────────────
73
+ body, err := io.ReadAll(r.Body)
74
+ if err != nil {
75
+ w.Header().Set("Content-Type", "application/json")
76
+ w.WriteHeader(http.StatusBadRequest)
77
+ json.NewEncoder(w).Encode(actionResponse{
78
+ Success: false,
79
+ Error: "Failed to read request body",
80
+ })
81
+ return
82
+ }
83
+ defer r.Body.Close()
84
+
85
+ var actionReq actionRequest
86
+ if err := json.Unmarshal(body, &actionReq); err != nil {
87
+ w.Header().Set("Content-Type", "application/json")
88
+ w.WriteHeader(http.StatusBadRequest)
89
+ json.NewEncoder(w).Encode(actionResponse{
90
+ Success: false,
91
+ Error: "Invalid request body",
92
+ })
93
+ return
94
+ }
95
+
96
+ if actionReq.Action == "" {
97
+ w.Header().Set("Content-Type", "application/json")
98
+ w.WriteHeader(http.StatusBadRequest)
99
+ json.NewEncoder(w).Encode(actionResponse{
100
+ Success: false,
101
+ Error: "Action name is required",
102
+ })
103
+ return
104
+ }
105
+
106
+ // ── Forward to Node SSR server ────────────────────────
107
+ nodeURL := fmt.Sprintf("http://localhost:4000/action")
108
+
109
+ nodeBody, _ := json.Marshal(map[string]interface{}{
110
+ "action": actionReq.Action,
111
+ "input": actionReq.Input,
112
+ })
113
+
114
+ nodeReq, err := http.NewRequest("POST", nodeURL, strings.NewReader(string(nodeBody)))
115
+ if err != nil {
116
+ log.Printf("Action proxy error: %v", err)
117
+ w.Header().Set("Content-Type", "application/json")
118
+ w.WriteHeader(http.StatusInternalServerError)
119
+ json.NewEncoder(w).Encode(actionResponse{
120
+ Success: false,
121
+ Error: "Failed to create action request",
122
+ })
123
+ return
124
+ }
125
+ nodeReq.Header.Set("Content-Type", "application/json")
126
+
127
+ client := &http.Client{Timeout: 30 * time.Second}
128
+ resp, err := client.Do(nodeReq)
129
+ if err != nil {
130
+ log.Printf("Action execution error: %v", err)
131
+ w.Header().Set("Content-Type", "application/json")
132
+ w.WriteHeader(http.StatusServiceUnavailable)
133
+ json.NewEncoder(w).Encode(actionResponse{
134
+ Success: false,
135
+ Error: "Action server unavailable",
136
+ })
137
+ return
138
+ }
139
+ defer resp.Body.Close()
140
+
141
+ // Forward the response from Node
142
+ respBody, _ := io.ReadAll(resp.Body)
143
+ w.Header().Set("Content-Type", "application/json")
144
+ w.WriteHeader(resp.StatusCode)
145
+ w.Write(respBody)
146
+ }
147
+ }
@@ -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,9 +12,10 @@ import (
11
12
  )
12
13
 
13
14
  const (
14
- nodeSSRURL = "http://localhost:4000/render"
15
- nodeDataURL = "http://localhost:4000/data"
16
- nodeAPIURL = "http://localhost:4000/api"
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"
17
19
  )
18
20
 
19
21
  // Global page cache — stores rendered HTML in Go memory for near-instant responses.
@@ -234,7 +236,23 @@ func PageHandler(loader DataLoader) http.HandlerFunc {
234
236
  return
235
237
  }
236
238
 
237
- // CACHE MISS: render the page and cache the result
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
238
256
  html, revalidate := renderPage(r, loader)
239
257
  if html == "" {
240
258
  // renderPage already wrote the error response
@@ -330,6 +348,91 @@ func renderPage(r *http.Request, loader DataLoader) (string, int) {
330
348
  return ssrResp.HTML, revalidate
331
349
  }
332
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
+
333
436
  // fetchServerProps calls the Node SSR /data endpoint to run getServerProps.
334
437
  // Returns nil if the page doesn't export getServerProps.
335
438
  // Also returns the revalidate TTL (0 = no caching from getServerProps).
@@ -423,7 +423,7 @@ type SecurityConfig struct {
423
423
  // DefaultSecurityConfig returns production-ready security header defaults.
424
424
  func DefaultSecurityConfig() SecurityConfig {
425
425
  return SecurityConfig{
426
- CSP: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:;",
426
+ CSP: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:3100; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; connect-src 'self' ws: wss: http://localhost:3100 ws://localhost:3100;",
427
427
  HSTSMaxAge: 31536000, // 1 year
428
428
  HSTSIncludeSubdomains: true,
429
429
  FrameOptions: "DENY",
@@ -0,0 +1,203 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "log"
6
+ "net/http"
7
+ "os"
8
+ "regexp"
9
+ "strings"
10
+ )
11
+
12
+ // ─── Redirects & Rewrites Engine ───────────────────────────────
13
+ // Declarative URL routing configured via blumen.routes.json.
14
+ // Processed at the Go layer for zero-overhead URL manipulation.
15
+
16
+ // RedirectRule defines a URL redirect.
17
+ type RedirectRule struct {
18
+ Source string `json:"source"` // Source path pattern (e.g. "/old", "/blog/:slug")
19
+ Destination string `json:"destination"` // Destination URL
20
+ Permanent bool `json:"permanent"` // 301 (permanent) or 307 (temporary)
21
+ }
22
+
23
+ // RewriteRule defines a URL rewrite (internal, transparent to browser).
24
+ type RewriteRule struct {
25
+ Source string `json:"source"` // Source path pattern
26
+ Destination string `json:"destination"` // Internal destination path
27
+ }
28
+
29
+ // RoutesConfig holds all redirect and rewrite rules.
30
+ type RoutesConfig struct {
31
+ Redirects []RedirectRule `json:"redirects"`
32
+ Rewrites []RewriteRule `json:"rewrites"`
33
+ }
34
+
35
+ // compiledRedirect is a pre-compiled redirect rule for fast matching.
36
+ type compiledRedirect struct {
37
+ pattern *regexp.Regexp
38
+ paramNames []string
39
+ destination string
40
+ permanent bool
41
+ }
42
+
43
+ // compiledRewrite is a pre-compiled rewrite rule for fast matching.
44
+ type compiledRewrite struct {
45
+ pattern *regexp.Regexp
46
+ paramNames []string
47
+ destination string
48
+ }
49
+
50
+ // RoutesEngine processes redirects and rewrites.
51
+ type RoutesEngine struct {
52
+ redirects []compiledRedirect
53
+ rewrites []compiledRewrite
54
+ }
55
+
56
+ // LoadRoutesConfig reads the routes config from blumen.routes.json.
57
+ func LoadRoutesConfig(path string) (*RoutesConfig, error) {
58
+ data, err := os.ReadFile(path)
59
+ if err != nil {
60
+ return nil, err
61
+ }
62
+
63
+ var config RoutesConfig
64
+ if err := json.Unmarshal(data, &config); err != nil {
65
+ return nil, err
66
+ }
67
+
68
+ return &config, nil
69
+ }
70
+
71
+ // NewRoutesEngine compiles redirect and rewrite rules for fast matching.
72
+ func NewRoutesEngine(config *RoutesConfig) *RoutesEngine {
73
+ engine := &RoutesEngine{}
74
+
75
+ for _, r := range config.Redirects {
76
+ pattern, params := compileRoutePattern(r.Source)
77
+ engine.redirects = append(engine.redirects, compiledRedirect{
78
+ pattern: pattern,
79
+ paramNames: params,
80
+ destination: r.Destination,
81
+ permanent: r.Permanent,
82
+ })
83
+ }
84
+
85
+ for _, r := range config.Rewrites {
86
+ pattern, params := compileRoutePattern(r.Source)
87
+ engine.rewrites = append(engine.rewrites, compiledRewrite{
88
+ pattern: pattern,
89
+ paramNames: params,
90
+ destination: r.Destination,
91
+ })
92
+ }
93
+
94
+ return engine
95
+ }
96
+
97
+ // compileRoutePattern converts a route pattern like "/blog/:slug" or "/old/*"
98
+ // into a compiled regex and extracts parameter names.
99
+ func compileRoutePattern(source string) (*regexp.Regexp, []string) {
100
+ var paramNames []string
101
+ regexStr := "^"
102
+
103
+ parts := strings.Split(source, "/")
104
+ for _, part := range parts {
105
+ if part == "" {
106
+ continue
107
+ }
108
+
109
+ regexStr += "/"
110
+
111
+ if part == "*" {
112
+ // Wildcard: match everything
113
+ regexStr += "(.*)"
114
+ paramNames = append(paramNames, "*")
115
+ } else if strings.HasPrefix(part, ":") {
116
+ // Named parameter: match path segment
117
+ paramName := strings.TrimPrefix(part, ":")
118
+ paramNames = append(paramNames, paramName)
119
+ regexStr += "([^/]+)"
120
+ } else {
121
+ // Literal match
122
+ regexStr += regexp.QuoteMeta(part)
123
+ }
124
+ }
125
+
126
+ regexStr += "$"
127
+ return regexp.MustCompile(regexStr), paramNames
128
+ }
129
+
130
+ // interpolateDestination replaces :param placeholders in the destination
131
+ // with matched values from the source pattern.
132
+ func interpolateDestination(destination string, paramNames []string, matches []string) string {
133
+ result := destination
134
+ for i, name := range paramNames {
135
+ if i+1 < len(matches) {
136
+ if name == "*" {
137
+ result = strings.ReplaceAll(result, ":splat", matches[i+1])
138
+ } else {
139
+ result = strings.ReplaceAll(result, ":"+name, matches[i+1])
140
+ }
141
+ }
142
+ }
143
+ return result
144
+ }
145
+
146
+ // RedirectsMiddleware creates a middleware that processes redirects and rewrites.
147
+ func RedirectsMiddleware(engine *RoutesEngine) MiddlewareFunc {
148
+ return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
149
+ path := r.URL.Path
150
+
151
+ // Check redirects first (external, browser follows)
152
+ for _, redirect := range engine.redirects {
153
+ matches := redirect.pattern.FindStringSubmatch(path)
154
+ if matches != nil {
155
+ dest := interpolateDestination(redirect.destination, redirect.paramNames, matches)
156
+ status := http.StatusTemporaryRedirect // 307
157
+ if redirect.permanent {
158
+ status = http.StatusMovedPermanently // 301
159
+ }
160
+ http.Redirect(w, r, dest, status)
161
+ return
162
+ }
163
+ }
164
+
165
+ // Check rewrites (internal, transparent to browser)
166
+ for _, rewrite := range engine.rewrites {
167
+ matches := rewrite.pattern.FindStringSubmatch(path)
168
+ if matches != nil {
169
+ dest := interpolateDestination(rewrite.destination, rewrite.paramNames, matches)
170
+ r.URL.Path = dest
171
+ next(w, r)
172
+ return
173
+ }
174
+ }
175
+
176
+ // No match — continue to handler
177
+ next(w, r)
178
+ }
179
+ }
180
+
181
+ // SetupRedirects loads the routes config and registers the middleware.
182
+ // Returns the number of rules loaded, or -1 if no config file exists.
183
+ func SetupRedirects(chain *MiddlewareChain, configPath string) int {
184
+ config, err := LoadRoutesConfig(configPath)
185
+ if err != nil {
186
+ if os.IsNotExist(err) {
187
+ return -1 // No config file — not an error
188
+ }
189
+ log.Printf("⚠ Failed to load routes config: %v", err)
190
+ return 0
191
+ }
192
+
193
+ total := len(config.Redirects) + len(config.Rewrites)
194
+ if total == 0 {
195
+ return 0
196
+ }
197
+
198
+ engine := NewRoutesEngine(config)
199
+ chain.Use(RedirectsMiddleware(engine))
200
+
201
+ log.Printf("🔀 Loaded %d redirect(s) and %d rewrite(s)", len(config.Redirects), len(config.Rewrites))
202
+ return total
203
+ }