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.
- package/dist/cli/blumen.js +875 -62
- package/dist/cli/commands/audit.js +204 -0
- package/dist/cli/commands/bench.js +227 -0
- package/dist/cli/commands/build.js +47 -6
- package/dist/cli/commands/export.js +241 -0
- package/dist/cli/commands/migrate.js +267 -0
- package/dist/cli/commands/test.js +118 -0
- package/dist/templates/app/client/entry.tsx +5 -1
- package/dist/templates/app/shared/DefaultDocument.tsx +19 -5
- package/dist/templates/app/shared/RouterContext.tsx +4 -1
- package/dist/templates/go-server/actions.go +147 -0
- package/dist/templates/go-server/main.go +107 -4
- package/dist/templates/go-server/middleware.go +1 -1
- package/dist/templates/go-server/redirects.go +203 -0
- package/dist/templates/go-server/ssg.go +230 -0
- package/dist/templates/node-ssr/server.ts +222 -2
- package/dist/templates/scripts/generate-routes.ts +141 -9
- package/package.json +16 -4
|
@@ -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
|
-
<
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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:
|
|
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
|
+
}
|