diff --git a/README.md b/README.md index 6c8b199..178856a 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,17 @@ If the user agent is `Amazon CloudFront`, the module checks for the following he - `CloudFront-Is-Mobile-Viewer` - `CloudFront-Is-Tablet-Viewer` +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). -> [!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 d3cfdd1..8e5ef99 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). @@ -62,37 +63,36 @@ function getBrowserName(userAgent: string): string { return '' } -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 (headers['cloudfront-is-mobile-viewer'] === 'true') { - mobile = true - mobileOrTablet = true - } +type CloudFrontViewer = 'mobile' | 'tablet' | 'desktop' | 'ios' | 'android' - if (headers['cloudfront-is-tablet-viewer'] === 'true') { - mobile = false - mobileOrTablet = true - } +// 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`] - if (headers['cloudfront-is-desktop-viewer'] === 'true') { - mobile = false - mobileOrTablet = false - } + return value === undefined ? undefined : value === 'true' +} - if (headers['cloudfront-is-ios-viewer'] === 'true') { - ios = true - } +export default function generateFlags(userAgent: string, headers: Record = {}): Device { + let mobile = isMobile(userAgent) + let mobileOrTablet = isMobileOrTablet(userAgent) - 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 (getCloudFrontHeader(headers, 'tablet')) { + mobile = false + mobileOrTablet = true } - else if (headers && headers['cf-device-type']) { // Cloudflare + 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 @@ -111,16 +111,12 @@ export default function generateFlags(userAgent: string, headers: Record { + // 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' + const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36' + + 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', }, }) @@ -272,34 +279,34 @@ export function describeDevice(name: string, rootDir: string) { 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', }, }) @@ -308,10 +315,10 @@ export function describeDevice(name: string, rootDir: string) { 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', }, }) @@ -320,6 +327,106 @@ 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('does not detect linux when the cloudfront android header is true', async () => { + 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: { + '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: { @@ -342,6 +449,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: {