Skip to content

Commit e711fbe

Browse files
committed
chore: add device hints + add headers module
1 parent ba73147 commit e711fbe

File tree

6 files changed

+196
-38
lines changed

6 files changed

+196
-38
lines changed

playground/nuxt.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default defineNuxtConfig({
66
httpClientHints: {
77
detectBrowser: true,
88
detectOS: 'windows-11',
9+
device: 'memory',
910
critical: {
1011
width: true,
1112
prefersColorScheme: true,

src/runtime/plugins/critical.server.ts

+9-33
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
import type { Browser } from 'detect-browser-es'
22
import { parseUserAgent } from 'detect-browser-es'
3-
import { appendHeader } from 'h3'
43
import type {
54
ResolvedHttpClientHintsOptions,
65
CriticalClientHints,
76
CriticalClientHintsConfiguration,
87
} from '../shared-types/types'
98
import { useHttpClientHintsState } from './state'
9+
import { writeClientHintHeaders, writeHeaders } from './headers'
1010
import {
1111
defineNuxtPlugin,
1212
useCookie,
13-
useNuxtApp,
1413
useRuntimeConfig,
15-
useRequestEvent,
1614
useRequestHeaders,
1715
} from '#imports'
1816

@@ -307,12 +305,6 @@ function collectClientHints(
307305
return hints
308306
}
309307

310-
function writeClientHintHeaders(key: string, headers: Record<string, string[]>) {
311-
ClientHeaders.forEach((header) => {
312-
headers[header] = (headers[header] ? headers[header] : []).concat(key)
313-
})
314-
}
315-
316308
function writeClientHintsResponseHeaders(
317309
criticalClientHints: CriticalClientHints,
318310
criticalClientHintsConfiguration: CriticalClientHintsConfiguration,
@@ -322,41 +314,25 @@ function writeClientHintsResponseHeaders(
322314
const headers: Record<string, string[]> = {}
323315

324316
if (criticalClientHintsConfiguration.prefersColorScheme && criticalClientHints.prefersColorSchemeAvailable)
325-
writeClientHintHeaders(AcceptClientHintsHeaders.prefersColorScheme, headers)
317+
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.prefersColorScheme, headers)
326318

327319
if (criticalClientHintsConfiguration.prefersReducedMotion && criticalClientHints.prefersReducedMotionAvailable)
328-
writeClientHintHeaders(AcceptClientHintsHeaders.prefersReducedMotion, headers)
320+
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.prefersReducedMotion, headers)
329321

330322
if (criticalClientHintsConfiguration.prefersReducedTransparency && criticalClientHints.prefersReducedTransparencyAvailable)
331-
writeClientHintHeaders(AcceptClientHintsHeaders.prefersReducedTransparency, headers)
323+
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.prefersReducedTransparency, headers)
332324

333325
if (criticalClientHintsConfiguration.viewportSize && criticalClientHints.viewportHeightAvailable && criticalClientHints.viewportWidthAvailable) {
334-
writeClientHintHeaders(AcceptClientHintsHeaders.viewportHeight, headers)
335-
writeClientHintHeaders(AcceptClientHintsHeaders.viewportWidth, headers)
326+
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.viewportHeight, headers)
327+
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.viewportWidth, headers)
336328
if (criticalClientHints.devicePixelRatioAvailable)
337-
writeClientHintHeaders(AcceptClientHintsHeaders.devicePixelRatio, headers)
329+
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.devicePixelRatio, headers)
338330
}
339331

340332
if (criticalClientHintsConfiguration.width && criticalClientHints.widthAvailable)
341-
writeClientHintHeaders(AcceptClientHintsHeaders.width, headers)
342-
343-
if (Object.keys(headers).length === 0)
344-
return
333+
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.width, headers)
345334

346-
const nuxtApp = useNuxtApp()
347-
const callback = () => {
348-
const event = useRequestEvent(nuxtApp)
349-
if (event) {
350-
for (const [key, value] of Object.entries(headers)) {
351-
appendHeader(event, key, value)
352-
}
353-
}
354-
}
355-
const unhook = nuxtApp.hooks.hookOnce('app:rendered', callback)
356-
nuxtApp.hooks.hookOnce('app:error', () => {
357-
unhook()
358-
return callback()
359-
})
335+
writeHeaders(headers)
360336
}
361337

