Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/runtime/plugin.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,22 @@ export default defineNuxtPlugin(async (nuxtApp) => {
headers,
})

// Set Client Hints response headers when enabled.
const { clientHints } = viewportOptions.value

if (clientHints && nuxtApp.ssrContext?.event) {
const hints = typeof clientHints === 'object' ? clientHints : {}

if (hints.viewportWidth) {
const res = nuxtApp.ssrContext.event.node.res

res.setHeader('Accept-CH', 'Sec-CH-Viewport-Width')

if (hints.critical) {
res.setHeader('Critical-CH', 'Sec-CH-Viewport-Width')
}
}
}

return nuxtApp.provide('viewport', manager)
})
23 changes: 23 additions & 0 deletions src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export type ViewportOptions = {
*/
breakpoints: Record<string, number>

/**
* Enable HTTP Client Hints for improved cold-start detection.
*
* When `true`, uses `Sec-CH-UA-Mobile` (sent by default on Chromium).
* Pass an object to also request `Sec-CH-Viewport-Width` for pixel-accurate detection.
*/
clientHints?: boolean | ClientHintsOptions

/**
* Cookie options.
*/
Expand Down Expand Up @@ -84,6 +92,21 @@ declare module 'vue-router' {
}
}

/**
* Client Hints options.
*/
export type ClientHintsOptions = {
/**
* Set Critical-CH header so the browser retries the request with hints if missing.
*/
critical?: boolean

/**
* Request Sec-CH-Viewport-Width from the browser via Accept-CH response header.
*/
viewportWidth?: boolean
}

interface PluginInjection {
$viewport: ViewportManager
}
Expand Down
58 changes: 58 additions & 0 deletions src/runtime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@ export async function detectBreakpoint(options: ViewportOptions, input: Partial<

let deviceType = ''

// Detect the device by Client Hints.
if (options.clientHints && input.headers) {
const hintsOptions = typeof options.clientHints === 'object' ? options.clientHints : {}

// Sec-CH-Viewport-Width gives pixel-accurate detection.
if (hintsOptions.viewportWidth) {
const viewportWidth = input.headers['sec-ch-viewport-width']

if (viewportWidth) {
const width = Number(viewportWidth)

if (!Number.isNaN(width) && width > 0) {
const breakpoint = resolveBreakpointFromWidth(options, width)

if (breakpoint) {
return breakpoint
}
}
}
}

// Sec-CH-UA-Mobile is sent by default on Chromium (no Accept-CH needed).
const uaMobile = input.headers['sec-ch-ua-mobile']

if (uaMobile === '?1' && 'mobile' in options.defaultBreakpoints) {
return options.defaultBreakpoints.mobile
}
}

// Detect the device by headers.
if (input.headers) {
// Amazon CloudFront.
Expand Down Expand Up @@ -93,3 +122,32 @@ export function parseCookie(input: string): Record<string, string> {

return Object.fromEntries(input.split(/; */).map(cookie => cookie.split('=', 2)))
}

export function resolveBreakpointFromWidth(options: ViewportOptions, width: number): string | undefined {
const entries = Object.entries(options.breakpoints).sort((a, b) => a[1] - b[1])

if (!entries.length) {
return undefined
}

if (options.feature === 'minWidth') {
// Find the largest breakpoint value <= width (first breakpoint catches anything >= 1px)
if (width >= 1) {
for (let i = entries.length - 1; i >= 0; i--) {
if (width >= entries[i][1] || i === 0) {
return entries[i][0]
}
}
}
}
else {
// Find the smallest breakpoint value >= width
for (const [name, size] of entries) {
if (width <= size) {
return name
}
}
}

return undefined
}