mirror of
https://github.com/ibelick/ui-skills.git
synced 2026-09-14 18:43:13 +08:00
Merge pull request #61 from ibelick/cursor/improve-edge-caching-f5bc
Improve edge caching to reduce Cloudflare Worker invocations
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self'; connect-src 'self' https://api.interfaceoffice.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
|
||||
Permissions-Policy: camera=(), geolocation=(), microphone=()
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
|
||||
/favicon.svg
|
||||
Cache-Control: public, max-age=86400
|
||||
|
||||
/analytics/*
|
||||
Cache-Control: public, max-age=3600
|
||||
@@ -1,12 +1,11 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
import { getGithubStars } from "../lib/github-stars.ts";
|
||||
import NewsletterInput from "../ui/newsletter-input";
|
||||
import { skills } from "../data/skills";
|
||||
import { playbook } from "../data/playbook";
|
||||
import Footer from "../ui/footer.astro";
|
||||
import { CommandDialog } from "../ui/command-dialog";
|
||||
import GitHubStarButton from "../ui/github-star-button.astro";
|
||||
import { GitHubStarButton } from "../ui/github-star-button";
|
||||
import { WebMcpTools } from "../ui/webmcp-tools";
|
||||
import { serializeJsonLd } from "../lib/serialize-json-ld";
|
||||
|
||||
@@ -76,7 +75,6 @@ const commandItems = [
|
||||
})),
|
||||
];
|
||||
|
||||
const githubStars = await getGithubStars();
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -155,8 +153,8 @@ const githubStars = await getGithubStars();
|
||||
<div class="flex items-center gap-3">
|
||||
<CommandDialog items={commandItems} client:idle />
|
||||
<GitHubStarButton
|
||||
client:idle
|
||||
href="https://github.com/ibelick/ui-skills"
|
||||
label={githubStars?.label}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Browser revalidates often; edge caches longer to cut Worker invocations. */
|
||||
export const EDGE_HTML_CACHE =
|
||||
"public, max-age=60, s-maxage=3600, stale-while-revalidate=86400" as const;
|
||||
|
||||
/** Mostly-static listing and docs pages. */
|
||||
export const LISTING_HTML_CACHE =
|
||||
"public, max-age=300, s-maxage=86400, stale-while-revalidate=86400" as const;
|
||||
|
||||
/** Semi-static discovery and text routes. */
|
||||
export const SEMI_STATIC_CACHE =
|
||||
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400" as const;
|
||||
|
||||
/** Registry manifest — fresh enough for agents, cacheable at the edge. */
|
||||
export const REGISTRY_JSON_CACHE =
|
||||
"public, max-age=60, s-maxage=3600, stale-while-revalidate=86400" as const;
|
||||
|
||||
/** OAuth and discovery JSON — edge cache with hourly browser revalidation. */
|
||||
export const DISCOVERY_JSON_CACHE =
|
||||
"public, max-age=3600, s-maxage=86400" as const;
|
||||
|
||||
const LISTING_PATH_PREFIXES = [
|
||||
"/cli",
|
||||
"/mcp/docs",
|
||||
"/playbook",
|
||||
"/agents",
|
||||
"/skills/topics",
|
||||
] as const;
|
||||
|
||||
const LISTING_EXACT_PATHS = new Set(["/skills"]);
|
||||
|
||||
export function getHtmlCacheControl(pathname: string): string | null {
|
||||
const normalized =
|
||||
pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
||||
|
||||
if (LISTING_EXACT_PATHS.has(normalized)) {
|
||||
return LISTING_HTML_CACHE;
|
||||
}
|
||||
|
||||
if (
|
||||
LISTING_PATH_PREFIXES.some(
|
||||
(prefix) =>
|
||||
normalized === prefix || normalized.startsWith(`${prefix}/`),
|
||||
)
|
||||
) {
|
||||
return LISTING_HTML_CACHE;
|
||||
}
|
||||
|
||||
if (normalized === "/" || normalized.startsWith("/skills/")) {
|
||||
if (
|
||||
normalized.endsWith("/llms.txt") ||
|
||||
normalized === "/skills/registry.txt"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return EDGE_HTML_CACHE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getSiteOrigin } from "./agent-discovery";
|
||||
import { DISCOVERY_JSON_CACHE } from "./cache-headers";
|
||||
|
||||
export { getSiteOrigin };
|
||||
|
||||
@@ -163,12 +164,12 @@ without a token.
|
||||
|
||||
export const jsonHeaders = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Cache-Control": DISCOVERY_JSON_CACHE,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
} as const;
|
||||
|
||||
export const markdownHeaders = {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Cache-Control": DISCOVERY_JSON_CACHE,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
} as const;
|
||||
|
||||
+24
-7
@@ -1,6 +1,7 @@
|
||||
import { defineMiddleware } from "astro:middleware";
|
||||
|
||||
import { buildDiscoveryLinkHeader, getSiteOrigin } from "./lib/agent-discovery";
|
||||
import { getHtmlCacheControl } from "./lib/cache-headers";
|
||||
import { maybeNegotiateMarkdown } from "./lib/markdown-negotiation";
|
||||
|
||||
const securityHeaders = {
|
||||
@@ -15,15 +16,25 @@ const securityHeaders = {
|
||||
function applyResponseHeaders(
|
||||
response: Response,
|
||||
origin: string,
|
||||
pathname: string,
|
||||
): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
Object.entries(securityHeaders).forEach(([key, value]) =>
|
||||
response.headers.set(key, value),
|
||||
headers.set(key, value),
|
||||
);
|
||||
// RFC 8288 discovery links for agents (isitagentready.com linkHeaders check).
|
||||
if (!response.headers.has("Link")) {
|
||||
response.headers.set("Link", buildDiscoveryLinkHeader(origin));
|
||||
if (!headers.has("Link")) {
|
||||
headers.set("Link", buildDiscoveryLinkHeader(origin));
|
||||
}
|
||||
return response;
|
||||
const cacheControl = getHtmlCacheControl(pathname);
|
||||
if (cacheControl && !headers.has("Cache-Control")) {
|
||||
headers.set("Cache-Control", cacheControl);
|
||||
}
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
@@ -37,9 +48,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
? 301
|
||||
: 308;
|
||||
const response = Response.redirect(url, status);
|
||||
return applyResponseHeaders(response, origin);
|
||||
return applyResponseHeaders(response, origin, url.pathname);
|
||||
}
|
||||
|
||||
const response = applyResponseHeaders(await next(), origin);
|
||||
return maybeNegotiateMarkdown(context.request, response);
|
||||
const response = await next();
|
||||
|
||||
if (context.isPrerendered) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const withHeaders = applyResponseHeaders(response, origin, url.pathname);
|
||||
return maybeNegotiateMarkdown(context.request, withHeaders);
|
||||
});
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
buildDiscoveryLinkHeader,
|
||||
getSiteOrigin,
|
||||
} from "../../lib/agent-discovery";
|
||||
import { DISCOVERY_JSON_CACHE } from "../../lib/cache-headers";
|
||||
|
||||
const catalogHeaders = (origin: string): HeadersInit => ({
|
||||
"Content-Type": apiCatalogContentType(),
|
||||
Link: buildDiscoveryLinkHeader(origin),
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Cache-Control": DISCOVERY_JSON_CACHE,
|
||||
});
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
---
|
||||
return Astro.redirect("/agents", 301);
|
||||
---
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import designDocument from "../../DESIGN.md?raw";
|
||||
|
||||
import { SEMI_STATIC_CACHE } from "../lib/cache-headers";
|
||||
|
||||
export const GET: APIRoute = () =>
|
||||
new Response(designDocument, {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Cache-Control": SEMI_STATIC_CACHE,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { skills } from "../data/skills";
|
||||
|
||||
import { SEMI_STATIC_CACHE } from "../lib/cache-headers";
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
const navigation = [
|
||||
{ label: "Home", path: "/" },
|
||||
@@ -33,6 +35,7 @@ export const GET: APIRoute = () => {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": SEMI_STATIC_CACHE,
|
||||
"X-Robots-Tag": "noindex, nofollow",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
import { SEMI_STATIC_CACHE } from "../lib/cache-headers";
|
||||
|
||||
const SITE_URL = "https://www.ui-skills.com";
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
@@ -18,6 +20,7 @@ export const GET: APIRoute = ({ site }) => {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": SEMI_STATIC_CACHE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
import { SEMI_STATIC_CACHE } from "../lib/cache-headers";
|
||||
import { skills, type Skill } from "../data/skills";
|
||||
import { agents } from "../data/agents";
|
||||
import { playbook } from "../data/playbook";
|
||||
@@ -75,6 +77,7 @@ export const GET: APIRoute = ({ site }) => {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
"Cache-Control": SEMI_STATIC_CACHE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,11 +3,13 @@ import type { APIRoute } from "astro";
|
||||
import { registry } from "../../data/registry";
|
||||
import { topics } from "../../data/topics";
|
||||
|
||||
import { REGISTRY_JSON_CACHE } from "../../lib/cache-headers";
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
return new Response(JSON.stringify({ registry, topics }), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
"Cache-Control": REGISTRY_JSON_CACHE,
|
||||
"X-Robots-Tag": "noindex, nofollow",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { APIRoute } from "astro";
|
||||
import type { RegistrySkill } from "../../data/registry";
|
||||
import { registry } from "../../data/registry";
|
||||
|
||||
import { SEMI_STATIC_CACHE } from "../../lib/cache-headers";
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
const body = registry
|
||||
.map(
|
||||
@@ -14,6 +16,7 @@ export const GET: APIRoute = () => {
|
||||
return new Response(`${body}\n`, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": SEMI_STATIC_CACHE,
|
||||
"X-Robots-Tag": "noindex, nofollow",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,42 +1,64 @@
|
||||
---
|
||||
import Button from "./button.astro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
href: string;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
const { href, label } = Astro.props as Props;
|
||||
---
|
||||
type GithubStars = {
|
||||
stars: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
{
|
||||
label ? (
|
||||
<Button
|
||||
export function GitHubStarButton({ href }: Props) {
|
||||
const [label, setLabel] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch("/api/github-stars")
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data: GithubStars | null) => {
|
||||
if (!cancelled && data?.label) {
|
||||
setLabel(data.label);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the icon-only button when the stars API is unavailable.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
variant="black"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group/github-stars h-8 gap-2 px-2.5"
|
||||
className="group/github-stars inline-flex h-8 items-center justify-center gap-2 rounded-[6px] border border-parchment-900 bg-black px-2.5 text-[14px] font-[450] text-white transition-colors hover:border-parchment-800 hover:bg-black focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
<span class="sr-only">Star on GitHub</span>
|
||||
<span className="sr-only">Star on GitHub</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
className="size-4"
|
||||
viewBox="0 0 98 96"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
class="fill-white"
|
||||
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 0 48.9043 0C21.8203 0 0 22.1074 0 49.1914C0 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
|
||||
className="fill-white"
|
||||
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 0 48.9043 0C21.8203 0 0 22.1074 0 49.1914C0 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4891 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
|
||||
/>
|
||||
</svg>
|
||||
<span aria-hidden="true" class="text-sm">
|
||||
{label}
|
||||
</span>
|
||||
{label ? (
|
||||
<span aria-hidden="true" className="text-sm">
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="text-white/60 transition-colors duration-100 group-hover/github-stars:text-[#e3b341]"
|
||||
className="text-white/60 transition-colors duration-100 group-hover/github-stars:text-[#e3b341]"
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 13 13"
|
||||
@@ -44,12 +66,12 @@ const { href, label } = Astro.props as Props;
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.45803 2.89654e-06C6.6061 0.000101587 6.75082 0.0440267 6.87397 0.126243C6.99712 0.208458 7.09317 0.325286 7.15003 0.462003L8.56003 3.855L12.224 4.148C12.3717 4.15988 12.5125 4.2152 12.6287 4.30699C12.7449 4.39878 12.8314 4.52293 12.8772 4.66379C12.923 4.80464 12.926 4.9559 12.8859 5.09849C12.8459 5.24108 12.7645 5.36861 12.652 5.465L9.86103 7.855L10.714 11.43C10.7483 11.574 10.7392 11.725 10.6878 11.8638C10.6364 12.0027 10.5451 12.1233 10.4253 12.2103C10.3056 12.2973 10.1627 12.347 10.0148 12.353C9.86685 12.359 9.72045 12.3211 9.59403 12.244L6.45603 10.33L3.32103 12.245C3.19461 12.3221 3.04821 12.36 2.90027 12.354C2.75234 12.348 2.60949 12.2983 2.48972 12.2113C2.36996 12.1243 2.27864 12.0037 2.22726 11.8649C2.17589 11.726 2.16676 11.575 2.20103 11.431L3.05303 7.857L0.263028 5.467C0.150277 5.37074 0.0685828 5.24323 0.028266 5.10056C-0.0120509 4.9579 -0.00918217 4.80648 0.0365099 4.66545C0.082202 4.52441 0.168667 4.40008 0.284984 4.30816C0.401301 4.21624 0.54225 4.16086 0.690028 4.149L4.35303 3.856L5.76303 0.463003C5.81993 0.325626 5.91638 0.208264 6.04013 0.125824C6.16387 0.0433847 6.30933 -0.000410263 6.45803 2.89654e-06Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
) : null
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getHtmlCacheControl } from "../src/lib/cache-headers.ts";
|
||||
|
||||
describe("cache header routing", () => {
|
||||
test("edge-caches the homepage and skill detail pages", () => {
|
||||
assert.match(getHtmlCacheControl("/") ?? "", /s-maxage=3600/);
|
||||
assert.match(
|
||||
getHtmlCacheControl("/skills/ibelick/improve-ui") ?? "",
|
||||
/s-maxage=3600/,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses longer edge cache for listing and docs pages", () => {
|
||||
assert.match(getHtmlCacheControl("/skills") ?? "", /s-maxage=86400/);
|
||||
assert.match(
|
||||
getHtmlCacheControl("/playbook/use-large-touch-targets") ?? "",
|
||||
/s-maxage=86400/,
|
||||
);
|
||||
assert.match(getHtmlCacheControl("/cli") ?? "", /s-maxage=86400/);
|
||||
});
|
||||
|
||||
test("skips cache headers for machine-readable skill routes", () => {
|
||||
assert.equal(getHtmlCacheControl("/skills/registry.txt"), null);
|
||||
assert.equal(
|
||||
getHtmlCacheControl("/skills/ibelick/improve-ui/llms.txt"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user