blumenjs 0.2.2 → 0.2.4

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.
@@ -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
+ }
@@ -0,0 +1,230 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "log"
7
+ "net/http"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "sync"
12
+ "time"
13
+ )
14
+
15
+ // ─── Static Site Generation (SSG) Server ───────────────────────
16
+ // Serves pre-rendered HTML pages directly from disk.
17
+ // Supports Incremental Static Regeneration (ISR) — pages can be
18
+ // revalidated in the background on a configurable timer.
19
+
20
+ // SSGPage tracks a pre-rendered page with ISR metadata.
21
+ type SSGPage struct {
22
+ FilePath string
23
+ Route string
24
+ Revalidate int // TTL in seconds; 0 = never revalidate
25
+ LastRender time.Time // When the page was last rendered
26
+ IsStale bool // Set true when revalidation is in progress
27
+ }
28
+
29
+ // SSGServer manages pre-rendered static pages with ISR.
30
+ type SSGServer struct {
31
+ pages map[string]*SSGPage
32
+ staticDir string
33
+ mu sync.RWMutex
34
+ }
35
+
36
+ // SSGManifest matches the format written by ssg-prerender.ts
37
+ type SSGManifest struct {
38
+ Pages []SSGManifestPage `json:"pages"`
39
+ }
40
+
41
+ type SSGManifestPage struct {
42
+ Route string `json:"route"`
43
+ ComponentId string `json:"componentId"`
44
+ HasGetServerProps bool `json:"hasGetServerProps"`
45
+ HasGetStaticProps bool `json:"hasGetStaticProps"`
46
+ HasGetStaticPaths bool `json:"hasGetStaticPaths"`
47
+ Revalidate int `json:"revalidate"`
48
+ }
49
+
50
+ // NewSSGServer loads the SSG manifest and maps routes to HTML files.
51
+ func NewSSGServer(staticDir string) *SSGServer {
52
+ server := &SSGServer{
53
+ pages: make(map[string]*SSGPage),
54
+ staticDir: staticDir,
55
+ }
56
+
57
+ manifestPath := filepath.Join(staticDir, "manifest.json")
58
+ data, err := os.ReadFile(manifestPath)
59
+ if err != nil {
60
+ return server // No manifest — return empty server
61
+ }
62
+
63
+ // Try the new format (SSGManifest with full metadata)
64
+ var manifest SSGManifest
65
+ if err := json.Unmarshal(data, &manifest); err == nil && len(manifest.Pages) > 0 {
66
+ for _, page := range manifest.Pages {
67
+ filePath := server.routeToFilePath(page.Route)
68
+ if _, err := os.Stat(filePath); err == nil {
69
+ server.pages[page.Route] = &SSGPage{
70
+ FilePath: filePath,
71
+ Route: page.Route,
72
+ Revalidate: page.Revalidate,
73
+ LastRender: time.Now(),
74
+ }
75
+ }
76
+ }
77
+ } else {
78
+ // Fall back to the old format (simple string array)
79
+ var routes []string
80
+ if err := json.Unmarshal(data, &routes); err != nil {
81
+ log.Printf("⚠ SSG manifest parse error: %v", err)
82
+ return server
83
+ }
84
+ for _, route := range routes {
85
+ filePath := server.routeToFilePath(route)
86
+ if _, err := os.Stat(filePath); err == nil {
87
+ server.pages[route] = &SSGPage{
88
+ FilePath: filePath,
89
+ Route: route,
90
+ Revalidate: 0,
91
+ LastRender: time.Now(),
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ if len(server.pages) > 0 {
98
+ isrCount := 0
99
+ for _, p := range server.pages {
100
+ if p.Revalidate > 0 {
101
+ isrCount++
102
+ }
103
+ }
104
+ log.Printf("📄 SSG: %d pre-rendered page(s) loaded", len(server.pages))
105
+ if isrCount > 0 {
106
+ log.Printf("🔄 ISR: %d page(s) with revalidation enabled", isrCount)
107
+ }
108
+ }
109
+
110
+ return server
111
+ }
112
+
113
+ func (s *SSGServer) routeToFilePath(route string) string {
114
+ if route == "/" {
115
+ return filepath.Join(s.staticDir, "index.html")
116
+ }
117
+ return filepath.Join(s.staticDir, strings.TrimPrefix(route, "/"), "index.html")
118
+ }
119
+
120
+ // HasPage checks if a route has a pre-rendered page.
121
+ func (s *SSGServer) HasPage(route string) bool {
122
+ s.mu.RLock()
123
+ defer s.mu.RUnlock()
124
+ _, ok := s.pages[route]
125
+ return ok
126
+ }
127
+
128
+ // ServePage serves a pre-rendered HTML page with ISR support.
129
+ // Returns true if the page was served, false if not found.
130
+ func (s *SSGServer) ServePage(w http.ResponseWriter, r *http.Request) bool {
131
+ s.mu.RLock()
132
+ page, ok := s.pages[r.URL.Path]
133
+ s.mu.RUnlock()
134
+
135
+ if !ok {
136
+ return false
137
+ }
138
+
139
+ // Read the pre-rendered HTML
140
+ html, err := os.ReadFile(page.FilePath)
141
+ if err != nil {
142
+ return false
143
+ }
144
+
145
+ // Check if ISR revalidation is needed
146
+ if page.Revalidate > 0 {
147
+ age := time.Since(page.LastRender)
148
+ ttl := time.Duration(page.Revalidate) * time.Second
149
+
150
+ if age > ttl && !page.IsStale {
151
+ // Mark as stale and trigger background revalidation
152
+ s.mu.Lock()
153
+ page.IsStale = true
154
+ s.mu.Unlock()
155
+
156
+ go s.revalidatePage(page)
157
+ w.Header().Set("X-Blumen-Cache", "STALE")
158
+ } else {
159
+ w.Header().Set("X-Blumen-Cache", "SSG")
160
+ }
161
+ w.Header().Set("X-Blumen-Revalidate", string(rune(page.Revalidate)))
162
+ } else {
163
+ w.Header().Set("X-Blumen-Cache", "SSG")
164
+ }
165
+
166
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
167
+ w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
168
+ w.WriteHeader(http.StatusOK)
169
+ w.Write(html)
170
+ return true
171
+ }
172
+
173
+ // revalidatePage re-renders a static page in the background via the Node SSR server.
174
+ func (s *SSGServer) revalidatePage(page *SSGPage) {
175
+ defer func() {
176
+ s.mu.Lock()
177
+ page.IsStale = false
178
+ s.mu.Unlock()
179
+ }()
180
+
181
+ log.Printf("🔄 ISR: Revalidating %s", page.Route)
182
+
183
+ // Call the Node SSR server to re-render
184
+ body := `{"path":"` + page.Route + `","query":{},"params":{}}`
185
+ resp, err := http.Post("http://localhost:4000/render", "application/json", strings.NewReader(body))
186
+ if err != nil {
187
+ log.Printf("⚠ ISR revalidation failed for %s: %v", page.Route, err)
188
+ return
189
+ }
190
+ defer resp.Body.Close()
191
+
192
+ respBody, err := io.ReadAll(resp.Body)
193
+ if err != nil {
194
+ log.Printf("⚠ ISR revalidation read failed for %s: %v", page.Route, err)
195
+ return
196
+ }
197
+
198
+ var ssrResp struct {
199
+ HTML string `json:"html"`
200
+ }
201
+ if err := json.Unmarshal(respBody, &ssrResp); err != nil || ssrResp.HTML == "" {
202
+ log.Printf("⚠ ISR revalidation parse failed for %s", page.Route)
203
+ return
204
+ }
205
+
206
+ // Atomic write: write to temp file, then rename
207
+ tmpFile := page.FilePath + ".tmp"
208
+ if err := os.WriteFile(tmpFile, []byte(ssrResp.HTML), 0644); err != nil {
209
+ log.Printf("⚠ ISR write failed for %s: %v", page.Route, err)
210
+ return
211
+ }
212
+ if err := os.Rename(tmpFile, page.FilePath); err != nil {
213
+ log.Printf("⚠ ISR rename failed for %s: %v", page.Route, err)
214
+ return
215
+ }
216
+
217
+ // Update metadata
218
+ s.mu.Lock()
219
+ page.LastRender = time.Now()
220
+ s.mu.Unlock()
221
+
222
+ log.Printf("✅ ISR: Revalidated %s", page.Route)
223
+ }
224
+
225
+ // PageCount returns the number of pre-rendered pages.
226
+ func (s *SSGServer) PageCount() int {
227
+ s.mu.RLock()
228
+ defer s.mu.RUnlock()
229
+ return len(s.pages)
230
+ }
@@ -342,8 +342,9 @@ async function handleRender(
342
342
  };
343
343
 
344
344
  // Render React to HTML
345
- // Wrap in the same page-transition div that the client-side
346
- // RouterProvider renders, so hydration trees match exactly.
345
+ // Must match the client's INITIAL hydration tree exactly:
346
+ // div.page-transition > App
347
+ // (Client adds Suspense/ErrorBoundary AFTER hydration via useEffect)
347
348
  const appElement = React.createElement(
348
349
  "div",
349
350
  { className: "page-transition page-transition-active" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blumenjs",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "The React framework powered by Go. Lightning-fast SSR with the DX you deserve.",
5
5
  "type": "module",
6
6
  "bin": {