362338
function writeThemeCookie(

src/runtime/plugins/device.server.ts

+142-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
1+
import type { Browser } from 'detect-browser-es'
2+
import { parseUserAgent } from 'detect-browser-es'
3+
import type {
4+
DeviceClientHints,
5+
DeviceHints,
6+
ResolvedHttpClientHintsOptions,
7+
} from '../shared-types/types'
18
import { useHttpClientHintsState } from './state'
2-
import { defineNuxtPlugin } from '#imports'
9+
import { writeClientHintHeaders, writeHeaders } from './headers'
10+
import { defineNuxtPlugin, useRequestHeaders, useRuntimeConfig } from '#imports'
11+
12+
const DeviceClientHintsHeaders: Record<DeviceHints, string> = {
13+
memory: 'Device-Memory',
14+
}
15+
16+
type DeviceClientHintsHeadersKey = keyof typeof DeviceClientHintsHeaders
17+
18+
const AcceptClientHintsRequestHeaders = Object.entries(DeviceClientHintsHeaders).reduce((acc, [key, value]) => {
19+
acc[key as DeviceClientHintsHeadersKey] = value.toLowerCase() as Lowercase<string>
20+
return acc
21+
}, {} as Record<DeviceClientHintsHeadersKey, Lowercase<string>>)
22+
23+
const HttpRequestHeaders = Array.from(Object.values(DeviceClientHintsHeaders)).concat('user-agent')
324

425
export default defineNuxtPlugin({
526
name: 'http-client-hints:device-server:plugin',
@@ -9,5 +30,125 @@ export default defineNuxtPlugin({
930
dependsOn: ['http-client-hints:init-server:plugin'],
1031
setup() {
1132
const state = useHttpClientHintsState()
33+
const httpClientHints = useRuntimeConfig().public.httpClientHints as ResolvedHttpClientHintsOptions
34+
const requestHeaders = useRequestHeaders<string>(HttpRequestHeaders)
35+
const userAgentHeader = requestHeaders['user-agent']
36+
37+
// 1. extract browser info
38+
const userAgent = userAgentHeader
39+
? parseUserAgent(userAgentHeader)
40+
: null
41+
// 2. prepare client hints request
42+
const clientHintsRequest = collectClientHints(userAgent, httpClientHints.device!, requestHeaders)
43+
// 3. write client hints response headers
44+
writeClientHintsResponseHeaders(clientHintsRequest, httpClientHints.device!)
45+
state.value.deviceInfo = clientHintsRequest
1246
},
1347
})
48+
49+
type BrowserFeatureAvailable = (android: boolean, versions: number[]) => boolean
50+
type BrowserFeatures = Record<DeviceClientHintsHeadersKey, BrowserFeatureAvailable>
51+
52+
// Tests for Browser compatibility
53+
// https://developer.mozilla.org/en-US/docs/Web/API/Device_Memory_API
54+
const chromiumBasedBrowserFeatures: BrowserFeatures = {
55+
memory: (_, v) => v[0] >= 63,
56+
}
57+
const allowedBrowsers: [browser: Browser, features: BrowserFeatures][] = [
58+
['chrome', chromiumBasedBrowserFeatures],
59+
['edge-chromium', {
60+
memory: (_, v) => v[0] >= 79,
61+
}],
62+
['chromium-webview', chromiumBasedBrowserFeatures],
63+
['opera', {
64+
memory: (android, v) => v[0] >= (android ? 50 : 46),
65+
}],
66+
]
67+
68+
const ClientHeaders = ['Accept-CH']
69+
70+
function browserFeatureAvailable(userAgent: ReturnType<typeof parseUserAgent>, feature: DeviceClientHintsHeadersKey) {
71+
if (userAgent == null || userAgent.type !== 'browser')
72+
return false
73+
74+
try {
75+
const browserName = userAgent.name
76+
const android = userAgent.os?.toLowerCase().startsWith('android') ?? false
77+
const versions = userAgent.version.split('.').map(v => Number.parseInt(v))
78+
return allowedBrowsers.some(([name, check]) => {
79+
if (browserName !== name)
80+
return false
81+
82+
try {
83+
return check[feature](android, versions)
84+
}
85+
catch {
86+
return false
87+
}
88+
})
89+
}
90+
catch {
91+
return false
92+
}
93+
}
94+
95+
function lookupClientHints(
96+
userAgent: ReturnType<typeof parseUserAgent>,
97+
deviceHints: DeviceHints[],
98+
) {
99+
const features: DeviceClientHints = {
100+
memoryAvailable: false,
101+
}
102+
103+
if (userAgent == null || userAgent.type !== 'browser')
104+
return features
105+
106+
for (const hint of deviceHints) {
107+
features[`${hint}Available`] = browserFeatureAvailable(userAgent, hint)
108+
}
109+
110+
return features
111+
}
112+
113+
function collectClientHints(
114+
userAgent: ReturnType<typeof parseUserAgent>,
115+
deviceHints: DeviceHints[],
116+
headers: { [key in Lowercase<string>]?: string | undefined },
117+
) {
118+
// collect client hints
119+
const hints = lookupClientHints(userAgent, deviceHints)
120+
121+
for (const hint of deviceHints) {
122+
// TODO: review this logic, we need some helpers to parse headers
123+
if (hint === 'memory') {
124+
if (hints.memoryAvailable) {
125+
const header = headers[AcceptClientHintsRequestHeaders.memory]
126+
if (header) {
127+
try {
128+
hints.memory = Number.parseFloat(header)
129+
}
130+
catch {
131+
// just ignore
132+
}
133+
}
134+
}
135+
}
136+
}
137+
138+
return hints
139+
}
140+
141+
function writeClientHintsResponseHeaders(
142+
deviceClientHints: DeviceClientHints,
143+
deviceHints: DeviceHints[],
144+
) {
145+
const headers: Record<string, string[]> = {}
146+
147+
for (const hint of deviceHints) {
148+
if (deviceClientHints[`${hint}Available`]) {
149+
writeClientHintHeaders(ClientHeaders, DeviceClientHintsHeaders[hint], headers)
150+
}
151+
}
152+
153+
writeHeaders(headers)
154+
}

src/runtime/plugins/headers.ts

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { appendHeader } from 'h3'
2+
import { useNuxtApp, useRequestEvent } from '#imports'
3+
4+
export function writeClientHintHeaders(headerNames: string[], key: string, headers: Record<string, string[]>) {
5+
headerNames.forEach((header) => {
6+
headers[header] = (headers[header] ? headers[header] : []).concat(key)
7+
})
8+
}
9+
10+
export function writeHeaders(headers: Record<string, string[]>) {
11+
if (Object.keys(headers).length === 0)
12+
return
13+
14+
const nuxtApp = useNuxtApp()
15+
const callback = () => {
16+
const event = useRequestEvent(nuxtApp)
17+
if (event) {
18+
for (const [key, value] of Object.entries(headers)) {
19+
appendHeader(event, key, value)
20+
}
21+
}
22+
}
23+
const unhook = nuxtApp.hooks.hookOnce('app:rendered', callback)
24+
nuxtApp.hooks.hookOnce('app:error', () => {
25+
unhook()
26+
return callback()
27+
})
28+
}

src/runtime/shared-types/types.ts

+15-3
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ export type NetworkHints = 'Save-Data' | 'Downlink' | 'ECT' | 'RTT'
1111
/**
1212
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#client_hints
1313
*/
14-
export type DeviceHints = 'Device-Memory'
14+
export type DeviceHints = 'memory'
1515

16-
export interface CriticalClientHintRequestFeatures {
16+
export interface CriticalClientHintsRequestFeatures {
1717
firstRequest: boolean
1818
prefersColorSchemeAvailable: boolean
1919
prefersReducedMotionAvailable: boolean
@@ -23,7 +23,7 @@ export interface CriticalClientHintRequestFeatures {
2323
widthAvailable: boolean
2424
devicePixelRatioAvailable: boolean
2525
}
26-
export interface CriticalClientHints extends CriticalClientHintRequestFeatures {
26+
export interface CriticalClientHints extends CriticalClientHintsRequestFeatures {
2727
prefersColorScheme?: 'dark' | 'light' | 'no-preference'
2828
prefersReducedMotion?: 'no-preference' | 'reduce'
2929
prefersReducedTransparency?: 'no-preference' | 'reduce'
@@ -35,6 +35,13 @@ export interface CriticalClientHints extends CriticalClientHintRequestFeatures {
3535
colorSchemeCookie?: string
3636
}
3737

38+
export interface DeviceClientHintsRequestFeatures {
39+
memoryAvailable: boolean
40+
}
41+
export interface DeviceClientHints extends DeviceClientHintsRequestFeatures {
42+
memory?: number
43+
}
44+
3845
export interface BrowserInfo {
3946
type: DetectedInfoType
4047
bot?: boolean
@@ -44,8 +51,13 @@ export interface BrowserInfo {
4451
ua?: UserAgentDataInfo | null
4552
}
4653

54+
export interface DeviceInfo {
55+
memory?: number
56+
}
57+
4758
export interface HttpClientHintsState {
4859
browserInfo?: BrowserInfo
60+
deviceInfo?: DeviceInfo
4961
userAgentData?: UserAgentDataInfo
5062
clientHints?: CriticalClientHints
5163
}

src/utils/configuration.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export function configure(resolver: Resolver, options: HttpClientHintsOptions, n
7575
}
7676
if (device) {
7777
if (device === true) {
78-
resolvedOptions.device.push('Device-Memory')
78+
resolvedOptions.device.push('memory')
7979
}
8080
else if (Array.isArray(device)) {
8181
resolvedOptions.device.push(...device)

0 commit comments

Comments
 (0)