@brightsideoy/kama 0.1.0-alpha.149 → 0.1.0-alpha.151

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.
@@ -0,0 +1,293 @@
1
+ import { defineMiddleware } from "astro:middleware";
2
+ import { env } from "cloudflare:workers";
3
+
4
+ export const onRequest = defineMiddleware(async (context, next) => {
5
+ const pathname = context.url.pathname;
6
+ const cfContext = (context.locals as any).cfContext;
7
+ const workerEnv = cfContext?.env || (env as any);
8
+ const HTML_CACHE = workerEnv?.HTML_CACHE; // KV Namespace binding
9
+
10
+ // 🎯 0. DIRECT EDGE INTERCEPT FOR CACHE PURGE ENDPOINT
11
+ if (pathname === "/api/purge" || pathname === "/api/purge/") {
12
+ if (context.request.method !== "POST") {
13
+ return new Response(JSON.stringify({ error: "Method Not Allowed" }), {
14
+ status: 405,
15
+ headers: { "Content-Type": "application/json" },
16
+ });
17
+ }
18
+
19
+ const authHeader = context.request.headers.get("Authorization");
20
+ const expectedToken = workerEnv.CLOUDFLARE_API_TOKEN;
21
+
22
+ if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) {
23
+ return new Response(JSON.stringify({ error: "Unauthorized Token Mismatch" }), {
24
+ status: 401,
25
+ headers: { "Content-Type": "application/json" },
26
+ });
27
+ }
28
+
29
+ try {
30
+ const body = await context.request.json();
31
+ const cache = typeof caches !== "undefined" && caches.default ? caches.default : null;
32
+ const origin = context.url.origin;
33
+
34
+ // A. Purge Workers Caching Layer (Astro cfContext)
35
+ if (typeof cfContext?.cache?.purge === 'function') {
36
+ try {
37
+ await cfContext.cache.purge({ purgeEverything: true });
38
+ console.log("✅ New Cloudflare Workers Cache Engine Purged");
39
+ } catch (e) {
40
+ console.error("❌ Failed to purge new Workers Cache Engine", e);
41
+ }
42
+ }
43
+
44
+ // B. Version-Based Instant Invalidation (0ms KV Write)
45
+ if (HTML_CACHE) {
46
+ try {
47
+ const now = Date.now().toString();
48
+
49
+ if (body.purge_everything) {
50
+ // 🎯 GLOBAL PURGE: Bumping site_version instantly invalidates ALL KV pages globally!
51
+ await HTML_CACHE.put("site_version", now);
52
+ console.log(`✅ [Version Tagging] Global site_version updated to ${now}`);
53
+ } else if (Array.isArray(body.urls) && body.urls.length > 0) {
54
+ // 🎯 SURGICAL PURGE: Parallel execution for page version bumps
55
+ const purgeTasks = body.urls.map((targetUrl: string) => {
56
+ const urlPath = new URL(targetUrl).pathname;
57
+ const cleanPath = urlPath.replace(/\/$/, "") || "/";
58
+ return HTML_CACHE.put(`v:${cleanPath}`, now);
59
+ });
60
+ await Promise.all(purgeTasks);
61
+ console.log(`✅ [Version Tagging] Updated versions for ${body.urls.length} URLs`);
62
+ }
63
+ } catch (kvErr) {
64
+ console.error("❌ KV Version Tagging Error:", kvErr);
65
+ }
66
+ }
67
+
68
+ // C. Global Legacy Worker Cache Purge
69
+ if (body.purge_everything) {
70
+ if (cache) {
71
+ const rootUrls = [
72
+ origin,
73
+ `${origin}/`,
74
+ `${origin}/home`,
75
+ `${origin}/home/`
76
+ ];
77
+
78
+ for (const url of rootUrls) {
79
+ await cache.delete(new Request(url));
80
+ }
81
+ }
82
+
83
+ return new Response(JSON.stringify({
84
+ success: true,
85
+ message: "All Worker Caches Purged"
86
+ }), {
87
+ status: 200,
88
+ headers: { "Content-Type": "application/json" },
89
+ });
90
+ }
91
+
92
+ // D. Surgical Legacy Worker Cache Purge (For Updated Pages)
93
+ if (Array.isArray(body.urls) && body.urls.length > 0) {
94
+ const results = [];
95
+
96
+ for (const targetUrl of body.urls) {
97
+ const cleanUrl = targetUrl.split('?')[0];
98
+ const urlNoSlash = cleanUrl.replace(/\/$/, "");
99
+ const urlWithSlash = urlNoSlash + "/";
100
+
101
+ let p1 = false, p2 = false;
102
+ if (cache) {
103
+ p1 = await cache.delete(new Request(urlNoSlash));
104
+ p2 = await cache.delete(new Request(urlWithSlash));
105
+ }
106
+
107
+ results.push({ url: targetUrl, purged: p1 || p2 });
108
+ }
109
+
110
+ return new Response(JSON.stringify({
111
+ success: true,
112
+ message: "URLs processed and version tags updated",
113
+ results
114
+ }), {
115
+ status: 200,
116
+ headers: { "Content-Type": "application/json" },
117
+ });
118
+ }
119
+
120
+ // 🎯 E. SURGICAL DELETION FOR DELETED PAGES
121
+ if (Array.isArray(body.deleted_urls) && body.deleted_urls.length > 0) {
122
+ const results: any[] = [];
123
+
124
+ const deleteTasks = body.deleted_urls.map(async (targetUrl: string) => {
125
+ const urlPath = new URL(targetUrl).pathname;
126
+ const cleanPath = urlPath.replace(/\/$/, "") || "/";
127
+
128
+ let purgedKv = false;
129
+
130
+ if (HTML_CACHE) {
131
+ const [siteVer, pageVer] = await Promise.all([
132
+ HTML_CACHE.get("site_version"),
133
+ HTML_CACHE.get(`v:${cleanPath}`)
134
+ ]);
135
+
136
+ if (siteVer || pageVer) {
137
+ const activeCacheKey = `${cleanPath}:v:${siteVer || "1"}:${pageVer || "1"}`;
138
+ await HTML_CACHE.delete(activeCacheKey);
139
+ purgedKv = true;
140
+ }
141
+
142
+ await HTML_CACHE.delete(`v:${cleanPath}`);
143
+ }
144
+
145
+ const cleanUrl = targetUrl.split('?')[0];
146
+ const urlNoSlash = cleanUrl.replace(/\/$/, "");
147
+ const urlWithSlash = urlNoSlash + "/";
148
+
149
+ let p1 = false, p2 = false;
150
+ if (cache) {
151
+ p1 = await cache.delete(new Request(urlNoSlash));
152
+ p2 = await cache.delete(new Request(urlWithSlash));
153
+ }
154
+
155
+ results.push({ url: targetUrl, purgedKv, purgedLegacy: p1 || p2 });
156
+ });
157
+
158
+ await Promise.all(deleteTasks);
159
+
160
+ return new Response(JSON.stringify({
161
+ success: true,
162
+ message: "Deleted URLs processed and KV keys permanently removed",
163
+ results
164
+ }), {
165
+ status: 200,
166
+ headers: { "Content-Type": "application/json" },
167
+ });
168
+ }
169
+
170
+ return new Response(JSON.stringify({ error: "Invalid payload format" }), {
171
+ status: 400,
172
+ headers: { "Content-Type": "application/json" },
173
+ });
174
+
175
+ } catch (err: any) {
176
+ return new Response(JSON.stringify({ error: err.message }), {
177
+ status: 500,
178
+ headers: { "Content-Type": "application/json" },
179
+ });
180
+ }
181
+ }
182
+
183
+ // 🎯 1. KV STATIC "FILE" CACHE READ WITH VERSION TAGGING
184
+ const isWarmer = context.request.headers.get("X-Cache-Warmer") === "true";
185
+ const isHtmlPage = !pathname.includes('.') && !pathname.startsWith('/api/');
186
+ const cleanLookupPath = pathname.replace(/\/$/, "") || "/";
187
+
188
+ let activeCacheKey = cleanLookupPath;
189
+
190
+ if (isHtmlPage && HTML_CACHE && !isWarmer) {
191
+ try {
192
+ const [siteVer, pageVer] = await Promise.all([
193
+ HTML_CACHE.get("site_version"),
194
+ HTML_CACHE.get(`v:${cleanLookupPath}`)
195
+ ]);
196
+
197
+ activeCacheKey = `${cleanLookupPath}:v:${siteVer || "1"}:${pageVer || "1"}`;
198
+
199
+ const cachedHtml = await HTML_CACHE.get(activeCacheKey);
200
+ if (cachedHtml) {
201
+ return new Response(cachedHtml, {
202
+ status: 200,
203
+ headers: {
204
+ "Content-Type": "text/html; charset=utf-8",
205
+ "X-Cache-Status": "HIT-KV-STATICFILE",
206
+ "X-Cache-Key": activeCacheKey,
207
+ "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate"
208
+ }
209
+ });
210
+ }
211
+ } catch (e) {
212
+ console.warn("[Middleware] KV lookup bypass:", e);
213
+ }
214
+ }
215
+
216
+ // Process downstream SSR routes
217
+ const response = await next();
218
+
219
+ // 🎯 2. KV STATIC "FILE" CACHE WRITE (Save HTML under active Version Key)
220
+ if (
221
+ isHtmlPage &&
222
+ HTML_CACHE &&
223
+ response.status === 200 &&
224
+ response.headers.get("content-type")?.includes("text/html")
225
+ ) {
226
+ const htmlText = await response.clone().text();
227
+
228
+ const writeTask = (async () => {
229
+ if (activeCacheKey === cleanLookupPath || isWarmer) {
230
+ const forcedVersion = Date.now().toString();
231
+
232
+ await HTML_CACHE.put(`v:${cleanLookupPath}`, forcedVersion);
233
+
234
+ const siteVer = await HTML_CACHE.get("site_version") || "1";
235
+
236
+ activeCacheKey = `${cleanLookupPath}:v:${siteVer}:${forcedVersion}`;
237
+ }
238
+
239
+ await HTML_CACHE.put(activeCacheKey, htmlText);
240
+ console.log(`✅ [Middleware] Saved HTML to KV under key: ${activeCacheKey}`);
241
+ })();
242
+
243
+ if (cfContext && typeof cfContext.waitUntil === 'function') {
244
+ cfContext.waitUntil(writeTask);
245
+ } else {
246
+ writeTask.catch((e: any) => console.error("KV write error:", e));
247
+ }
248
+ }
249
+
250
+ // 🎯 3. SERVE LLMS & LLXML ENDPOINTS
251
+ if (pathname === "/llms.txt" || pathname === "/llms-full.txt") {
252
+ response.headers.set("Content-Type", "text/plain; charset=utf-8");
253
+ response.headers.set("Cache-Control", "public, max-age=3600");
254
+ response.headers.set("Access-Control-Allow-Origin", "*");
255
+ return response;
256
+ }
257
+
258
+ if (pathname === "/llxml.txt") {
259
+ response.headers.set("Content-Type", "application/xml; charset=utf-8");
260
+ response.headers.set("Cache-Control", "public, max-age=3600");
261
+ response.headers.set("Access-Control-Allow-Origin", "*");
262
+ return response;
263
+ }
264
+
265
+ // 🎯 4. IMAGE & STATIC ASSET CACHING
266
+ if (
267
+ pathname.startsWith("/media/") ||
268
+ pathname.startsWith("/_image") ||
269
+ pathname.startsWith("/cdn-cgi/image/") ||
270
+ pathname.match(/\.(png|jpg|jpeg|webp|avif|svg|gif|ico|woff2?|css|js)$/i)
271
+ ) {
272
+ response.headers.set(
273
+ "Cache-Control",
274
+ "public, max-age=31536000, s-maxage=31536000, immutable"
275
+ );
276
+ response.headers.set("Access-Control-Allow-Origin", "*");
277
+ return response;
278
+ }
279
+
280
+ // 🎯 5. SECURITY HEADERS FOR HTML PAGES
281
+ if (response.headers.get("content-type")?.includes("text/html")) {
282
+ response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
283
+ response.headers.set("X-Frame-Options", "SAMEORIGIN");
284
+ response.headers.set("X-Content-Type-Options", "nosniff");
285
+ response.headers.set("Cross-Origin-Opener-Policy", "same-origin-allow-popups");
286
+ response.headers.set(
287
+ "Content-Security-Policy",
288
+ "default-src 'self' https: data: 'unsafe-inline' 'unsafe-eval'; img-src 'self' https: data: blob:; connect-src 'self' https:; frame-ancestors 'self';"
289
+ );
290
+ }
291
+
292
+ return response;
293
+ });
@@ -6,18 +6,28 @@ import Breadcrumb from "../components/global/Breadcrumb.astro";
6
6
  import { getNodeByPath, getChannelId, getTree, getTreeId } from "../lib/cms";
