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
|
@@ -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
|
-
//
|
|
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.
|
|
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
|
-
<
|
|
60
|
-
<
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
//
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
170
|
-
className=
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
}
|