blumenjs 0.1.0
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/README.md +127 -0
- package/dist/cli/blumen.js +697 -0
- package/dist/cli/commands/build.js +85 -0
- package/dist/cli/commands/create.js +384 -0
- package/dist/cli/commands/dev.js +163 -0
- package/dist/cli/commands/start.js +129 -0
- package/dist/cli/utils.js +85 -0
- package/dist/templates/app/client/entry.tsx +41 -0
- package/dist/templates/app/pages/BlumenStarter.tsx +398 -0
- package/dist/templates/app/pages/NotFound.tsx +22 -0
- package/dist/templates/app/shared/DefaultApp.tsx +5 -0
- package/dist/templates/app/shared/DefaultDocument.tsx +76 -0
- package/dist/templates/app/shared/Link.tsx +73 -0
- package/dist/templates/app/shared/RouterContext.tsx +176 -0
- package/dist/templates/app/shared/router.ts +23 -0
- package/dist/templates/go-server/main.go +175 -0
- package/dist/templates/node-ssr/server.ts +141 -0
- package/dist/templates/scripts/generate-routes.ts +220 -0
- package/package.json +77 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
// HMR: In development, load the client bundle from Webpack Dev Server
|
|
4
|
+
// so the HMR runtime + React Fast Refresh are active.
|
|
5
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
6
|
+
const BUNDLE_SRC = isDev
|
|
7
|
+
? "http://localhost:3100/static/js/bundle.js"
|
|
8
|
+
: "/static/js/bundle.js";
|
|
9
|
+
|
|
10
|
+
export function DefaultDocument({ children, initialProps }: any) {
|
|
11
|
+
const css = `
|
|
12
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
13
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; color: #e2e8f0; background: #1a1025; }
|
|
14
|
+
#root { min-height: 100vh; }
|
|
15
|
+
.about-page, .documentation-page { max-width: 800px; margin: 0 auto; padding: 2rem; color: #333; }
|
|
16
|
+
.about-page .page-content, .documentation-page .page-content { color: #333; }
|
|
17
|
+
.about-page .info-card, .documentation-page .info-card { background: white; color: #333; }
|
|
18
|
+
.about-page .page-header, .documentation-page .page-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
|
19
|
+
.home-page { min-height: 100vh; background: linear-gradient(180deg, #1a111d 0%, #2d1b4e 100%); padding: 0; display: flex; flex-direction: column; }
|
|
20
|
+
.hero { text-align: center; padding: 4rem 2rem 3rem; }
|
|
21
|
+
.logo { display: flex; align-items: center; justify-content: center; margin-bottom: 1.5rem; }
|
|
22
|
+
.logo h1 { font-size: 3rem; font-weight: 700; background: linear-gradient(135deg, #a855f7 0%, #c084fc 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
|
23
|
+
.version { margin-left: 0.75rem; padding: 0.25rem 0.5rem; background: rgba(168, 85, 247, 0.15); color: #c084fc; border-radius: 4px; font-size: 0.875rem; font-weight: 500; border: 1px solid rgba(168, 85, 247, 0.3); }
|
|
24
|
+
.tagline { color: #94a3b8; font-size: 1.25rem; line-height: 1.7; max-width: 600px; margin: 0 auto; }
|
|
25
|
+
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; max-width: 1000px; margin: 0 auto; padding: 0 2rem 3rem; }
|
|
26
|
+
.feature-card-large { grid-column: span 2; }
|
|
27
|
+
.feature-card { background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 12px; padding: 1.5rem; text-decoration: none; transition: all 0.2s ease; display: flex; flex-direction: column; }
|
|
28
|
+
.feature-card:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(168, 85, 247, 0.4); transform: translateY(-2px); }
|
|
29
|
+
.card-icon { display: flex; align-items: center; justify-content: center; width: 42px; height: 42px; background: rgba(168, 85, 247, 0.1); border: 1px solid rgba(168, 85, 247, 0.2); border-radius: 10px; color: #a855f7; margin-bottom: 1.25rem; transition: all 0.2s ease; }
|
|
30
|
+
.feature-card:hover .card-icon { background: rgba(168, 85, 247, 0.2); border-color: rgba(168, 85, 247, 0.4); transform: scale(1.05); }
|
|
31
|
+
.feature-card h3 { color: #f1f5f9; font-size: 1.125rem; margin-bottom: 0.5rem; font-weight: 600; }
|
|
32
|
+
.feature-card p { color: #94a3b8; font-size: 0.9rem; line-height: 1.6; margin-bottom: 1rem; flex-grow: 1; }
|
|
33
|
+
.card-code { background: rgba(0, 0, 0, 0.3); color: #c084fc; padding: 0.5rem 0.75rem; border-radius: 6px; font-family: "SF Mono", Monaco, monospace; font-size: 0.8rem; border: 1px solid rgba(168, 85, 247, 0.3); }
|
|
34
|
+
.home-footer { text-align: center; padding: 2rem; border-top: 1px solid rgba(168, 85, 247, 0.15); margin-top: auto; }
|
|
35
|
+
.footer-nav { display: flex; gap: 1.5rem; justify-content: center; margin-bottom: 1rem; }
|
|
36
|
+
.footer-link { color: #94a3b8; text-decoration: none; font-size: 0.9rem; transition: color 0.2s; }
|
|
37
|
+
.footer-link:hover { color: #c084fc; }
|
|
38
|
+
.footer-copy { color: #c084fc; font-size: 0.8rem; }
|
|
39
|
+
.page-header { text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 8px; }
|
|
40
|
+
.page-content { display: flex; flex-direction: column; gap: 1.5rem; }
|
|
41
|
+
.info-card { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); }
|
|
42
|
+
.info-card h2 { color: #667eea; margin-bottom: 1rem; font-size: 1.3rem; }
|
|
43
|
+
.page-nav { margin-top: 2rem; text-align: center; }
|
|
44
|
+
.nav-link { display: inline-block; padding: 0.75rem 1.5rem; background: #667eea; color: white; text-decoration: none; border-radius: 4px; transition: background 0.2s; }
|
|
45
|
+
.nav-link:hover { background: #5a6fd6; }
|
|
46
|
+
.server-info { text-align: center; color: #666; margin-top: 1rem; }
|
|
47
|
+
.not-found { text-align: center; padding: 4rem 2rem; }
|
|
48
|
+
.not-found h1 { color: #667eea; font-size: 2rem; }
|
|
49
|
+
.page-transition { will-change: opacity; }
|
|
50
|
+
.page-transition-active { opacity: 1; transition: opacity 200ms ease-in; }
|
|
51
|
+
.page-transition-exit { opacity: 0; transition: opacity 150ms ease-out; }
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<html lang="en">
|
|
56
|
+
<head>
|
|
57
|
+
<meta charSet="UTF-8" />
|
|
58
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
59
|
+
<meta name="description" content="SSR Go + React - Minimal Production-Quality SSR Engine" />
|
|
60
|
+
<title>SSR Go + React</title>
|
|
61
|
+
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
62
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
<div id="root">{children}</div>
|
|
65
|
+
<script
|
|
66
|
+
id="ssr-props"
|
|
67
|
+
type="application/json"
|
|
68
|
+
dangerouslySetInnerHTML={{
|
|
69
|
+
__html: JSON.stringify(initialProps).replace(/</g, '\\u003c')
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
<script src={BUNDLE_SRC} defer></script>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blumen <Link> Component
|
|
3
|
+
*
|
|
4
|
+
* Drop-in replacement for <a> that enables SPA navigation.
|
|
5
|
+
* External links, new-tab links, and modified clicks (ctrl/cmd)
|
|
6
|
+
* are passed through to the browser as normal.
|
|
7
|
+
*
|
|
8
|
+
* During SSR (no RouterProvider), renders a plain <a> tag.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* <Link href="/about" className="nav-link">About</Link>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useContext } from "react";
|
|
15
|
+
|
|
16
|
+
// Import the context directly to do a safe check without throwing
|
|
17
|
+
// We need the raw context object, not the hook
|
|
18
|
+
import { RouterContextRef, useRouter } from "./RouterContext";
|
|
19
|
+
|
|
20
|
+
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
21
|
+
/** The destination path */
|
|
22
|
+
href: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isExternal(href: string): boolean {
|
|
26
|
+
return /^https?:\/\//.test(href) || href.startsWith("mailto:") || href.startsWith("tel:");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isModifiedClick(e: React.MouseEvent): boolean {
|
|
30
|
+
return e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Link({ href, children, onClick, target, ...rest }: LinkProps) {
|
|
34
|
+
// Safe check: if we're in SSR (no provider), just render a plain <a>
|
|
35
|
+
const ctx = useContext(RouterContextRef);
|
|
36
|
+
|
|
37
|
+
if (!ctx) {
|
|
38
|
+
// SSR fallback — no router available
|
|
39
|
+
return (
|
|
40
|
+
<a href={href} target={target} {...rest}>
|
|
41
|
+
{children}
|
|
42
|
+
</a>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { navigate } = ctx;
|
|
47
|
+
|
|
48
|
+
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
49
|
+
// Let the caller's onClick run first
|
|
50
|
+
if (onClick) onClick(e);
|
|
51
|
+
if (e.defaultPrevented) return;
|
|
52
|
+
|
|
53
|
+
// Pass through to browser for:
|
|
54
|
+
// - External links
|
|
55
|
+
// - New-tab targets
|
|
56
|
+
// - Modified clicks (ctrl+click, cmd+click, etc.)
|
|
57
|
+
if (isExternal(href) || target === "_blank" || isModifiedClick(e)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// SPA navigation
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
navigate(href);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<a href={href} onClick={handleClick} target={target} {...rest}>
|
|
68
|
+
{children}
|
|
69
|
+
</a>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default Link;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blumen Client-Side Router
|
|
3
|
+
*
|
|
4
|
+
* Provides SPA navigation without full page reloads.
|
|
5
|
+
* On the first load, SSR content is hydrated normally.
|
|
6
|
+
* Subsequent navigations swap React components client-side
|
|
7
|
+
* using the History API and the generated route map.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, {
|
|
11
|
+
createContext,
|
|
12
|
+
useContext,
|
|
13
|
+
useState,
|
|
14
|
+
useCallback,
|
|
15
|
+
useEffect,
|
|
16
|
+
useRef,
|
|
17
|
+
} from "react";
|
|
18
|
+
import { matchRoute, type RouteDef } from "./router";
|
|
19
|
+
|
|
20
|
+
// ── Context types ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface RouterContextValue {
|
|
23
|
+
/** Current URL pathname */
|
|
24
|
+
path: string;
|
|
25
|
+
/** Extracted dynamic params for the current route (e.g. { id: "42" }) */
|
|
26
|
+
params: Record<string, string>;
|
|
27
|
+
/** Programmatic navigation */
|
|
28
|
+
navigate: (to: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const RouterContext = createContext<RouterContextValue | null>(null);
|
|
32
|
+
|
|
33
|
+
// Exported for Link.tsx to do safe SSR checks without throwing
|
|
34
|
+
export { RouterContext as RouterContextRef };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Hook for page components to access the router.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* const { path, params, navigate } = useRouter();
|
|
41
|
+
*/
|
|
42
|
+
export function useRouter(): RouterContextValue {
|
|
43
|
+
const ctx = useContext(RouterContext);
|
|
44
|
+
if (!ctx) {
|
|
45
|
+
throw new Error("useRouter must be used inside <RouterProvider>");
|
|
46
|
+
}
|
|
47
|
+
return ctx;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Provider props ─────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
interface RouterProviderProps {
|
|
53
|
+
/** The auto-generated route definitions */
|
|
54
|
+
routes: RouteDef[];
|
|
55
|
+
/** The App wrapper component */
|
|
56
|
+
App: React.ComponentType<any>;
|
|
57
|
+
/** The NotFound component for unmatched routes */
|
|
58
|
+
notFoundComponent: React.ComponentType<any>;
|
|
59
|
+
/** Initial SSR props passed from the server */
|
|
60
|
+
initialProps?: Record<string, any>;
|
|
61
|
+
children?: never; // Provider renders its own tree
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Provider ───────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export function RouterProvider({
|
|
67
|
+
routes,
|
|
68
|
+
App,
|
|
69
|
+
notFoundComponent: NotFound,
|
|
70
|
+
initialProps = {},
|
|
71
|
+
}: RouterProviderProps) {
|
|
72
|
+
const [path, setPath] = useState<string>(
|
|
73
|
+
() => window.location.pathname,
|
|
74
|
+
);
|
|
75
|
+
const [transitioning, setTransitioning] = useState(false);
|
|
76
|
+
const [dynamicProps, setDynamicProps] = useState<Record<string, any>>({});
|
|
77
|
+
|
|
78
|
+
// Track whether we're on the very first render (SSR hydration).
|
|
79
|
+
// On the first render we re-use the server-provided props.
|
|
80
|
+
const isFirstRender = useRef(true);
|
|
81
|
+
|
|
82
|
+
// ── Route matching ──────────────────────────────────────────
|
|
83
|
+
const match = matchRoute(path, routes);
|
|
84
|
+
const PageComponent = match ? match.component : NotFound;
|
|
85
|
+
const params = match ? match.params : {};
|
|
86
|
+
|
|
87
|
+
// Build props for the page component
|
|
88
|
+
const pageProps = isFirstRender.current
|
|
89
|
+
? { ...initialProps, params: { ...(initialProps.params || {}), ...params } }
|
|
90
|
+
: {
|
|
91
|
+
...dynamicProps,
|
|
92
|
+
path,
|
|
93
|
+
params,
|
|
94
|
+
query: {},
|
|
95
|
+
timestamp: Date.now(),
|
|
96
|
+
serverRendered: false,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// After the first render we stop using the SSR props
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
isFirstRender.current = false;
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
// ── Navigation function ─────────────────────────────────────
|
|
105
|
+
const navigate = useCallback(
|
|
106
|
+
(to: string) => {
|
|
107
|
+
if (to === path) return; // no-op for same path
|
|
108
|
+
|
|
109
|
+
// Animate out → change route → animate in
|
|
110
|
+
setTransitioning(true);
|
|
111
|
+
|
|
112
|
+
// Fetch dynamic data from Go server
|
|
113
|
+
const fetchPromise = fetch(to, {
|
|
114
|
+
headers: { "X-Blumen-Data": "1" }
|
|
115
|
+
}).then(res => res.ok ? res.json() : {}).catch(err => {
|
|
116
|
+
console.error("Failed to fetch route data", err);
|
|
117
|
+
return {};
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Allow the exit transition to play (150ms)
|
|
121
|
+
const delayPromise = new Promise(resolve => setTimeout(resolve, 150));
|
|
122
|
+
|
|
123
|
+
Promise.all([fetchPromise, delayPromise]).then(([newData]) => {
|
|
124
|
+
setDynamicProps(newData);
|
|
125
|
+
window.history.pushState(null, "", to);
|
|
126
|
+
setPath(to);
|
|
127
|
+
window.scrollTo(0, 0);
|
|
128
|
+
|
|
129
|
+
// Small delay for the enter transition
|
|
130
|
+
requestAnimationFrame(() => {
|
|
131
|
+
setTransitioning(false);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
[path],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// ── Back / Forward button support ───────────────────────────
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const onPopState = () => {
|
|
141
|
+
setTransitioning(true);
|
|
142
|
+
const to = window.location.pathname;
|
|
143
|
+
|
|
144
|
+
const fetchPromise = fetch(to, {
|
|
145
|
+
headers: { "X-Blumen-Data": "1" }
|
|
146
|
+
}).then(res => res.ok ? res.json() : {}).catch(() => ({}));
|
|
147
|
+
|
|
148
|
+
const delayPromise = new Promise(resolve => setTimeout(resolve, 150));
|
|
149
|
+
|
|
150
|
+
Promise.all([fetchPromise, delayPromise]).then(([newData]) => {
|
|
151
|
+
setDynamicProps(newData);
|
|
152
|
+
setPath(to);
|
|
153
|
+
|
|
154
|
+
requestAnimationFrame(() => {
|
|
155
|
+
setTransitioning(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
window.addEventListener("popstate", onPopState);
|
|
161
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
// ── Render ──────────────────────────────────────────────────
|
|
165
|
+
const contextValue: RouterContextValue = { path, params, navigate };
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<RouterContext.Provider value={contextValue}>
|
|
169
|
+
<div
|
|
170
|
+
className={`page-transition ${transitioning ? "page-transition-exit" : "page-transition-active"}`}
|
|
171
|
+
>
|
|
172
|
+
<App Component={PageComponent} pageProps={pageProps} />
|
|
173
|
+
</div>
|
|
174
|
+
</RouterContext.Provider>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface RouteDef {
|
|
2
|
+
path: string;
|
|
3
|
+
pattern: RegExp;
|
|
4
|
+
keys: string[];
|
|
5
|
+
component: React.ComponentType<any>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function matchRoute(path: string, routes: RouteDef[]) {
|
|
9
|
+
// Strip trailing slash for matching (unless it's exactly "/")
|
|
10
|
+
const normalizedPath = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
11
|
+
|
|
12
|
+
for (const route of routes) {
|
|
13
|
+
const match = normalizedPath.match(route.pattern);
|
|
14
|
+
if (match) {
|
|
15
|
+
const params: Record<string, string> = {};
|
|
16
|
+
route.keys.forEach((key: string, index: number) => {
|
|
17
|
+
params[key] = match[index + 1];
|
|
18
|
+
});
|
|
19
|
+
return { component: route.component, params };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"log"
|
|
8
|
+
"net"
|
|
9
|
+
"net/http"
|
|
10
|
+
"time"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const (
|
|
14
|
+
nodeSSRURL = "http://localhost:4000/render"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// SSRResponse from Node service
|
|
18
|
+
type SSRResponse struct {
|
|
19
|
+
HTML string `json:"html"`
|
|
20
|
+
Props map[string]interface{} `json:"props"`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// SSRRequest to Node service
|
|
24
|
+
type SSRRequest struct {
|
|
25
|
+
Path string `json:"path"`
|
|
26
|
+
Query map[string][]string `json:"query"`
|
|
27
|
+
Params map[string]interface{} `json:"params"`
|
|
28
|
+
Data map[string]interface{} `json:"data,omitempty"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var httpClient = &http.Client{
|
|
32
|
+
Timeout: 10 * time.Second,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func main() {
|
|
36
|
+
mux := http.NewServeMux()
|
|
37
|
+
|
|
38
|
+
// Static files are served directly — must be registered first
|
|
39
|
+
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
|
40
|
+
|
|
41
|
+
// Specific routes with Go loaders for data fetching
|
|
42
|
+
mux.HandleFunc("/dashboard/settings", PageHandler(func(r *http.Request) (map[string]interface{}, error) {
|
|
43
|
+
return map[string]interface{}{
|
|
44
|
+
"serverMessage": "Welcome to the settings. This data was securely fetched from Go at " + time.Now().Format(time.RFC1123),
|
|
45
|
+
"stats": map[string]int{"users": 150, "sales": 4200},
|
|
46
|
+
}, nil
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
mux.HandleFunc("/users/{id}", PageHandler(func(r *http.Request) (map[string]interface{}, error) {
|
|
50
|
+
id := r.PathValue("id")
|
|
51
|
+
return map[string]interface{}{
|
|
52
|
+
"userId": id,
|
|
53
|
+
"userProfile": "Profile data for user " + id + " fetched from Go db",
|
|
54
|
+
}, nil
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
// Catch-all: forward every other request to the Node SSR server.
|
|
58
|
+
// This decouples Go from knowing about specific React page paths.
|
|
59
|
+
mux.HandleFunc("/", PageHandler(nil))
|
|
60
|
+
|
|
61
|
+
startPort := 3000
|
|
62
|
+
var listener net.Listener
|
|
63
|
+
var err error
|
|
64
|
+
|
|
65
|
+
for {
|
|
66
|
+
addr := fmt.Sprintf(":%d", startPort)
|
|
67
|
+
listener, err = net.Listen("tcp", addr)
|
|
68
|
+
if err == nil {
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
log.Printf("Port %d is in use, trying %d...", startPort, startPort+1)
|
|
72
|
+
startPort++
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
log.Printf("Go server starting on http://localhost:%d", startPort)
|
|
76
|
+
if err := http.Serve(listener, mux); err != nil {
|
|
77
|
+
log.Fatalf("Server error: %v", err)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type DataLoader func(r *http.Request) (map[string]interface{}, error)
|
|
82
|
+
|
|
83
|
+
func PageHandler(loader DataLoader) http.HandlerFunc {
|
|
84
|
+
return func(w http.ResponseWriter, r *http.Request) {
|
|
85
|
+
if r.Method != http.MethodGet {
|
|
86
|
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var props map[string]interface{}
|
|
91
|
+
var err error
|
|
92
|
+
if loader != nil {
|
|
93
|
+
props, err = loader(r)
|
|
94
|
+
if err != nil {
|
|
95
|
+
log.Printf("Loader error: %v", err)
|
|
96
|
+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// If this is a SPA data fetch request, return JSON props
|
|
102
|
+
if r.Header.Get("X-Blumen-Data") == "1" || r.URL.Query().Get("_data") == "1" {
|
|
103
|
+
w.Header().Set("Content-Type", "application/json")
|
|
104
|
+
if props == nil {
|
|
105
|
+
props = make(map[string]interface{})
|
|
106
|
+
}
|
|
107
|
+
json.NewEncoder(w).Encode(props)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
handleRouteWithProps(w, r, props)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func handleRouteWithProps(w http.ResponseWriter, r *http.Request, props map[string]interface{}) {
|
|
116
|
+
// Prepare SSR request
|
|
117
|
+
ssrReq := SSRRequest{
|
|
118
|
+
Path: r.URL.Path,
|
|
119
|
+
Query: r.URL.Query(),
|
|
120
|
+
Params: map[string]interface{}{
|
|
121
|
+
"url": r.URL.String(),
|
|
122
|
+
"headers": r.Header,
|
|
123
|
+
},
|
|
124
|
+
Data: props,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Call Node SSR service
|
|
128
|
+
ssrResp, err := callNodeSSR(ssrReq)
|
|
129
|
+
if err != nil {
|
|
130
|
+
log.Printf("SSR error: %v", err)
|
|
131
|
+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
// Security headers
|
|
138
|
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
139
|
+
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
140
|
+
w.Header().Set("X-Frame-Options", "DENY")
|
|
141
|
+
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
142
|
+
|
|
143
|
+
w.WriteHeader(http.StatusOK)
|
|
144
|
+
w.Write([]byte(ssrResp.HTML))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func callNodeSSR(req SSRRequest) (*SSRResponse, error) {
|
|
148
|
+
reqBody, err := json.Marshal(req)
|
|
149
|
+
if err != nil {
|
|
150
|
+
return nil, fmt.Errorf("marshal request: %w", err)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
resp, err := httpClient.Post(
|
|
154
|
+
nodeSSRURL,
|
|
155
|
+
"application/json",
|
|
156
|
+
bytes.NewReader(reqBody),
|
|
157
|
+
)
|
|
158
|
+
if err != nil {
|
|
159
|
+
return nil, fmt.Errorf("http post: %w", err)
|
|
160
|
+
}
|
|
161
|
+
defer resp.Body.Close()
|
|
162
|
+
|
|
163
|
+
if resp.StatusCode != http.StatusOK {
|
|
164
|
+
return nil, fmt.Errorf("node ssr returned %d", resp.StatusCode)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
var ssrResp SSRResponse
|
|
168
|
+
if err := json.NewDecoder(resp.Body).Decode(&ssrResp); err != nil {
|
|
169
|
+
return nil, fmt.Errorf("decode response: %w", err)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return &ssrResp, nil
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
// Auto-generated route map (run `npm run routes` to regenerate)
|
|
6
|
+
import { routes, App, Document } from "./generated-routes";
|
|
7
|
+
import { matchRoute } from "../app/shared/router";
|
|
8
|
+
import NotFoundPage from "../app/pages/NotFound";
|
|
9
|
+
|
|
10
|
+
const NotFound = NotFoundPage;
|
|
11
|
+
|
|
12
|
+
interface SSRRequest {
|
|
13
|
+
path: string;
|
|
14
|
+
query: Record<string, string[]>;
|
|
15
|
+
params: Record<string, any>;
|
|
16
|
+
data?: Record<string, any>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SSRResponse {
|
|
20
|
+
html: string;
|
|
21
|
+
props: Record<string, any>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PORT = process.env.PORT || 4000;
|
|
25
|
+
|
|
26
|
+
const server = http.createServer(async (req, res) => {
|
|
27
|
+
// CORS and security headers
|
|
28
|
+
res.setHeader("Content-Type", "application/json");
|
|
29
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
30
|
+
res.setHeader("Access-Control-Allow-Methods", "POST");
|
|
31
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
32
|
+
|
|
33
|
+
if (req.method === "OPTIONS") {
|
|
34
|
+
res.writeHead(200);
|
|
35
|
+
res.end();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (req.method !== "POST" || req.url !== "/render") {
|
|
40
|
+
res.writeHead(404);
|
|
41
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
// Parse request body
|
|
47
|
+
const body = await readBody(req);
|
|
48
|
+
const ssrReq: SSRRequest = JSON.parse(body);
|
|
49
|
+
|
|
50
|
+
// Match route
|
|
51
|
+
const match = matchRoute(ssrReq.path, routes);
|
|
52
|
+
|
|
53
|
+
if (!match) {
|
|
54
|
+
// Render 404 page
|
|
55
|
+
const props = {
|
|
56
|
+
path: ssrReq.path,
|
|
57
|
+
status: 404,
|
|
58
|
+
serverRendered: true,
|
|
59
|
+
};
|
|
60
|
+
const element = React.createElement(NotFound, props);
|
|
61
|
+
const html = renderToString(element);
|
|
62
|
+
|
|
63
|
+
res.writeHead(200);
|
|
64
|
+
res.end(JSON.stringify({ html, props }));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Prepare props for the page
|
|
69
|
+
const props = {
|
|
70
|
+
...(ssrReq.data || {}),
|
|
71
|
+
path: ssrReq.path,
|
|
72
|
+
query: ssrReq.query,
|
|
73
|
+
params: { ...ssrReq.params, ...match.params },
|
|
74
|
+
timestamp: Date.now(),
|
|
75
|
+
serverRendered: true,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Render React to HTML
|
|
79
|
+
const appElement = React.createElement(App, {
|
|
80
|
+
Component: match.component,
|
|
81
|
+
pageProps: props,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const documentElement = React.createElement(Document, {
|
|
85
|
+
initialProps: props,
|
|
86
|
+
children: appElement,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const html = "<!doctype html>\n" + renderToString(documentElement);
|
|
90
|
+
|
|
91
|
+
const ssrResp: SSRResponse = {
|
|
92
|
+
html,
|
|
93
|
+
props,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
res.writeHead(200);
|
|
97
|
+
res.end(JSON.stringify(ssrResp));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error("SSR Error:", error);
|
|
100
|
+
res.writeHead(500);
|
|
101
|
+
res.end(
|
|
102
|
+
JSON.stringify({
|
|
103
|
+
error: "Internal Server Error",
|
|
104
|
+
message:
|
|
105
|
+
process.env.NODE_ENV === "development" ? String(error) : undefined,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
let body = "";
|
|
114
|
+
req.on("data", (chunk) => {
|
|
115
|
+
body += chunk.toString();
|
|
116
|
+
});
|
|
117
|
+
req.on("end", () => {
|
|
118
|
+
resolve(body);
|
|
119
|
+
});
|
|
120
|
+
req.on("error", reject);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
server.listen(PORT, () => {
|
|
125
|
+
console.log(`Node SSR server running on http://localhost:${PORT}`);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Handle graceful shutdown
|
|
129
|
+
process.on("SIGTERM", () => {
|
|
130
|
+
console.log("SIGTERM received, shutting down...");
|
|
131
|
+
server.close(() => {
|
|
132
|
+
process.exit(0);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
process.on("SIGINT", () => {
|
|
137
|
+
console.log("SIGINT received, shutting down...");
|
|
138
|
+
server.close(() => {
|
|
139
|
+
process.exit(0);
|
|
140
|
+
});
|
|
141
|
+
});
|