7
7
  import type { NodeResponse, TreeNode } from "../lib/cms";
8
8
 
9
+ // 🎯 Import environment variables directly from Cloudflare
10
+ import { env } from "cloudflare:workers";
11
+
9
12
  export const prerender = false;
10
13
 
11
14
  const { slug } = Astro.params;
15
+
16
+ // Extract Preview & Revision parameters from the URL
12
17
  const searchParams = Astro.url.searchParams;
13
18
  const isPreview = searchParams.get("preview") === "true";
14
19
  const revisionId = searchParams.get("revisionId");
15
20
 
21
+ // Smart Edge Caching Logic
22
+ const isTimeTraveling = isPreview || !!revisionId;
23
+
24
+ // 🎯 FIX: Allow Cloudflare Edge to revalidate with Worker KV middleware
16
25
  Astro.response.headers.set(
17
26
  "Cache-Control",
18
- "no-store, no-cache, must-revalidate, proxy-revalidate",
27
+ "public, no-cache, must-revalidate",
19
28
  );
20
29
 
30
+ // 1. STATIC ASSET CHECK
21
31
  const staticExts = [
22
32
  ".ico",
23
33
  ".png",
@@ -37,11 +47,21 @@ if (isStaticAsset) {
37
47
  return new Response(null, { status: 404 });
38
48
  }
39
49
 
40
- // 🎯 DUAL-ARCHITECTURE ENV RESOLUTION (No CF Imports, No runtime.env!)
41
- const homeSlug = String(import.meta.env.PUBLIC_HOME_SLUG || "home").trim();
50
+ // 2. ENV & WORKER RESOLUTION
51
+ const workerEnv = env || {};
52
+ const cmsWorker = workerEnv.CMS_WORKER;
53
+
54
+ // Extract environment configuration & fallback slug
55
+ const envHomeSlug = import.meta.env.PUBLIC_HOME_SLUG;
56
+ const workerHomeSlug = (workerEnv as any).PUBLIC_HOME_SLUG;
57
+ const homeSlug = String(envHomeSlug || workerHomeSlug || "home").trim();
58
+
59
+ const envApiUrl = import.meta.env.PUBLIC_API_URL;
60
+ const workerApiUrl = workerEnv.PUBLIC_API_URL;
42
61
  const baseApiUrl = String(
43
- import.meta.env.PUBLIC_API_URL || "http://localhost:8787",
62
+ envApiUrl || workerApiUrl || "https://admin.kamaengine.com",
44
63
  );
64
+
45
65
  const cleanApiUrl = baseApiUrl.endsWith("/")
46
66
  ? baseApiUrl.slice(0, -1)
47
67
  : baseApiUrl;
@@ -51,55 +71,71 @@ const customFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
51
71
  if (urlStr.startsWith("/")) urlStr = `${cleanApiUrl}${urlStr}`;
52
72
 
53
73
  const urlObj = new URL(urlStr);
54
- urlObj.searchParams.set("_t", Date.now().toString());
55
-
56
- if (revisionId) urlObj.searchParams.set("revisionId", revisionId);
57
- if (isPreview) urlObj.searchParams.set("preview", "true");
58
-
59
- return fetch(
60
- new Request(urlObj.toString(), {
61
- ...init,
62
- cache: "no-store",
63
- headers: {
64
- ...init?.headers,
65
- "X-Bypass-Cache": "true",
66
- "Cache-Control": "no-cache, no-store, must-revalidate",
67
- },
68
- }),
69
- );
74
+
75
+ if (isTimeTraveling) {
76
+ if (revisionId) urlObj.searchParams.set("revisionId", revisionId);
77
+ if (isPreview) urlObj.searchParams.set("preview", "true");
78
+ urlObj.searchParams.set("_t", Date.now().toString());
79
+ } else {
80
+ urlObj.searchParams.set("_cb", Date.now().toString());
81
+ }
82
+
83
+ urlStr = urlObj.toString();
84
+
85
+ const reqHeaders: Record<string, string> = {
86
+ ...init?.headers,
87
+ "Cache-Control": "no-cache, no-store, must-revalidate",
88
+ Pragma: "no-cache",
89
+ // 🎯 CRITICAL FIX: Direct Service Bindings require explicit header flags to bypass CMS KV cache!
90
+ "X-Bypass-Cache": "true",
91
+ };
92
+
93
+ const fetchOpts: RequestInit = {
94
+ ...init,
95
+ headers: reqHeaders,
96
+ cache: "no-store",
97
+ };
98
+
99
+ const req = new Request(urlStr, fetchOpts);
100
+
101
+ if (cmsWorker && typeof cmsWorker.fetch === "function") {
102
+ return cmsWorker.fetch(req);
103
+ }
104
+ return fetch(req);
70
105
  };
