blumenjs 0.1.6 → 0.2.0
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 +258 -13
- package/dist/cli/commands/build.js +13 -4
- 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/templates/BlumenDashboard.tsx +99 -46
- package/dist/templates/app/shared/RouterContext.tsx +4 -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 +208 -40
- package/dist/templates/node-ssr/server.ts +120 -8
- package/dist/templates/scripts/generate-routes.ts +43 -3
- package/package.json +7 -3
|
@@ -11,9 +11,14 @@ import (
|
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
const (
|
|
14
|
-
nodeSSRURL
|
|
14
|
+
nodeSSRURL = "http://localhost:4000/render"
|
|
15
|
+
nodeDataURL = "http://localhost:4000/data"
|
|
15
16
|
)
|
|
16
17
|
|
|
18
|
+
// Global page cache — stores rendered HTML in Go memory for near-instant responses.
|
|
19
|
+
// Max 500 entries with LRU eviction. A single Go server can cache the entire site.
|
|
20
|
+
var pageCache = NewPageCache(500)
|
|
21
|
+
|
|
17
22
|
// SSRResponse from Node service
|
|
18
23
|
type SSRResponse struct {
|
|
19
24
|
HTML string `json:"html"`
|
|
@@ -28,6 +33,21 @@ type SSRRequest struct {
|
|
|
28
33
|
Data map[string]interface{} `json:"data,omitempty"`
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
// DataRequest to Node /data endpoint
|
|
37
|
+
type DataRequest struct {
|
|
38
|
+
Path string `json:"path"`
|
|
39
|
+
Query map[string][]string `json:"query"`
|
|
40
|
+
Params map[string]interface{} `json:"params"`
|
|
41
|
+
Headers map[string]string `json:"headers,omitempty"`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// DataResponse from Node /data endpoint
|
|
45
|
+
type DataResponse struct {
|
|
46
|
+
Props map[string]interface{} `json:"props"`
|
|
47
|
+
HasServerProps bool `json:"hasServerProps"`
|
|
48
|
+
Revalidate int `json:"revalidate"` // TTL in seconds
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
var httpClient = &http.Client{
|
|
32
52
|
Timeout: 10 * time.Second,
|
|
33
53
|
}
|
|
@@ -35,8 +55,20 @@ var httpClient = &http.Client{
|
|
|
35
55
|
func main() {
|
|
36
56
|
mux := http.NewServeMux()
|
|
37
57
|
|
|
38
|
-
// Static files
|
|
39
|
-
|
|
58
|
+
// Static files with immutable cache headers
|
|
59
|
+
staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
|
|
60
|
+
mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
|
|
61
|
+
// Set aggressive cache headers for static assets
|
|
62
|
+
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
63
|
+
w.Header().Set("X-Blumen-Cache", "STATIC")
|
|
64
|
+
staticHandler.ServeHTTP(w, r)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Image optimization endpoint - processes and caches images on the fly
|
|
68
|
+
mux.HandleFunc("/_blumen/image", ImageHandler())
|
|
69
|
+
|
|
70
|
+
// Cache management endpoint (for development/debugging)
|
|
71
|
+
mux.HandleFunc("/_blumen/cache", cacheStatusHandler)
|
|
40
72
|
|
|
41
73
|
// Specific routes with Go loaders for data fetching
|
|
42
74
|
mux.HandleFunc("/dashboard/settings", PageHandler(func(r *http.Request) (map[string]interface{}, error) {
|
|
@@ -46,16 +78,9 @@ func main() {
|
|
|
46
78
|
}, nil
|
|
47
79
|
}))
|
|
48
80
|
|
|
49
|
-
mux.HandleFunc("/users/{id}", PageHandler(func(r *http.Request) (map[string]interface{}, error) {
|
|
50
|
-
id := r.PathValue("id")
|
|
51
|
-
return map[string]interface{}{
|
|
52
|
-
"userId": id,
|
|
53
|
-
"userProfile": "Profile data for user " + id + " fetched from Go db",
|
|
54
|
-
}, nil
|
|
55
|
-
}))
|
|
56
|
-
|
|
57
81
|
// Catch-all: forward every other request to the Node SSR server.
|
|
58
|
-
//
|
|
82
|
+
// If the page exports getServerProps, Node will fetch the data first.
|
|
83
|
+
// If a Go DataLoader is registered above, it takes priority.
|
|
59
84
|
mux.HandleFunc("/", PageHandler(nil))
|
|
60
85
|
|
|
61
86
|
startPort := 3000
|
|
@@ -73,6 +98,7 @@ func main() {
|
|
|
73
98
|
}
|
|
74
99
|
|
|
75
100
|
log.Printf("Go server starting on http://localhost:%d", startPort)
|
|
101
|
+
log.Printf("Page cache initialized (max %d entries)", 500)
|
|
76
102
|
if err := http.Serve(listener, mux); err != nil {
|
|
77
103
|
log.Fatalf("Server error: %v", err)
|
|
78
104
|
}
|
|
@@ -87,33 +113,114 @@ func PageHandler(loader DataLoader) http.HandlerFunc {
|
|
|
87
113
|
return
|
|
88
114
|
}
|
|
89
115
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
116
|
+
// Generate cache key from the full URL (path + query string)
|
|
117
|
+
cacheKey := r.URL.RequestURI()
|
|
118
|
+
|
|
119
|
+
// Check the page cache first
|
|
120
|
+
if entry, found, stale := pageCache.Get(cacheKey); found {
|
|
121
|
+
// Handle ETag conditional request
|
|
122
|
+
if match := r.Header.Get("If-None-Match"); match == entry.ETag {
|
|
123
|
+
w.Header().Set("X-Blumen-Cache", "HIT")
|
|
124
|
+
w.WriteHeader(http.StatusNotModified)
|
|
97
125
|
return
|
|
98
126
|
}
|
|
99
|
-
}
|
|
100
127
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
props = make(map[string]interface{})
|
|
128
|
+
if !stale {
|
|
129
|
+
// CACHE HIT: serve directly from Go memory (sub-millisecond)
|
|
130
|
+
serveCachedPage(w, entry, "HIT")
|
|
131
|
+
return
|
|
106
132
|
}
|
|
107
|
-
|
|
133
|
+
|
|
134
|
+
// STALE: serve stale content immediately, revalidate in background
|
|
135
|
+
serveCachedPage(w, entry, "STALE")
|
|
136
|
+
|
|
137
|
+
// Background revalidation (fire and forget)
|
|
138
|
+
go func() {
|
|
139
|
+
html, revalidate := renderPage(r, loader)
|
|
140
|
+
if html != "" {
|
|
141
|
+
pageCache.Set(cacheKey, html, revalidate)
|
|
142
|
+
}
|
|
143
|
+
}()
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// CACHE MISS: render the page and cache the result
|
|
148
|
+
html, revalidate := renderPage(r, loader)
|
|
149
|
+
if html == "" {
|
|
150
|
+
// renderPage already wrote the error response
|
|
108
151
|
return
|
|
109
152
|
}
|
|
110
153
|
|
|
111
|
-
|
|
154
|
+
// Cache the rendered page
|
|
155
|
+
pageCache.Set(cacheKey, html, revalidate)
|
|
156
|
+
|
|
157
|
+
// Serve the fresh response
|
|
158
|
+
entry, _, _ := pageCache.Get(cacheKey)
|
|
159
|
+
if entry != nil {
|
|
160
|
+
serveCachedPage(w, entry, "MISS")
|
|
161
|
+
} else {
|
|
162
|
+
// Fallback: serve directly without cache metadata
|
|
163
|
+
writeHTMLResponse(w, html, "MISS", "")
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// serveCachedPage writes a cached page response with proper headers.
|
|
169
|
+
func serveCachedPage(w http.ResponseWriter, entry *CacheEntry, cacheStatus string) {
|
|
170
|
+
writeHTMLResponse(w, entry.HTML, cacheStatus, entry.ETag)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// writeHTMLResponse writes an HTML response with cache and security headers.
|
|
174
|
+
func writeHTMLResponse(w http.ResponseWriter, html string, cacheStatus string, etag string) {
|
|
175
|
+
// Security headers
|
|
176
|
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
177
|
+
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
178
|
+
w.Header().Set("X-Frame-Options", "DENY")
|
|
179
|
+
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
180
|
+
|
|
181
|
+
// Cache headers
|
|
182
|
+
w.Header().Set("X-Blumen-Cache", cacheStatus)
|
|
183
|
+
if etag != "" {
|
|
184
|
+
w.Header().Set("ETag", etag)
|
|
112
185
|
}
|
|
186
|
+
// Tell browsers and CDNs they can cache for 10s, serve stale for 60s while revalidating
|
|
187
|
+
w.Header().Set("Cache-Control", "public, s-maxage=10, stale-while-revalidate=60")
|
|
188
|
+
|
|
189
|
+
w.WriteHeader(http.StatusOK)
|
|
190
|
+
w.Write([]byte(html))
|
|
113
191
|
}
|
|
114
192
|
|
|
115
|
-
|
|
116
|
-
|
|
193
|
+
// renderPage performs the full SSR pipeline: fetch data + render HTML.
|
|
194
|
+
// Returns the HTML string and the revalidation TTL in seconds.
|
|
195
|
+
func renderPage(r *http.Request, loader DataLoader) (string, int) {
|
|
196
|
+
var props map[string]interface{}
|
|
197
|
+
var err error
|
|
198
|
+
revalidate := 0
|
|
199
|
+
|
|
200
|
+
if loader != nil {
|
|
201
|
+
// Use Go DataLoader (takes priority over getServerProps)
|
|
202
|
+
props, err = loader(r)
|
|
203
|
+
if err != nil {
|
|
204
|
+
log.Printf("Loader error: %v", err)
|
|
205
|
+
return "", 0
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
// No Go DataLoader - check if the page has getServerProps
|
|
209
|
+
tsProps, tsRevalidate, tsErr := fetchServerProps(r)
|
|
210
|
+
if tsErr != nil {
|
|
211
|
+
log.Printf("getServerProps error: %v", tsErr)
|
|
212
|
+
// Non-fatal: render the page without server props
|
|
213
|
+
} else if tsProps != nil {
|
|
214
|
+
props = tsProps
|
|
215
|
+
revalidate = tsRevalidate
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// If this is a SPA data fetch request, return JSON props (don't cache)
|
|
220
|
+
// This check shouldn't happen in renderPage since it's called from the handler,
|
|
221
|
+
// but the handler already handles this case before reaching here.
|
|
222
|
+
|
|
223
|
+
// Call Node SSR to render the HTML
|
|
117
224
|
ssrReq := SSRRequest{
|
|
118
225
|
Path: r.URL.Path,
|
|
119
226
|
Query: r.URL.Query(),
|
|
@@ -124,24 +231,64 @@ func handleRouteWithProps(w http.ResponseWriter, r *http.Request, props map[stri
|
|
|
124
231
|
Data: props,
|
|
125
232
|
}
|
|
126
233
|
|
|
127
|
-
// Call Node SSR service
|
|
128
234
|
ssrResp, err := callNodeSSR(ssrReq)
|
|
129
235
|
if err != nil {
|
|
130
236
|
log.Printf("SSR error: %v", err)
|
|
131
|
-
|
|
132
|
-
return
|
|
237
|
+
return "", 0
|
|
133
238
|
}
|
|
134
239
|
|
|
240
|
+
return ssrResp.HTML, revalidate
|
|
241
|
+
}
|
|
135
242
|
|
|
243
|
+
// fetchServerProps calls the Node SSR /data endpoint to run getServerProps.
|
|
244
|
+
// Returns nil if the page doesn't export getServerProps.
|
|
245
|
+
// Also returns the revalidate TTL (0 = no caching from getServerProps).
|
|
246
|
+
func fetchServerProps(r *http.Request) (map[string]interface{}, int, error) {
|
|
247
|
+
// Build a subset of headers to forward
|
|
248
|
+
headers := make(map[string]string)
|
|
249
|
+
for _, key := range []string{"Authorization", "Cookie", "Accept-Language", "User-Agent"} {
|
|
250
|
+
if val := r.Header.Get(key); val != "" {
|
|
251
|
+
headers[key] = val
|
|
252
|
+
}
|
|
253
|
+
}
|
|
136
254
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
255
|
+
dataReq := DataRequest{
|
|
256
|
+
Path: r.URL.Path,
|
|
257
|
+
Query: r.URL.Query(),
|
|
258
|
+
Params: map[string]interface{}{},
|
|
259
|
+
Headers: headers,
|
|
260
|
+
}
|
|
142
261
|
|
|
143
|
-
|
|
144
|
-
|
|
262
|
+
reqBody, err := json.Marshal(dataReq)
|
|
263
|
+
if err != nil {
|
|
264
|
+
return nil, 0, fmt.Errorf("marshal data request: %w", err)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
resp, err := httpClient.Post(
|
|
268
|
+
nodeDataURL,
|
|
269
|
+
"application/json",
|
|
270
|
+
bytes.NewReader(reqBody),
|
|
271
|
+
)
|
|
272
|
+
if err != nil {
|
|
273
|
+
return nil, 0, fmt.Errorf("http post /data: %w", err)
|
|
274
|
+
}
|
|
275
|
+
defer resp.Body.Close()
|
|
276
|
+
|
|
277
|
+
if resp.StatusCode != http.StatusOK {
|
|
278
|
+
return nil, 0, fmt.Errorf("node /data returned %d", resp.StatusCode)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
var dataResp DataResponse
|
|
282
|
+
if err := json.NewDecoder(resp.Body).Decode(&dataResp); err != nil {
|
|
283
|
+
return nil, 0, fmt.Errorf("decode data response: %w", err)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if !dataResp.HasServerProps {
|
|
287
|
+
// Page doesn't export getServerProps, skip
|
|
288
|
+
return nil, 0, nil
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return dataResp.Props, dataResp.Revalidate, nil
|
|
145
292
|
}
|
|
146
293
|
|
|
147
294
|
func callNodeSSR(req SSRRequest) (*SSRResponse, error) {
|
|
@@ -172,4 +319,25 @@ func callNodeSSR(req SSRRequest) (*SSRResponse, error) {
|
|
|
172
319
|
return &ssrResp, nil
|
|
173
320
|
}
|
|
174
321
|
|
|
175
|
-
|
|
322
|
+
// cacheStatusHandler provides cache introspection for debugging.
|
|
323
|
+
// GET /_blumen/cache — returns current cache size and status
|
|
324
|
+
// DELETE /_blumen/cache — clears the entire cache
|
|
325
|
+
func cacheStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
326
|
+
switch r.Method {
|
|
327
|
+
case http.MethodGet:
|
|
328
|
+
w.Header().Set("Content-Type", "application/json")
|
|
329
|
+
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
330
|
+
"size": pageCache.Size(),
|
|
331
|
+
"maxSize": 500,
|
|
332
|
+
"status": "active",
|
|
333
|
+
})
|
|
334
|
+
case http.MethodDelete:
|
|
335
|
+
pageCache.Clear()
|
|
336
|
+
w.Header().Set("Content-Type", "application/json")
|
|
337
|
+
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
338
|
+
"message": "Cache cleared",
|
|
339
|
+
})
|
|
340
|
+
default:
|
|
341
|
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -3,7 +3,7 @@ import { renderToString } from "react-dom/server";
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
|
|
5
5
|
// Auto-generated route map (run `npm run routes` to regenerate)
|
|
6
|
-
import { routes, App, Document } from "./generated-routes";
|
|
6
|
+
import { routes, App, Document, serverPropsMap } from "./generated-routes";
|
|
7
7
|
import { matchRoute } from "../app/shared/router";
|
|
8
8
|
import NotFoundPage from "../app/pages/NotFound";
|
|
9
9
|
|
|
@@ -21,6 +21,19 @@ interface SSRResponse {
|
|
|
21
21
|
props: Record<string, any>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
interface DataRequest {
|
|
25
|
+
path: string;
|
|
26
|
+
query: Record<string, string[]>;
|
|
27
|
+
params: Record<string, any>;
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface DataResponse {
|
|
32
|
+
props: Record<string, any>;
|
|
33
|
+
hasServerProps: boolean;
|
|
34
|
+
revalidate: number; // TTL in seconds; 0 = no caching
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
const PORT = process.env.PORT || 4000;
|
|
25
38
|
|
|
26
39
|
const server = http.createServer(async (req, res) => {
|
|
@@ -36,12 +49,97 @@ const server = http.createServer(async (req, res) => {
|
|
|
36
49
|
return;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
if (req.method !== "POST"
|
|
52
|
+
if (req.method !== "POST") {
|
|
40
53
|
res.writeHead(404);
|
|
41
54
|
res.end(JSON.stringify({ error: "Not Found" }));
|
|
42
55
|
return;
|
|
43
56
|
}
|
|
44
57
|
|
|
58
|
+
// Route to the correct handler
|
|
59
|
+
if (req.url === "/data") {
|
|
60
|
+
return handleData(req, res);
|
|
61
|
+
} else if (req.url === "/render") {
|
|
62
|
+
return handleRender(req, res);
|
|
63
|
+
} else {
|
|
64
|
+
res.writeHead(404);
|
|
65
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* POST /data - Execute getServerProps for a matched route.
|
|
71
|
+
*
|
|
72
|
+
* Called by the Go server before /render to fetch TypeScript-defined
|
|
73
|
+
* server data. If the page doesn't export getServerProps, returns
|
|
74
|
+
* { hasServerProps: false } and Go skips the data merge.
|
|
75
|
+
*/
|
|
76
|
+
async function handleData(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
77
|
+
try {
|
|
78
|
+
const body = await readBody(req);
|
|
79
|
+
const dataReq: DataRequest = JSON.parse(body);
|
|
80
|
+
|
|
81
|
+
// Match the route to find the page
|
|
82
|
+
const match = matchRoute(dataReq.path, routes);
|
|
83
|
+
|
|
84
|
+
if (!match) {
|
|
85
|
+
res.writeHead(200);
|
|
86
|
+
res.end(JSON.stringify({ props: {}, hasServerProps: false }));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Find the route definition to look up in serverPropsMap
|
|
91
|
+
const routeDef = routes.find(r => r.component === match.component);
|
|
92
|
+
const getServerProps = routeDef ? serverPropsMap[routeDef.path] : undefined;
|
|
93
|
+
|
|
94
|
+
if (!getServerProps) {
|
|
95
|
+
// This page doesn't export getServerProps
|
|
96
|
+
res.writeHead(200);
|
|
97
|
+
res.end(JSON.stringify({ props: {}, hasServerProps: false }));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Build the BlumenContext
|
|
102
|
+
const ctx = {
|
|
103
|
+
params: { ...(dataReq.params || {}), ...match.params },
|
|
104
|
+
query: dataReq.query || {},
|
|
105
|
+
path: dataReq.path,
|
|
106
|
+
headers: dataReq.headers || {},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Run getServerProps
|
|
110
|
+
const result = await getServerProps(ctx);
|
|
111
|
+
|
|
112
|
+
const dataResp: DataResponse = {
|
|
113
|
+
props: result?.props || {},
|
|
114
|
+
hasServerProps: true,
|
|
115
|
+
revalidate: result?.revalidate || 0,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
res.writeHead(200);
|
|
119
|
+
res.end(JSON.stringify(dataResp));
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error("getServerProps Error:", error);
|
|
122
|
+
res.writeHead(500);
|
|
123
|
+
res.end(
|
|
124
|
+
JSON.stringify({
|
|
125
|
+
error: "getServerProps failed",
|
|
126
|
+
message:
|
|
127
|
+
process.env.NODE_ENV === "development" ? String(error) : undefined,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* POST /render - Render the matched page to HTML.
|
|
135
|
+
*
|
|
136
|
+
* This is the existing SSR endpoint. It receives props (possibly
|
|
137
|
+
* enriched by /data) and renders the React component tree to HTML.
|
|
138
|
+
*/
|
|
139
|
+
async function handleRender(
|
|
140
|
+
req: http.IncomingMessage,
|
|
141
|
+
res: http.ServerResponse,
|
|
142
|
+
) {
|
|
45
143
|
try {
|
|
46
144
|
// Parse request body
|
|
47
145
|
const body = await readBody(req);
|
|
@@ -57,7 +155,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
57
155
|
status: 404,
|
|
58
156
|
serverRendered: true,
|
|
59
157
|
};
|
|
60
|
-
const element = React.createElement(
|
|
158
|
+
const element = React.createElement(
|
|
159
|
+
"div",
|
|
160
|
+
{ className: "page-transition page-transition-active" },
|
|
161
|
+
React.createElement(NotFound, props),
|
|
162
|
+
);
|
|
61
163
|
const html = renderToString(element);
|
|
62
164
|
|
|
63
165
|
res.writeHead(200);
|
|
@@ -76,10 +178,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
76
178
|
};
|
|
77
179
|
|
|
78
180
|
// Render React to HTML
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
181
|
+
// Wrap in the same page-transition div that the client-side
|
|
182
|
+
// RouterProvider renders, so hydration trees match exactly.
|
|
183
|
+
const appElement = React.createElement(
|
|
184
|
+
"div",
|
|
185
|
+
{ className: "page-transition page-transition-active" },
|
|
186
|
+
React.createElement(App, {
|
|
187
|
+
Component: match.component,
|
|
188
|
+
pageProps: props,
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
83
191
|
|
|
84
192
|
const documentElement = React.createElement(Document, {
|
|
85
193
|
initialProps: props,
|
|
@@ -106,7 +214,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
106
214
|
}),
|
|
107
215
|
);
|
|
108
216
|
}
|
|
109
|
-
}
|
|
217
|
+
}
|
|
110
218
|
|
|
111
219
|
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
112
220
|
return new Promise((resolve, reject) => {
|
|
@@ -123,6 +231,10 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
|
|
123
231
|
|
|
124
232
|
server.listen(PORT, () => {
|
|
125
233
|
console.log(`Node SSR server running on http://localhost:${PORT}`);
|
|
234
|
+
const gspCount = Object.keys(serverPropsMap).length;
|
|
235
|
+
if (gspCount > 0) {
|
|
236
|
+
console.log(` ${gspCount} page(s) with getServerProps detected`);
|
|
237
|
+
}
|
|
126
238
|
});
|
|
127
239
|
|
|
128
240
|
// Handle graceful shutdown
|
|
@@ -40,6 +40,8 @@ interface RouteEntry {
|
|
|
40
40
|
patternStr: string;
|
|
41
41
|
/** Extracted parameter keys */
|
|
42
42
|
keys: string[];
|
|
43
|
+
/** Whether this page exports a getServerProps function */
|
|
44
|
+
hasGetServerProps: boolean;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
|
|
@@ -87,12 +89,17 @@ function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
|
|
|
87
89
|
patternStr = `^${patternStr}$`;
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
// Check if the page exports getServerProps
|
|
93
|
+
const fileContent = fs.readFileSync(fullPath, "utf-8");
|
|
94
|
+
const hasGetServerProps = /export\s+(async\s+)?function\s+getServerProps/.test(fileContent);
|
|
95
|
+
|
|
90
96
|
results.push({
|
|
91
97
|
route: routePath,
|
|
92
98
|
componentId,
|
|
93
99
|
importPath: relPath.replace(".tsx", "").replace(/\\/g, "/"),
|
|
94
100
|
patternStr,
|
|
95
|
-
keys
|
|
101
|
+
keys,
|
|
102
|
+
hasGetServerProps
|
|
96
103
|
});
|
|
97
104
|
}
|
|
98
105
|
}
|
|
@@ -139,6 +146,18 @@ function generateRouteFile(
|
|
|
139
146
|
`import ${r.componentId} from "${importPathPrefix}/${r.importPath}";`,
|
|
140
147
|
);
|
|
141
148
|
|
|
149
|
+
// For SSR: import getServerProps from pages that export it
|
|
150
|
+
const gspImports: string[] = [];
|
|
151
|
+
const gspRoutes = routes.filter(r => r.hasGetServerProps);
|
|
152
|
+
if (isServer && gspRoutes.length > 0) {
|
|
153
|
+
for (const r of gspRoutes) {
|
|
154
|
+
const gspId = "gsp_" + r.componentId.replace("Page_", "");
|
|
155
|
+
gspImports.push(
|
|
156
|
+
`import { getServerProps as ${gspId} } from "${importPathPrefix}/${r.importPath}";`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
142
161
|
const hasApp = fs.existsSync(path.join(PAGES_DIR, "_app.tsx"));
|
|
143
162
|
const hasDoc = fs.existsSync(path.join(PAGES_DIR, "_document.tsx"));
|
|
144
163
|
|
|
@@ -163,6 +182,25 @@ function generateRouteFile(
|
|
|
163
182
|
\t}`;
|
|
164
183
|
});
|
|
165
184
|
|
|
185
|
+
// Generate serverPropsMap for SSR routes
|
|
186
|
+
let serverPropsMapStr = "";
|
|
187
|
+
if (isServer && gspRoutes.length > 0) {
|
|
188
|
+
const entries = gspRoutes.map(r => {
|
|
189
|
+
const gspId = "gsp_" + r.componentId.replace("Page_", "");
|
|
190
|
+
return `\t"${r.route}": ${gspId},`;
|
|
191
|
+
});
|
|
192
|
+
serverPropsMapStr = [
|
|
193
|
+
"",
|
|
194
|
+
"// Map of routes that export getServerProps",
|
|
195
|
+
"// Used by the SSR server to run data fetching before rendering",
|
|
196
|
+
"export const serverPropsMap: Record<string, Function> = {",
|
|
197
|
+
...entries,
|
|
198
|
+
"};",
|
|
199
|
+
].join("\n");
|
|
200
|
+
} else if (isServer) {
|
|
201
|
+
serverPropsMapStr = "\nexport const serverPropsMap: Record<string, Function> = {};";
|
|
202
|
+
}
|
|
203
|
+
|
|
166
204
|
const map = [
|
|
167
205
|
"",
|
|
168
206
|
"export interface RouteDef {",
|
|
@@ -178,10 +216,11 @@ function generateRouteFile(
|
|
|
178
216
|
"",
|
|
179
217
|
appImport,
|
|
180
218
|
docImport,
|
|
219
|
+
serverPropsMapStr,
|
|
181
220
|
"",
|
|
182
221
|
];
|
|
183
222
|
|
|
184
|
-
return [...header, ...imports, ...map].join("\n");
|
|
223
|
+
return [...header, ...imports, ...gspImports, ...map].join("\n");
|
|
185
224
|
}
|
|
186
225
|
|
|
187
226
|
function main() {
|
|
@@ -197,7 +236,8 @@ function main() {
|
|
|
197
236
|
|
|
198
237
|
console.log(` Found ${routes.length} route(s):`);
|
|
199
238
|
for (const r of routes) {
|
|
200
|
-
|
|
239
|
+
const gspTag = r.hasGetServerProps ? " [getServerProps]" : "";
|
|
240
|
+
console.log(` ${r.route.padEnd(25)} → ${r.importPath}.tsx${gspTag}`);
|
|
201
241
|
}
|
|
202
242
|
|
|
203
243
|
// Generate SSR routes (node-ssr/ → ../app/pages)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blumenjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The React framework powered by Go. Lightning-fast SSR with the DX you deserve.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
"dev:legacy": "npm run routes && concurrently \"npm run dev:client\" \"npm run dev:ssr\" \"npm run dev:go\"",
|
|
27
27
|
"dev:client": "webpack serve --mode development",
|
|
28
28
|
"dev:ssr": "NODE_ENV=development tsx watch node-ssr/server.ts",
|
|
29
|
-
"dev:go": "go run go-server/main.go",
|
|
29
|
+
"dev:go": "go run go-server/main.go go-server/image.go go-server/cache.go",
|
|
30
30
|
"build:client": "webpack --mode production",
|
|
31
|
-
"build:ssr": "esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --external
|
|
31
|
+
"build:ssr": "esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external",
|
|
32
32
|
"clean": "rm -rf dist static/js/bundle.js"
|
|
33
33
|
},
|
|
34
34
|
"keywords": [
|
|
@@ -64,12 +64,16 @@
|
|
|
64
64
|
"@types/react-dom": "^18.2.0",
|
|
65
65
|
"babel-loader": "^10.1.1",
|
|
66
66
|
"concurrently": "^8.2.2",
|
|
67
|
+
"css-loader": "^7.1.4",
|
|
67
68
|
"esbuild": "^0.19.0",
|
|
69
|
+
"mini-css-extract-plugin": "^2.10.2",
|
|
68
70
|
"react-refresh": "^0.18.0",
|
|
71
|
+
"style-loader": "^4.0.0",
|
|
69
72
|
"ts-loader": "^9.5.1",
|
|
70
73
|
"tsx": "^4.6.0",
|
|
71
74
|
"typescript": "^5.3.0",
|
|
72
75
|
"webpack": "^5.89.0",
|
|
76
|
+
"webpack-bundle-analyzer": "^5.3.0",
|
|
73
77
|
"webpack-cli": "^5.1.4",
|
|
74
78
|
"webpack-dev-server": "^5.2.3"
|
|
75
79
|
},
|