diff --git a/src/runtime/plugin.server.ts b/src/runtime/plugin.server.ts index b609664..0e62c27 100644 --- a/src/runtime/plugin.server.ts +++ b/src/runtime/plugin.server.ts @@ -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) }) diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 6c2b8b0..2dc8dd8 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -23,6 +23,14 @@ export type ViewportOptions = { */ breakpoints: Record + /** + * 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. */ @@ -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 } diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 980f07f..7b1f109 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -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. @@ -93,3 +122,32 @@ export function parseCookie(input: string): Record { 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 +}