71
106
 
72
107
  const channelId = getChannelId();
73
108
  const treeId = getTreeId();
74
109
 
110
+ // 3. PATH & CONCURRENT FETCH RESOLUTION
75
111
  const rawSlug = Array.isArray(slug) ? slug.join("/") : slugStr;
76
112
  const pathParts = rawSlug.split("/").filter(part => part.trim() !== "");
113
+ const requestedSlug = pathParts.join("/");
114
+ const targetSlug = requestedSlug || homeSlug;
77
115
 
78
116
  let pageData: NodeResponse | null = null;
79
117
  let navTree: TreeNode[] = [];
80
- let targetSlug = pathParts.join("/");
81
118
 
119
+ // 🎯 Fetch Tree and Node in PARALLEL using the cache-busting customFetch
82
120
  try {
83
- navTree = await getTree(treeId, channelId, customFetch);
84
- } catch (err) {
85
- console.error(`❌ [SSR Error] Failed to fetch tree "${treeId}":`, err);
86
- }
87
-
88
- if (!targetSlug) {
89
- const rootTreeSlug =
90
- navTree && navTree.length > 0
91
- ? navTree[0].slug || (navTree[0] as any).key
92
- : "";
93
- targetSlug = rootTreeSlug || homeSlug;
94
- }
121
+ const [treeResult, nodeResult] = await Promise.all([
122
+ getTree(treeId, channelId, customFetch).catch(err => {
123
+ console.error(`❌ [SSR Error] Failed to fetch tree "${treeId}":`, err);
124
+ return [];
125
+ }),
126
+ getNodeByPath(targetSlug, channelId, isPreview, customFetch).catch(err => {
127
+ console.error(
128
+ `❌ [SSR Error] Failed to fetch page data for path "${targetSlug}":`,
129
+ err,
130
+ );
131
+ return null;
132
+ }),
133
+ ]);
95
134
 
