sdxc

[ HTTP & responses ]

@sdxc/user-agent

Read a User-Agent string into its browser, engine, operating system and device

npm add @sdxc/user-agent remix
pnpm add @sdxc/user-agent remix
yarn add @sdxc/user-agent remix
bun add @sdxc/user-agent remix
Used by
auth-saas

Read a User-Agent string into its browser, engine, operating system and device.

Installation

npm add @sdxc/user-agent

The ./middleware and ./helpers entry points plug into the router from remix.

Usage

Parse a header

import { parse } from "@sdxc/user-agent";

let ua = parse(request.headers.get("user-agent") ?? "");

ua.browser; // { name: "Safari", version: "17.4" }
ua.engine; // { name: "WebKit", version: "605.1.15" }
ua.os; // { name: "iOS", version: "17.4" }
ua.device; // { type: "mobile", vendor: "Apple", model: "iPhone" }

Every field is string | null: a part the string does not reveal reads as null, so a crawler or an empty header comes back as the same shape with nothing in it.

Tell bots apart

import { isBot } from "@sdxc/user-agent";

isBot("Mozilla/5.0 (compatible; Googlebot/2.1)"); // true
isBot(""); // true

Parse once per request

import { userAgent } from "@sdxc/user-agent/middleware";
import { createRouter } from "remix/router";

let router = createRouter({ middleware: [userAgent()] });

router.get("/download", (ctx) => {
	ctx.userAgent.os.name; // "macOS"
	ctx.userAgent.device.type; // "desktop"
	return new Response("ok");
});

Ask a question of it

import { parse } from "@sdxc/user-agent";
import { isTouch } from "@sdxc/user-agent/helpers";

isTouch(); // the current request, through asyncContext()
isTouch(ctx); // a request context
isTouch(parse(header)); // a user agent already parsed

API

parse(userAgent: string): UserAgent

Reads the browser, engine, operating system and device a string describes. Each part is read on its own, so a half-recognized string answers for the parts it reveals; it never throws.

isBot(header: string): boolean

Whether the header names automated software: search and AI crawlers, link-preview fetchers (facebookexternalhit, Slackbot, WhatsApp), uptime monitors, headless browsers and HTTP libraries such as curl. An empty or blank header counts as a bot, since every browser sends one.

userAgent(): Middleware

From @sdxc/user-agent/middleware. Parses the request's User-Agent header and publishes it as ctx.userAgent, typed on every request context once the module is imported.

CurrentUserAgent

From @sdxc/user-agent/middleware. The context key the middleware writes, read as ctx.get(CurrentUserAgent). Its default is the all-null shape, so a context the middleware never touched still answers.

Predicates

From @sdxc/user-agent/helpers. Each takes an optional UserAgentSource and returns a boolean. With no argument it reads the current request through asyncContext() from remix/middleware/async-context; outside a request it reads the all-null shape and answers false.

  • isApplePlatform(): macOS, iOS or iPadOS.

  • isAndroid(): Android, phones and tablets alike.

  • isMobile(): a phone.

  • isTablet(): a tablet.

  • isDesktop(): a computer, including an iPad that requested the desktop site.

  • isTouch(): a phone or a tablet.

Types

UserAgent

{ browser: Browser; engine: Engine; os: OperatingSystem; device: Device }.

Browser

{ name: BrowserName | null; version: string | null }, the version spelled as the string spells it ("122.0.6261.89"). A browser built on another's engine names itself, so Edge, Opera, Samsung Internet and the iOS builds of Chrome, Firefox and Edge are reported as those browsers.

Engine

{ name: EngineName | null; version: string | null }. Every iOS browser renders through WebKit, so Chrome on an iPhone reports Chrome as its browser and WebKit as its engine. Blink and WebKit report the WebKit build number.

OperatingSystem

{ name: OperatingSystemName | null; version: string | null }, in the platform's own numbering. Windows 11 sends the same token as Windows 10 and reads as "10"; Safari reports macOS as 10.15.7 whatever the real release; an iPad reads as "iPadOS" from version 13 and "iOS" before it.

Device

{ type: DeviceType | null; vendor: DeviceVendor | null; model: string | null }. The model is "iPhone", "Pixel 8" or "SM-G991B" where the string names one, and null where Chromium sends its K placeholder. An iPad that requested the desktop site reads as a desktop Mac.

UserAgentSource

From @sdxc/user-agent/helpers. A UserAgent or a request context the middleware has populated.

BrowserName, EngineName, OperatingSystemName, DeviceType, DeviceVendor

String unions of every value each field can hold, so a switch over a name is exhaustive. DeviceType is "mobile", "tablet", "desktop", "tv" or "console".

Pattern: Naming A Device A Person Will Recognize

A session list or a login notification needs one short line naming where a request came from: the model when the string gives one, otherwise the browser and the system together.

import { parse } from "@sdxc/user-agent";

export function describeDevice(header: string): string {
	let { browser, os, device } = parse(header);

	if (device.model) return device.model;
	if (browser.name && os.name) return `${browser.name} on ${os.name}`;

	return browser.name ?? os.name ?? "Unknown device";
}

describeDevice(chromeOnAPixel); // "Pixel 8"
describeDevice(safariOnAMac); // "Safari on macOS"

Pattern: Serving A Platform's Own Download

A download page picks the build that matches the visitor and lists the rest below it. A null means the string gave no answer, and the page shows every build.

import type { OperatingSystemName } from "@sdxc/user-agent";

import { parse } from "@sdxc/user-agent";

function buildFor(name: OperatingSystemName | null) {
	switch (name) {
		case "iOS":
		case "iPadOS":
			return "app-store";
		case "Android":
			return "play-store";
		case "macOS":
			return "macos-dmg";
		case "Windows":
			return "windows-exe";
		default:
			return null;
	}
}

let suggested = buildFor(parse(request.headers.get("user-agent") ?? "").os.name);

Pattern: Counting Only Human Visits

Crawlers and preview fetchers parse as all-null, so isBot is the check that keeps them out of analytics and session storage.

import { isBot } from "@sdxc/user-agent";
import { userAgent } from "@sdxc/user-agent/middleware";
import { createRouter } from "remix/router";

let router = createRouter({
	middleware: [
		userAgent(),
		async (ctx, next) => {
			if (!isBot(ctx.request.headers.get("user-agent") ?? "")) {
				await analytics.track(ctx.url.pathname, ctx.userAgent.browser.name);
			}
			return next();
		},
	],
});

Pattern: Adapting A View Deep In A Render

With asyncContext() before userAgent(), a helper or component anywhere in the request asks without being handed the context.

import { isApplePlatform, isTouch } from "@sdxc/user-agent/helpers";
import { userAgent } from "@sdxc/user-agent/middleware";
import { asyncContext } from "remix/middleware/async-context";
import { createRouter } from "remix/router";

let router = createRouter({ middleware: [asyncContext(), userAgent()] });

export function searchShortcut() {
	if (isTouch()) return null;
	return isApplePlatform() ? "⌘K" : "Ctrl K";
}

Pattern: Gating On A Browser Version

Versions stay strings, so compare the major number you parse from them.

import { parse } from "@sdxc/user-agent";

let { browser } = parse(request.headers.get("user-agent") ?? "");

let major = Number.parseInt(browser.version ?? "", 10);
let needsPolyfill = browser.name === "Safari" && major < 16;

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.