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
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,25 @@ 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`
- `CloudFront-Is-IOS-Viewer`
- `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

Expand Down
66 changes: 31 additions & 35 deletions src/runtime/generateFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -62,37 +63,36 @@ function getBrowserName(userAgent: string): string {
return ''
}

export default function generateFlags(userAgent: string, headers: Record<string, string> = {}): 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<string, string>, 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<string, string> = {}): 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
Expand All @@ -111,16 +111,12 @@ export default function generateFlags(userAgent: string, headers: Record<string,
break
}
}
else {
mobile = isMobile(userAgent)
mobileOrTablet = isMobileOrTablet(userAgent)
ios = isIos(userAgent)
android = isAndroid(userAgent)
}

const ios = getCloudFrontHeader(headers, 'ios') ?? isIos(userAgent)
const android = getCloudFrontHeader(headers, 'android') ?? isAndroid(userAgent)
const windows = isWindows(userAgent)
const macOS = isMacOS(userAgent)
const linux = isLinux(userAgent)
const linux = isLinux(userAgent, android)
const browserName = getBrowserName(userAgent)
const isSafari = browserName === 'Safari'
const isFirefox = browserName === 'Firefox'
Expand Down
147 changes: 133 additions & 14 deletions test/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,17 @@ export function describeDevice(name: string, rootDir: string) {
})
})

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'
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',
},
})
Expand All @@ -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',
},
})
Expand All @@ -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',
},
})
Expand All @@ -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: {
Expand All @@ -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: {
Expand Down
Loading