blumenjs 0.2.1 → 0.2.2

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.
@@ -1,5 +1,7 @@
1
1
  // cli/commands/build.ts
2
2
  import { execSync } from "child_process";
3
+ import * as fs2 from "fs";
4
+ import * as path2 from "path";
3
5
 
4
6
  // cli/utils.ts
5
7
  import * as fs from "fs";
@@ -61,6 +63,37 @@ function divider() {
61
63
  }
62
64
 
63
65
  // cli/commands/build.ts
66
+ function generateChunkManifest() {
67
+ const jsDir = path2.resolve("static/js");
68
+ const chunksDir = path2.resolve("static/js/chunks");
69
+ const manifest = {};
70
+ if (fs2.existsSync(jsDir)) {
71
+ for (const file of fs2.readdirSync(jsDir)) {
72
+ if (!file.endsWith(".js") || file.endsWith(".map"))
73
+ continue;
74
+ const match = file.match(/^([^.]+)\.[a-f0-9]+\.js$/);
75
+ if (match) {
76
+ manifest[match[1]] = `/static/js/${file}`;
77
+ }
78
+ }
79
+ }
80
+ if (fs2.existsSync(chunksDir)) {
81
+ for (const file of fs2.readdirSync(chunksDir)) {
82
+ if (!file.endsWith(".js") || file.endsWith(".map"))
83
+ continue;
84
+ const match = file.match(/^([^.]+)\.[a-f0-9]+\.js$/);
85
+ if (match) {
86
+ manifest[match[1]] = `/static/js/chunks/${file}`;
87
+ }
88
+ }
89
+ }
90
+ fs2.mkdirSync("dist", { recursive: true });
91
+ fs2.writeFileSync("dist/chunk-manifest.json", JSON.stringify(manifest, null, 2));
92
+ const coreChunks = ["runtime", "vendor", "framework", "main"].filter((k) => manifest[k]);
93
+ const pageChunks = Object.keys(manifest).filter((k) => k.startsWith("page-"));
94
+ log.info(` Core chunks: ${coreChunks.join(", ")} (${coreChunks.length})`);
95
+ log.info(` Page chunks: ${pageChunks.length} page(s)`);
96
+ }
64
97
  async function build(args = []) {
65
98
  banner();
66
99
  const analyze = args.includes("--analyze");
@@ -75,10 +108,14 @@ async function build(args = []) {
75
108
  cmd: "npx tsx scripts/generate-routes.ts"
76
109
  },
77
110
  {
78
- label: "Building client bundle",
111
+ label: "Building client bundle (code splitting)",
79
112
  cmd: "npx webpack --mode production",
80
113
  env: analyze ? { BLUMEN_ANALYZE: "1" } : void 0
81
114
  },
115
+ {
116
+ label: "Generating chunk manifest",
117
+ fn: generateChunkManifest
118
+ },
82
119
  {
83
120
  label: "Building SSR server",
84
121
  cmd: "npx esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external --alias:@=./app"
@@ -95,11 +132,15 @@ async function build(args = []) {
95
132
  const step = steps[i];
96
133
  log.step(`[${i + 1}/${steps.length}] ${step.label}...`);
97
134
  try {
98
- execSync(step.cmd, {
99
- stdio: "inherit",
100
- cwd: process.cwd(),
101
- env: { ...process.env, ...step.env || {} }
102
- });
135
+ if (step.fn) {
136
+ step.fn();
137
+ } else if (step.cmd) {
138
+ execSync(step.cmd, {
139
+ stdio: "inherit",
140
+ cwd: process.cwd(),
141
+ env: { ...process.env, ...step.env || {} }
142
+ });
143
+ }
103
144
  log.success(step.label);
104
145
  } catch {
105
146
  if (step.optional) {
@@ -3,9 +3,13 @@ import { hydrateRoot } from "react-dom/client";
3
3
  import NotFoundPage from "../pages/NotFound";
4
4
 
5
5
  // Auto-generated route map (run `npm run routes` to regenerate)
6
- import { routes, App } from "./generated-routes";
6
+ import { routes, App, routeChunkMap } from "./generated-routes";
7
7
  import { RouterProvider } from "../shared/RouterContext";
8
8
 
9
+ // Expose chunk map globally for the prefetch system
10
+ // (prefetchCache.ts reads this to know which JS chunk to preload on hover)
11
+ (window as any).__BLUMEN_CHUNK_MAP__ = routeChunkMap;
12
+
9
13
  function init() {
10
14
  const container = document.getElementById("root");
11
15
  if (!container) {
@@ -19,12 +19,19 @@ function sanitizeForHydration(data: any): string {
19
19
  }
20
20
  // HMR: In development, load the client bundle from Webpack Dev Server
21
21
  // so the HMR runtime + React Fast Refresh are active.
22
+ // With code splitting, WDS serves multiple chunks instead of one bundle.js.
22
23
  const isDev = process.env.NODE_ENV === "development";
23
- const BUNDLE_SRC = isDev
24
- ? "http://localhost:3100/static/js/bundle.js"
25
- : "/static/js/bundle.js";
24
+ const WDS_BASE = "http://localhost:3100/static/js";
26
25
 
27
- export function DefaultDocument({ children, initialProps, metadata }: any) {
26
+ // Dev mode core scripts loaded in dependency order
27
+ const DEV_SCRIPTS = [
28
+ `${WDS_BASE}/runtime.js`,
29
+ `${WDS_BASE}/vendor.js`,
30
+ `${WDS_BASE}/framework.js`,
31
+ `${WDS_BASE}/main.js`,
32
+ ];
33
+
34
+ export function DefaultDocument({ children, initialProps, metadata, scripts }: any) {
28
35
  const css = `
29
36
  * { margin: 0; padding: 0; box-sizing: border-box; }
30
37
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; color: #e2e8f0; background: #1a1025; }
@@ -114,7 +121,14 @@ export function DefaultDocument({ children, initialProps, metadata }: any) {
114
121
  __html: sanitizeForHydration(initialProps)
115
122
  }}
116
123
  />
117
- <script src={BUNDLE_SRC} defer></script>
124
+ {isDev
125
+ ? DEV_SCRIPTS.map((src, i) => (
126
+ <script key={i} src={src} defer></script>
127
+ ))
128
+ : (scripts || ['/static/js/main.js']).map((src: string, i: number) => (
129
+ <script key={i} src={src} defer></script>
130
+ ))
131
+ }
118
132
  </body>
119
133
  </html>
120
134
  );
@@ -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
  )}
@@ -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",
@@ -1,15 +1,66 @@
1
1
  import * as http from "http";
2
- import { renderToString } from "react-dom/server";
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;