Skip to content

Commit 0285ffb

Browse files
committed
feat: add the human cache analysis
1 parent ecf522e commit 0285ffb

File tree

7 files changed

+243
-2
lines changed

7 files changed

+243
-2
lines changed

app/app.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ main {
7878
7979
.run-panels {
8080
flex-wrap: wrap;
81+
align-items: stretch;
8182
}
8283
8384
.run-panels>* {

app/components/CacheAnalysis.vue

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
cacheHeaders: Record<string, string>;
4+
}>();
5+
6+
const { servedBy, cacheStatus } = getCacheAnalysis(props.cacheHeaders);
7+
</script>
8+
9+
<template>
10+
<div>
11+
<dl>
12+
<dt>Served by</dt>
13+
<dd>{{ servedBy }}</dd>
14+
</dl>
15+
16+
<hr />
17+
18+
<dl>
19+
<template v-for="{ cacheName, parameters } in cacheStatus">
20+
<!-- This is a bit of a hack to use the pretty <dt> styling but with sections. -->
21+
<!-- I should probably just do something custom instead. -->
22+
<dt class="cache-heading">
23+
<h4>{{ cacheName }}</h4>
24+
</dt>
25+
<dd></dd>
26+
27+
<dt>Hit</dt>
28+
<dd>{{ parameters.hit ? "✅" : "❌" }}</dd>
29+
30+
<template v-if="parameters.fwd">
31+
<dt>Forwarded because</dt>
32+
<dd>{{ parameters.fwd }}</dd>
33+
</template>
34+
35+
<template v-if="parameters['fwd-status']">
36+
<dt>Forwarded status</dt>
37+
<dd>{{ parameters["fwd-status"] }}</dd>
38+
</template>
39+
40+
<template v-if="parameters.ttl">
41+
<dt>TTL</dt>
42+
<dd>{{ parameters.ttl }}</dd>
43+
</template>
44+
45+
<template v-if="parameters.stored">
46+
<dt>Stored the response</dt>
47+
<dd>{{ parameters.stored ? "✅" : "❌" }}</dd>
48+
</template>
49+
50+
<template v-if="parameters.collapsed">
51+
<dt>Collapsed w/ other reqs</dt>
52+
<dd>{{ parameters.collapsed ? "✅" : "❌" }}</dd>
53+
</template>
54+
55+
<template v-if="parameters.key">
56+
<dt>Cache key</dt>
57+
<dd>{{ parameters.key }}</dd>
58+
</template>
59+
60+
<template v-if="parameters.detail">
61+
<dt>Extra details</dt>
62+
<dd>{{ parameters.detail }}</dd>
63+
</template>
64+
</template>
65+
</dl>
66+
</div>
67+
</template>
68+
69+
<style scoped>
70+
.cache-heading h4 {
71+
padding: 0;
72+
/* I'm sorry */
73+
margin-left: -0.5em;
74+
75+
font-size: 1.1em;
76+
}
77+
78+
/* Default Netlify Examples styles add ": " */
79+
.cache-heading::after {
80+
content: none;
81+
}
82+
</style>

app/components/RawCacheHeaders.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,18 @@ onUpdated(highlightJson);
1919
</template>
2020

2121
<style scoped>
22+
/* FIXME(serhalp) This is leaky. I'm doing this:
23+
* https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_box_alignment/Box_alignment_in_flexbox#alignment_and_auto_margins.
24+
* But this component shouldn't "know" about its parent's layout needs.
25+
*/
26+
pre {
27+
margin-top: auto;
28+
}
29+
2230
code {
2331
font-size: 0.7em;
32+
33+
max-width: 40vw;
34+
overflow-x: scroll;
2435
}
2536
</style>

app/components/RunPanel.vue

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,28 @@ const props = defineProps<{
88
</script>
99

1010
<template>
11-
<div>
11+
<div class="panel run-panel">
1212
<h3>{{ props.url }}</h3>
1313

1414
<small>HTTP {{ props.status }} ({{ props.durationInMs }} ms)</small>
1515

16+
<CacheAnalysis :cacheHeaders="props.cacheHeaders" />
1617
<RawCacheHeaders :cacheHeaders="props.cacheHeaders" />
1718
</div>
1819
</template>
1920

2021
<style scoped>
21-
h3 {
22+
.run-panel {
23+
display: flex;
24+
flex-direction: column;
25+
justify-content: flex-start;
26+
align-content: center;
27+
28+
margin: 1em;
29+
}
30+
31+
.run-panel h3 {
2232
font-size: 1em;
33+
align-self: start;
2334
}
2435
</style>

app/utils/getCacheAnalysis.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
export enum ServedBy {
2+
CDN = "CDN",
3+
DurableCache = "Durable Cache",
4+
Function = "Function",
5+
EdgeFunction = "Edge Function",
6+
}
7+
8+
interface ParsedCacheStatusEntry {
9+
cacheName: string;
10+
parameters: {
11+
hit: boolean;
12+
fwd?:
13+
| "bypass"
14+
| "method"
15+
| "uri-miss"
16+
| "vary-miss"
17+
| "miss"
18+
| "request"
19+
| "stale"
20+
| "partial";
21+
"fwd-status"?: number;
22+
ttl?: number;
23+
stored?: boolean;
24+
collapsed?: boolean;
25+
key?: string;
26+
detail?: string;
27+
};
28+
}
29+
export const parseCacheStatus = (
30+
// See https://httpwg.org/specs/rfc9211.html
31+
// example string:
32+
// "\"Next.js\"; hit, \"Netlify Durable\"; fwd=miss; stored, \"Netlify Edge\"; fwd=miss"
33+
cacheStatus: string,
34+
): ParsedCacheStatusEntry[] => {
35+
return (
36+
cacheStatus
37+
.split(", ")
38+
.map((entry): null | ParsedCacheStatusEntry => {
39+
const [cacheName, ...parameters] = entry.split("; ");
40+
if (!cacheName || parameters.length === 0) {
41+
console.warn("Ignoring invalid cache status entry", { entry });
42+
return null;
43+
}
44+
45+
const parametersByKey = new Map(
46+
parameters
47+
.map((parameter) => {
48+
const [key, value] = parameter.split("=");
49+
if (!key || !value) {
50+
console.warn("Ignoring invalid cache status entry", { entry });
51+
return null;
52+
}
53+
return [key, value];
54+
})
55+
.filter((kv): kv is [string, string] => kv != null),
56+
);
57+
return {
58+
cacheName: cacheName.slice(1, -1), // "Netlify Edge" -> Netlify Edge
59+
parameters: {
60+
hit: parametersByKey.has("hit"),
61+
fwd: parametersByKey.get(
62+
"fwd",
63+
) as ParsedCacheStatusEntry["parameters"]["fwd"],
64+
"fwd-status": Number(parametersByKey.get("fwd-status")),
65+
ttl: Number(parametersByKey.get("ttl")),
66+
stored: parametersByKey.has("stored"),
67+
collapsed: parametersByKey.has("collapsed"),
68+
key: parametersByKey.get("key"),
69+
detail: parametersByKey.get("detail"),
70+
},
71+
};
72+
})
73+
.filter((e): e is ParsedCacheStatusEntry => e != null)
74+
// Per the spec, these are sorted from "the cache closest to the origin server" to "the cache closest to the user".
75+
// As a user interpreting what happened, you want these to start from yourself.
76+
.toReversed()
77+
);
78+
};
79+
80+
const getServedBy = (
81+
cacheHeaders: Headers,
82+
cacheStatus: ParsedCacheStatusEntry[],
83+
): ServedBy => {
84+
// Per the spec, these are sorted from "the cache closest to the origin server" to "the cache closest to the user".
85+
// So, the first cache hit (starting from the user) is the one that served the request.
86+
// But we don't quite want to return exactly the same concept of "caches" as in `Cache-Status`, so
87+
// we need a bit of extra logic to map to other sources.
88+
for (const {
89+
cacheName,
90+
parameters: { hit },
91+
} of cacheStatus) {
92+
if (!hit) continue;
93+
94+
if (cacheName === "Netlify Edge") return ServedBy.CDN;
95+
if (cacheName === "Netlify Durable") return ServedBy.DurableCache;
96+
}
97+
98+
// NOTE: the order is important here, since a response can be served by a Function even
99+
// though one or more Edge Functions are also invoked (as middleware).
100+
if (cacheHeaders.has("X-NF-Function-Type")) return ServedBy.Function;
101+
102+
if (cacheHeaders.has("X-NF-Edge-Functions")) return ServedBy.EdgeFunction;
103+
104+
throw new Error(
105+
`Could not determine who served the request. Cache status: ${cacheStatus}`,
106+
);
107+
};
108+
109+
export interface CacheAnalysis {
110+
servedBy: ServedBy;
111+
cacheStatus: ParsedCacheStatusEntry[];
112+
}
113+
114+
export default function getCacheAnalysis(
115+
cacheHeadersObj: Record<string, string>,
116+
): CacheAnalysis {
117+
// Use a Headers instance for case insensitivity and multi-value handling
118+
const cacheHeaders = new Headers(cacheHeadersObj);
119+
const cacheStatus = parseCacheStatus(cacheHeaders.get("Cache-Status") ?? "");
120+
121+
return {
122+
servedBy: getServedBy(cacheHeaders, cacheStatus),
123+
cacheStatus,
124+
};
125+
}

app/utils/getCacheHeaders.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,28 @@ const CACHE_HEADER_NAMES = [
33
"CDN-Cache-Control",
44
"Cache-Control",
55
"Cache-Status",
6+
"Cache-Tag",
67
"Content-Encoding",
8+
"Content-Language",
79
"Content-Type",
810
"Date",
911
"ETag",
1012
"Netlify-CDN-Cache-Control",
13+
"Netlify-Cache-Tag",
14+
"Netlify-Vary",
1115
"Vary",
1216
"X-BB-Deploy-Id",
1317
"X-BB-Gen",
1418
"X-NF-Cache-Info",
1519
"X-NF-Cache-Result",
20+
// TODO(serhalp) These two probably shouldn't be here but I use it to determine who served the
21+
// request. Need to refactor to pass the whole headers obj to `getServedBy` to remove this.
22+
"X-NF-Edge-Functions",
23+
"X-NF-Function-Type",
24+
"X-Nextjs-Cache",
1625
];
1726

27+
// TODO(serhalp) Magically parse `Netlify-Vary` if present and also include headers referenced by it
1828
export default function getCacheHeaders(headersObj: Record<string, string>) {
1929
// Use a Headers instance for case insensitivity and multi-value handling
2030
const headers = new Headers(headersObj);

server/api/inspect-url/[url].get.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default defineEventHandler(async (event) => {
99
}
1010

1111
const startTime = Date.now();
12+
// TODO(serhalp) `$fetch` automatically throws on 4xx, but we'd like to treat those as valid.
1213
const { status, headers } = await $fetch.raw(url, {
1314
headers: {
1415
"x-nf-debug-logging": "1",

0 commit comments

Comments
 (0)