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
|
@@ -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
|
+
}
|
|
@@ -1,15 +1,66 @@
|
|
|
1
1
|
import * as http from "http";
|
|
2
|
-
import
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { renderToString, renderToPipeableStream } from "react-dom/server";
|
|
3
5
|
import React from "react";
|
|
4
6
|
|
|
5
7
|
// Auto-generated route map (run `npm run routes` to regenerate)
|
|
6
|
-
import { routes, App, Document, serverPropsMap, metadataMap, generateMetadataMap, layoutMetadataMap, layoutGenerateMetadataMap } from "./generated-routes";
|
|
8
|
+
import { routes, App, Document, serverPropsMap, staticPropsMap, staticPathsMap, metadataMap, generateMetadataMap, layoutMetadataMap, layoutGenerateMetadataMap } from "./generated-routes";
|
|
7
9
|
import { apiRoutes } from "./generated-api-routes";
|
|
8
10
|
import { matchRoute } from "../app/shared/router";
|
|
9
11
|
import { executeAction, getRegisteredActions } from "../app/shared/serverAction";
|
|
10
12
|
import NotFoundPage from "../app/pages/NotFound";
|
|
11
13
|
|
|
12
14
|
const NotFound = NotFoundPage;
|
|
15
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
16
|
+
|
|
17
|
+
// ── Chunk Manifest (Production) ────────────────────────────────
|
|
18
|
+
// Maps chunk names (e.g. "runtime", "vendor", "page-home") to their
|
|
19
|
+
// hashed filenames (e.g. "/static/js/runtime.abc12345.js").
|
|
20
|
+
// Generated by `blumen build` after the Webpack step.
|
|
21
|
+
let chunkManifest: Record<string, string> = {};
|
|
22
|
+
if (!isDev) {
|
|
23
|
+
try {
|
|
24
|
+
const manifestPath = path.resolve("dist/chunk-manifest.json");
|
|
25
|
+
if (fs.existsSync(manifestPath)) {
|
|
26
|
+
chunkManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
27
|
+
console.log(`Chunk manifest loaded: ${Object.keys(chunkManifest).length} chunks`);
|
|
28
|
+
}
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.warn("Could not load chunk manifest:", err);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the list of <script> src URLs for a given page.
|
|
36
|
+
* Order: runtime → vendor → framework → main → page chunk
|
|
37
|
+
*/
|
|
38
|
+
function getScriptsForPage(routePath: string): string[] {
|
|
39
|
+
if (isDev) return []; // Dev mode: DefaultDocument handles its own scripts
|
|
40
|
+
|
|
41
|
+
const scripts: string[] = [];
|
|
42
|
+
// Core chunks in dependency order
|
|
43
|
+
for (const name of ["runtime", "vendor", "framework", "main"]) {
|
|
44
|
+
if (chunkManifest[name]) {
|
|
45
|
+
scripts.push(chunkManifest[name]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Page-specific chunk
|
|
50
|
+
// Convert route path to chunk name: "/docs" → "page-docs", "/users/[id]" → "page-users-id"
|
|
51
|
+
const chunkName = "page-" + (routePath === "/" ? "home" : routePath
|
|
52
|
+
.replace(/^\//, "")
|
|
53
|
+
.replace(/\/index$/, "")
|
|
54
|
+
.replace(/\[([^\]]+)\]/g, "$1")
|
|
55
|
+
.replace(/\//g, "-")
|
|
56
|
+
.toLowerCase());
|
|
57
|
+
|
|
58
|
+
if (chunkManifest[chunkName]) {
|
|
59
|
+
scripts.push(chunkManifest[chunkName]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return scripts;
|
|
63
|
+
}
|
|
13
64
|
|
|
14
65
|
interface SSRRequest {
|
|
15
66
|
path: string;
|
|
@@ -61,12 +112,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
61
112
|
// Route to the correct handler
|
|
62
113
|
if (req.url === "/data") {
|
|
63
114
|
return handleData(req, res);
|
|
115
|
+
} else if (req.url === "/stream-render") {
|
|
116
|
+
return handleStreamRender(req, res);
|
|
64
117
|
} else if (req.url === "/render") {
|
|
65
118
|
return handleRender(req, res);
|
|
66
119
|
} else if (req.url === "/api") {
|
|
67
120
|
return handleAPI(req, res);
|
|
68
121
|
} else if (req.url === "/action") {
|
|
69
122
|
return handleAction(req, res);
|
|
123
|
+
} else if (req.url === "/static-paths") {
|
|
124
|
+
return handleStaticPaths(req, res);
|
|
70
125
|
} else {
|
|
71
126
|
res.writeHead(404);
|
|
72
127
|
res.end(JSON.stringify({ error: "Not Found" }));
|
|
@@ -313,6 +368,7 @@ async function handleRender(
|
|
|
313
368
|
initialProps: props,
|
|
314
369
|
children: appElement,
|
|
315
370
|
metadata,
|
|
371
|
+
scripts: getScriptsForPage(routeDef?.path || ssrReq.path),
|
|
316
372
|
});
|
|
317
373
|
|
|
318
374
|
const html = "<!doctype html>\n" + renderToString(documentElement);
|
|
@@ -337,6 +393,135 @@ async function handleRender(
|
|
|
337
393
|
}
|
|
338
394
|
}
|
|
339
395
|
|
|
396
|
+
/**
|
|
397
|
+
* POST /stream-render — Streaming SSR
|
|
398
|
+
*
|
|
399
|
+
* Uses React 18's renderToPipeableStream to send HTML progressively.
|
|
400
|
+
* The shell (everything outside <Suspense>) arrives immediately.
|
|
401
|
+
* Data-dependent sections stream in as they resolve.
|
|
402
|
+
*
|
|
403
|
+
* Props are sent via X-Blumen-Props header (JSON-encoded)
|
|
404
|
+
* so Go can extract them for caching without parsing the HTML stream.
|
|
405
|
+
*
|
|
406
|
+
* Go proxies this stream directly to the browser using chunked
|
|
407
|
+
* transfer encoding for instant Time to First Byte.
|
|
408
|
+
*/
|
|
409
|
+
async function handleStreamRender(
|
|
410
|
+
req: http.IncomingMessage,
|
|
411
|
+
res: http.ServerResponse,
|
|
412
|
+
) {
|
|
413
|
+
try {
|
|
414
|
+
const body = await readBody(req);
|
|
415
|
+
const ssrReq: SSRRequest = JSON.parse(body);
|
|
416
|
+
|
|
417
|
+
const match = matchRoute(ssrReq.path, routes);
|
|
418
|
+
|
|
419
|
+
if (!match) {
|
|
420
|
+
// No streaming for 404 — just use renderToString
|
|
421
|
+
const props = {
|
|
422
|
+
path: ssrReq.path,
|
|
423
|
+
status: 404,
|
|
424
|
+
serverRendered: true,
|
|
425
|
+
};
|
|
426
|
+
const element = React.createElement(
|
|
427
|
+
"div",
|
|
428
|
+
{ className: "page-transition page-transition-active" },
|
|
429
|
+
React.createElement(NotFound, props),
|
|
430
|
+
);
|
|
431
|
+
const html = renderToString(element);
|
|
432
|
+
|
|
433
|
+
res.writeHead(200, {
|
|
434
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
435
|
+
"X-Blumen-Props": JSON.stringify(props),
|
|
436
|
+
"X-Blumen-Status": "404",
|
|
437
|
+
});
|
|
438
|
+
res.end("<!doctype html>\n" + html);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Prepare props
|
|
443
|
+
const props = {
|
|
444
|
+
...(ssrReq.data || {}),
|
|
445
|
+
path: ssrReq.path,
|
|
446
|
+
query: ssrReq.query,
|
|
447
|
+
params: { ...ssrReq.params, ...match.params },
|
|
448
|
+
timestamp: Date.now(),
|
|
449
|
+
serverRendered: true,
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// Build the app element
|
|
453
|
+
const appElement = React.createElement(
|
|
454
|
+
"div",
|
|
455
|
+
{ className: "page-transition page-transition-active" },
|
|
456
|
+
React.createElement(App, {
|
|
457
|
+
Component: match.component,
|
|
458
|
+
pageProps: props,
|
|
459
|
+
}),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
// Resolve metadata
|
|
463
|
+
const routeDef = routes.find(r => r.component === match.component);
|
|
464
|
+
const metadata = routeDef
|
|
465
|
+
? await resolveMetadata(routeDef.path, {
|
|
466
|
+
params: props.params || {},
|
|
467
|
+
query: ssrReq.query || {},
|
|
468
|
+
path: ssrReq.path,
|
|
469
|
+
headers: {},
|
|
470
|
+
})
|
|
471
|
+
: {};
|
|
472
|
+
|
|
473
|
+
// Build the full document element
|
|
474
|
+
const documentElement = React.createElement(Document, {
|
|
475
|
+
initialProps: props,
|
|
476
|
+
children: appElement,
|
|
477
|
+
metadata,
|
|
478
|
+
scripts: getScriptsForPage(routeDef?.path || ssrReq.path),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Use renderToPipeableStream for progressive HTML streaming
|
|
482
|
+
const { pipe, abort } = renderToPipeableStream(documentElement, {
|
|
483
|
+
onShellReady() {
|
|
484
|
+
// Shell is ready — send headers and start streaming immediately.
|
|
485
|
+
// This is the key performance win: TTFB happens NOW,
|
|
486
|
+
// before data-dependent Suspense boundaries resolve.
|
|
487
|
+
res.writeHead(200, {
|
|
488
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
489
|
+
"Transfer-Encoding": "chunked",
|
|
490
|
+
"X-Blumen-Props": JSON.stringify(props),
|
|
491
|
+
"X-Blumen-Streaming": "1",
|
|
492
|
+
});
|
|
493
|
+
// Write the doctype before piping React's output
|
|
494
|
+
res.write("<!doctype html>\n");
|
|
495
|
+
pipe(res);
|
|
496
|
+
},
|
|
497
|
+
onShellError(error) {
|
|
498
|
+
// Shell failed to render — fall back to error response
|
|
499
|
+
console.error("Streaming SSR shell error:", error);
|
|
500
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
501
|
+
res.end("<!doctype html><html><body><h1>Server Error</h1></body></html>");
|
|
502
|
+
},
|
|
503
|
+
onError(error) {
|
|
504
|
+
// Non-fatal error during streaming (e.g., a Suspense boundary failed)
|
|
505
|
+
// The stream continues — React will send the fallback UI
|
|
506
|
+
console.error("Streaming SSR error:", error);
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Safety timeout: abort streaming after 10 seconds
|
|
511
|
+
setTimeout(() => abort(), 10000);
|
|
512
|
+
|
|
513
|
+
} catch (error) {
|
|
514
|
+
console.error("Stream render error:", error);
|
|
515
|
+
if (!res.headersSent) {
|
|
516
|
+
res.writeHead(500);
|
|
517
|
+
res.end(JSON.stringify({
|
|
518
|
+
error: "Internal Server Error",
|
|
519
|
+
message: isDev ? String(error) : undefined,
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
340
525
|
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
341
526
|
return new Promise((resolve, reject) => {
|
|
342
527
|
let body = "";
|
|
@@ -469,6 +654,41 @@ async function handleAction(req: http.IncomingMessage, res: http.ServerResponse)
|
|
|
469
654
|
}
|
|
470
655
|
}
|
|
471
656
|
|
|
657
|
+
/**
|
|
658
|
+
* POST /static-paths — Resolve dynamic SSG routes.
|
|
659
|
+
*
|
|
660
|
+
* Called by the SSG prerender script to get the list of paths
|
|
661
|
+
* that a page with getStaticPaths should be pre-rendered for.
|
|
662
|
+
*
|
|
663
|
+
* Request: { route: "/blog/[slug]" }
|
|
664
|
+
* Response: { paths: [{ params: { slug: "hello-world" } }, ...] }
|
|
665
|
+
*/
|
|
666
|
+
async function handleStaticPaths(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
667
|
+
try {
|
|
668
|
+
const rawBody = await readBody(req);
|
|
669
|
+
const { route } = JSON.parse(rawBody) as { route: string };
|
|
670
|
+
|
|
671
|
+
const getStaticPaths = staticPathsMap[route];
|
|
672
|
+
if (!getStaticPaths) {
|
|
673
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
674
|
+
res.end(JSON.stringify({ paths: [] }));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const result = await getStaticPaths();
|
|
679
|
+
const paths = result?.paths || [];
|
|
680
|
+
|
|
681
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
682
|
+
res.end(JSON.stringify({ paths }));
|
|
683
|
+
} catch (error: any) {
|
|
684
|
+
console.error("getStaticPaths Error:", error);
|
|
685
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
686
|
+
res.end(JSON.stringify({ error: "getStaticPaths failed", message: error.message }));
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ─── Server Startup ──────────────────────────────────────────────
|
|
691
|
+
|
|
472
692
|
server.listen(PORT, () => {
|
|
473
693
|
console.log(`Node SSR server running on http://localhost:${PORT}`);
|
|
474
694
|
const gspCount = Object.keys(serverPropsMap).length;
|