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.
- package/dist/cli/blumen.js +875 -62
- package/dist/cli/commands/build.js +47 -6
- 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/main.go +107 -4
- package/dist/templates/go-server/middleware.go +1 -1
- package/dist/templates/node-ssr/server.ts +222 -2
- package/dist/templates/scripts/generate-routes.ts +141 -9
- package/package.json +16 -4
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
|
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",
|
|
@@ -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;
|