Skip to content

Commit b3d306d

Browse files
committed
feat: add response cacheability breakdown and more
1 parent eeda8b3 commit b3d306d

12 files changed

+1277
-14
lines changed

app/app.vue

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,6 @@ const runs = ref([]);
33
const error = ref(null);
44
55
const handleRequestFormSubmit = async ({ url }): void => {
6-
if (!url.startsWith("http")) {
7-
url = `https://${url}`;
8-
}
9-
106
try {
117
// Destructuring would be confusing, since the response body contains fields named `status` and
128
// `headers` (it's a request about a request...)

app/components/CacheAnalysis.vue

Lines changed: 143 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,78 @@
11
<script setup lang="ts">
2+
import { formatDuration, intervalToDuration } from "date-fns";
3+
24
const props = defineProps<{
35
cacheHeaders: Record<string, string>;
46
}>();
57
6-
const { servedBy, cacheStatus } = getCacheAnalysis(props.cacheHeaders);
8+
const formatSeconds = (seconds: number): string => {
9+
return `${seconds} s`;
10+
};
11+
12+
const formatHumanSeconds = (seconds: number): string => {
13+
const d = new Date(); // arbitrary date
14+
return formatDuration(
15+
intervalToDuration({
16+
start: d,
17+
end: new Date(d.getTime() + Math.abs(seconds) * 1000),
18+
}),
19+
);
20+
};
21+
22+
const formatDate = (date: Date): string =>
23+
date.toLocaleString(undefined, {
24+
timeZoneName: "short",
25+
});
26+
27+
let now = ref(Date.now());
28+
29+
const cacheAnalysis = computed(() =>
30+
getCacheAnalysis(props.cacheHeaders, now.value),
31+
);
32+
33+
let timerId;
34+
35+
onMounted(() => {
36+
timerId = setInterval(() => {
37+
now.value = Date.now();
38+
}, 1000);
39+
});
40+
41+
onUnmounted(() => {
42+
if (timerId) clearInterval(timerId);
43+
});
744
</script>
845

946
<template>
1047
<div class="container">
1148
<div>
12-
Served by: <strong>{{ servedBy.source }}</strong>
49+
Served by: <strong>{{ cacheAnalysis.servedBy.source }}</strong>
1350
</div>
1451
<div>
15-
CDN node(s): <code>{{ servedBy.cdnNodes }}</code>
52+
CDN node(s): <code>{{ cacheAnalysis.servedBy.cdnNodes }}</code>
1653
</div>
1754

1855
<hr />
1956

2057
<dl>
2158
<dt class="cache-heading">
22-
<h4>🎬 Request from client</h4>
59+
<h4>
60+
🎬 Request from client
61+
<br />
62+
63+
</h4>
2364
</dt>
2465
<dd></dd>
2566

26-
<template v-for="({ cacheName, parameters }, cacheIndex) in cacheStatus">
67+
<template v-for="(
68+
{ cacheName, parameters }, cacheIndex
69+
) in cacheAnalysis.cacheStatus">
2770
<!-- This is a bit of a hack to use the pretty <dt> styling but with sections. -->
2871
<!-- I should probably just do something custom instead. -->
2972
<dt class="cache-heading">
30-
<h4>↳ {{ cacheName }}</h4>
73+
<h4>
74+
↳ <em>{{ cacheName }}</em> cache
75+
</h4>
3176
</dt>
3277
<dd></dd>
3378

@@ -46,7 +91,9 @@ const { servedBy, cacheStatus } = getCacheAnalysis(props.cacheHeaders);
4691

4792
<template v-if="parameters.ttl">
4893
<dt>TTL</dt>
49-
<dd>{{ parameters.ttl }}</dd>
94+
<dd :title="formatHumanSeconds(parameters.ttl)">
95+
{{ formatSeconds(parameters.ttl) }}
96+
</dd>
5097
</template>
5198

5299
<template v-if="parameters.stored">
@@ -78,6 +125,90 @@ const { servedBy, cacheStatus } = getCacheAnalysis(props.cacheHeaders);
78125
</h4>
79126
</dt>
80127
<dd></dd>
128+
129+
<dt>Cacheable</dt>
130+
<dd>{{ cacheAnalysis.cacheControl.isCacheable ? "✅" : "❌" }}</dd>
131+
132+
<template v-if="cacheAnalysis.cacheControl.age">
133+
<dt>Age</dt>
134+
<dd :title="formatHumanSeconds(cacheAnalysis.cacheControl.age)">
135+
{{ formatSeconds(cacheAnalysis.cacheControl.age) }}
136+
</dd>
137+
</template>
138+
139+
<template v-if="cacheAnalysis.cacheControl.date">
140+
<dt>Date</dt>
141+
<dd>
142+
{{ formatDate(cacheAnalysis.cacheControl.date) }}
143+
</dd>
144+
</template>
145+
146+
<template v-if="cacheAnalysis.cacheControl.etag">
147+
<dt>ETag</dt>
148+
<dd>
149+
<code>{{ cacheAnalysis.cacheControl.etag }}</code>
150+
</dd>
151+
</template>
152+
153+
<template v-if="cacheAnalysis.cacheControl.expiresAt">
154+
<dt>Expires at</dt>
155+
<dd>{{ formatDate(cacheAnalysis.cacheControl.expiresAt) }}</dd>
156+
</template>
157+
158+
<template v-if="cacheAnalysis.cacheControl.ttl">
159+
<dt>
160+
TTL{{
161+
cacheAnalysis.cacheControl.netlifyCdnTttl ||
162+
cacheAnalysis.cacheControl.cdnTttl
163+
? " (browser)"
164+
: ""
165+
}}
166+
</dt>
167+
<dd :title="formatHumanSeconds(cacheAnalysis.cacheControl.ttl)">
168+
{{ formatSeconds(cacheAnalysis.cacheControl.ttl) }}
169+
</dd>
170+
</template>
171+
172+
<template v-if="cacheAnalysis.cacheControl.cdnTtl">
173+
<dt>
174+
TTL ({{
175+
cacheAnalysis.cacheControl.netlifyCdnTttl
176+
? "other CDNs"
177+
: "Netlify CDN"
178+
}})
179+
</dt>
180+
<dd :title="formatHumanSeconds(cacheAnalysis.cacheControl.cdnTtl)">
181+
{{ formatSeconds(cacheAnalysis.cacheControl.cdnTtl) }}
182+
</dd>
183+
</template>
184+
185+
<template v-if="cacheAnalysis.cacheControl.netlifyCdnTttl">
186+
<dt>TTL (Netlify CDN)</dt>
187+
<dd :title="formatHumanSeconds(cacheAnalysis.cacheControl.netlifyCdnTtl)">
188+
{{ formatSeconds(cacheAnalysis.cacheControl.netlifyCdnTttl) }}
189+
</dd>
190+
</template>
191+
192+
<template v-if="cacheAnalysis.cacheControl.vary">
193+
<dt>Vary</dt>
194+
<dd>
195+
<code>{{ cacheAnalysis.cacheControl.vary }}</code>
196+
</dd>
197+
</template>
198+
199+
<template v-if="cacheAnalysis.cacheControl.netlifyVary">
200+
<dt>Netlify-Vary</dt>
201+
<dd>
202+
<code>{{ cacheAnalysis.cacheControl.netlifyVary }}</code>
203+
</dd>
204+
</template>
205+
206+
<template v-if="cacheAnalysis.cacheControl.revalidate">
207+
<dt>Revalidation</dt>
208+
<dd>
209+
<code>{{ cacheAnalysis.cacheControl.revalidate }}</code>
210+
</dd>
211+
</template>
81212
</dl>
82213
</div>
83214
</template>
@@ -111,4 +242,9 @@ dt.cache-heading h4 {
111242
.cache-heading::after {
112243
content: none;
113244
}
245+
246+
dd code {
247+
font-size: 0.8em;
248+
overflow-wrap: anywhere;
249+
}
114250
</style>

app/components/RequestForm.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
<script setup lang="ts">
2-
const inputUrl = ref();
2+
const inputUrl = ref(
3+
"https://nextjs-netlify-durable-cache-demo.netlify.app/isr-page",
4+
);
35
46
const emit = defineEmits(["submit"]);
57
68
const handleSubmit = () => {
9+
if (!inputUrl.value.startsWith("http")) {
10+
inputUrl.value = `https://${inputUrl.value}`;
11+
}
12+
713
emit("submit", { url: inputUrl.value });
814
};
915
</script>

app/utils/cache-control.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* This is blatantly lifted from https://github.com/tusbar/cache-control.
3+
* No libraries I could find did quite what I wanted. This one was close, but quietly ignored
4+
* extensions (e.g. `durable`), so I just made that change (and I removed `format()` because I
5+
* didn't need it).
6+
*
7+
* TODO(serhalp) Fork instead of inlining? Open a PR?
8+
*/
9+
10+
const HEADER_REGEXP =
11+
/([a-zA-Z][a-zA-Z_-]*)\s*(?:=(?:"([^"]*)"|([^ \t",;]*)))?/g;
12+
13+
const SUPPORTED_DIRECTIVES = {
14+
maxAge: "max-age",
15+
sharedMaxAge: "s-maxage",
16+
maxStale: "max-stale",
17+
minFresh: "min-fresh",
18+
immutable: "immutable",
19+
mustRevalidate: "must-revalidate",
20+
noCache: "no-cache",
21+
noStore: "no-store",
22+
noTransform: "no-transform",
23+
onlyIfCached: "only-if-cached",
24+
private: "private",
25+
proxyRevalidate: "proxy-revalidate",
26+
public: "public",
27+
staleWhileRevalidate: "stale-while-revalidate",
28+
staleIfError: "stale-if-error",
29+
};
30+
31+
function parseBooleanOnly(value: string | null | undefined) {
32+
return value === null;
33+
}
34+
35+
function parseDuration(value: string | null | undefined) {
36+
if (!value) {
37+
return null;
38+
}
39+
40+
const duration: number = Number.parseInt(value, 10);
41+
42+
if (!Number.isFinite(duration) || duration < 0) {
43+
return null;
44+
}
45+
46+
return duration;
47+
}
48+
49+
export class CacheControl {
50+
maxAge: number | null;
51+
sharedMaxAge: number | null;
52+
maxStale: boolean | null;
53+
maxStaleDuration: number | null;
54+
minFresh: number | null;
55+
immutable: boolean | null;
56+
mustRevalidate: boolean | null;
57+
noCache: boolean | null;
58+
noStore: boolean | null;
59+
noTransform: boolean | null;
60+
onlyIfCached: boolean | null;
61+
private: boolean | null;
62+
proxyRevalidate: boolean | null;
63+
public: boolean | null;
64+
staleWhileRevalidate: number | null;
65+
staleIfError: number | null;
66+
extensions: Record<string, string | null>;
67+
68+
constructor() {
69+
this.maxAge = null;
70+
this.sharedMaxAge = null;
71+
this.maxStale = null;
72+
this.maxStaleDuration = null;
73+
this.minFresh = null;
74+
this.immutable = null;
75+
this.mustRevalidate = null;
76+
this.noCache = null;
77+
this.noStore = null;
78+
this.noTransform = null;
79+
this.onlyIfCached = null;
80+
this.private = null;
81+
this.proxyRevalidate = null;
82+
this.public = null;
83+
this.staleWhileRevalidate = null;
84+
this.staleIfError = null;
85+
this.extensions = {};
86+
}
87+
88+
parse(header: string | null | undefined): this {
89+
if (!header || header.length === 0) {
90+
return this;
91+
}
92+
93+
const values: Record<string, string | null> = {};
94+
const matches = header.match(HEADER_REGEXP) ?? [];
95+
96+
for (const match of matches) {
97+
const tokens: string[] = match.split("=", 2);
98+
const [key] = tokens;
99+
100+
if (key == null) {
101+
throw new Error("Invalid Cache-Control header");
102+
}
103+
104+
values[key.toLowerCase()] = tokens[1] != null ? tokens[1].trim() : null;
105+
}
106+
107+
this.maxAge = parseDuration(values[SUPPORTED_DIRECTIVES.maxAge]);
108+
this.sharedMaxAge = parseDuration(
109+
values[SUPPORTED_DIRECTIVES.sharedMaxAge],
110+
);
111+
112+
this.maxStale = parseBooleanOnly(values[SUPPORTED_DIRECTIVES.maxStale]);
113+
this.maxStaleDuration = parseDuration(
114+
values[SUPPORTED_DIRECTIVES.maxStale],
115+
);
116+
if (this.maxStaleDuration) {
117+
this.maxStale = true;
118+
}
119+
120+
this.minFresh = parseDuration(values[SUPPORTED_DIRECTIVES.minFresh]);
121+
122+
this.immutable = parseBooleanOnly(values[SUPPORTED_DIRECTIVES.immutable]);
123+
this.mustRevalidate = parseBooleanOnly(
124+
values[SUPPORTED_DIRECTIVES.mustRevalidate],
125+
);
126+
this.noCache = parseBooleanOnly(values[SUPPORTED_DIRECTIVES.noCache]);
127+
this.noStore = parseBooleanOnly(values[SUPPORTED_DIRECTIVES.noStore]);
128+
this.noTransform = parseBooleanOnly(
129+
values[SUPPORTED_DIRECTIVES.noTransform],
130+
);
131+
this.onlyIfCached = parseBooleanOnly(
132+
values[SUPPORTED_DIRECTIVES.onlyIfCached],
133+
);
134+
this.private = parseBooleanOnly(values[SUPPORTED_DIRECTIVES.private]);
135+
this.proxyRevalidate = parseBooleanOnly(
136+
values[SUPPORTED_DIRECTIVES.proxyRevalidate],
137+
);
138+
this.public = parseBooleanOnly(values[SUPPORTED_DIRECTIVES.public]);
139+
this.staleWhileRevalidate = parseDuration(
140+
values[SUPPORTED_DIRECTIVES.staleWhileRevalidate],
141+
);
142+
this.staleIfError = parseDuration(
143+
values[SUPPORTED_DIRECTIVES.staleIfError],
144+
);
145+
146+
for (const [key, value] of Object.entries(values)) {
147+
if (!Object.keys(SUPPORTED_DIRECTIVES).includes(key)) {
148+
this.extensions[key] = value;
149+
}
150+
}
151+
152+
return this;
153+
}
154+
}
155+
156+
export function parse(header?: string | null): CacheControl {
157+
const cc = new CacheControl();
158+
return cc.parse(header);
159+
}

0 commit comments

Comments
 (0)