Skip to content

Commit a6bab29

Browse files
committed
chore: add network hints
1 parent 34e3fde commit a6bab29

File tree

6 files changed

+210
-38
lines changed

6 files changed

+210
-38
lines changed

playground/nuxt.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export default defineNuxtConfig({
77
detectBrowser: true,
88
detectOS: 'windows-11',
99
device: 'memory',
10+
network: ['savedata', 'downlink', 'ect', 'rtt'],
1011
critical: {
1112
width: true,
1213
viewportSize: true,

src/runtime/plugins/critical.server.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Browser } from 'detect-browser-es'
22
import { parseUserAgent } from 'detect-browser-es'
33
import type {
44
ResolvedHttpClientHintsOptions,
5-
CriticalClientHints,
5+
CriticalInfo,
66
CriticalClientHintsConfiguration,
77
} from '../shared-types/types'
88
import { useHttpClientHintsState } from './state'
@@ -54,9 +54,9 @@ export default defineNuxtPlugin({
5454
const clientHintsRequest = collectClientHints(userAgent, httpClientHints.critical!, requestHeaders)
5555
// 3. write client hints response headers
5656
writeClientHintsResponseHeaders(clientHintsRequest, httpClientHints.critical!)
57-
state.value.clientHints = clientHintsRequest
57+
state.value.critical = clientHintsRequest
5858
// 4. send the theme cookie to the client when required
59-
state.value.clientHints.colorSchemeCookie = writeThemeCookie(
59+
state.value.critical.colorSchemeCookie = writeThemeCookie(
6060
clientHintsRequest,
6161
httpClientHints.critical!,
6262
)
@@ -134,7 +134,7 @@ function lookupClientHints(
134134
criticalClientHintsConfiguration: CriticalClientHintsConfiguration,
135135
headers: { [key in Lowercase<string>]?: string | undefined },
136136
) {
137-
const features: CriticalClientHints = {
137+
const features: CriticalInfo = {
138138
firstRequest: true,
139139
prefersColorSchemeAvailable: false,
140140
prefersReducedMotionAvailable: false,
@@ -189,7 +189,7 @@ function collectClientHints(
189189
headers: { [key in Lowercase<string>]?: string | undefined },
190190
) {
191191
// collect client hints
192-
const hints: CriticalClientHints = lookupClientHints(userAgent, criticalClientHintsConfiguration, headers)
192+
const hints = lookupClientHints(userAgent, criticalClientHintsConfiguration, headers)
193193

194194
if (criticalClientHintsConfiguration.prefersColorScheme) {
195195
if (criticalClientHintsConfiguration.prefersColorSchemeOptions) {
@@ -317,49 +317,49 @@ function collectClientHints(
317317
}
318318

319319
function writeClientHintsResponseHeaders(
320-
criticalClientHints: CriticalClientHints,
320+
criticalInfo: CriticalInfo,
321321
criticalClientHintsConfiguration: CriticalClientHintsConfiguration,
322322
) {
323323
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Critical-CH
324324
// Each header listed in the Critical-CH header should also be present in the Accept-CH and Vary headers.
325325
const headers: Record<string, string[]> = {}
326326

327-
if (criticalClientHintsConfiguration.prefersColorScheme && criticalClientHints.prefersColorSchemeAvailable)
327+
if (criticalClientHintsConfiguration.prefersColorScheme && criticalInfo.prefersColorSchemeAvailable)
328328
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.prefersColorScheme, headers)
329329

330-
if (criticalClientHintsConfiguration.prefersReducedMotion && criticalClientHints.prefersReducedMotionAvailable)
330+
if (criticalClientHintsConfiguration.prefersReducedMotion && criticalInfo.prefersReducedMotionAvailable)
331331
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.prefersReducedMotion, headers)
332332

333-
if (criticalClientHintsConfiguration.prefersReducedTransparency && criticalClientHints.prefersReducedTransparencyAvailable)
333+
if (criticalClientHintsConfiguration.prefersReducedTransparency && criticalInfo.prefersReducedTransparencyAvailable)
334334
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.prefersReducedTransparency, headers)
335335

336-
if (criticalClientHintsConfiguration.viewportSize && criticalClientHints.viewportHeightAvailable && criticalClientHints.viewportWidthAvailable) {
336+
if (criticalClientHintsConfiguration.viewportSize && criticalInfo.viewportHeightAvailable && criticalInfo.viewportWidthAvailable) {
337337
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.viewportHeight, headers)
338338
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.viewportWidth, headers)
339-
if (criticalClientHints.devicePixelRatioAvailable)
339+
if (criticalInfo.devicePixelRatioAvailable)
340340
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.devicePixelRatio, headers)
341341
}
342342

343-
if (criticalClientHintsConfiguration.width && criticalClientHints.widthAvailable)
343+
if (criticalClientHintsConfiguration.width && criticalInfo.widthAvailable)
344344
writeClientHintHeaders(ClientHeaders, AcceptClientHintsHeaders.width, headers)
345345

346346
writeHeaders(headers)
347347
}
348348

349349
function writeThemeCookie(
350-
criticalClientHints: CriticalClientHints,
350+
criticalInfo: CriticalInfo,
351351
criticalClientHintsConfiguration: CriticalClientHintsConfiguration,
352352
) {
353353
if (!criticalClientHintsConfiguration.prefersColorScheme || !criticalClientHintsConfiguration.prefersColorSchemeOptions)
354354
return
355355

356356
const cookieName = criticalClientHintsConfiguration.prefersColorSchemeOptions.cookieName
357-
const themeName = criticalClientHints.colorSchemeFromCookie ?? criticalClientHintsConfiguration.prefersColorSchemeOptions.defaultTheme
357+
const themeName = criticalInfo.colorSchemeFromCookie ?? criticalClientHintsConfiguration.prefersColorSchemeOptions.defaultTheme
358358
const path = criticalClientHintsConfiguration.prefersColorSchemeOptions.baseUrl
359359

360360
const date = new Date()
361361
const expires = new Date(date.setDate(date.getDate() + 365))
362-
if (!criticalClientHints.firstRequest || !criticalClientHintsConfiguration.reloadOnFirstRequest) {
362+
if (!criticalInfo.firstRequest || !criticalClientHintsConfiguration.reloadOnFirstRequest) {
363363
useCookie(cookieName, {
364364
path,
365365
expires,

src/runtime/plugins/detect.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,13 @@ export default defineNuxtPlugin({
6363
httpHeaders: requestHeaders,
6464
})
6565
if (browserInfo) {
66-
state.value.browserInfo = JSON.parse(JSON.stringify(browserInfo))
66+
state.value.browser = JSON.parse(JSON.stringify(browserInfo))
6767
}
6868
}
6969
else if (userAgentHeader) {
7070
const browserInfo = detect(userAgentHeader)
7171
if (browserInfo) {
72-
state.value.browserInfo = JSON.parse(JSON.stringify(browserInfo))
72+
state.value.browser = JSON.parse(JSON.stringify(browserInfo))
7373
}
7474
}
7575

src/runtime/plugins/device.server.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Browser } from 'detect-browser-es'
22
import { parseUserAgent } from 'detect-browser-es'
33
import type {
4-
DeviceClientHints,
4+
DeviceInfo,
55
DeviceHints,
66
ResolvedHttpClientHintsOptions,
77
} from '../shared-types/types'
@@ -46,7 +46,7 @@ export default defineNuxtPlugin({
4646
const clientHintsRequest = collectClientHints(userAgent, httpClientHints.device!, requestHeaders)
4747
// 3. write client hints response headers
4848
writeClientHintsResponseHeaders(clientHintsRequest, httpClientHints.device!)
49-
state.value.deviceInfo = clientHintsRequest
49+
state.value.device = clientHintsRequest
5050
},
5151
})
5252

@@ -99,8 +99,8 @@ function browserFeatureAvailable(userAgent: ReturnType<typeof parseUserAgent>, f
9999
function lookupClientHints(
100100
userAgent: ReturnType<typeof parseUserAgent>,
101101
deviceHints: DeviceHints[],
102-
) {
103-
const features: DeviceClientHints = {
102+
): DeviceInfo {
103+
const features: DeviceInfo = {
104104
memoryAvailable: false,
105105
}
106106

@@ -129,7 +129,6 @@ function collectClientHints(
129129
AcceptClientHintsRequestHeaders[hint],
130130
headers,
131131
)
132-
console.log({ hint, value })
133132
if (typeof value !== 'undefined') {
134133
hints[hint] = value as typeof hints[typeof hint]
135134
}
@@ -140,13 +139,13 @@ function collectClientHints(
140139
}
141140

142141
function writeClientHintsResponseHeaders(
143-
deviceClientHints: DeviceClientHints,
142+
deviceInfo: DeviceInfo,
144143
deviceHints: DeviceHints[],
145144
) {
146145
const headers: Record<string, string[]> = {}
147146

148147
for (const hint of deviceHints) {
149-
if (deviceClientHints[`${hint}Available`]) {
148+
if (deviceInfo[`${hint}Available`]) {
150149
writeClientHintHeaders(ClientHeaders, DeviceClientHintsHeaders[hint], headers)
151150
}
152151
}

src/runtime/plugins/network.server.ts

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
1+
import type { Browser } from 'detect-browser-es'
2+
import { parseUserAgent } from 'detect-browser-es'
3+
import type { NetworkInfo, NetworkHints, ResolvedHttpClientHintsOptions } from '../shared-types/types'
14
import { useHttpClientHintsState } from './state'
2-
import { defineNuxtPlugin } from '#imports'
5+
import type { GetHeaderType } from './headers'
6+
import { lookupHeader, writeClientHintHeaders, writeHeaders } from './headers'
7+
import { defineNuxtPlugin, useRequestHeaders, useRuntimeConfig } from '#imports'
8+
9+
const NetworkClientHintsHeaders: Record<NetworkHints, string> = {
10+
savedata: 'Save-Data',
11+
downlink: 'Downlink',
12+
ect: 'ECT',
13+
rtt: 'RTT',
14+
}
15+
16+
const NetworkClientHintsHeadersTypes: Record<NetworkHints, GetHeaderType> = {
17+
savedata: 'string',
18+
downlink: 'float',
19+
ect: 'string',
20+
rtt: 'int',
21+
}
22+
23+
type NetworkClientHintsHeadersKey = keyof typeof NetworkClientHintsHeaders
24+
25+
const AcceptClientHintsRequestHeaders = Object.entries(NetworkClientHintsHeaders).reduce((acc, [key, value]) => {
26+
acc[key as NetworkClientHintsHeadersKey] = value.toLowerCase() as Lowercase<string>
27+
return acc
28+
}, {} as Record<NetworkClientHintsHeadersKey, Lowercase<string>>)
29+
30+
const HttpRequestHeaders = Array.from(Object.values(NetworkClientHintsHeaders)).concat('user-agent')
331

432
export default defineNuxtPlugin({
533
name: 'http-client-hints:network-server:plugin',
@@ -9,5 +37,138 @@ export default defineNuxtPlugin({
937
dependsOn: ['http-client-hints:init-server:plugin'],
1038
setup() {
1139
const state = useHttpClientHintsState()
40+
const httpClientHints = useRuntimeConfig().public.httpClientHints as ResolvedHttpClientHintsOptions
41+
const requestHeaders = useRequestHeaders<string>(HttpRequestHeaders)
42+
const userAgentHeader = requestHeaders['user-agent']
43+
44+
// 1. extract browser info
45+
const userAgent = userAgentHeader
46+
? parseUserAgent(userAgentHeader)
47+
: null
48+
// 2. prepare client hints request
49+
const clientHintsRequest = collectClientHints(userAgent, httpClientHints.network!, requestHeaders)
50+
// 3. write client hints response headers
51+
writeClientHintsResponseHeaders(clientHintsRequest, httpClientHints.network!)
52+
state.value.network = clientHintsRequest
1253
},
1354
})
55+
56+
type BrowserFeatureAvailable = (android: boolean, versions: number[]) => boolean
57+
type BrowserFeatures = Record<NetworkClientHintsHeadersKey, BrowserFeatureAvailable>
58+
59+
// Tests for Browser compatibility
60+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Save-Data
61+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Downlink
62+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ECT
63+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/RTT
64+
const chromiumBasedBrowserFeatures: BrowserFeatures = {
65+
savedata: (android, v) => v[0] >= 49,
66+
downlink: (_, v) => v[0] >= 67,
67+
ect: (_, v) => v[0] >= 67,
68+
rtt: (_, v) => v[0] >= 67,
69+
}
70+
const allowedBrowsers: [browser: Browser, features: BrowserFeatures][] = [
71+
['chrome', chromiumBasedBrowserFeatures],
72+
['edge-chromium', {
73+
savedata: (_, v) => v[0] >= 79,
74+
downlink: (_, v) => v[0] >= 79,
75+
ect: (_, v) => v[0] >= 79,
76+
rtt: (_, v) => v[0] >= 79,
77+
}],
78+
['chromium-webview', chromiumBasedBrowserFeatures],
79+
['opera', {
80+
savedata: (_, v) => v[0] >= 35,
81+
downlink: (android, v) => v[0] >= (android ? 48 : 54),
82+
ect: (android, v) => v[0] >= (android ? 48 : 54),
83+
rtt: (android, v) => v[0] >= (android ? 48 : 54),
84+
}],
85+
]
86+
87+
const ClientHeaders = ['Accept-CH', 'Vary']
88+
89+
function browserFeatureAvailable(userAgent: ReturnType<typeof parseUserAgent>, feature: NetworkClientHintsHeadersKey) {
90+
if (userAgent == null || userAgent.type !== 'browser')
91+
return false
92+
93+
try {
94+
const browserName = userAgent.name
95+
const android = userAgent.os?.toLowerCase().startsWith('android') ?? false
96+
const versions = userAgent.version.split('.').map(v => Number.parseInt(v))
97+
return allowedBrowsers.some(([name, check]) => {
98+
if (browserName !== name)
99+
return false
100+
101+
try {
102+
return check[feature](android, versions)
103+
}
104+
catch {
105+
return false
106+
}
107+
})
108+
}
109+
catch {
110+
return false
111+
}
112+
}
113+
114+
function lookupClientHints(
115+
userAgent: ReturnType<typeof parseUserAgent>,
116+
networkHints: NetworkHints[],
117+
) {
118+
const features: NetworkInfo = {
119+
savedataAvailable: false,
120+
downlinkAvailable: false,
121+
ectAvailable: false,
122+
rttAvailable: false,
123+
}
124+
125+
if (userAgent == null || userAgent.type !== 'browser')
126+
return features
127+
128+
for (const hint of networkHints) {
129+
features[`${hint}Available`] = browserFeatureAvailable(userAgent, hint)
130+
}
131+
132+
return features
133+
}
134+
135+
function collectClientHints(
136+
userAgent: ReturnType<typeof parseUserAgent>,
137+
networkHints: NetworkHints[],
138+
headers: { [key in Lowercase<string>]?: string | undefined },
139+
) {
140+
// collect client hints
141+
const hints = lookupClientHints(userAgent, networkHints)
142+
143+
for (const hint of networkHints) {
144+
if (hints[`${hint}Available`]) {
145+
const value = lookupHeader(
146+
NetworkClientHintsHeadersTypes[hint],
147+
AcceptClientHintsRequestHeaders[hint],
148+
headers,
149+
)
150+
console.log({ hint, value })
151+
if (typeof value !== 'undefined') {
152+
// @ts-expect-error Type 'number | "on" | NetworkECT | undefined' is not assignable to type 'undefined'.
153+
hints[hint] = value as typeof hints[typeof hint]
154+
}
155+
}
156+
}
157+
158+
return hints
159+
}
160+
161+
function writeClientHintsResponseHeaders(
162+
networkInfo: NetworkInfo,
163+
networkHints: NetworkHints[],
164+
) {
165+
const headers: Record<string, string[]> = {}
166+
167+
for (const hint of networkHints) {
168+
if (networkInfo[`${hint}Available`]) {
169+
writeClientHintHeaders(ClientHeaders, NetworkClientHintsHeaders[hint], headers)
170+
}
171+
}
172+
173+
writeHeaders(headers)
174+
}

0 commit comments

Comments
 (0)