blumenjs 0.2.0 → 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 +890 -67
- package/dist/cli/commands/build.js +60 -9
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/start.js +1 -1
- package/dist/templates/app/client/entry.tsx +5 -1
- package/dist/templates/app/pages/BlumenStarter.tsx +1 -1
- package/dist/templates/app/shared/DefaultDocument.tsx +67 -8
- package/dist/templates/app/shared/Link.tsx +60 -6
- package/dist/templates/app/shared/RouterContext.tsx +83 -20
- package/dist/templates/app/shared/router.ts +2 -1
- package/dist/templates/go-server/main.go +294 -4
- package/dist/templates/go-server/middleware.go +546 -0
- package/dist/templates/go-server/websocket.go +430 -0
- package/dist/templates/node-ssr/server.ts +467 -3
- package/dist/templates/scripts/generate-routes.ts +457 -17
- package/package.json +21 -7
|
@@ -1,13 +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 } from "./generated-routes";
|
|
8
|
+
import { routes, App, Document, serverPropsMap, staticPropsMap, staticPathsMap, metadataMap, generateMetadataMap, layoutMetadataMap, layoutGenerateMetadataMap } from "./generated-routes";
|
|
9
|
+
import { apiRoutes } from "./generated-api-routes";
|
|
7
10
|
import { matchRoute } from "../app/shared/router";
|
|
11
|
+
import { executeAction, getRegisteredActions } from "../app/shared/serverAction";
|
|
8
12
|
import NotFoundPage from "../app/pages/NotFound";
|
|
9
13
|
|
|
10
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
|
+
}
|
|
11
64
|
|
|
12
65
|
interface SSRRequest {
|
|
13
66
|
path: string;
|
|
@@ -32,6 +85,7 @@ interface DataResponse {
|
|
|
32
85
|
props: Record<string, any>;
|
|
33
86
|
hasServerProps: boolean;
|
|
34
87
|
revalidate: number; // TTL in seconds; 0 = no caching
|
|
88
|
+
metadata?: Record<string, any>; // Resolved page metadata for <head> tags
|
|
35
89
|
}
|
|
36
90
|
|
|
37
91
|
const PORT = process.env.PORT || 4000;
|
|
@@ -58,14 +112,113 @@ const server = http.createServer(async (req, res) => {
|
|
|
58
112
|
// Route to the correct handler
|
|
59
113
|
if (req.url === "/data") {
|
|
60
114
|
return handleData(req, res);
|
|
115
|
+
} else if (req.url === "/stream-render") {
|
|
116
|
+
return handleStreamRender(req, res);
|
|
61
117
|
} else if (req.url === "/render") {
|
|
62
118
|
return handleRender(req, res);
|
|
119
|
+
} else if (req.url === "/api") {
|
|
120
|
+
return handleAPI(req, res);
|
|
121
|
+
} else if (req.url === "/action") {
|
|
122
|
+
return handleAction(req, res);
|
|
123
|
+
} else if (req.url === "/static-paths") {
|
|
124
|
+
return handleStaticPaths(req, res);
|
|
63
125
|
} else {
|
|
64
126
|
res.writeHead(404);
|
|
65
127
|
res.end(JSON.stringify({ error: "Not Found" }));
|
|
66
128
|
}
|
|
67
129
|
});
|
|
68
130
|
|
|
131
|
+
/**
|
|
132
|
+
* POST /api - Handle API route requests forwarded from Go.
|
|
133
|
+
*
|
|
134
|
+
* Go sends a JSON envelope: { method, path, query, headers, body }
|
|
135
|
+
* This function matches the path against generated apiRoutes,
|
|
136
|
+
* finds the correct HTTP method handler, and returns { status, headers, body }.
|
|
137
|
+
*/
|
|
138
|
+
async function handleAPI(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
139
|
+
try {
|
|
140
|
+
const rawBody = await readBody(req);
|
|
141
|
+
const apiReq = JSON.parse(rawBody) as {
|
|
142
|
+
method: string;
|
|
143
|
+
path: string;
|
|
144
|
+
query: Record<string, string[]>;
|
|
145
|
+
headers: Record<string, string>;
|
|
146
|
+
body: any;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Match the path against API routes
|
|
150
|
+
let matchedRoute = null;
|
|
151
|
+
let params: Record<string, string> = {};
|
|
152
|
+
|
|
153
|
+
for (const route of apiRoutes) {
|
|
154
|
+
const match = apiReq.path.match(route.pattern);
|
|
155
|
+
if (match) {
|
|
156
|
+
matchedRoute = route;
|
|
157
|
+
// Extract dynamic parameters
|
|
158
|
+
route.keys.forEach((key, i) => {
|
|
159
|
+
params[key] = match[i + 1];
|
|
160
|
+
});
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!matchedRoute) {
|
|
166
|
+
res.writeHead(200);
|
|
167
|
+
res.end(JSON.stringify({
|
|
168
|
+
status: 404,
|
|
169
|
+
headers: { "Content-Type": "application/json" },
|
|
170
|
+
body: { error: "API route not found" },
|
|
171
|
+
}));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check if the HTTP method is supported
|
|
176
|
+
const handler = matchedRoute.handlers[apiReq.method];
|
|
177
|
+
if (!handler) {
|
|
178
|
+
const allowed = Object.keys(matchedRoute.handlers).join(", ");
|
|
179
|
+
res.writeHead(200);
|
|
180
|
+
res.end(JSON.stringify({
|
|
181
|
+
status: 405,
|
|
182
|
+
headers: {
|
|
183
|
+
"Content-Type": "application/json",
|
|
184
|
+
"Allow": allowed,
|
|
185
|
+
},
|
|
186
|
+
body: { error: `Method ${apiReq.method} not allowed. Allowed: ${allowed}` },
|
|
187
|
+
}));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Build the request object for the handler
|
|
192
|
+
const blumenReq = {
|
|
193
|
+
method: apiReq.method,
|
|
194
|
+
path: apiReq.path,
|
|
195
|
+
params,
|
|
196
|
+
query: apiReq.query || {},
|
|
197
|
+
headers: apiReq.headers || {},
|
|
198
|
+
body: apiReq.body,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Call the handler
|
|
202
|
+
const result = await handler(blumenReq);
|
|
203
|
+
|
|
204
|
+
// The handler returns a BlumenAPIResponse: { status, headers, body }
|
|
205
|
+
res.writeHead(200);
|
|
206
|
+
res.end(JSON.stringify({
|
|
207
|
+
status: result?.status ?? 200,
|
|
208
|
+
headers: result?.headers ?? { "Content-Type": "application/json" },
|
|
209
|
+
body: result?.body ?? null,
|
|
210
|
+
}));
|
|
211
|
+
} catch (error: any) {
|
|
212
|
+
console.error("API handler error:", error);
|
|
213
|
+
res.writeHead(200);
|
|
214
|
+
res.end(JSON.stringify({
|
|
215
|
+
status: 500,
|
|
216
|
+
headers: { "Content-Type": "application/json" },
|
|
217
|
+
body: { error: "Internal Server Error", message: error?.message },
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
69
222
|
/**
|
|
70
223
|
* POST /data - Execute getServerProps for a matched route.
|
|
71
224
|
*
|
|
@@ -93,8 +246,15 @@ async function handleData(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
|
93
246
|
|
|
94
247
|
if (!getServerProps) {
|
|
95
248
|
// This page doesn't export getServerProps
|
|
249
|
+
// Still resolve metadata if available
|
|
250
|
+
const metadata = await resolveMetadata(routeDef.path, {
|
|
251
|
+
params: { ...(dataReq.params || {}), ...match.params },
|
|
252
|
+
query: dataReq.query || {},
|
|
253
|
+
path: dataReq.path,
|
|
254
|
+
headers: dataReq.headers || {},
|
|
255
|
+
});
|
|
96
256
|
res.writeHead(200);
|
|
97
|
-
res.end(JSON.stringify({ props: {}, hasServerProps: false }));
|
|
257
|
+
res.end(JSON.stringify({ props: {}, hasServerProps: false, metadata }));
|
|
98
258
|
return;
|
|
99
259
|
}
|
|
100
260
|
|
|
@@ -109,10 +269,14 @@ async function handleData(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
|
109
269
|
// Run getServerProps
|
|
110
270
|
const result = await getServerProps(ctx);
|
|
111
271
|
|
|
272
|
+
// Resolve metadata
|
|
273
|
+
const metadata = await resolveMetadata(routeDef.path, ctx);
|
|
274
|
+
|
|
112
275
|
const dataResp: DataResponse = {
|
|
113
276
|
props: result?.props || {},
|
|
114
277
|
hasServerProps: true,
|
|
115
278
|
revalidate: result?.revalidate || 0,
|
|
279
|
+
metadata,
|
|
116
280
|
};
|
|
117
281
|
|
|
118
282
|
res.writeHead(200);
|
|
@@ -189,9 +353,22 @@ async function handleRender(
|
|
|
189
353
|
}),
|
|
190
354
|
);
|
|
191
355
|
|
|
356
|
+
// Resolve page metadata for <head> rendering
|
|
357
|
+
const routeDef = routes.find(r => r.component === match.component);
|
|
358
|
+
const metadata = routeDef
|
|
359
|
+
? await resolveMetadata(routeDef.path, {
|
|
360
|
+
params: props.params || {},
|
|
361
|
+
query: ssrReq.query || {},
|
|
362
|
+
path: ssrReq.path,
|
|
363
|
+
headers: {},
|
|
364
|
+
})
|
|
365
|
+
: {};
|
|
366
|
+
|
|
192
367
|
const documentElement = React.createElement(Document, {
|
|
193
368
|
initialProps: props,
|
|
194
369
|
children: appElement,
|
|
370
|
+
metadata,
|
|
371
|
+
scripts: getScriptsForPage(routeDef?.path || ssrReq.path),
|
|
195
372
|
});
|
|
196
373
|
|
|
197
374
|
const html = "<!doctype html>\n" + renderToString(documentElement);
|
|
@@ -216,6 +393,135 @@ async function handleRender(
|
|
|
216
393
|
}
|
|
217
394
|
}
|
|
218
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
|
+
|
|
219
525
|
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
220
526
|
return new Promise((resolve, reject) => {
|
|
221
527
|
let body = "";
|
|
@@ -229,12 +535,170 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
|
|
229
535
|
});
|
|
230
536
|
}
|
|
231
537
|
|
|
538
|
+
/**
|
|
539
|
+
* Deep merge two objects. Values from `source` override `target`.
|
|
540
|
+
* Nested plain objects are merged recursively; arrays and primitives
|
|
541
|
+
* are replaced outright.
|
|
542
|
+
*/
|
|
543
|
+
function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
|
|
544
|
+
const result: Record<string, any> = { ...target };
|
|
545
|
+
for (const key of Object.keys(source)) {
|
|
546
|
+
const srcVal = source[key];
|
|
547
|
+
const tgtVal = result[key];
|
|
548
|
+
if (
|
|
549
|
+
srcVal && typeof srcVal === "object" && !Array.isArray(srcVal) &&
|
|
550
|
+
tgtVal && typeof tgtVal === "object" && !Array.isArray(tgtVal)
|
|
551
|
+
) {
|
|
552
|
+
result[key] = deepMerge(tgtVal, srcVal);
|
|
553
|
+
} else {
|
|
554
|
+
result[key] = srcVal;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return result;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Determine the layout chain for a given route path.
|
|
562
|
+
* E.g. "/docs" → ["/", "/docs"]
|
|
563
|
+
* E.g. "/users/42" → ["/"]
|
|
564
|
+
*/
|
|
565
|
+
function getLayoutChain(routePath: string): string[] {
|
|
566
|
+
const prefixes: string[] = ["/"]; // root always included
|
|
567
|
+
const segments = routePath.split("/").filter(Boolean);
|
|
568
|
+
for (let i = 1; i <= segments.length; i++) {
|
|
569
|
+
const prefix = "/" + segments.slice(0, i).join("/");
|
|
570
|
+
if (prefix !== "/") prefixes.push(prefix);
|
|
571
|
+
}
|
|
572
|
+
return prefixes;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Resolve metadata for a route.
|
|
577
|
+
*
|
|
578
|
+
* Walks the layout chain (root → segment → … → page), resolving
|
|
579
|
+
* each layer's metadata (static or dynamic), then deep-merges them
|
|
580
|
+
* so deeper values override shallower ones and page wins over all.
|
|
581
|
+
*/
|
|
582
|
+
async function resolveMetadata(
|
|
583
|
+
routePath: string,
|
|
584
|
+
ctx: { params: Record<string, any>; query: Record<string, any>; path: string; headers?: Record<string, string> },
|
|
585
|
+
): Promise<Record<string, any>> {
|
|
586
|
+
try {
|
|
587
|
+
let merged: Record<string, any> = { title: "Blumen App" };
|
|
588
|
+
|
|
589
|
+
// 1. Walk layout chain and merge each layout's metadata
|
|
590
|
+
const chain = getLayoutChain(routePath);
|
|
591
|
+
for (const prefix of chain) {
|
|
592
|
+
// Dynamic layout metadata takes priority over static
|
|
593
|
+
const layoutGenMeta = layoutGenerateMetadataMap[prefix];
|
|
594
|
+
if (layoutGenMeta) {
|
|
595
|
+
const layoutMeta = await layoutGenMeta(ctx);
|
|
596
|
+
merged = deepMerge(merged, layoutMeta);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const layoutStaticMeta = layoutMetadataMap[prefix];
|
|
601
|
+
if (layoutStaticMeta) {
|
|
602
|
+
merged = deepMerge(merged, layoutStaticMeta);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// 2. Apply page-level metadata on top (page wins)
|
|
607
|
+
const generateMeta = generateMetadataMap[routePath];
|
|
608
|
+
if (generateMeta) {
|
|
609
|
+
const pageMeta = await generateMeta(ctx);
|
|
610
|
+
merged = deepMerge(merged, pageMeta);
|
|
611
|
+
return merged;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const staticMeta = metadataMap[routePath];
|
|
615
|
+
if (staticMeta) {
|
|
616
|
+
merged = deepMerge(merged, staticMeta);
|
|
617
|
+
return merged;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return merged;
|
|
621
|
+
} catch (error) {
|
|
622
|
+
console.error("Metadata resolution error:", error);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Fallback default
|
|
626
|
+
return { title: "Blumen App" };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* POST /action - Execute a server action.
|
|
631
|
+
*
|
|
632
|
+
* Go forwards the action request after CSRF validation.
|
|
633
|
+
* This function looks up the action in the registry and executes it.
|
|
634
|
+
*/
|
|
635
|
+
async function handleAction(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
636
|
+
try {
|
|
637
|
+
const rawBody = await readBody(req);
|
|
638
|
+
const { action: actionName, input } = JSON.parse(rawBody) as {
|
|
639
|
+
action: string;
|
|
640
|
+
input: any;
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const result = await executeAction(actionName, input);
|
|
644
|
+
|
|
645
|
+
res.writeHead(200);
|
|
646
|
+
res.end(JSON.stringify(result));
|
|
647
|
+
} catch (error) {
|
|
648
|
+
console.error("Server Action Error:", error);
|
|
649
|
+
res.writeHead(500);
|
|
650
|
+
res.end(JSON.stringify({
|
|
651
|
+
success: false,
|
|
652
|
+
error: process.env.NODE_ENV === "development" ? String(error) : "Server action failed",
|
|
653
|
+
}));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
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
|
+
|
|
232
692
|
server.listen(PORT, () => {
|
|
233
693
|
console.log(`Node SSR server running on http://localhost:${PORT}`);
|
|
234
694
|
const gspCount = Object.keys(serverPropsMap).length;
|
|
235
695
|
if (gspCount > 0) {
|
|
236
696
|
console.log(` ${gspCount} page(s) with getServerProps detected`);
|
|
237
697
|
}
|
|
698
|
+
const actionCount = getRegisteredActions().length;
|
|
699
|
+
if (actionCount > 0) {
|
|
700
|
+
console.log(` ${actionCount} server action(s) registered`);
|
|
701
|
+
}
|
|
238
702
|
});
|
|
239
703
|
|
|
240
704
|
// Handle graceful shutdown
|