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.
@@ -87,7 +87,7 @@ async function start() {
87
87
  label: " go",
88
88
  color: c.green,
89
89
  cmd: "go",
90
- args: ["run", "go-server/main.go"],
90
+ args: ["run", "./go-server/"],
91
91
  readyPattern: /Go server starting/
92
92
  }
93
93
  ];
@@ -19,7 +19,8 @@ function init() {
19
19
  const initialProps = propsText ? JSON.parse(propsText) : {};
20
20
 
21
21
  // Hydrate the app with the RouterProvider.
22
- // The provider handles route matching, page rendering, and SPA navigation.
22
+ // Error boundaries are handled inside RouterProvider to ensure
23
+ // the DOM tree matches what the SSR server produces.
23
24
  hydrateRoot(
24
25
  container,
25
26
  <RouterProvider
@@ -39,3 +40,4 @@ if (document.readyState === "loading") {
39
40
  } else {
40
41
  init();
41
42
  }
43
+
@@ -316,7 +316,7 @@ const HomePage: React.FC<HomeProps> = () => {
316
316
  <div className="blumen-content">
317
317
  <div className="blumen-badge">
318
318
  <span className="blumen-badge-dot" />
319
- Framework v0.1.0
319
+ Framework v0.2.0
320
320
  </div>
321
321
 
322
322
  <h1 className="blumen-logo">🌸 Blumen</h1>
@@ -1,5 +1,22 @@
1
1
  import React from "react";
2
2
 
3
+ /**
4
+ * Sanitize data for safe embedding in a <script type="application/json"> tag.
5
+ * Escapes all characters that could break out of the JSON context:
6
+ * - `<` → `\u003c` (prevents </script> injection)
7
+ * - `>` → `\u003e` (prevents HTML entity injection)
8
+ * - `&` → `\u0026` (prevents HTML entity injection)
9
+ * - U+2028 → `\u2028` (line separator - breaks JS in some contexts)
10
+ * - U+2029 → `\u2029` (paragraph separator - breaks JS in some contexts)
11
+ */
12
+ function sanitizeForHydration(data: any): string {
13
+ return JSON.stringify(data)
14
+ .replace(/</g, '\\u003c')
15
+ .replace(/>/g, '\\u003e')
16
+ .replace(/&/g, '\\u0026')
17
+ .replace(/\u2028/g, '\\u2028')
18
+ .replace(/\u2029/g, '\\u2029');
19
+ }
3
20
  // HMR: In development, load the client bundle from Webpack Dev Server
4
21
  // so the HMR runtime + React Fast Refresh are active.
5
22
  const isDev = process.env.NODE_ENV === "development";
@@ -7,7 +24,7 @@ const BUNDLE_SRC = isDev
7
24
  ? "http://localhost:3100/static/js/bundle.js"
8
25
  : "/static/js/bundle.js";
9
26
 
10
- export function DefaultDocument({ children, initialProps }: any) {
27
+ export function DefaultDocument({ children, initialProps, metadata }: any) {
11
28
  const css = `
12
29
  * { margin: 0; padding: 0; box-sizing: border-box; }
13
30
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; color: #e2e8f0; background: #1a1025; }
@@ -51,13 +68,41 @@ export function DefaultDocument({ children, initialProps }: any) {
51
68
  .page-transition-exit { opacity: 0; transition: opacity 150ms ease-out; }
52
69
  `;
53
70
 
71
+ // Resolve metadata with defaults
72
+ const meta = metadata || {};
73
+ const title = meta.title || "Blumen App";
74
+ const description = meta.description || "";
75
+ const og = meta.openGraph || {};
76
+ const tw = meta.twitter || {};
77
+
54
78
  return (
55
79
  <html lang="en">
56
80
  <head>
57
81
  <meta charSet="UTF-8" />
58
82
  <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>
83
+ <title>{title}</title>
84
+ {description && <meta name="description" content={description} />}
85
+ {meta.keywords?.length && <meta name="keywords" content={meta.keywords.join(", ")} />}
86
+ {meta.robots && <meta name="robots" content={meta.robots} />}
87
+ {meta.canonical && <link rel="canonical" href={meta.canonical} />}
88
+ {/* Open Graph */}
89
+ {og.title && <meta property="og:title" content={og.title} />}
90
+ {og.description && <meta property="og:description" content={og.description} />}
91
+ {og.image && <meta property="og:image" content={og.image} />}
92
+ {og.url && <meta property="og:url" content={og.url} />}
93
+ {og.type && <meta property="og:type" content={og.type} />}
94
+ {og.siteName && <meta property="og:site_name" content={og.siteName} />}
95
+ {/* Twitter Card */}
96
+ {tw.card && <meta name="twitter:card" content={tw.card} />}
97
+ {tw.title && <meta name="twitter:title" content={tw.title} />}
98
+ {tw.description && <meta name="twitter:description" content={tw.description} />}
99
+ {tw.image && <meta name="twitter:image" content={tw.image} />}
100
+ {tw.creator && <meta name="twitter:creator" content={tw.creator} />}
101
+ {tw.site && <meta name="twitter:site" content={tw.site} />}
102
+ {/* Custom meta tags */}
103
+ {meta.other && Object.entries(meta.other).map(([name, content]: [string, any]) => (
104
+ <meta key={name} name={name} content={content} />
105
+ ))}
61
106
  <style dangerouslySetInnerHTML={{ __html: css }} />
62
107
  </head>
63
108
  <body>
@@ -66,7 +111,7 @@ export function DefaultDocument({ children, initialProps }: any) {
66
111
  id="ssr-props"
67
112
  type="application/json"
68
113
  dangerouslySetInnerHTML={{
69
- __html: JSON.stringify(initialProps).replace(/</g, '\\u003c')
114
+ __html: sanitizeForHydration(initialProps)
70
115
  }}
71
116
  />
72
117
  <script src={BUNDLE_SRC} defer></script>
@@ -5,21 +5,26 @@
5
5
  * External links, new-tab links, and modified clicks (ctrl/cmd)
6
6
  * are passed through to the browser as normal.
7
7
  *
8
- * During SSR (no RouterProvider), renders a plain <a> tag.
8
+ * Prefetching:
9
+ * - On hover: preloads page data so navigation is instant
10
+ * - On viewport: uses IntersectionObserver to prefetch when visible
11
+ * - Set prefetch={false} to disable for specific links
9
12
  *
10
- * @example
11
- * <Link href="/about" className="nav-link">About</Link>
13
+ * During SSR (no RouterProvider), renders a plain <a> tag.
12
14
  */
13
15
 
14
- import React, { useContext } from "react";
16
+ import React, { useContext, useRef, useEffect, useCallback } from "react";
15
17
 
16
18
  // Import the context directly to do a safe check without throwing
17
19
  // We need the raw context object, not the hook
18
20
  import { RouterContextRef, useRouter } from "./RouterContext";
21
+ import { prefetch as prefetchData } from "./prefetchCache";
19
22
 
20
23
  interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
21
24
  /** The destination path */
22
25
  href: string;
26
+ /** Enable/disable prefetching. Defaults to true. Set to false for rarely-visited links. */
27
+ prefetch?: boolean;
23
28
  }
24
29
 
25
30
  function isExternal(href: string): boolean {
@@ -30,9 +35,51 @@ function isModifiedClick(e: React.MouseEvent): boolean {
30
35
  return e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0;
31
36
  }
32
37
 
33
- export function Link({ href, children, onClick, target, ...rest }: LinkProps) {
38
+ export function Link({ href, children, onClick, target, prefetch = true, ...rest }: LinkProps) {
34
39
  // Safe check: if we're in SSR (no provider), just render a plain <a>
35
40
  const ctx = useContext(RouterContextRef);
41
+ const anchorRef = useRef<HTMLAnchorElement>(null);
42
+ const hasPrefetched = useRef(false);
43
+
44
+ // Determine if this is a local link that can be prefetched
45
+ const isLocal = !isExternal(href) && target !== "_blank";
46
+
47
+ // ── Hover prefetch ───────────────────────────────────────────
48
+ const handleMouseEnter = useCallback(() => {
49
+ if (!isLocal || !prefetch || hasPrefetched.current) return;
50
+ hasPrefetched.current = true;
51
+ prefetchData(href);
52
+ }, [href, isLocal, prefetch]);
53
+
54
+ // ── Viewport prefetch (IntersectionObserver) ─────────────────
55
+ useEffect(() => {
56
+ if (!isLocal || !prefetch || !anchorRef.current) return;
57
+ if (typeof IntersectionObserver === "undefined") return;
58
+
59
+ const el = anchorRef.current;
60
+
61
+ const observer = new IntersectionObserver(
62
+ (entries) => {
63
+ for (const entry of entries) {
64
+ if (entry.isIntersecting && !hasPrefetched.current) {
65
+ hasPrefetched.current = true;
66
+ prefetchData(href);
67
+ observer.unobserve(el);
68
+ }
69
+ }
70
+ },
71
+ {
72
+ // Start prefetching when the link is within 200px of the viewport
73
+ rootMargin: "200px",
74
+ },
75
+ );
76
+
77
+ observer.observe(el);
78
+
79
+ return () => {
80
+ observer.unobserve(el);
81
+ };
82
+ }, [href, isLocal, prefetch]);
36
83
 
37
84
  if (!ctx) {
38
85
  // SSR fallback — no router available
@@ -64,7 +111,14 @@ export function Link({ href, children, onClick, target, ...rest }: LinkProps) {
64
111
  };
65
112
 
66
113
  return (
67
- <a href={href} onClick={handleClick} target={target} {...rest}>
114
+ <a
115
+ ref={anchorRef}
116
+ href={href}
117
+ onClick={handleClick}
118
+ onMouseEnter={handleMouseEnter}
119
+ target={target}
120
+ {...rest}
121
+ >
68
122
  {children}
69
123
  </a>
70
124
  );
@@ -16,6 +16,10 @@ import React, {
16
16
  useRef,
17
17
  } from "react";
18
18
  import { matchRoute, type RouteDef } from "./router";
19
+ import { BlumenErrorBoundary } from "./ErrorBoundary";
20
+ import { applyMetadata } from "./BlumenHead";
21
+ import { consumeCached } from "./prefetchCache";
22
+ import DefaultLoading from "./DefaultLoading";
19
23
 
20
24
  // ── Context types ──────────────────────────────────────────────
21
25
 
@@ -73,6 +77,8 @@ export function RouterProvider({
73
77
  () => window.location.pathname,
74
78
  );
75
79
  const [transitioning, setTransitioning] = useState(false);
80
+ const [isLoading, setIsLoading] = useState(false);
81
+ const [loadingComponent, setLoadingComponent] = useState<React.ComponentType<any> | null>(null);
76
82
  const [dynamicProps, setDynamicProps] = useState<Record<string, any>>({});
77
83
 
78
84
  // Track whether we're on the very first render (SSR hydration).
@@ -109,25 +115,48 @@ export function RouterProvider({
109
115
  // Animate out → change route → animate in
110
116
  setTransitioning(true);
111
117
 
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
- });
118
+ // Check prefetch cache first (populated by <Link> on hover)
119
+ const cached = consumeCached(to);
120
+
121
+ // Determine the loading component for the target route
122
+ const targetMatch = matchRoute(to, routes);
123
+ const targetLoading = targetMatch?.loading || null;
124
+
125
+ // If we don't have cached data and route has a loading component, show it
126
+ if (!cached && targetLoading) {
127
+ setLoadingComponent(() => targetLoading);
128
+ setIsLoading(true);
129
+ }
130
+
131
+ // Fetch dynamic data from Go server (or use cached data)
132
+ const fetchPromise = cached
133
+ ? Promise.resolve(cached)
134
+ : fetch(to, {
135
+ headers: { "X-Blumen-Data": "1" }
136
+ }).then(res => res.ok ? res.json() : {}).catch(err => {
137
+ console.error("Failed to fetch route data", err);
138
+ return {};
139
+ });
119
140
 
120
141
  // Allow the exit transition to play (150ms)
121
142
  const delayPromise = new Promise(resolve => setTimeout(resolve, 150));
122
143
 
123
- Promise.all([fetchPromise, delayPromise]).then(([newData]) => {
144
+ Promise.all([fetchPromise, delayPromise]).then(([newData]: any[]) => {
124
145
  setDynamicProps(newData);
146
+
147
+ // Update <head> metadata for the new page
148
+ if (newData?.metadata) {
149
+ applyMetadata(newData.metadata);
150
+ }
151
+
125
152
  window.history.pushState(null, "", to);
126
153
  setPath(to);
127
154
  window.scrollTo(0, 0);
128
155
 
129
- // Small delay for the enter transition
156
+ // Clear loading state and transition
130
157
  requestAnimationFrame(() => {
158
+ setIsLoading(false);
159
+ setLoadingComponent(null);
131
160
  setTransitioning(false);
132
161
  });
133
162
  });
@@ -141,17 +170,38 @@ export function RouterProvider({
141
170
  setTransitioning(true);
142
171
  const to = window.location.pathname;
143
172
 
144
- const fetchPromise = fetch(to, {
145
- headers: { "X-Blumen-Data": "1" }
146
- }).then(res => res.ok ? res.json() : {}).catch(() => ({}));
173
+ // Check prefetch cache for back/forward navigation
174
+ const cached = consumeCached(to);
175
+
176
+ // Show loading component if available
177
+ if (!cached) {
178
+ const targetMatch = matchRoute(to, routes);
179
+ if (targetMatch?.loading) {
180
+ setLoadingComponent(() => targetMatch.loading!);
181
+ setIsLoading(true);
182
+ }
183
+ }
184
+
185
+ const fetchPromise = cached
186
+ ? Promise.resolve(cached)
187
+ : fetch(to, {
188
+ headers: { "X-Blumen-Data": "1" }
189
+ }).then(res => res.ok ? res.json() : {}).catch(() => ({}));
147
190
 
148
191
  const delayPromise = new Promise(resolve => setTimeout(resolve, 150));
149
192
 
150
- Promise.all([fetchPromise, delayPromise]).then(([newData]) => {
193
+ Promise.all([fetchPromise, delayPromise]).then(([newData]: any[]) => {
151
194
  setDynamicProps(newData);
152
195
  setPath(to);
196
+
197
+ // Update <head> metadata for the new page
198
+ if (newData?.metadata) {
199
+ applyMetadata(newData.metadata);
200
+ }
153
201
 
154
202
  requestAnimationFrame(() => {
203
+ setIsLoading(false);
204
+ setLoadingComponent(null);
155
205
  setTransitioning(false);
156
206
  });
157
207
  });
@@ -164,13 +214,26 @@ export function RouterProvider({
164
214
  // ── Render ──────────────────────────────────────────────────
165
215
  const contextValue: RouterContextValue = { path, params, navigate };
166
216
 
217
+ // Determine what to render:
218
+ // 1. If loading — show the loading component
219
+ // 2. Otherwise — show the page component
220
+ const LoadingComp = loadingComponent;
221
+
167
222
  return (
168
223
  <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>
224
+ {isLoading && LoadingComp ? (
225
+ <div className="page-transition page-transition-active">
226
+ <LoadingComp />
227
+ </div>
228
+ ) : (
229
+ <div
230
+ className={`page-transition ${transitioning ? "page-transition-exit" : "page-transition-active"}`}
231
+ >
232
+ <BlumenErrorBoundary>
233
+ <App Component={PageComponent} pageProps={pageProps} />
234
+ </BlumenErrorBoundary>
235
+ </div>
236
+ )}
174
237
  </RouterContext.Provider>
175
238
  );
176
239
  }
@@ -3,6 +3,7 @@ export interface RouteDef {
3
3
  pattern: RegExp;
4
4
  keys: string[];
5
5
  component: React.ComponentType<any>;
6
+ loading?: React.ComponentType<any>;
6
7
  }
7
8
 
8
9
  export function matchRoute(path: string, routes: RouteDef[]) {
@@ -16,7 +17,7 @@ export function matchRoute(path: string, routes: RouteDef[]) {
16
17
  route.keys.forEach((key: string, index: number) => {
17
18
  params[key] = match[index + 1];
18
19
  });
19
- return { component: route.component, params };
20
+ return { component: route.component, params, loading: route.loading };
20
21
  }
21
22
  }
22
23
  return null;
@@ -0,0 +1,147 @@
1
+ package main
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "sync"
7
+ "time"
8
+ )
9
+
10
+ // CacheEntry represents a single cached page response.
11
+ type CacheEntry struct {
12
+ HTML string
13
+ ETag string
14
+ CreatedAt time.Time
15
+ Revalidate int // TTL in seconds; 0 = cache forever until evicted
16
+ Stale bool
17
+ }
18
+
19
+ // PageCache is a thread-safe in-memory LRU cache for rendered HTML pages.
20
+ // It uses sync.RWMutex for maximum concurrent read performance —
21
+ // thousands of goroutines can read simultaneously while writes are serialized.
22
+ type PageCache struct {
23
+ mu sync.RWMutex
24
+ entries map[string]*CacheEntry
25
+ order []string // LRU order: most recently used at end
26
+ maxSize int
27
+ }
28
+
29
+ // NewPageCache creates a new cache with the given maximum number of entries.
30
+ func NewPageCache(maxSize int) *PageCache {
31
+ return &PageCache{
32
+ entries: make(map[string]*CacheEntry),
33
+ order: make([]string, 0, maxSize),
34
+ maxSize: maxSize,
35
+ }
36
+ }
37
+
38
+ // Get retrieves a cached page. Returns the entry and whether it was found.
39
+ // Also returns whether the entry is stale (past its revalidate TTL).
40
+ func (c *PageCache) Get(key string) (entry *CacheEntry, found bool, stale bool) {
41
+ c.mu.RLock()
42
+ e, ok := c.entries[key]
43
+ c.mu.RUnlock()
44
+
45
+ if !ok {
46
+ return nil, false, false
47
+ }
48
+
49
+ // Check if the entry has expired
50
+ if e.Revalidate > 0 {
51
+ age := time.Since(e.CreatedAt).Seconds()
52
+ if age > float64(e.Revalidate) {
53
+ return e, true, true // Found but stale
54
+ }
55
+ }
56
+
57
+ // Move to end of LRU order (most recently used)
58
+ c.mu.Lock()
59
+ c.moveToEnd(key)
60
+ c.mu.Unlock()
61
+
62
+ return e, true, false
63
+ }
64
+
65
+ // Set stores a page in the cache with the given revalidation TTL.
66
+ func (c *PageCache) Set(key string, html string, revalidate int) {
67
+ etag := generateETag(html)
68
+
69
+ c.mu.Lock()
70
+ defer c.mu.Unlock()
71
+
72
+ // If entry exists, update it
73
+ if _, exists := c.entries[key]; exists {
74
+ c.entries[key] = &CacheEntry{
75
+ HTML: html,
76
+ ETag: etag,
77
+ CreatedAt: time.Now(),
78
+ Revalidate: revalidate,
79
+ }
80
+ c.moveToEnd(key)
81
+ return
82
+ }
83
+
84
+ // Evict LRU entry if at capacity
85
+ if len(c.entries) >= c.maxSize && len(c.order) > 0 {
86
+ oldest := c.order[0]
87
+ c.order = c.order[1:]
88
+ delete(c.entries, oldest)
89
+ }
90
+
91
+ // Add new entry
92
+ c.entries[key] = &CacheEntry{
93
+ HTML: html,
94
+ ETag: etag,
95
+ CreatedAt: time.Now(),
96
+ Revalidate: revalidate,
97
+ }
98
+ c.order = append(c.order, key)
99
+ }
100
+
101
+ // Invalidate removes a specific key from the cache.
102
+ func (c *PageCache) Invalidate(key string) {
103
+ c.mu.Lock()
104
+ defer c.mu.Unlock()
105
+
106
+ delete(c.entries, key)
107
+ for i, k := range c.order {
108
+ if k == key {
109
+ c.order = append(c.order[:i], c.order[i+1:]...)
110
+ break
111
+ }
112
+ }
113
+ }
114
+
115
+ // Clear removes all entries from the cache.
116
+ func (c *PageCache) Clear() {
117
+ c.mu.Lock()
118
+ defer c.mu.Unlock()
119
+
120
+ c.entries = make(map[string]*CacheEntry)
121
+ c.order = make([]string, 0, c.maxSize)
122
+ }
123
+
124
+ // Size returns the current number of cached entries.
125
+ func (c *PageCache) Size() int {
126
+ c.mu.RLock()
127
+ defer c.mu.RUnlock()
128
+ return len(c.entries)
129
+ }
130
+
131
+ // moveToEnd moves a key to the end of the LRU order (most recently used).
132
+ // Must be called with the write lock held.
133
+ func (c *PageCache) moveToEnd(key string) {
134
+ for i, k := range c.order {
135
+ if k == key {
136
+ c.order = append(c.order[:i], c.order[i+1:]...)
137
+ c.order = append(c.order, key)
138
+ return
139
+ }
140
+ }
141
+ }
142
+
143
+ // generateETag creates an ETag from the HTML content.
144
+ func generateETag(content string) string {
145
+ hash := sha256.Sum256([]byte(content))
146
+ return `"` + hex.EncodeToString(hash[:8]) + `"`
147
+ }