From d4fab2605e790e4f0b64214daa759295dc7af0b4 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 10 Oct 2026 16:47:28 +0100 Subject: [PATCH 1/3] fix: detect CloudFront device headers without relying on user-agent CloudFront only replaces the viewer's `User-Agent` with `Amazon CloudFront` when the header is not part of the cache/origin request policy. When it is forwarded (e.g. with an `AllViewer` policy), the module ignored the `CloudFront-Is-*-Viewer` headers and fell back to user-agent parsing. Detect the CloudFront device headers by presence instead, so detection works regardless of the user agent. The headers are still prioritized over user-agent parsing and the existing mobile/tablet/desktop precedence is preserved. Closes #141 --- README.md | 8 +++-- src/runtime/generateFlags.ts | 16 +++++++++- test/basic.test.ts | 57 +++++++++++++++++++++++++++--------- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6c8b199a..9bad79fd 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ To detect the device on each request, deploy with `nuxt build` instead. For a cl ## Amazon CloudFront Support -If the user agent is `Amazon CloudFront`, the module checks for the following headers: +When [CloudFront device detection](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-device-type) is enabled, the module checks for the following headers, regardless of the incoming user agent: - `CloudFront-Is-Android-Viewer` - `CloudFront-Is-Desktop-Viewer` @@ -99,10 +99,12 @@ If the user agent is `Amazon CloudFront`, the module checks for the following he - `CloudFront-Is-Mobile-Viewer` - `CloudFront-Is-Tablet-Viewer` +These headers take precedence over user agent parsing. This works both when CloudFront forwards the viewer's browser user agent and when it replaces it with `Amazon CloudFront`. + Read more about determining the viewer's device type in the [Amazon CloudFront docs](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-device-type). -> [!CAUTION] -> `isWindows` and `isMacOS` flags are not available in Amazon CloudFront. +> [!NOTE] +> If CloudFront replaces the user agent with `Amazon CloudFront`, `isWindows`, `isMacOS` and the browser flags can't be detected, because the original user agent is no longer available. Forward the `User-Agent` header to the origin (in your cache or origin request policy) if you need them. ## Cloudflare Support diff --git a/src/runtime/generateFlags.ts b/src/runtime/generateFlags.ts index d3cfdd14..a31ad16f 100644 --- a/src/runtime/generateFlags.ts +++ b/src/runtime/generateFlags.ts @@ -62,13 +62,27 @@ function getBrowserName(userAgent: string): string { return '' } +// CloudFront adds these headers when device detection is enabled, regardless of +// whether the viewer's `User-Agent` is forwarded or replaced with `Amazon CloudFront`. +const CLOUDFRONT_DEVICE_HEADERS = [ + 'cloudfront-is-mobile-viewer', + 'cloudfront-is-tablet-viewer', + 'cloudfront-is-desktop-viewer', + 'cloudfront-is-ios-viewer', + 'cloudfront-is-android-viewer', +] + +function isCloudFrontRequest(headers: Record): boolean { + return CLOUDFRONT_DEVICE_HEADERS.some(header => headers[header] !== undefined) +} + export default function generateFlags(userAgent: string, headers: Record = {}): Device { let mobile = false let mobileOrTablet = false let ios = false let android = false - if (userAgent === 'Amazon CloudFront') { + if (isCloudFrontRequest(headers)) { if (headers['cloudfront-is-mobile-viewer'] === 'true') { mobile = true mobileOrTablet = true diff --git a/test/basic.test.ts b/test/basic.test.ts index 7d15d03a..2bb8802d 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -259,10 +259,16 @@ describe('ssr', async () => { }) }) - it('detects cloudfront headers - mobile', async () => { + // CloudFront device headers must win over the User-Agent, which can be either + // the viewer's browser (when it is forwarded by the cache policy) or + // `Amazon CloudFront` (when CloudFront replaces it). + const DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1' + + it('detects cloudfront headers - mobile (browser user agent)', async () => { const html = await $fetch('/', { headers: { - 'User-Agent': 'Amazon CloudFront', + 'User-Agent': DESKTOP_UA, 'Cloudfront-Is-Mobile-Viewer': 'true', }, }) @@ -271,34 +277,34 @@ describe('ssr', async () => { expect({ isMobile, isMobileOrTablet }).toEqual({ isMobile: true, isMobileOrTablet: true }) }) - it('detects cloudfront headers - tablet', async () => { + it('detects cloudfront headers - tablet (browser user agent)', async () => { const html = await $fetch('/', { headers: { - 'User-Agent': 'Amazon CloudFront', + 'User-Agent': DESKTOP_UA, 'Cloudfront-Is-Tablet-Viewer': 'true', }, }) - const { isMobile, isMobileOrTablet } = parseHtml(html) + const { isMobile, isMobileOrTablet, isTablet } = parseHtml(html) - expect({ isMobile, isMobileOrTablet }).toEqual({ isMobile: false, isMobileOrTablet: true }) + expect({ isMobile, isMobileOrTablet, isTablet }).toEqual({ isMobile: false, isMobileOrTablet: true, isTablet: true }) }) - it('detects cloudfront headers - desktop', async () => { + it('detects cloudfront headers - desktop (browser user agent)', async () => { const html = await $fetch('/', { headers: { - 'User-Agent': 'Amazon CloudFront', + 'User-Agent': IPHONE_UA, 'Cloudfront-Is-Desktop-Viewer': 'true', }, }) - const { isDesktop } = parseHtml(html) + const { isDesktop, isMobile } = parseHtml(html) - expect(isDesktop).toEqual(true) + expect({ isDesktop, isMobile }).toEqual({ isDesktop: true, isMobile: false }) }) - it('detects cloudfront headers - ios', async () => { + it('detects cloudfront headers - ios (browser user agent)', async () => { const html = await $fetch('/', { headers: { - 'User-Agent': 'Amazon CloudFront', + 'User-Agent': DESKTOP_UA, 'Cloudfront-Is-Ios-Viewer': 'true', }, }) @@ -307,10 +313,10 @@ describe('ssr', async () => { expect(isIos).toEqual(true) }) - it('detects cloudfront headers - android', async () => { + it('detects cloudfront headers - android (browser user agent)', async () => { const html = await $fetch('/', { headers: { - 'User-Agent': 'Amazon CloudFront', + 'User-Agent': DESKTOP_UA, 'Cloudfront-Is-Android-Viewer': 'true', }, }) @@ -319,6 +325,29 @@ describe('ssr', async () => { expect(isAndroid).toEqual(true) }) + it('detects cloudfront headers - replaced user agent', async () => { + const html = await $fetch('/', { + headers: { + 'User-Agent': 'Amazon CloudFront', + 'Cloudfront-Is-Mobile-Viewer': 'true', + }, + }) + const { isMobile, isMobileOrTablet } = parseHtml(html) + + expect({ isMobile, isMobileOrTablet }).toEqual({ isMobile: true, isMobileOrTablet: true }) + }) + + it('does not detect cloudfront when no headers are present', async () => { + const html = await $fetch('/', { + headers: { + 'User-Agent': DESKTOP_UA, + }, + }) + const { isDesktop, isMobile } = parseHtml(html) + + expect({ isDesktop, isMobile }).toEqual({ isDesktop: true, isMobile: false }) + }) + it('detects cloudflare headers - mobile', async () => { const html = await $fetch('/', { headers: { From abb8236e66a5b56b6696c89c287b1cf161ac342b Mon Sep 17 00:00:00 2001 From: Damian Glowala Date: Thu, 12 Nov 2026 15:55:33 +0100 Subject: [PATCH 2/3] fix: fall back to the user agent for missing CloudFront headers CloudFront only sends the device headers that are included in the cache or origin request policy. Treating any one of them as authoritative for every flag forced missing information to `false`, e.g. an iPhone with only the mobile/tablet/desktop headers forwarded lost `isIos`, and a phone with `CloudFront-Is-Desktop-Viewer: false` was detected as desktop. Use each header only for the flags it covers and detect the rest from the user agent. This also detects iOS and Android from the user agent when the Cloudflare `CF-Device-Type` header is present. --- README.md | 7 +++- src/runtime/generateFlags.ts | 71 ++++++++++++--------------------- test/basic.ts | 77 ++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 9bad79fd..178856a0 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,12 @@ When [CloudFront device detection](https://docs.aws.amazon.com/AmazonCloudFront/ - `CloudFront-Is-Mobile-Viewer` - `CloudFront-Is-Tablet-Viewer` -These headers take precedence over user agent parsing. This works both when CloudFront forwards the viewer's browser user agent and when it replaces it with `Amazon CloudFront`. +Each header that is present takes precedence over user agent parsing for the flags it covers, and anything a header doesn't cover is detected from the user agent: + +- The device type comes from the first of `CloudFront-Is-Desktop-Viewer`, `CloudFront-Is-Tablet-Viewer` and `CloudFront-Is-Mobile-Viewer` that is `true`. If none of them is `true`, the user agent is used. +- `isIos` and `isAndroid` come from `CloudFront-Is-IOS-Viewer` and `CloudFront-Is-Android-Viewer` when they are present, and from the user agent otherwise. + +This works both when CloudFront forwards the viewer's browser user agent and when it replaces it with `Amazon CloudFront`. If CloudFront caches your pages, include every device header you rely on in the cache policy, so each cached page matches the device type it was rendered for. Read more about determining the viewer's device type in the [Amazon CloudFront docs](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-device-type). diff --git a/src/runtime/generateFlags.ts b/src/runtime/generateFlags.ts index a31ad16f..5d78c9b0 100644 --- a/src/runtime/generateFlags.ts +++ b/src/runtime/generateFlags.ts @@ -62,51 +62,36 @@ function getBrowserName(userAgent: string): string { return '' } -// CloudFront adds these headers when device detection is enabled, regardless of -// whether the viewer's `User-Agent` is forwarded or replaced with `Amazon CloudFront`. -const CLOUDFRONT_DEVICE_HEADERS = [ - 'cloudfront-is-mobile-viewer', - 'cloudfront-is-tablet-viewer', - 'cloudfront-is-desktop-viewer', - 'cloudfront-is-ios-viewer', - 'cloudfront-is-android-viewer', -] +type CloudFrontViewer = 'mobile' | 'tablet' | 'desktop' | 'ios' | 'android' + +// CloudFront only sends the `CloudFront-Is-*-Viewer` headers that are included in the +// cache or origin request policy, each set to `true` or `false`, regardless of whether +// the viewer's `User-Agent` is forwarded or replaced with `Amazon CloudFront`. +// A missing header tells us nothing, so the user agent is used instead. +function getCloudFrontHeader(headers: Record, viewer: CloudFrontViewer): boolean | undefined { + const value = headers[`cloudfront-is-${viewer}-viewer`] -function isCloudFrontRequest(headers: Record): boolean { - return CLOUDFRONT_DEVICE_HEADERS.some(header => headers[header] !== undefined) + return value === undefined ? undefined : value === 'true' } export default function generateFlags(userAgent: string, headers: Record = {}): Device { - let mobile = false - let mobileOrTablet = false - let ios = false - let android = false - - if (isCloudFrontRequest(headers)) { - if (headers['cloudfront-is-mobile-viewer'] === 'true') { - mobile = true - mobileOrTablet = true - } - - if (headers['cloudfront-is-tablet-viewer'] === 'true') { - mobile = false - mobileOrTablet = true - } - - if (headers['cloudfront-is-desktop-viewer'] === 'true') { - mobile = false - mobileOrTablet = false - } + let mobile = isMobile(userAgent) + let mobileOrTablet = isMobileOrTablet(userAgent) - if (headers['cloudfront-is-ios-viewer'] === 'true') { - ios = true - } - - if (headers['cloudfront-is-android-viewer'] === 'true') { - android = true - } + // Some tablets are reported as both mobile and tablet, so tablet takes precedence over mobile. + if (getCloudFrontHeader(headers, 'desktop')) { + mobile = false + mobileOrTablet = false } - else if (headers && headers['cf-device-type']) { // Cloudflare + else if (getCloudFrontHeader(headers, 'tablet')) { + mobile = false + mobileOrTablet = true + } + else if (getCloudFrontHeader(headers, 'mobile')) { + mobile = true + mobileOrTablet = true + } + else if (headers['cf-device-type']) { // Cloudflare switch (headers['cf-device-type']) { case 'mobile': mobile = true @@ -125,13 +110,9 @@ export default function generateFlags(userAgent: string, headers: Record { const html = await $fetch('/', { @@ -326,6 +327,70 @@ export function describeDevice(name: string, rootDir: string) { expect(isAndroid).toEqual(true) }) + it('detects cloudfront headers - tablet reported as both mobile and tablet', async () => { + const html = await $fetch('/', { + headers: { + 'User-Agent': IPHONE_UA, + 'Cloudfront-Is-Mobile-Viewer': 'true', + 'Cloudfront-Is-Tablet-Viewer': 'true', + 'Cloudfront-Is-Desktop-Viewer': 'false', + }, + }) + const { isMobile, isTablet, isDesktop } = parseHtml(html) + + expect({ isMobile, isTablet, isDesktop }).toEqual({ isMobile: false, isTablet: true, isDesktop: false }) + }) + + it('falls back to the user agent for ios and android when their cloudfront headers are missing', async () => { + const html = await $fetch('/', { + headers: { + 'User-Agent': IPHONE_UA, + 'Cloudfront-Is-Mobile-Viewer': 'true', + 'Cloudfront-Is-Tablet-Viewer': 'false', + 'Cloudfront-Is-Desktop-Viewer': 'false', + }, + }) + const { isMobile, isIos, isAndroid } = parseHtml(html) + + expect({ isMobile, isIos, isAndroid }).toEqual({ isMobile: true, isIos: true, isAndroid: false }) + }) + + it('falls back to the user agent for the device type when no cloudfront device type header is true', async () => { + const html = await $fetch('/', { + headers: { + 'User-Agent': ANDROID_UA, + 'Cloudfront-Is-Desktop-Viewer': 'false', + }, + }) + const { isMobile, isDesktop, isAndroid } = parseHtml(html) + + expect({ isMobile, isDesktop, isAndroid }).toEqual({ isMobile: true, isDesktop: false, isAndroid: true }) + }) + + it('falls back to the user agent for the device type when only the cloudfront ios header is present', async () => { + const html = await $fetch('/', { + headers: { + 'User-Agent': IPHONE_UA, + 'Cloudfront-Is-Ios-Viewer': 'true', + }, + }) + const { isMobile, isDesktop, isIos } = parseHtml(html) + + expect({ isMobile, isDesktop, isIos }).toEqual({ isMobile: true, isDesktop: false, isIos: true }) + }) + + it('prefers the cloudfront ios header over the user agent', async () => { + const html = await $fetch('/', { + headers: { + 'User-Agent': IPHONE_UA, + 'Cloudfront-Is-Ios-Viewer': 'false', + }, + }) + const { isIos } = parseHtml(html) + + expect(isIos).toEqual(false) + }) + it('detects cloudfront headers - replaced user agent', async () => { const html = await $fetch('/', { headers: { @@ -371,6 +436,18 @@ export function describeDevice(name: string, rootDir: string) { expect({ isMobile, isMobileOrTablet }).toEqual({ isMobile: false, isMobileOrTablet: true }) }) + it('detects ios and android from the user agent with cloudflare headers', async () => { + const html = await $fetch('/', { + headers: { + 'User-Agent': IPHONE_UA, + 'cf-device-type': 'mobile', + }, + }) + const { isMobile, isIos, isAndroid } = parseHtml(html) + + expect({ isMobile, isIos, isAndroid }).toEqual({ isMobile: true, isIos: true, isAndroid: false }) + }) + it('detects cloudflare headers - desktop', async () => { const html = await $fetch('/', { headers: { From d8fea50b1052cd576b8760838483f9de64ad6b63 Mon Sep 17 00:00:00 2001 From: Damian Glowala Date: Thu, 12 Nov 2026 16:06:59 +0100 Subject: [PATCH 3/3] fix: do not detect linux when the CloudFront android header is true `isAndroid` can come from the `CloudFront-Is-Android-Viewer` header, but `isLinux` only excluded Android based on the user agent. Android Chrome with "Request desktop site" sends a Linux user agent, so it was detected as both Android and Linux. --- src/runtime/generateFlags.ts | 7 ++++--- test/basic.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/runtime/generateFlags.ts b/src/runtime/generateFlags.ts index 5d78c9b0..8e5ef996 100644 --- a/src/runtime/generateFlags.ts +++ b/src/runtime/generateFlags.ts @@ -37,8 +37,9 @@ function isMacOS(userAgent: string): boolean { return /Mac OS X/.test(userAgent) } -function isLinux(userAgent: string): boolean { - return /Linux/i.test(userAgent) && !isAndroid(userAgent) +// `android` is passed in, as it may come from the CloudFront header instead of the user agent. +function isLinux(userAgent: string, android: boolean): boolean { + return /Linux/i.test(userAgent) && !android } // Following regular expressions are originated from bowser(https://github.com/lancedikson/bowser). @@ -115,7 +116,7 @@ export default function generateFlags(userAgent: string, headers: Record { + const html = await $fetch('/', { + headers: { + // Android Chrome with "Request desktop site" enabled + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Cloudfront-Is-Android-Viewer': 'true', + }, + }) + const { isAndroid, isLinux } = parseHtml(html) + + expect({ isAndroid, isLinux }).toEqual({ isAndroid: true, isLinux: false }) + }) + it('detects cloudfront headers - replaced user agent', async () => { const html = await $fetch('/', { headers: {