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.
@@ -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 { 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;