blumenjs 0.1.7 → 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.
- package/dist/cli/blumen.js +270 -15
- package/dist/cli/commands/build.js +25 -6
- package/dist/cli/commands/create.js +1 -1
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/fonts.js +232 -0
- package/dist/cli/commands/start.js +1 -1
- package/dist/templates/app/client/entry.tsx +3 -1
- package/dist/templates/app/pages/BlumenStarter.tsx +1 -1
- package/dist/templates/app/shared/DefaultDocument.tsx +49 -4
- package/dist/templates/app/shared/Link.tsx +60 -6
- package/dist/templates/app/shared/RouterContext.tsx +81 -18
- package/dist/templates/app/shared/router.ts +2 -1
- package/dist/templates/go-server/cache.go +147 -0
- package/dist/templates/go-server/image.go +200 -0
- package/dist/templates/go-server/main.go +394 -39
- 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 +364 -8
- package/dist/templates/scripts/generate-routes.ts +355 -7
- package/package.json +12 -6
|
@@ -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 } 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;
|
|
@@ -21,6 +23,20 @@ interface SSRResponse {
|
|
|
21
23
|
props: Record<string, any>;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
interface DataRequest {
|
|
27
|
+
path: string;
|
|
28
|
+
query: Record<string, string[]>;
|
|
29
|
+
params: Record<string, any>;
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DataResponse {
|
|
34
|
+
props: Record<string, any>;
|
|
35
|
+
hasServerProps: boolean;
|
|
36
|
+
revalidate: number; // TTL in seconds; 0 = no caching
|
|
37
|
+
metadata?: Record<string, any>; // Resolved page metadata for <head> tags
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
const PORT = process.env.PORT || 4000;
|
|
25
41
|
|
|
26
42
|
const server = http.createServer(async (req, res) => {
|
|
@@ -36,12 +52,203 @@ const server = http.createServer(async (req, res) => {
|
|
|
36
52
|
return;
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
if (req.method !== "POST"
|
|
55
|
+
if (req.method !== "POST") {
|
|
40
56
|
res.writeHead(404);
|
|
41
57
|
res.end(JSON.stringify({ error: "Not Found" }));
|
|
42
58
|
return;
|
|
43
59
|
}
|
|
44
60
|
|
|
61
|
+
// Route to the correct handler
|
|
62
|
+
if (req.url === "/data") {
|
|
63
|
+
return handleData(req, res);
|
|
64
|
+
} else if (req.url === "/render") {
|
|
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);
|
|
70
|
+
} else {
|
|
71
|
+
res.writeHead(404);
|
|
72
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
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
|
+
|
|
167
|
+
/**
|
|
168
|
+
* POST /data - Execute getServerProps for a matched route.
|
|
169
|
+
*
|
|
170
|
+
* Called by the Go server before /render to fetch TypeScript-defined
|
|
171
|
+
* server data. If the page doesn't export getServerProps, returns
|
|
172
|
+
* { hasServerProps: false } and Go skips the data merge.
|
|
173
|
+
*/
|
|
174
|
+
async function handleData(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
175
|
+
try {
|
|
176
|
+
const body = await readBody(req);
|
|
177
|
+
const dataReq: DataRequest = JSON.parse(body);
|
|
178
|
+
|
|
179
|
+
// Match the route to find the page
|
|
180
|
+
const match = matchRoute(dataReq.path, routes);
|
|
181
|
+
|
|
182
|
+
if (!match) {
|
|
183
|
+
res.writeHead(200);
|
|
184
|
+
res.end(JSON.stringify({ props: {}, hasServerProps: false }));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Find the route definition to look up in serverPropsMap
|
|
189
|
+
const routeDef = routes.find(r => r.component === match.component);
|
|
190
|
+
const getServerProps = routeDef ? serverPropsMap[routeDef.path] : undefined;
|
|
191
|
+
|
|
192
|
+
if (!getServerProps) {
|
|
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
|
+
});
|
|
201
|
+
res.writeHead(200);
|
|
202
|
+
res.end(JSON.stringify({ props: {}, hasServerProps: false, metadata }));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Build the BlumenContext
|
|
207
|
+
const ctx = {
|
|
208
|
+
params: { ...(dataReq.params || {}), ...match.params },
|
|
209
|
+
query: dataReq.query || {},
|
|
210
|
+
path: dataReq.path,
|
|
211
|
+
headers: dataReq.headers || {},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Run getServerProps
|
|
215
|
+
const result = await getServerProps(ctx);
|
|
216
|
+
|
|
217
|
+
// Resolve metadata
|
|
218
|
+
const metadata = await resolveMetadata(routeDef.path, ctx);
|
|
219
|
+
|
|
220
|
+
const dataResp: DataResponse = {
|
|
221
|
+
props: result?.props || {},
|
|
222
|
+
hasServerProps: true,
|
|
223
|
+
revalidate: result?.revalidate || 0,
|
|
224
|
+
metadata,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
res.writeHead(200);
|
|
228
|
+
res.end(JSON.stringify(dataResp));
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error("getServerProps Error:", error);
|
|
231
|
+
res.writeHead(500);
|
|
232
|
+
res.end(
|
|
233
|
+
JSON.stringify({
|
|
234
|
+
error: "getServerProps failed",
|
|
235
|
+
message:
|
|
236
|
+
process.env.NODE_ENV === "development" ? String(error) : undefined,
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* POST /render - Render the matched page to HTML.
|
|
244
|
+
*
|
|
245
|
+
* This is the existing SSR endpoint. It receives props (possibly
|
|
246
|
+
* enriched by /data) and renders the React component tree to HTML.
|
|
247
|
+
*/
|
|
248
|
+
async function handleRender(
|
|
249
|
+
req: http.IncomingMessage,
|
|
250
|
+
res: http.ServerResponse,
|
|
251
|
+
) {
|
|
45
252
|
try {
|
|
46
253
|
// Parse request body
|
|
47
254
|
const body = await readBody(req);
|
|
@@ -57,7 +264,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
57
264
|
status: 404,
|
|
58
265
|
serverRendered: true,
|
|
59
266
|
};
|
|
60
|
-
const element = React.createElement(
|
|
267
|
+
const element = React.createElement(
|
|
268
|
+
"div",
|
|
269
|
+
{ className: "page-transition page-transition-active" },
|
|
270
|
+
React.createElement(NotFound, props),
|
|
271
|
+
);
|
|
61
272
|
const html = renderToString(element);
|
|
62
273
|
|
|
63
274
|
res.writeHead(200);
|
|
@@ -76,14 +287,32 @@ const server = http.createServer(async (req, res) => {
|
|
|
76
287
|
};
|
|
77
288
|
|
|
78
289
|
// Render React to HTML
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
290
|
+
// Wrap in the same page-transition div that the client-side
|
|
291
|
+
// RouterProvider renders, so hydration trees match exactly.
|
|
292
|
+
const appElement = React.createElement(
|
|
293
|
+
"div",
|
|
294
|
+
{ className: "page-transition page-transition-active" },
|
|
295
|
+
React.createElement(App, {
|
|
296
|
+
Component: match.component,
|
|
297
|
+
pageProps: props,
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
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
|
+
: {};
|
|
83
311
|
|
|
84
312
|
const documentElement = React.createElement(Document, {
|
|
85
313
|
initialProps: props,
|
|
86
314
|
children: appElement,
|
|
315
|
+
metadata,
|
|
87
316
|
});
|
|
88
317
|
|
|
89
318
|
const html = "<!doctype html>\n" + renderToString(documentElement);
|
|
@@ -106,7 +335,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
106
335
|
}),
|
|
107
336
|
);
|
|
108
337
|
}
|
|
109
|
-
}
|
|
338
|
+
}
|
|
110
339
|
|
|
111
340
|
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
112
341
|
return new Promise((resolve, reject) => {
|
|
@@ -121,8 +350,135 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
|
|
121
350
|
});
|
|
122
351
|
}
|
|
123
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
|
+
|
|
124
472
|
server.listen(PORT, () => {
|
|
125
473
|
console.log(`Node SSR server running on http://localhost:${PORT}`);
|
|
474
|
+
const gspCount = Object.keys(serverPropsMap).length;
|
|
475
|
+
if (gspCount > 0) {
|
|
476
|
+
console.log(` ${gspCount} page(s) with getServerProps detected`);
|
|
477
|
+
}
|
|
478
|
+
const actionCount = getRegisteredActions().length;
|
|
479
|
+
if (actionCount > 0) {
|
|
480
|
+
console.log(` ${actionCount} server action(s) registered`);
|
|
481
|
+
}
|
|
126
482
|
});
|
|
127
483
|
|
|
128
484
|
// Handle graceful shutdown
|