From 4e9f918cefd27fe243536101c4fd29982f83405d Mon Sep 17 00:00:00 2001 From: Shinji Yamada Date: Sat, 2 Apr 2022 15:36:30 +0900 Subject: [PATCH 1/2] refactor: pass headers only to extractDevices() --- lib/plugin.js | 16 ++++++++++------ test/module.test.js | 42 ++++++++++++++++++------------------------ 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/lib/plugin.js b/lib/plugin.js index 2122b71..f2396f0 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -61,23 +61,23 @@ function getBrowserName(a) { const DEFAULT_USER_AGENT = '<%= options.defaultUserAgent %>' const REFRESH_ON_RESIZE = <%= options.refreshOnResize %> -function extractDevices (ctx, userAgent = DEFAULT_USER_AGENT) { +function extractDevices (headers, userAgent = DEFAULT_USER_AGENT) { let mobile = null let mobileOrTablet = null let ios = null let android = null if (userAgent === 'Amazon CloudFront') { - if (ctx.req.headers['cloudfront-is-mobile-viewer'] === 'true') { + if (headers['cloudfront-is-mobile-viewer'] === 'true') { mobile = true mobileOrTablet = true } - if (ctx.req.headers['cloudfront-is-tablet-viewer'] === 'true') { + if (headers['cloudfront-is-tablet-viewer'] === 'true') { mobile = false mobileOrTablet = true } - } else if (ctx.req && ctx.req.headers['cf-device-type']) { // Cloudflare - switch (ctx.req.headers['cf-device-type']) { + } else if (headers['cf-device-type']) { // Cloudflare + switch (headers['cf-device-type']) { case 'mobile': mobile = true mobileOrTablet = true @@ -118,7 +118,11 @@ export default async function (ctx, inject) { } else if (typeof navigator !== 'undefined') { userAgent = navigator.userAgent } - const { mobile, mobileOrTablet, ios, android, windows, macOS, isSafari, isFirefox, isEdge, isChrome, isSamsung, isCrawler } = extractDevices(ctx, userAgent) + let headers = {} + if (ctx && ctx.req) { + headers = ctx.req.headers + } + const { mobile, mobileOrTablet, ios, android, windows, macOS, isSafari, isFirefox, isEdge, isChrome, isSamsung, isCrawler } = extractDevices(headers, userAgent) return { <% if (options.test) { %> extractDevices, diff --git a/test/module.test.js b/test/module.test.js index 56e6192..a942516 100644 --- a/test/module.test.js +++ b/test/module.test.js @@ -9,7 +9,6 @@ const createBrowserFlags = (override) => { describe('Device module', () => { let nuxt - let ctx let headers let extractDevices @@ -25,11 +24,6 @@ describe('Device module', () => { beforeEach(() => { headers = {} - ctx = { - req: { - headers - } - } }) test('injects properties', async () => { @@ -42,38 +36,38 @@ describe('Device module', () => { it('Samsung Galaxy S9', () => { const userAgent = 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36' - expect(extractDevices(ctx, userAgent)).toEqual({ mobile: true, mobileOrTablet: true, android: true, ios: false, macOS: false, windows: false, ...createBrowserFlags({ isChrome: true }) }) + expect(extractDevices(headers, userAgent)).toEqual({ mobile: true, mobileOrTablet: true, android: true, ios: false, macOS: false, windows: false, ...createBrowserFlags({ isChrome: true }) }) }) it('Apple iPhone X', () => { const userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1' - expect(extractDevices(ctx, userAgent)).toEqual({ mobile: true, mobileOrTablet: true, android: false, ios: true, macOS: true, windows: false, ...createBrowserFlags({ isSafari: true }) }) + expect(extractDevices(headers, userAgent)).toEqual({ mobile: true, mobileOrTablet: true, android: false, ios: true, macOS: true, windows: false, ...createBrowserFlags({ isSafari: true }) }) }) it('Samsung Galaxy Tab S3', () => { const userAgent = 'Mozilla/5.0 (Linux; Android 7.0; SM-T827R4 Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.116 Safari/537.36' - expect(extractDevices(ctx, userAgent)).toEqual({ mobile: false, mobileOrTablet: true, android: true, ios: false, macOS: false, windows: false, ...createBrowserFlags({ isChrome: true }) }) + expect(extractDevices(headers, userAgent)).toEqual({ mobile: false, mobileOrTablet: true, android: true, ios: false, macOS: false, windows: false, ...createBrowserFlags({ isChrome: true }) }) }) it('Windows 10-based PC using Edge browser', () => { const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246' - expect(extractDevices(ctx, userAgent)).toEqual({ mobile: false, mobileOrTablet: false, android: false, ios: false, macOS: false, windows: true, ...createBrowserFlags({ isEdge: true }) }) + expect(extractDevices(headers, userAgent)).toEqual({ mobile: false, mobileOrTablet: false, android: false, ios: false, macOS: false, windows: true, ...createBrowserFlags({ isEdge: true }) }) }) it('Mac OS X-based computer using a Safari browser', () => { const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9' - expect(extractDevices(ctx, userAgent)).toEqual({ mobile: false, mobileOrTablet: false, android: false, ios: false, macOS: true, windows: false, ...createBrowserFlags({ isSafari: true }) }) + expect(extractDevices(headers, userAgent)).toEqual({ mobile: false, mobileOrTablet: false, android: false, ios: false, macOS: true, windows: false, ...createBrowserFlags({ isSafari: true }) }) }) }) describe('detects a browser', () => { it('detects Chrome', () => { const userAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36'; - const result = extractDevices(ctx, userAgent) + const result = extractDevices(headers, userAgent) expect(result.isChrome).toEqual(true) expect(result.isFirefox).toEqual(false) expect(result.isEdge).toEqual(false) @@ -82,7 +76,7 @@ describe('Device module', () => { }) it('detects Firefox', () => { const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0'; - const result = extractDevices(ctx, userAgent) + const result = extractDevices(headers, userAgent) expect(result.isChrome).toEqual(false) expect(result.isFirefox).toEqual(true) expect(result.isEdge).toEqual(false) @@ -91,7 +85,7 @@ describe('Device module', () => { }) it('detects Edge', () => { const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36 Edg/81.0.416.72'; - const result = extractDevices(ctx, userAgent) + const result = extractDevices(headers, userAgent) expect(result.isChrome).toEqual(false) expect(result.isFirefox).toEqual(false) expect(result.isEdge).toEqual(true) @@ -100,7 +94,7 @@ describe('Device module', () => { }) it('detects Safari', () => { const userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1' - const result = extractDevices(ctx, userAgent) + const result = extractDevices(headers, userAgent) expect(result.isChrome).toEqual(false) expect(result.isFirefox).toEqual(false) expect(result.isEdge).toEqual(false) @@ -109,7 +103,7 @@ describe('Device module', () => { }) it('detects Samsung', () => { const userAgent = 'Mozilla/5.0 (Linux; Android 10; SAMSUNG SM-A515F) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/12.1 Chrome/79.0.3945.136 Mobile Safari/537.36' - const result = extractDevices(ctx, userAgent) + const result = extractDevices(headers, userAgent) expect(result.isChrome).toEqual(false) expect(result.isFirefox).toEqual(false) expect(result.isEdge).toEqual(false) @@ -123,33 +117,33 @@ describe('Device module', () => { const userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15A372 Instagram'; - expect(extractDevices(ctx, userAgent)).toEqual({ mobile: true, mobileOrTablet: true, android: false, ios: true, macOS: true, windows: false, ...createBrowserFlags( { isSafari: true })}) + expect(extractDevices(headers, userAgent)).toEqual({ mobile: true, mobileOrTablet: true, android: false, ios: true, macOS: true, windows: false, ...createBrowserFlags( { isSafari: true })}) }) it('Facebook', () => { const userAgent = "Mozilla/5.0 ((iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit (KHTML, like Gecko) Mobile [FBAN/FBForIPhone;FBAV/4.1;FBBV/4100.0;FBDV/iPhone3,1;FBMD/iPhone;FBSN/iPhone OS;FBSV/5.1.1;FBSS/2; tablet;FBLC/en_US]"; - expect(extractDevices(ctx, userAgent)).toEqual({ mobile: true, mobileOrTablet: true, android: false, ios: true, macOS: true, windows: false, ...createBrowserFlags({ isSafari: true })}) + expect(extractDevices(headers, userAgent)).toEqual({ mobile: true, mobileOrTablet: true, android: false, ios: true, macOS: true, windows: false, ...createBrowserFlags({ isSafari: true })}) }) }) it('detects cloudflare headers', () => { headers['cf-device-type'] = 'mobile' - expect(extractDevices(ctx, '')).toEqual({ mobile: true, mobileOrTablet: true, ...defaultOsSettings, ...defaultBrowserFlags }) + expect(extractDevices(headers, '')).toEqual({ mobile: true, mobileOrTablet: true, ...defaultOsSettings, ...defaultBrowserFlags }) headers['cf-device-type'] = 'tablet' - expect(extractDevices(ctx, '')).toEqual({ mobile: false, mobileOrTablet: true, ...defaultOsSettings, ...defaultBrowserFlags }) + expect(extractDevices(headers, '')).toEqual({ mobile: false, mobileOrTablet: true, ...defaultOsSettings, ...defaultBrowserFlags }) headers['cf-device-type'] = 'desktop' - expect(extractDevices(ctx, '')).toEqual({ mobile: false, mobileOrTablet: false, ...defaultOsSettings, ...defaultBrowserFlags }) + expect(extractDevices(headers, '')).toEqual({ mobile: false, mobileOrTablet: false, ...defaultOsSettings, ...defaultBrowserFlags }) }) it('detects crawlers', () => { const googlebots = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" - expect(extractDevices(ctx, googlebots).isCrawler).toEqual(true) + expect(extractDevices(headers, googlebots).isCrawler).toEqual(true) const yahoobots = "Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)" - expect(extractDevices(ctx, yahoobots).isCrawler).toEqual(true) + expect(extractDevices(headers, yahoobots).isCrawler).toEqual(true) const biduspider = "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" - expect(extractDevices(ctx, biduspider).isCrawler).toEqual(true) + expect(extractDevices(headers, biduspider).isCrawler).toEqual(true) }); }) From fe6651eed2ac45ad9db3f814326b86d23af64f10 Mon Sep 17 00:00:00 2001 From: Shinji Yamada Date: Sat, 2 Apr 2022 17:22:37 +0900 Subject: [PATCH 2/2] feat: support client hints --- README.md | 23 ++++++++ lib/module.js | 3 + lib/plugin.js | 80 ++++++++++++++++++++++++- test/module.test.js | 139 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 16e5b67..3bcdd0c 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,30 @@ export default function ({ $device }) { } ``` +`clientHints.enabled` enables client hints feature.(default by false) + Note that the default user agent value is set to `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.39 Safari/537.36`. + +## User-Agent Client Hints Support + +To enable Client Hints, set clientHints.enabled options to true. + +### Client Side + +`navigator.userAgentData` are referred to detect a device and a platform. + +results from `navigator.userAgent` are overridden. + +### Server Side + +the following request headers are referred to detect a device and a platform. + + - sec-ch-ua + - sec-ch-mobile + - sec-ch-platform + +results from user-agent header are overridden. + ## CloudFront Support If a user-agent is `Amazon CloudFront`, this module checks diff --git a/lib/module.js b/lib/module.js index 30aba5c..0a581f8 100644 --- a/lib/module.js +++ b/lib/module.js @@ -4,6 +4,9 @@ const { defu } = require('defu') module.exports = function (moduleOptions) { const options = defu(moduleOptions, this.options.device, { defaultUserAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.39 Safari/537.36', + clientHints: { + enabled: false + }, refreshOnResize: false }) // Register plugin diff --git a/lib/plugin.js b/lib/plugin.js index f2396f0..9ee66d6 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -60,6 +60,7 @@ function getBrowserName(a) { const DEFAULT_USER_AGENT = '<%= options.defaultUserAgent %>' const REFRESH_ON_RESIZE = <%= options.refreshOnResize %> +const USE_CLIENT_HINT = <%= options.clientHints.enabled %> function extractDevices (headers, userAgent = DEFAULT_USER_AGENT) { let mobile = null @@ -110,6 +111,73 @@ function extractDevices (headers, userAgent = DEFAULT_USER_AGENT) { return { mobile, mobileOrTablet, ios, android, windows, macOS, isSafari, isFirefox, isEdge, isChrome, isSamsung, isCrawler } } +function extractFromUserAgentData(userAgentData) { + const hasBrand = (brandName) => userAgentData.brands.some(b => b.brand === brandName) + const platform = userAgentData.platform + + const mobile = userAgentData.mobile + let mobileOrTablet = undefined + if (mobile) { + mobileOrTablet = mobile + } + const ios = undefined + const android = undefined + const windows = platform === 'Windows' + const macOS = platform === 'macOS' + const isSafari = undefined + const isFirefox = undefined + const isEdge = hasBrand('Microsoft Edge') + const isChrome = hasBrand('Google Chrome') + const isSamsung = undefined + const isCrawler = undefined + return deleteUndefinedProperties({ mobile, mobileOrTablet, ios, android, windows, macOS, isSafari, isFirefox, isEdge, isChrome, isSamsung, isCrawler }) +} + +const REGEX_CLIENT_HINT_BRAND = /"([^"]*)";v="([^"]*)"/ +function extractFromUserHint(headers) { + const uaHeader = headers['sec-ch-ua'] + const mobileHeader = headers['sec-ch-ua-mobile'] + const platform = headers['sec-ch-ua-platform'] + if (typeof uaHeader === 'undefined') { + return {} + } + const brands = uaHeader.split(',').map(b => b.trim()).map(brandStr => { + const parsed = brandStr.match(REGEX_CLIENT_HINT_BRAND) + console.log(brandStr, parsed) + return {brand: parsed[1], version: parsed[2]} + }) + const hasBrand = (brandName) => brands.some(b => b.brand === brandName) + + let mobile = undefined + if (mobileHeader) { + mobile = mobileHeader === '?1' + } + let mobileOrTablet = undefined + if (mobile) { + mobileOrTablet = mobile + } + const ios = undefined + const android = undefined + const windows = platform === 'Windows' + const macOS = platform === 'macOS' + const isSafari = undefined + const isFirefox = undefined + const isEdge = hasBrand('Microsoft Edge') + const isChrome = hasBrand('Google Chrome') + const isSamsung = undefined + const isCrawler = undefined + return deleteUndefinedProperties({ mobile, mobileOrTablet, ios, android, windows, macOS, isSafari, isFirefox, isEdge, isChrome, isSamsung, isCrawler }) +} + +function deleteUndefinedProperties(obj) { + for (const key of Object.keys(obj)) { + if (typeof obj[key] === 'undefined') { + delete obj[key] + } + } + return obj +} + export default async function (ctx, inject) { const makeFlags = () => { let userAgent = '' @@ -122,10 +190,20 @@ export default async function (ctx, inject) { if (ctx && ctx.req) { headers = ctx.req.headers } - const { mobile, mobileOrTablet, ios, android, windows, macOS, isSafari, isFirefox, isEdge, isChrome, isSamsung, isCrawler } = extractDevices(headers, userAgent) + const uaResult = extractDevices(headers, userAgent) + let result = uaResult + if (USE_CLIENT_HINT) { + if (typeof navigator !== 'undefined' && typeof navigator.userAgentData !== 'undefined') { + Object.assign(result, extractFromUserAgentData(navigator.userAgentData)) + } + Object.assign(result, extractFromUserHint(headers)) + } + const { mobile, mobileOrTablet, ios, android, windows, macOS, isSafari, isFirefox, isEdge, isChrome, isSamsung, isCrawler } = result return { <% if (options.test) { %> extractDevices, + extractFromUserAgentData, + extractFromUserHint, <% } %> userAgent, isMobile: mobile, diff --git a/test/module.test.js b/test/module.test.js index a942516..c7caff4 100644 --- a/test/module.test.js +++ b/test/module.test.js @@ -11,11 +11,15 @@ describe('Device module', () => { let headers let extractDevices + let extractFromUserAgentData + let extractFromUserHint beforeAll(async () => { ({ nuxt } = await setup(loadConfig(__dirname))) const window = await nuxt.renderAndGetWindow(url('/')) extractDevices = window.$nuxt.$device.extractDevices + extractFromUserAgentData = window.$nuxt.$device.extractFromUserAgentData + extractFromUserHint = window.$nuxt.$device.extractFromUserHint }, 60000) afterAll(async () => { @@ -146,4 +150,139 @@ describe('Device module', () => { const biduspider = "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" expect(extractDevices(headers, biduspider).isCrawler).toEqual(true) }); + + describe('UserAgentData', () => { + const base = { + "brands": [ + { + "brand": " Not A;Brand", + "version": "99" + }, + { + "brand": "Chromium", + "version": "100" + }, + { + "brand": "Google Chrome", + "version": "100" + } + ], + "mobile": false, + "platform": "macOS" + } + it('can detect brand', () => { + { + let { + isChrome, + isEdge + } = extractFromUserAgentData({ ...base, "brands": [{"brand": "Google Chrome", "version": "100"}] }) + expect([isChrome, isEdge]).toEqual([true, false]) + } + { + let { + isChrome, + isEdge + } = extractFromUserAgentData({ ...base, "brands": [{"brand": "Microsoft Edge", "version": "100"}] }) + expect([isChrome, isEdge]).toEqual([false, true]) + } + }) + + it('can detect platform', () => { + { + let { + macOS, + windows + } = extractFromUserAgentData({ ...base }) + expect([macOS, windows]).toEqual([true, false]) + } + { + let { + macOS, + windows + } = extractFromUserAgentData({ + ...base, + "platform": "Windows" + }) + expect([macOS, windows]).toEqual([false, true]) + } + }) + it('can detect mobile', () => { + { + let { + mobile + } = extractFromUserAgentData({ ...base, mobile: false }) + expect(mobile).toEqual(false) + } + { + let { + mobile, + } = extractFromUserAgentData({ + ...base, + mobile: true + }) + expect(mobile).toEqual(true) + } + }) + }) + + describe('clienthint header', () => { + const base = { + 'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': 'macOS' + } + it('can parse arbitrary brand ', () => { + const result = extractFromUserHint({ + ...base, + 'sec-ch-ua': ' "()-./:;=?_ Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"' + }) + expect(result).toEqual({ + "mobile": false, + "windows": false, + "macOS": true, + "isEdge": false, + "isChrome": true + }) + }) + it('can detect ua', () => { + const result = extractFromUserHint({ + ...base, + 'sec-ch-ua': '"(Not A;Brand";v="99", "Chromium";v="100", "Microsoft Edge";v="100"' + }) + expect(result).toEqual({ + "mobile": false, + "windows": false, + "macOS": true, + "isEdge": true, + "isChrome": false + }) + }) + it('can detect mobile', () => { + const result = extractFromUserHint({ + ...base, + 'sec-ch-ua-mobile': '?1', + }) + expect(result).toEqual({ + "mobile": true, + "mobileOrTablet": true, + "windows": false, + "macOS": true, + "isEdge": false, + "isChrome": true + }) + }) + it('can detect platform', () => { + const result = extractFromUserHint({ + ...base, + 'sec-ch-ua-platform': 'Windows', + }) + expect(result).toEqual({ + "mobile": false, + "windows": true, + "macOS": false, + "isEdge": false, + "isChrome": true + }) + }) + }) })