blumenjs 0.2.0 → 0.2.1

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.
@@ -3,8 +3,10 @@ import { renderToString } from "react-dom/server";
3
3
  import React from "react";
4
4
 
5
5
  // Auto-generated route map (run `npm run routes` to regenerate)
6
- import { routes, App, Document, serverPropsMap } from "./generated-routes";
6
+ import { routes, App, Document, serverPropsMap, metadataMap, generateMetadataMap, layoutMetadataMap, layoutGenerateMetadataMap } from "./generated-routes";
7
+ import { apiRoutes } from "./generated-api-routes";
7
8
  import { matchRoute } from "../app/shared/router";
9
+ import { executeAction, getRegisteredActions } from "../app/shared/serverAction";
8
10
  import NotFoundPage from "../app/pages/NotFound";
9
11
 
10
12
  const NotFound = NotFoundPage;
@@ -32,6 +34,7 @@ interface DataResponse {
32
34
  props: Record<string, any>;
33
35
  hasServerProps: boolean;
34
36
  revalidate: number; // TTL in seconds; 0 = no caching
37
+ metadata?: Record<string, any>; // Resolved page metadata for <head> tags
35
38
  }
36
39
 
37
40
  const PORT = process.env.PORT || 4000;
@@ -60,12 +63,107 @@ const server = http.createServer(async (req, res) => {
60
63
  return handleData(req, res);
61
64
  } else if (req.url === "/render") {
62
65
  return handleRender(req, res);
66
+ } else if (req.url === "/api") {
67
+ return handleAPI(req, res);
68
+ } else if (req.url === "/action") {
69
+ return handleAction(req, res);
63
70
  } else {
64
71
  res.writeHead(404);
65
72
  res.end(JSON.stringify({ error: "Not Found" }));
66
73
  }
67
74
  });
68
75
 
