1
- import type { Browser , parseUserAgent } from 'detect-browser-es'
2
- import type {
3
- ResolvedHttpClientHintsOptions ,
4
- CriticalInfo ,
5
- CriticalClientHintsConfiguration ,
6
- } from '../shared-types/types'
1
+ import type { parseUserAgent } from 'detect-browser-es'
2
+ import { CriticalHintsHeaders , extractCriticalHints } from '../utils/critical'
3
+ import type { ResolvedHttpClientHintsOptions } from '../shared-types/types'
7
4
import { useHttpClientHintsState } from './state'
8
- import { lookupHeader , writeClientHintHeaders , writeHeaders } from './headers'
9
- import { browserFeatureAvailable } from './features'
10
- import {
11
- defineNuxtPlugin ,
12
- useCookie ,
13
- useRuntimeConfig ,
14
- useRequestHeaders ,
15
- } from '#imports'
5
+ import { writeHeaders } from './headers'
6
+ import { defineNuxtPlugin , useCookie , useRequestHeaders , useRuntimeConfig } from '#imports'
16
7
import type { Plugin } from '#app'
17
8
18
- const AcceptClientHintsHeaders = {
19
- prefersColorScheme : 'Sec-CH-Prefers-Color-Scheme' ,
20
- prefersReducedMotion : 'Sec-CH-Prefers-Reduced-Motion' ,
21
- prefersReducedTransparency : 'Sec-CH-Prefers-Reduced-Transparency' ,
22
- viewportHeight : 'Sec-CH-Viewport-Height' ,
23
- viewportWidth : 'Sec-CH-Viewport-Width' ,
24
- width : 'Sec-CH-Width' ,
25
- devicePixelRatio : 'Sec-CH-DPR' ,
26
- }
27
-
28
- type AcceptClientHintsHeadersKey = keyof typeof AcceptClientHintsHeaders
29
-
30
- const AcceptClientHintsRequestHeaders = Object . entries ( AcceptClientHintsHeaders ) . reduce ( ( acc , [ key , value ] ) => {
31
- acc [ key as AcceptClientHintsHeadersKey ] = value . toLowerCase ( ) as Lowercase < string >
32
- return acc
33
- } , { } as Record < AcceptClientHintsHeadersKey , Lowercase < string > > )
34
-
35
- const SecChUaMobile = 'Sec-CH-UA-Mobile' . toLowerCase ( ) as Lowercase < string >
36
- const HttpRequestHeaders = Array . from ( Object . values ( AcceptClientHintsRequestHeaders ) ) . concat ( 'user-agent' , 'cookie' , SecChUaMobile )
37
-
38
9
const plugin : Plugin = defineNuxtPlugin ( {
39
10
name : 'http-client-hints:critical-server:plugin' ,
40
11
enforce : 'pre' ,
@@ -44,303 +15,22 @@ const plugin: Plugin = defineNuxtPlugin({
44
15
async setup ( nuxtApp ) {
45
16
const state = useHttpClientHintsState ( )
46
17
const httpClientHints = useRuntimeConfig ( ) . public . httpClientHints as ResolvedHttpClientHintsOptions
47
- const requestHeaders = useRequestHeaders < string > ( HttpRequestHeaders )
48
-
49
- // 1. extract browser info
18
+ const requestHeaders = useRequestHeaders < string > ( CriticalHintsHeaders )
50
19
const userAgent = nuxtApp . ssrContext ?. _httpClientHintsUserAgent as ReturnType < typeof parseUserAgent >
51
- // 2. prepare client hints request
52
- const clientHintsRequest = collectClientHints ( userAgent , httpClientHints . critical ! , requestHeaders )
53
- // 3. write client hints response headers
54
- writeClientHintsResponseHeaders ( clientHintsRequest , httpClientHints . critical ! )
55
- state . value . critical = clientHintsRequest
56
- // 4. send the theme cookie to the client when required
57
- state . value . critical . colorSchemeCookie = writeThemeCookie (
58
- clientHintsRequest ,
59
- httpClientHints . critical ! ,
20
+ state . value . critical = extractCriticalHints (
21
+ httpClientHints ,
22
+ requestHeaders ,
23
+ userAgent ,
24
+ writeHeaders ,
25
+ ( cookieName , path , expires , themeName ) => {
26
+ useCookie ( cookieName , {
27
+ path,
28
+ expires,
29
+ sameSite : 'lax' ,
30
+ } ) . value = themeName
31
+ } ,
60
32
)
61
33
} ,
62
34
} )
63
35
64
36
export default plugin
65
-
66
- type BrowserFeatureAvailable = ( android : boolean , versions : number [ ] ) => boolean
67
- type BrowserFeatures = Record < AcceptClientHintsHeadersKey , BrowserFeatureAvailable >
68
-
69
- // Tests for Browser compatibility
70
- // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Reduced-Motion#browser_compatibility
71
- // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Reduced-Transparency
72
- // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Color-Scheme#browser_compatibility
73
- // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/DPR#browser_compatibility
74
- const chromiumBasedBrowserFeatures : BrowserFeatures = {
75
- prefersColorScheme : ( _ , v ) => v [ 0 ] >= 93 ,
76
- prefersReducedMotion : ( _ , v ) => v [ 0 ] >= 108 ,
77
- prefersReducedTransparency : ( _ , v ) => v [ 0 ] >= 119 ,
78
- viewportHeight : ( _ , v ) => v [ 0 ] >= 108 ,
79
- viewportWidth : ( _ , v ) => v [ 0 ] >= 108 ,
80
- // TODO: check if this is correct, no entry in mozilla docs, using DPR
81
- width : ( _ , v ) => v [ 0 ] >= 46 ,
82
- devicePixelRatio : ( _ , v ) => v [ 0 ] >= 46 ,
83
- }
84
- const allowedBrowsers : [ browser : Browser , features : BrowserFeatures ] [ ] = [
85
- // 'edge',
86
- // 'edge-ios',
87
- [ 'chrome' , chromiumBasedBrowserFeatures ] ,
88
- [ 'edge-chromium' , {
89
- ...chromiumBasedBrowserFeatures ,
90
- devicePixelRatio : ( _ , v ) => v [ 0 ] >= 79 ,
91
- } ] ,
92
- [ 'chromium-webview' , chromiumBasedBrowserFeatures ] ,
93
- [ 'opera' , {
94
- prefersColorScheme : ( android , v ) => v [ 0 ] >= ( android ? 66 : 79 ) ,
95
- prefersReducedMotion : ( android , v ) => v [ 0 ] >= ( android ? 73 : 94 ) ,
96
- prefersReducedTransparency : ( _ , v ) => v [ 0 ] >= 79 ,
97
- viewportHeight : ( android , v ) => v [ 0 ] >= ( android ? 73 : 94 ) ,
98
- viewportWidth : ( android , v ) => v [ 0 ] >= ( android ? 73 : 94 ) ,
99
- // TODO: check if this is correct, no entry in mozilla docs, using DPR
100
- width : ( _ , v ) => v [ 0 ] >= 33 ,
101
- devicePixelRatio : ( _ , v ) => v [ 0 ] >= 33 ,
102
- } ] ,
103
- ]
104
-
105
- const ClientHeaders = [ 'Accept-CH' , 'Vary' , 'Critical-CH' ]
106
-
107
- function lookupClientHints (
108
- userAgent : ReturnType < typeof parseUserAgent > ,
109
- criticalClientHintsConfiguration : CriticalClientHintsConfiguration ,
110
- headers : { [ key in Lowercase < string > ] ?: string | undefined } ,
111
- ) {
112
- const features : CriticalInfo = {
113
- firstRequest : true ,
114
- prefersColorSchemeAvailable : false ,
115
- prefersReducedMotionAvailable : false ,
116
- prefersReducedTransparencyAvailable : false ,
117
- viewportHeightAvailable : false ,
118
- viewportWidthAvailable : false ,
119
- widthAvailable : false ,
120
- devicePixelRatioAvailable : false ,
121
- }
122
-
123
- if ( userAgent == null || userAgent . type !== 'browser' )
124
- return features
125
-
126
- if ( criticalClientHintsConfiguration . prefersColorScheme )
127
- features . prefersColorSchemeAvailable = browserFeatureAvailable ( allowedBrowsers , userAgent , 'prefersColorScheme' )
128
-
129
- if ( criticalClientHintsConfiguration . prefersReducedMotion )
130
- features . prefersReducedMotionAvailable = browserFeatureAvailable ( allowedBrowsers , userAgent , 'prefersReducedMotion' )
131
-
132
- if ( criticalClientHintsConfiguration . prefersReducedTransparency )
133
- features . prefersReducedMotionAvailable = browserFeatureAvailable ( allowedBrowsers , userAgent , 'prefersReducedTransparency' )
134
-
135
- if ( criticalClientHintsConfiguration . viewportSize ) {
136
- features . viewportHeightAvailable = browserFeatureAvailable ( allowedBrowsers , userAgent , 'viewportHeight' )
137
- features . viewportWidthAvailable = browserFeatureAvailable ( allowedBrowsers , userAgent , 'viewportWidth' )
138
- }
139
-
140
- if ( criticalClientHintsConfiguration . width ) {
141
- features . widthAvailable = browserFeatureAvailable ( allowedBrowsers , userAgent , 'width' )
142
- }
143
-
144
- if ( features . viewportWidthAvailable || features . viewportHeightAvailable ) {
145
- // We don't need to include DPR on desktop browsers.
146
- // Since sec-ch-ua-mobile is a low entropy header, we don't need to include it in Accept-CH,
147
- // the user agent will send it always unless blocked by a user agent permission policy, check:
148
- // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA-Mobile
149
- const mobileHeader = lookupHeader (
150
- 'boolean' ,
151
- SecChUaMobile ,
152
- headers ,
153
- )
154
- if ( mobileHeader )
155
- features . devicePixelRatioAvailable = browserFeatureAvailable ( allowedBrowsers , userAgent , 'devicePixelRatio' )
156
- }
157
-
158
- return features
159
- }
160
-
161
- function collectClientHints (
162
- userAgent : ReturnType < typeof parseUserAgent > ,
163
- criticalClientHintsConfiguration : CriticalClientHintsConfiguration ,
164
- headers : { [ key in Lowercase < string > ] ?: string | undefined } ,
165
- ) {
166
- // collect client hints
167
- const hints = lookupClientHints ( userAgent , criticalClientHintsConfiguration , headers )
168
-
169
- if ( criticalClientHintsConfiguration . prefersColorScheme ) {
170
- if ( criticalClientHintsConfiguration . prefersColorSchemeOptions ) {
171
- const cookieName = criticalClientHintsConfiguration . prefersColorSchemeOptions . cookieName
172
- const cookieValue = headers . cookie ?. split ( ';' ) . find ( c => c . trim ( ) . startsWith ( `${ cookieName } =` ) )
173
- if ( cookieValue ) {
174
- const value = cookieValue . split ( '=' ) ?. [ 1 ] . trim ( )
175
- if ( criticalClientHintsConfiguration . prefersColorSchemeOptions . themeNames . includes ( value ) ) {
176
- hints . colorSchemeFromCookie = value
177
- hints . firstRequest = false
178
- }
179
- }
180
- }
181
- if ( ! hints . colorSchemeFromCookie ) {
182
- const value = hints . prefersColorSchemeAvailable
183
- ? headers [ AcceptClientHintsRequestHeaders . prefersColorScheme ] ?. toLowerCase ( )
184
- : undefined
185
- if ( value === 'dark' || value === 'light' || value === 'no-preference' ) {
186
- hints . prefersColorScheme = value
187
- hints . firstRequest = false
188
- }
189
-
190
- // update the color scheme cookie
191
- if ( criticalClientHintsConfiguration . prefersColorSchemeOptions ) {
192
- if ( ! value || value === 'no-preference' ) {
193
- hints . colorSchemeFromCookie = criticalClientHintsConfiguration . prefersColorSchemeOptions . defaultTheme
194
- }
195
- else {
196
- hints . colorSchemeFromCookie = value === 'dark'
197
- ? criticalClientHintsConfiguration . prefersColorSchemeOptions . darkThemeName
198
- : criticalClientHintsConfiguration . prefersColorSchemeOptions . lightThemeName
199
- }
200
- }
201
- }
202
- }
203
-
204
- if ( hints . prefersReducedMotionAvailable && criticalClientHintsConfiguration . prefersReducedMotion ) {
205
- const value = headers [ AcceptClientHintsRequestHeaders . prefersReducedMotion ] ?. toLowerCase ( )
206
- if ( value === 'no-preference' || value === 'reduce' ) {
207
- hints . prefersReducedMotion = value
208
- hints . firstRequest = false
209
- }
210
- }
211
-
212
- if ( hints . prefersReducedTransparencyAvailable && criticalClientHintsConfiguration . prefersReducedTransparency ) {
213
- const value = headers [ AcceptClientHintsRequestHeaders . prefersReducedTransparency ] ?. toLowerCase ( )
214
- if ( value ) {
215
- hints . prefersReducedTransparency = value === 'reduce' ? 'reduce' : 'no-preference'
216
- hints . firstRequest = false
217
- }
218
- }
219
-
220
- if ( hints . viewportHeightAvailable && criticalClientHintsConfiguration . viewportSize ) {
221
- const viewportHeight = lookupHeader (
222
- 'int' ,
223
- AcceptClientHintsRequestHeaders . viewportHeight ,
224
- headers ,
225
- )
226
- if ( typeof viewportHeight === 'number' ) {
227
- hints . firstRequest = false
228
- hints . viewportHeight = viewportHeight
229
- }
230
- else {
231
- hints . viewportHeight = criticalClientHintsConfiguration . clientHeight
232
- }
233
- }
234
- else {
235
- hints . viewportHeight = criticalClientHintsConfiguration . clientHeight
236
- }
237
-
238
- if ( hints . viewportWidthAvailable && criticalClientHintsConfiguration . viewportSize ) {
239
- const viewportWidth = lookupHeader (
240
- 'int' ,
241
- AcceptClientHintsRequestHeaders . viewportWidth ,
242
- headers ,
243
- )
244
- if ( typeof viewportWidth === 'number' ) {
245
- hints . firstRequest = false
246
- hints . viewportWidth = viewportWidth
247
- }
248
- else {
249
- hints . viewportWidth = criticalClientHintsConfiguration . clientWidth
250
- }
251
- }
252
- else {
253
- hints . viewportWidth = criticalClientHintsConfiguration . clientWidth
254
- }
255
-
256
- if ( hints . devicePixelRatioAvailable && criticalClientHintsConfiguration . viewportSize ) {
257
- const devicePixelRatio = lookupHeader (
258
- 'float' ,
259
- AcceptClientHintsRequestHeaders . devicePixelRatio ,
260
- headers ,
261
- )
262
- if ( typeof devicePixelRatio === 'number' ) {
263
- hints . firstRequest = false
264
- try {
265
- hints . devicePixelRatio = devicePixelRatio
266
- if ( ! Number . isNaN ( devicePixelRatio ) && devicePixelRatio > 0 ) {
267
- if ( typeof hints . viewportWidth === 'number' )
268
- hints . viewportWidth = Math . round ( hints . viewportWidth / devicePixelRatio )
269
- if ( typeof hints . viewportHeight === 'number' )
270
- hints . viewportHeight = Math . round ( hints . viewportHeight / devicePixelRatio )
271
- }
272
- }
273
- catch {
274
- // just ignore
275
- }
276
- }
277
- }
278
-
279
- if ( hints . widthAvailable && criticalClientHintsConfiguration . width ) {
280
- const width = lookupHeader (
281
- 'int' ,
282
- AcceptClientHintsRequestHeaders . width ,
283
- headers ,
284
- )
285
- if ( typeof width === 'number' ) {
286
- hints . firstRequest = false
287
- hints . width = width
288
- }
289
- }
290
-
291
- return hints
292
- }
293
-
294
- function writeClientHintsResponseHeaders (
295
- criticalInfo : CriticalInfo ,
296
- criticalClientHintsConfiguration : CriticalClientHintsConfiguration ,
297
- ) {
298
- // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Critical-CH
299
- // Each header listed in the Critical-CH header should also be present in the Accept-CH and Vary headers.
300
- const headers : Record < string , string [ ] > = { }
301
-
302
- if ( criticalClientHintsConfiguration . prefersColorScheme && criticalInfo . prefersColorSchemeAvailable )
303
- writeClientHintHeaders ( ClientHeaders , AcceptClientHintsHeaders . prefersColorScheme , headers )
304
-
305
- if ( criticalClientHintsConfiguration . prefersReducedMotion && criticalInfo . prefersReducedMotionAvailable )
306
- writeClientHintHeaders ( ClientHeaders , AcceptClientHintsHeaders . prefersReducedMotion , headers )
307
-
308
- if ( criticalClientHintsConfiguration . prefersReducedTransparency && criticalInfo . prefersReducedTransparencyAvailable )
309
- writeClientHintHeaders ( ClientHeaders , AcceptClientHintsHeaders . prefersReducedTransparency , headers )
310
-
311
- if ( criticalClientHintsConfiguration . viewportSize && criticalInfo . viewportHeightAvailable && criticalInfo . viewportWidthAvailable ) {
312
- writeClientHintHeaders ( ClientHeaders , AcceptClientHintsHeaders . viewportHeight , headers )
313
- writeClientHintHeaders ( ClientHeaders , AcceptClientHintsHeaders . viewportWidth , headers )
314
- if ( criticalInfo . devicePixelRatioAvailable )
315
- writeClientHintHeaders ( ClientHeaders , AcceptClientHintsHeaders . devicePixelRatio , headers )
316
- }
317
-
318
- if ( criticalClientHintsConfiguration . width && criticalInfo . widthAvailable )
319
- writeClientHintHeaders ( ClientHeaders , AcceptClientHintsHeaders . width , headers )
320
-
321
- writeHeaders ( headers )
322
- }
323
-
324
- function writeThemeCookie (
325
- criticalInfo : CriticalInfo ,
326
- criticalClientHintsConfiguration : CriticalClientHintsConfiguration ,
327
- ) {
328
- if ( ! criticalClientHintsConfiguration . prefersColorScheme || ! criticalClientHintsConfiguration . prefersColorSchemeOptions )
329
- return
330
-
331
- const cookieName = criticalClientHintsConfiguration . prefersColorSchemeOptions . cookieName
332
- const themeName = criticalInfo . colorSchemeFromCookie ?? criticalClientHintsConfiguration . prefersColorSchemeOptions . defaultTheme
333
- const path = criticalClientHintsConfiguration . prefersColorSchemeOptions . baseUrl
334
-
335
- const date = new Date ( )
336
- const expires = new Date ( date . setDate ( date . getDate ( ) + 365 ) )
337
- if ( ! criticalInfo . firstRequest || ! criticalClientHintsConfiguration . reloadOnFirstRequest ) {
338
- useCookie ( cookieName , {
339
- path,
340
- expires,
341
- sameSite : 'lax' ,
342
- } ) . value = themeName
343
- }
344
-
345
- return `${ cookieName } =${ themeName } ; Path=${ path } ; Expires=${ expires . toUTCString ( ) } ; SameSite=Lax`
346
- }
0 commit comments