diff --git a/packages/util-user-agent-browser/README.md b/packages/util-user-agent-browser/README.md index f2b6c62827e8a..70eb6b35973c5 100644 --- a/packages/util-user-agent-browser/README.md +++ b/packages/util-user-agent-browser/README.md @@ -3,8 +3,27 @@ [![NPM version](https://img.shields.io/npm/v/@aws-sdk/util-user-agent-browser/latest.svg)](https://www.npmjs.com/package/@aws-sdk/util-user-agent-browser) [![NPM downloads](https://img.shields.io/npm/dm/@aws-sdk/util-user-agent-browser.svg)](https://www.npmjs.com/package/@aws-sdk/util-user-agent-browser) -> An internal package - ## Usage -You probably shouldn't, at least directly. +In previous versions of the AWS SDK for JavaScript v3, the AWS SDK user agent header was provided by parsing the navigator user agent string with the `bowser` library. + +This was later changed to browser feature detection using the native Navigator APIs, but if you would like to have the previous functionality, use the following code: + +```js +import { createUserAgentStringParsingProvider } from "@aws-sdk/util-user-agent-browser"; + +import { S3Client } from "@aws-sdk/client-s3"; +import pkgInfo from "@aws-sdk/client-s3/package.json"; +// or any other client. + +const client = new S3Client({ + defaultUserAgentProvider: createUserAgentStringParsingProvider({ + // For a client's serviceId, check the corresponding shared runtimeConfig file + // https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/src/runtimeConfig.shared.ts + serviceId: "S3", + clientVersion: pkgInfo.version, + }), +}); +``` + +This usage is not recommended, due to the size of the additional parsing library. diff --git a/packages/util-user-agent-browser/src/createUserAgentStringParsingProvider.spec.ts b/packages/util-user-agent-browser/src/createUserAgentStringParsingProvider.spec.ts new file mode 100644 index 0000000000000..4b8647268772e --- /dev/null +++ b/packages/util-user-agent-browser/src/createUserAgentStringParsingProvider.spec.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest"; + +import { createUserAgentStringParsingProvider } from "./createUserAgentStringParsingProvider"; +import type { PreviouslyResolved } from "./index"; + +const ua = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36"; + +const mockConfig: PreviouslyResolved = { + userAgentAppId: vi.fn().mockResolvedValue(undefined), +}; + +describe("createUserAgentStringParsingProvider", () => { + beforeEach(() => { + vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue(ua); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should populate metrics", async () => { + const userAgent = await createUserAgentStringParsingProvider({ serviceId: "s3", clientVersion: "0.1.0" })( + mockConfig + ); + expect(userAgent[0]).toEqual(["aws-sdk-js", "0.1.0"]); + expect(userAgent[1]).toEqual(["ua", "2.1"]); + expect(userAgent[2]).toEqual(["os/macOS", "10.15.7"]); + expect(userAgent[3]).toEqual(["lang/js"]); + expect(userAgent[4]).toEqual(["md/browser", "Chrome_86.0.4240.111"]); + expect(userAgent[5]).toEqual(["api/s3", "0.1.0"]); + expect(userAgent.length).toBe(6); + }); + + it("should populate metrics when service id not available", async () => { + const userAgent = await createUserAgentStringParsingProvider({ serviceId: undefined, clientVersion: "0.1.0" })( + mockConfig + ); + expect(userAgent).not.toContainEqual(["api/s3", "0.1.0"]); + expect(userAgent.length).toBe(5); + }); + + it("should include appId when provided", async () => { + const configWithAppId: PreviouslyResolved = { + userAgentAppId: vi.fn().mockResolvedValue("test-app-id"), + }; + const userAgent = await createUserAgentStringParsingProvider({ serviceId: "s3", clientVersion: "0.1.0" })( + configWithAppId + ); + expect(userAgent[6]).toEqual(["app/test-app-id"]); + expect(userAgent.length).toBe(7); + }); + + it("should not include appId when not provided", async () => { + const userAgent = await createUserAgentStringParsingProvider({ serviceId: "s3", clientVersion: "0.1.0" })( + mockConfig + ); + expect(userAgent).not.toContainEqual(expect.arrayContaining(["app/"])); + expect(userAgent.length).toBe(6); + }); +}); diff --git a/packages/util-user-agent-browser/src/createUserAgentStringParsingProvider.ts b/packages/util-user-agent-browser/src/createUserAgentStringParsingProvider.ts new file mode 100644 index 0000000000000..3309d85034e2e --- /dev/null +++ b/packages/util-user-agent-browser/src/createUserAgentStringParsingProvider.ts @@ -0,0 +1,50 @@ +import type { UserAgent } from "@smithy/types"; + +import type { DefaultUserAgentOptions } from "./configurations"; +import type { PreviouslyResolved } from "./index"; + +/** + * This is an alternative to the default user agent provider that uses the bowser + * library to parse the user agent string. + * + * Use this with your client's `defaultUserAgentProvider` constructor object field + * to use the legacy behavior. + * + * @deprecated use the default provider unless you need the older UA-parsing functionality. + * @public + */ +export const createUserAgentStringParsingProvider = + ({ serviceId, clientVersion }: DefaultUserAgentOptions): ((config?: PreviouslyResolved) => Promise) => + async (config?: PreviouslyResolved) => { + const module = await import("bowser"); + const parse = module.parse ?? module.default.parse ?? (() => ""); + + const parsedUA = + typeof window !== "undefined" && window?.navigator?.userAgent ? parse(window.navigator.userAgent) : undefined; + const sections: UserAgent = [ + // sdk-metadata + ["aws-sdk-js", clientVersion], + // ua-metadata + ["ua", "2.1"], + // os-metadata + [`os/${parsedUA?.os?.name || "other"}`, parsedUA?.os?.version], + // language-metadata + // ECMAScript edition doesn't matter in JS. + ["lang/js"], + // browser vendor and version. + ["md/browser", `${parsedUA?.browser?.name ?? "unknown"}_${parsedUA?.browser?.version ?? "unknown"}`], + ]; + + if (serviceId) { + // api-metadata + // service Id may not appear in non-AWS clients + sections.push([`api/${serviceId}`, clientVersion]); + } + + const appId = await config?.userAgentAppId?.(); + if (appId) { + sections.push([`app/${appId}`]); + } + + return sections; + }; diff --git a/packages/util-user-agent-browser/src/index.native.ts b/packages/util-user-agent-browser/src/index.native.ts index a8a1ae0ea82e3..4a0373d1e853d 100644 --- a/packages/util-user-agent-browser/src/index.native.ts +++ b/packages/util-user-agent-browser/src/index.native.ts @@ -2,15 +2,16 @@ import { Provider, UserAgent } from "@smithy/types"; import { DefaultUserAgentOptions } from "./configurations"; +/** + * @internal + */ export interface PreviouslyResolved { userAgentAppId: Provider; } /** + * Default provider to the user agent in ReactNative. * @internal - * - * Default provider to the user agent in ReactNative. It's a best effort to infer - * the device information. It uses bowser library to detect the browser and virsion */ export const createDefaultUserAgentProvider = ({ serviceId, clientVersion }: DefaultUserAgentOptions): ((config?: PreviouslyResolved) => Promise) => diff --git a/packages/util-user-agent-browser/src/index.spec.ts b/packages/util-user-agent-browser/src/index.spec.ts index 3aed1897137ca..08165a123d3b4 100644 --- a/packages/util-user-agent-browser/src/index.spec.ts +++ b/packages/util-user-agent-browser/src/index.spec.ts @@ -1,56 +1,114 @@ -import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest"; +import { describe, expect, test as it, vi } from "vitest"; -import { createDefaultUserAgentProvider, PreviouslyResolved } from "."; +import { createDefaultUserAgentProvider, fallback } from "./index"; -const ua = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36"; - -const mockConfig: PreviouslyResolved = { - userAgentAppId: vi.fn().mockResolvedValue(undefined), +type NavigatorTestAugment = { + userAgentData?: { + brands?: { + brand?: string; + version?: string; + }[]; + platform?: string; + }; }; -describe("createDefaultUserAgentProvider", () => { - beforeEach(() => { - vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue(ua); - }); +describe("browser and os brand detection", () => { + it("should use navigator.userAgentData when available", async () => { + const navigator = window.navigator as typeof window.navigator & NavigatorTestAugment; + navigator.userAgentData = { + platform: "linux", + brands: [ + { brand: "test", version: "1" }, + { brand: "BROWSER_BRAND", version: "10000" }, + ], + }; - afterEach(() => { - vi.clearAllMocks(); - }); + const uaProvider = createDefaultUserAgentProvider({ + serviceId: "AWS", + clientVersion: "3.0.0", + }); - it("should populate metrics", async () => { - const userAgent = await createDefaultUserAgentProvider({ serviceId: "s3", clientVersion: "0.1.0" })(mockConfig); - expect(userAgent[0]).toEqual(["aws-sdk-js", "0.1.0"]); - expect(userAgent[1]).toEqual(["ua", "2.1"]); - expect(userAgent[2]).toEqual(["os/macOS", "10.15.7"]); - expect(userAgent[3]).toEqual(["lang/js"]); - expect(userAgent[4]).toEqual(["md/browser", "Chrome_86.0.4240.111"]); - expect(userAgent[5]).toEqual(["api/s3", "0.1.0"]); - expect(userAgent.length).toBe(6); + const sdkUa = await uaProvider(); + expect(sdkUa.flatMap((_) => _.filter(Boolean).join("#")).join(" ")).toEqual( + "aws-sdk-js#3.0.0 ua#2.1 os/linux lang/js md/browser#BROWSER_BRAND_10000 api/AWS#3.0.0" + ); }); - it("should populate metrics when service id not available", async () => { - const userAgent = await createDefaultUserAgentProvider({ serviceId: undefined, clientVersion: "0.1.0" })( - mockConfig + it("falls back to basic user agent parsing", async () => { + const navigator = window.navigator as typeof window.navigator & NavigatorTestAugment; + delete navigator.userAgentData; + + vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Linux Firefox/"); + + const uaProvider = createDefaultUserAgentProvider({ + serviceId: "AWS", + clientVersion: "3.0.0", + }); + + const sdkUa = await uaProvider(); + expect(sdkUa.flatMap((_) => _.filter(Boolean).join("#")).join(" ")).toEqual( + "aws-sdk-js#3.0.0 ua#2.1 os/Linux lang/js md/browser#Firefox_unknown api/AWS#3.0.0" ); - expect(userAgent).not.toContainEqual(["api/s3", "0.1.0"]); - expect(userAgent.length).toBe(5); }); - it("should include appId when provided", async () => { - const configWithAppId: PreviouslyResolved = { - userAgentAppId: vi.fn().mockResolvedValue("test-app-id"), - }; - const userAgent = await createDefaultUserAgentProvider({ serviceId: "s3", clientVersion: "0.1.0" })( - configWithAppId + it("uses defaults when unable to detect any specific OS or browser brand", async () => { + const navigator = window.navigator as typeof window.navigator & NavigatorTestAugment; + delete navigator.userAgentData; + + vi.spyOn(navigator, "userAgent", "get").mockReturnValue(""); + + const uaProvider = createDefaultUserAgentProvider({ + serviceId: "AWS", + clientVersion: "3.0.0", + }); + + const sdkUa = await uaProvider(); + expect(sdkUa.flatMap((_) => _.filter(Boolean).join("#")).join(" ")).toEqual( + "aws-sdk-js#3.0.0 ua#2.1 os/other lang/js md/browser#unknown_unknown api/AWS#3.0.0" ); - expect(userAgent[6]).toEqual(["app/test-app-id"]); - expect(userAgent.length).toBe(7); }); +}); + +describe("ua fallback parsing", () => { + const samples = [ + `Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15`, + `Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6.2 Mobile/15E148 Safari/604.1`, + `Mozilla/5.0 (iPad; CPU OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6.2 Mobile/15E148 Safari/604.1`, + `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36`, + `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36`, + `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36`, + `Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.7390.44 Mobile Safari/537.36`, + `Mozilla/5.0 (Linux; Android 16; LM-Q710(FGN)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.7390.44 Mobile Safari/537.36`, + `Mozilla/5.0 (Android 16; Mobile; LG-M255; rv:143.0) Gecko/143.0 Firefox/143.0`, + `Mozilla/5.0 (Android 16; Mobile; rv:68.0) Gecko/68.0 Firefox/143.0`, + `Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/140.3485.94 Mobile/15E148 Safari/605.1.15`, + `Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.7390.44 Mobile Safari/537.36 EdgA/140.0.3485.98`, + `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.3537.57`, + `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.3537.57`, + `Mozilla/5.0 (X11; Linux i686; rv:143.0) Gecko/20100101 Firefox/143.0`, + ]; + + const expectedBrands = [ + ["macOS", "Safari"], + ["iOS", "Safari"], + ["iOS", "Safari"], + ["macOS", "Chrome"], + ["Windows", "Chrome"], + ["Linux", "Chrome"], + ["Android", "Chrome"], + ["Android", "Chrome"], + ["Android", "Firefox"], + ["Android", "Firefox"], + ["iOS", "Microsoft Edge"], + ["Android", "Microsoft Edge"], + ["macOS", "Microsoft Edge"], + ["Windows", "Microsoft Edge"], + ["Linux", "Firefox"], + ]; - it("should not include appId when not provided", async () => { - const userAgent = await createDefaultUserAgentProvider({ serviceId: "s3", clientVersion: "0.1.0" })(mockConfig); - expect(userAgent).not.toContainEqual(expect.arrayContaining(["app/"])); - expect(userAgent.length).toBe(6); + it("should detect os and browser", () => { + for (let i = 0; i < expectedBrands.length; ++i) { + expect([fallback.os(samples[i]), fallback.browser(samples[i])]).toEqual(expectedBrands[i]); + } }); }); diff --git a/packages/util-user-agent-browser/src/index.ts b/packages/util-user-agent-browser/src/index.ts index 8cc1af612b42e..fbe774672e01e 100644 --- a/packages/util-user-agent-browser/src/index.ts +++ b/packages/util-user-agent-browser/src/index.ts @@ -1,42 +1,62 @@ import { Provider, UserAgent } from "@smithy/types"; -import bowser from "bowser"; -import { DefaultUserAgentOptions } from "./configurations"; +import type { DefaultUserAgentOptions } from "./configurations"; +export { createUserAgentStringParsingProvider } from "./createUserAgentStringParsingProvider"; + +/** + * @internal + */ export interface PreviouslyResolved { userAgentAppId: Provider; } /** * @internal - * - * Default provider to the user agent in browsers. It's a best effort to infer - * the device information. It uses bowser library to detect the browser and version + */ +type NavigatorAugment = { + userAgentData?: { + brands?: { + brand?: string; + version?: string; + }[]; + platform?: string; + }; +}; + +/** + * Default provider of the AWS SDK user agent string in react-native. + * @internal */ export const createDefaultUserAgentProvider = ({ serviceId, clientVersion }: DefaultUserAgentOptions): ((config?: PreviouslyResolved) => Promise) => async (config?: PreviouslyResolved) => { - const parsedUA = - typeof window !== "undefined" && window?.navigator?.userAgent - ? bowser.parse(window.navigator.userAgent) - : undefined; + const navigator = + typeof window !== "undefined" ? (window.navigator as typeof window.navigator & NavigatorAugment) : undefined; + + const uaString = navigator?.userAgent ?? ""; + + // Sample values: macOS, iOS, Windows, Android, Linux + const osName = navigator?.userAgentData?.platform ?? fallback.os(uaString) ?? "other"; + // We're not going to attempt to detect the OS version in a browser. + const osVersion = undefined; + + const brands = navigator?.userAgentData?.brands ?? []; + const brand = brands[brands.length - 1]; + + // Sample values: Safari, Chrome, Firefox, Microsoft Edge + const browserName = brand?.brand ?? fallback.browser(uaString) ?? "unknown"; + const browserVersion = brand?.version ?? "unknown"; + const sections: UserAgent = [ - // sdk-metadata ["aws-sdk-js", clientVersion], - // ua-metadata ["ua", "2.1"], - // os-metadata - [`os/${parsedUA?.os?.name || "other"}`, parsedUA?.os?.version], - // language-metadata - // ECMAScript edition doesn't matter in JS. + [`os/${osName}`, osVersion], ["lang/js"], - // browser vendor and version. - ["md/browser", `${parsedUA?.browser?.name ?? "unknown"}_${parsedUA?.browser?.version ?? "unknown"}`], + ["md/browser", `${browserName}_${browserVersion}`], ]; if (serviceId) { - // api-metadata - // service Id may not appear in non-AWS clients sections.push([`api/${serviceId}`, clientVersion]); } @@ -44,10 +64,31 @@ export const createDefaultUserAgentProvider = if (appId) { sections.push([`app/${appId}`]); } - return sections; }; +/** + * Rudimentary UA string parsing as a fallback. + * @internal + */ +export const fallback = { + os(ua: string): string | undefined { + if (/iPhone|iPad|iPod/.test(ua)) return "iOS"; + if (/Macintosh|Mac OS X/.test(ua)) return "macOS"; + if (/Windows NT/.test(ua)) return "Windows"; + if (/Android/.test(ua)) return "Android"; + if (/Linux/.test(ua)) return "Linux"; + return undefined; + }, + browser(ua: string): string | undefined { + if (/EdgiOS|EdgA|Edg\//.test(ua)) return "Microsoft Edge"; + if (/Firefox\//.test(ua)) return "Firefox"; + if (/Chrome\//.test(ua)) return "Chrome"; + if (/Safari\//.test(ua)) return "Safari"; + return undefined; + }, +}; + /** * @internal * @deprecated use createDefaultUserAgentProvider diff --git a/packages/util-user-agent-node/src/crt-availability.ts b/packages/util-user-agent-node/src/crt-availability.ts index 5fa6d554dced2..ed7b786506fa0 100644 --- a/packages/util-user-agent-node/src/crt-availability.ts +++ b/packages/util-user-agent-node/src/crt-availability.ts @@ -1,8 +1,7 @@ /** - * @internal - * * If \@aws-sdk/signature-v4-crt is installed and loaded, it will register * this value to true. + * @internal */ export const crtAvailability = { isCrtAvailable: false, diff --git a/packages/util-user-agent-node/src/defaultUserAgent.ts b/packages/util-user-agent-node/src/defaultUserAgent.ts index df23988414c2b..ed30d2617e8e3 100644 --- a/packages/util-user-agent-node/src/defaultUserAgent.ts +++ b/packages/util-user-agent-node/src/defaultUserAgent.ts @@ -4,21 +4,29 @@ import { env, versions } from "process"; import { isCrtAvailable } from "./is-crt-available"; +/** + * @internal + */ export { crtAvailability } from "./crt-availability"; +/** + * @internal + */ export interface DefaultUserAgentOptions { serviceId?: string; clientVersion: string; } +/** + * @internal + */ export interface PreviouslyResolved { userAgentAppId: Provider; } /** - * @internal - * * Collect metrics from runtime to put into user agent. + * @internal */ export const createDefaultUserAgentProvider = ({ serviceId, clientVersion }: DefaultUserAgentOptions) => { return async (config?: PreviouslyResolved) => { @@ -59,10 +67,7 @@ export const createDefaultUserAgentProvider = ({ serviceId, clientVersion }: Def }; /** - * * @internal - * * @deprecated use createDefaultUserAgentProvider - * */ export const defaultUserAgent = createDefaultUserAgentProvider; diff --git a/vitest.config.browser.mts b/vitest.config.browser.mts index e1c75a87c35ed..e2afe815a9c65 100644 --- a/vitest.config.browser.mts +++ b/vitest.config.browser.mts @@ -6,6 +6,7 @@ export default defineConfig({ include: [ "packages/util-user-agent-browser/src/index.spec.ts", "packages/util-user-agent-browser/src/index.native.spec.ts", + "packages/util-user-agent-browser/src/createUserAgentStringParsingProvider.spec.ts", "packages/credential-provider-cognito-identity/src/localStorage-inmemoryStorage.spec.ts", "packages/body-checksum-browser/src/index.spec.ts", "packages/middleware-websocket/src/get-event-signing-stream.spec.ts", diff --git a/vitest.config.mts b/vitest.config.mts index 51153de1e5c2e..3ae888a450aed 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -29,6 +29,7 @@ export default defineConfig({ "packages/types/**/*.ts", "packages/util-user-agent-browser/src/index.spec.ts", "packages/util-user-agent-browser/src/index.native.spec.ts", + "packages/util-user-agent-browser/src/createUserAgentStringParsingProvider.spec.ts", "packages/credential-provider-cognito-identity/src/localStorage-inmemoryStorage.spec.ts", "packages/body-checksum-browser/src/index.spec.ts", "packages/middleware-websocket/src/get-event-signing-stream.spec.ts",