76
+ /**
77
+ * POST /api - Handle API route requests forwarded from Go.
78
+ *
79
+ * Go sends a JSON envelope: { method, path, query, headers, body }
80
+ * This function matches the path against generated apiRoutes,
81
+ * finds the correct HTTP method handler, and returns { status, headers, body }.
82
+ */
83
+ async function handleAPI(req: http.IncomingMessage, res: http.ServerResponse) {
84
+ try {
85
+ const rawBody = await readBody(req);
86
+ const apiReq = JSON.parse(rawBody) as {
87
+ method: string;
88
+ path: string;
89
+ query: Record<string, string[]>;
90
+ headers: Record<string, string>;
91
+ body: any;
92
+ };
93
+
94
+ // Match the path against API routes
95
+ let matchedRoute = null;
96
+ let params: Record<string, string> = {};
97
+
98
+ for (const route of apiRoutes) {
99
+ const match = apiReq.path.match(route.pattern);
100
+ if (match) {
101
+ matchedRoute = route;
102
+ // Extract dynamic parameters
103
+ route.keys.forEach((key, i) => {
104
+ params[key] = match[i + 1];
105
+ });
106
+ break;
107
+ }
108
+ }
109
+
110
+ if (!matchedRoute) {
111
+ res.writeHead(200);
112
+ res.end(JSON.stringify({
113
+ status: 404,
114
+ headers: { "Content-Type": "application/json" },
115
+ body: { error: "API route not found" },
116
+ }));
117
+ return;
118
+ }
119
+
120
+ // Check if the HTTP method is supported
121
+ const handler = matchedRoute.handlers[apiReq.method];
122
+ if (!handler) {
123
+ const allowed = Object.keys(matchedRoute.handlers).join(", ");
124
+ res.writeHead(200);
125
+ res.end(JSON.stringify({
126
+ status: 405,
127
+ headers: {
128
+ "Content-Type": "application/json",
129
+ "Allow": allowed,
130
+ },
131
+ body: { error: `Method ${apiReq.method} not allowed. Allowed: ${allowed}` },
132
+ }));
133
+ return;
134
+ }
135
+
136
+ // Build the request object for the handler
137
+ const blumenReq = {
138
+ method: apiReq.method,
139
+ path: apiReq.path,
140
+ params,
141
+ query: apiReq.query || {},
142
+ headers: apiReq.headers || {},
143
+ body: apiReq.body,
144
+ };
145
+
146
+ // Call the handler
147
+ const result = await handler(blumenReq);
148
+
149
+ // The handler returns a BlumenAPIResponse: { status, headers, body }
150
+ res.writeHead(200);
151
+ res.end(JSON.stringify({
152
+ status: result?.status ?? 200,
153
+ headers: result?.headers ?? { "Content-Type": "application/json" },
154
+ body: result?.body ?? null,
155
+ }));
156
+ } catch (error: any) {
157
+ console.error("API handler error:", error);
158
+ res.writeHead(200);
159
+ res.end(JSON.stringify({
160
+ status: 500,
161
+ headers: { "Content-Type": "application/json" },
162
+ body: { error: "Internal Server Error", message: error?.message },
163
+ }));
164
+ }
165
+ }
166
+
69
167
  /**
70
168
  * POST /data - Execute getServerProps for a matched route.
71
169
  *
@@ -93,8 +191,15 @@ async function handleData(req: http.IncomingMessage, res: http.ServerResponse) {
93
191
 
94
192
  if (!getServerProps) {
95
193
  // This page doesn't export getServerProps
194
+ // Still resolve metadata if available
195
+ const metadata = await resolveMetadata(routeDef.path, {
196
+ params: { ...(dataReq.params || {}), ...match.params },
197
+ query: dataReq.query || {},
198
+ path: dataReq.path,
199
+ headers: dataReq.headers || {},
200
+ });
96
201
  res.writeHead(200);
97
- res.end(JSON.stringify({ props: {}, hasServerProps: false }));
202
+ res.end(JSON.stringify({ props: {}, hasServerProps: false, metadata }));
98
203
  return;
99
204
  }
100
205
 
@@ -109,10 +214,14 @@ async function handleData(req: http.IncomingMessage, res: http.ServerResponse) {
109
214
  // Run getServerProps
110
215
  const result = await getServerProps(ctx);
111
216
 
217
+ // Resolve metadata
218
+ const metadata = await resolveMetadata(routeDef.path, ctx);
219
+
112
220
  const dataResp: DataResponse = {
113
221
  props: result?.props || {},
114
222
  hasServerProps: true,
115
223
  revalidate: result?.revalidate || 0,
224
+ metadata,
116
225
  };
117
226
 
118
227
  res.writeHead(200);
@@ -189,9 +298,21 @@ async function handleRender(
189
298
  }),
190
299
  );
191
300
 
301
+ // Resolve page metadata for <head> rendering
302
+ const routeDef = routes.find(r => r.component === match.component);
303
+ const metadata = routeDef
304
+ ? await resolveMetadata(routeDef.path, {
305
+ params: props.params || {},
306
+ query: ssrReq.query || {},
307
+ path: ssrReq.path,
308
+ headers: {},
309
+ })
310
+ : {};
311
+
192
312
  const documentElement = React.createElement(Document, {
193
313
  initialProps: props,
194
314
  children: appElement,
315
+ metadata,
195
316
  });
196
317
 
197
318
  const html = "<!doctype html>\n" + renderToString(documentElement);
@@ -229,12 +350,135 @@ function readBody(req: http.IncomingMessage): Promise<string> {
229
350
  });
230
351
  }
231
352
 
353
+ /**
354
+ * Deep merge two objects. Values from `source` override `target`.
355
+ * Nested plain objects are merged recursively; arrays and primitives
356
+ * are replaced outright.
357
+ */
358
+ function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
359
+ const result: Record<string, any> = { ...target };
360
+ for (const key of Object.keys(source)) {
361
+ const srcVal = source[key];
362
+ const tgtVal = result[key];
363
+ if (
364
+ srcVal && typeof srcVal === "object" && !Array.isArray(srcVal) &&
365
+ tgtVal && typeof tgtVal === "object" && !Array.isArray(tgtVal)
366
+ ) {
367
+ result[key] = deepMerge(tgtVal, srcVal);
368
+ } else {
369
+ result[key] = srcVal;
370
+ }
371
+ }
372
+ return result;
373
+ }
374
+
375
+ /**
376
+ * Determine the layout chain for a given route path.
377
+ * E.g. "/docs" → ["/", "/docs"]
378
+ * E.g. "/users/42" → ["/"]
379
+ */
380
+ function getLayoutChain(routePath: string): string[] {
381
+ const prefixes: string[] = ["/"]; // root always included
382
+ const segments = routePath.split("/").filter(Boolean);
383
+ for (let i = 1; i <= segments.length; i++) {
384
+ const prefix = "/" + segments.slice(0, i).join("/");
385
+ if (prefix !== "/") prefixes.push(prefix);
386
+ }
387
+ return prefixes;
388
+ }
389
+
390
+ /**
391
+ * Resolve metadata for a route.
392
+ *
393
+ * Walks the layout chain (root → segment → … → page), resolving
394
+ * each layer's metadata (static or dynamic), then deep-merges them
395
+ * so deeper values override shallower ones and page wins over all.
396
+ */
397
+ async function resolveMetadata(
398
+ routePath: string,
399
+ ctx: { params: Record<string, any>; query: Record<string, any>; path: string; headers?: Record<string, string> },
400
+ ): Promise<Record<string, any>> {
401
+ try {
402
+ let merged: Record<string, any> = { title: "Blumen App" };
403
+
404
+ // 1. Walk layout chain and merge each layout's metadata
405
+ const chain = getLayoutChain(routePath);
406
+ for (const prefix of chain) {
407
+ // Dynamic layout metadata takes priority over static
408
+ const layoutGenMeta = layoutGenerateMetadataMap[prefix];
409
+ if (layoutGenMeta) {
410
+ const layoutMeta = await layoutGenMeta(ctx);
411
+ merged = deepMerge(merged, layoutMeta);
412
+ continue;
413
+ }
414
+
415
+ const layoutStaticMeta = layoutMetadataMap[prefix];
416
+ if (layoutStaticMeta) {
417
+ merged = deepMerge(merged, layoutStaticMeta);
418
+ }
419
+ }
420
+
421
+ // 2. Apply page-level metadata on top (page wins)
422
+ const generateMeta = generateMetadataMap[routePath];
423
+ if (generateMeta) {
424
+ const pageMeta = await generateMeta(ctx);
425
+ merged = deepMerge(merged, pageMeta);
426
+ return merged;
427
+ }
428
+
429
+ const staticMeta = metadataMap[routePath];
430
+ if (staticMeta) {
431
+ merged = deepMerge(merged, staticMeta);
432
+ return merged;
433
+ }
434
+
435
+ return merged;
436
+ } catch (error) {
437
+ console.error("Metadata resolution error:", error);
438
+ }
439
+
440
+ // Fallback default
441
+ return { title: "Blumen App" };
442
+ }
443
+
444
+ /**
445
+ * POST /action - Execute a server action.
446
+ *
447
+ * Go forwards the action request after CSRF validation.
448
+ * This function looks up the action in the registry and executes it.
449
+ */
450
+ async function handleAction(req: http.IncomingMessage, res: http.ServerResponse) {
451
+ try {
452
+ const rawBody = await readBody(req);
453
+ const { action: actionName, input } = JSON.parse(rawBody) as {
454
+ action: string;
455
+ input: any;
456
+ };
457
+
458
+ const result = await executeAction(actionName, input);
459
+
460
+ res.writeHead(200);
461
+ res.end(JSON.stringify(result));
462
+ } catch (error) {
463
+ console.error("Server Action Error:", error);
464
+ res.writeHead(500);
465
+ res.end(JSON.stringify({
466
+ success: false,
467
+ error: process.env.NODE_ENV === "development" ? String(error) : "Server action failed",
468
+ }));
469
+ }
470
+ }
471
+
232
472
  server.listen(PORT, () => {
233
473
  console.log(`Node SSR server running on http://localhost:${PORT}`);
234
474
  const gspCount = Object.keys(serverPropsMap).length;
235
475
  if (gspCount > 0) {
236
476
  console.log(` ${gspCount} page(s) with getServerProps detected`);
237
477
  }
478
+ const actionCount = getRegisteredActions().length;
479
+ if (actionCount > 0) {
480
+ console.log(` ${actionCount} server action(s) registered`);
481
+ }
238
482
  });
239
483
 
240
484
  // Handle graceful shutdown