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'
74import { 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'
167import type { Plugin } from '#app'
178
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-
389const plugin : Plugin = defineNuxtPlugin ( {
3910 name : 'http-client-hints:critical-server:plugin' ,
4011 enforce : 'pre' ,
@@ -44,303 +15,22 @@ const plugin: Plugin = defineNuxtPlugin({
4415 async setup ( nuxtApp ) {
4516 const state = useHttpClientHintsState ( )
4617 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 )
5019 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+ } ,
6032 )
6133 } ,
6234} )
6335
6436export 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