Skip to content

Commit 835b9b1

Browse files
committed
feat: add Nitro event handler
1 parent 54555fa commit 835b9b1

17 files changed

+868
-651
lines changed

package.json

+5
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@
6262
"vitest": "^2.1.1",
6363
"vue-tsc": "^2.1.6"
6464
},
65+
"build": {
66+
"externals": [
67+
"defu"
68+
]
69+
},
6570
"stackblitz": {
6671
"installDependencies": false,
6772
"startCommand": "pnpm install && pnpm dev:prepare && pnpm dev"

playground/nuxt.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export default defineNuxtConfig({
1414
viewportSize: true,
1515
prefersColorScheme: true,
1616
},
17+
serverImages: true,
1718
},
1819

1920
})
+18-328
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,11 @@
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'
74
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'
167
import 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-
389
const 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

6436
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

Comments
 (0)