Skip to content

Commit bd3cb8d

Browse files
committed
fix: work around a couple tiny Netlify CDN bugs
1 parent b09dea0 commit bd3cb8d

File tree

2 files changed

+86
-47
lines changed

2 files changed

+86
-47
lines changed

app/components/CacheAnalysis.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const { servedBy, cacheStatus } = getCacheAnalysis(props.cacheHeaders);
1212
Served by: <strong>{{ servedBy.source }}</strong>
1313
</div>
1414
<div>
15-
CDN node: <code>{{ servedBy.cdnNode }}</code>
15+
CDN node(s): <code>{{ servedBy.cdnNodes }}</code>
1616
</div>
1717

1818
<hr />

app/utils/getCacheAnalysis.ts

Lines changed: 85 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -26,55 +26,82 @@ interface ParsedCacheStatusEntry {
2626
detail?: string;
2727
};
2828
}
29+
30+
const CACHE_NAMES_SORTED_BY_RFC_9211 = [
31+
"Next.js",
32+
"Netlify Durable",
33+
"Netlify Edge",
34+
];
35+
36+
/**
37+
* Per the spec, these should be sorted from "the cache closest to the origin server" to "the cache
38+
* closest to the user", but there appear to be scenarios where this is not the case. This fixes
39+
* these for now as a stopgap.
40+
*/
41+
const fixNonconformingUnsortedEntries = (
42+
unsortedEntries: readonly ParsedCacheStatusEntry[],
43+
): ParsedCacheStatusEntry[] => {
44+
return unsortedEntries.toSorted((a, b) => {
45+
// If either or both of these is not in the array (i.e. index is -1), we wouldn't be able to
46+
// know where it should "go" so don't even bother explicitly handling it.
47+
return (
48+
CACHE_NAMES_SORTED_BY_RFC_9211.indexOf(a.cacheName) -
49+
CACHE_NAMES_SORTED_BY_RFC_9211.indexOf(b.cacheName)
50+
);
51+
});
52+
};
53+
2954
export const parseCacheStatus = (
3055
// See https://httpwg.org/specs/rfc9211.html
3156
// example string:
3257
// "\"Next.js\"; hit, \"Netlify Durable\"; fwd=miss; stored, \"Netlify Edge\"; fwd=miss"
3358
cacheStatus: string,
3459
): 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) {
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 | undefined] => 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-
);
60+
const unfixedEntries = cacheStatus
61+
.split(", ")
62+
.map((entry): null | ParsedCacheStatusEntry => {
63+
const [cacheName, ...parameters] = entry.split("; ");
64+
if (!cacheName || parameters.length === 0) {
65+
console.warn("Ignoring invalid cache status entry", { entry });
66+
return null;
67+
}
68+
69+
const parametersByKey = new Map(
70+
parameters
71+
.map((parameter) => {
72+
const [key, value] = parameter.split("=");
73+
if (!key) {
74+
console.warn("Ignoring invalid cache status entry", { entry });
75+
return null;
76+
}
77+
return [key, value];
78+
})
79+
.filter((kv): kv is [string, string | undefined] => kv != null),
80+
);
81+
return {
82+
cacheName: cacheName.slice(1, -1), // "Netlify Edge" -> Netlify Edge
83+
parameters: {
84+
hit: parametersByKey.has("hit"),
85+
fwd: parametersByKey.get(
86+
"fwd",
87+
) as ParsedCacheStatusEntry["parameters"]["fwd"],
88+
"fwd-status": Number(parametersByKey.get("fwd-status")),
89+
ttl: Number(parametersByKey.get("ttl")),
90+
stored: parametersByKey.has("stored"),
91+
collapsed: parametersByKey.has("collapsed"),
92+
key: parametersByKey.get("key"),
93+
detail: parametersByKey.get("detail"),
94+
},
95+
};
96+
})
97+
.filter((e): e is ParsedCacheStatusEntry => e != null);
98+
99+
const sortedEntries = fixNonconformingUnsortedEntries(unfixedEntries);
100+
101+
// Per the spec, these should be sorted from "the cache closest to the origin server" to "the cache closest to the user".
102+
// As a user interpreting what happened, you want these to start from yourself.
103+
// TODO(serhalp) More of a presentation layer concern? Move to the component?
104+
return sortedEntries.toReversed();
78105
};
79106

80107
const getServedBySource = (
@@ -107,18 +134,30 @@ const getServedBySource = (
107134
);
108135
};
109136

137+
/**
138+
* There is a bug where sometimes duplicate hosts are returned in the `X-BB-Host-Id` header. This is
139+
* doubly confusing because there are legitimate cases where the same node could be involved more
140+
* than once in the handling of a given request, but we can't distinguish those from dupes. So just dedupe.
141+
*/
142+
const fixDuplicatedCdnNodes = (unfixedCdnNodes: string): string => {
143+
return Array.from(new Set(unfixedCdnNodes.split(", "))).join(", ");
144+
};
145+
110146
interface ServedBy {
111147
source: ServedBySource;
112-
cdnNode: string;
148+
cdnNodes: string;
113149
}
114150

115151
const getServedBy = (
116152
cacheHeaders: Headers,
117153
cacheStatus: ParsedCacheStatusEntry[],
118154
): ServedBy => {
155+
const source = getServedBySource(cacheHeaders, cacheStatus);
156+
const unfixedCdnNodes =
157+
cacheHeaders.get("X-BB-Host-Id") ?? "unknown CDN node";
119158
return {
120-
source: getServedBySource(cacheHeaders, cacheStatus),
121-
cdnNode: cacheHeaders.get("X-BB-Host-Id") ?? "unknown CDN node",
159+
source,
160+
cdnNodes: fixDuplicatedCdnNodes(unfixedCdnNodes),
122161
};
123162
};
124163

0 commit comments

Comments
 (0)