96
- try {
97
- pageData = await getNodeByPath(targetSlug, channelId, isPreview, customFetch);
135
+ navTree = treeResult || [];
136
+ pageData = nodeResult;
98
137
  } catch (err) {
99
- console.error(
100
- `❌ [SSR Error] Failed to fetch page data for path "${targetSlug}":`,
101
- err,
102
- );
138
+ console.error("❌ [SSR Error] Failed concurrent fetch execution:", err);
103
139
  }
104
140
 
105
141
  if (!pageData || !pageData.node) {
@@ -113,6 +149,7 @@ const rootTreeSlug =
113
149
  navTree && navTree.length > 0
114
150
  ? navTree[0].slug || (navTree[0] as any).key
115
151
  : "";
152
+
116
153
  const isHomePage =
117
154
  targetSlug === homeSlug ||
118
155
  (rootTreeSlug && targetSlug === rootTreeSlug) ||
@@ -120,10 +157,13 @@ const isHomePage =
120
157
  pathParts.length === 0;
121
158
 
122
159
  const normalizedSlug = isHomePage ? "" : targetSlug;
160
+
123
161
  const headerBlocks = content?.header || [];
124
162
  const mainBlocks = content?.content || [];
125
163
  const sidebarBlocks = content?.sidebar || [];
126
164
  const hasSidebar = sidebarBlocks.length > 0;
165
+
166
+ // Detect if this node is a standalone Page Skeleton
127
167
  const isSkeleton = node?.nodeType === "page-skeleton";
128
168
  ---
129
169