@@ -26,55 +26,82 @@ interface ParsedCacheStatusEntry {
26
26
detail ?: string ;
27
27
} ;
28
28
}
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
+
29
54
export const parseCacheStatus = (
30
55
// See https://httpwg.org/specs/rfc9211.html
31
56
// example string:
32
57
// "\"Next.js\"; hit, \"Netlify Durable\"; fwd=miss; stored, \"Netlify Edge\"; fwd=miss"
33
58
cacheStatus : string ,
34
59
) : 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 ( ) ;
78
105
} ;
79
106
80
107
const getServedBySource = (
@@ -107,18 +134,30 @@ const getServedBySource = (
107
134
) ;
108
135
} ;
109
136
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
+
110
146
interface ServedBy {
111
147
source : ServedBySource ;
112
- cdnNode : string ;
148
+ cdnNodes : string ;
113
149
}
114
150
115
151
const getServedBy = (
116
152
cacheHeaders : Headers ,
117
153
cacheStatus : ParsedCacheStatusEntry [ ] ,
118
154
) : ServedBy => {
155
+ const source = getServedBySource ( cacheHeaders , cacheStatus ) ;
156
+ const unfixedCdnNodes =
157
+ cacheHeaders . get ( "X-BB-Host-Id" ) ?? "unknown CDN node" ;
119
158
return {
120
- source : getServedBySource ( cacheHeaders , cacheStatus ) ,
121
- cdnNode : cacheHeaders . get ( "X-BB-Host-Id" ) ?? "unknown CDN node" ,
159
+ source,
160
+ cdnNodes : fixDuplicatedCdnNodes ( unfixedCdnNodes ) ,
122
161
} ;
123
162
} ;
124
163
0 commit comments