DEV Community

Ido
Ido

Posted on

Built a User-Agent Parser for Kotlin Multiplatform

Docs | GitHub

I needed a User-Agent parser.

That sounds like a pretty small problem.

Take a string, find Chrome or Safari, figure out the OS, return an object. Done.

Except that once you start supporting more than one platform, the problem gets annoying pretty quickly.

I ended up building kmp-user-agent to solve that problem with one shared implementation for Kotlin Multiplatform.

The problem I was trying to solve

User-Agent parsing is usually implemented as a collection of string checks and regular expressions.

Something along these lines:

if (userAgent.contains("Chrome")) {
    // Chrome
}

if (userAgent.contains("Android")) {
    // Android
}
Enter fullscreen mode Exit fullscreen mode

That works until it doesn't.

Browsers contain tokens from other browsers for compatibility reasons. Mobile User-Agents have their own variations. WebViews are another case. Then there are crawlers and other automated clients.

And if you have the same logic in JavaScript, Android and iOS, you now have the same problem in three places.

I didn't want that.

Why KMP made sense here

The interesting thing about User-Agent parsing is that most of the logic is completely platform independent.

The input is a string.

The output is structured data.

There isn't much platform-specific work involved in deciding whether a User-Agent represents Chrome running on Android.

So this felt like a good fit for Kotlin Multiplatform.

The goal became:

              User-Agent
                   |
                   v
          Shared KMP parser
                   |
        +----------+----------+
        |          |          |
      Android     iOS       JVM / JS
Enter fullscreen mode Exit fullscreen mode

One set of detection rules.

One model.

Different platform bindings.

The current library supports Browser/Node.js, React Native, Android, iOS and JVM. The React Native implementation uses the same JS build, with Metro and Hermes tested separately.

What does the parser return?

I didn't want consumers to work with a bunch of strings.

The parser returns a structured UserAgentInfo.

The model currently covers things such as:

Browser
Engine
Operating System
Device
Bot
AI Agent
Enter fullscreen mode Exit fullscreen mode

So application code can work with the result instead of knowing anything about the actual User-Agent format.

For example:

val info = UserAgentParser.parse(userAgent)

println(info.browser)
println(info.operatingSystem)
println(info.device)
Enter fullscreen mode Exit fullscreen mode

The important part for me is that the application shouldn't need to know why a particular regular expression matched.

That's the parser's job.

Detection packs

One design decision I'm particularly happy with is the use of detection packs.

Instead of having one huge parser where everything is enabled by default, the detection categories can be composed.

For example, an application might only need browser and OS detection.

Another one might also need device detection.

The basic idea is:

UserAgentParser(
    packs = listOf(
        UserAgentBrowserTypes,
        UserAgentOperatingSystemTypes,
        UserAgentDeviceTypes
    )
)
Enter fullscreen mode Exit fullscreen mode

This also makes the JavaScript build more interesting because unused packs can be removed by the bundler.

I tested this with webpack and esbuild.

There is one caveat: React Native's Metro doesn't provide the same tree-shaking behavior, so I don't consider the JS behavior identical across every bundler.

I prefer documenting limitations like this rather than pretending the optimization works everywhere.

Parsing isn't the only thing

While building the parser, I realized that generating User-Agent strings was useful too.

It's handy for tests and for reproducing requests with different client characteristics.

So the library also has a UserAgentGenerator.

Conceptually:

User-Agent string
       |
       v
     Parser
       |
       v
 UserAgentInfo
Enter fullscreen mode Exit fullscreen mode

and the other direction:

UserAgentInfo / filters
       |
       v
   Generator
       |
       v
User-Agent string
Enter fullscreen mode Exit fullscreen mode

You can select things like browser, engine, OS and device and generate a plausible User-Agent.

It's also useful when testing code that has different behavior depending on the detected client.

Extending the parser

There is another problem with User-Agent libraries.

No matter how many signatures you add, somebody will eventually have a User-Agent that isn't recognized.

I didn't want the solution to be:

Fork the repository and maintain your own version.

The library therefore allows custom UserAgentTypePack implementations.

That means you can add your own detection rules or override existing results without changing the core parser.

There is also a custom map for information that doesn't fit into the predefined model.

This makes the parser useful for applications that have their own internal clients as well.

JavaScript is a first-class target

Since User-Agent parsing is particularly common on the web, I wanted the JavaScript package to be a real part of the project rather than an afterthought.

For example:

const info = UserAgentParser.parse(userAgent)

console.log(info.browser)
console.log(info.operatingSystem)
Enter fullscreen mode Exit fullscreen mode

The package is published to npm and can be used from browser and Node.js applications.

The same shared implementation is also used by the other targets.

That's the part I like most about the project.

If I find a problem in the detection rules, I'm fixing the shared implementation rather than maintaining separate JavaScript, Android and iOS parsers.

What about bots and AI crawlers?

This is an area I'm still expanding.

A User-Agent doesn't necessarily represent a person using a browser.

There are search crawlers, link preview bots, application clients and AI-related crawlers.

I wanted these to be represented separately rather than simply returning:

isBrowser = false
Enter fullscreen mode Exit fullscreen mode

The library therefore has separate concepts for bots and AI agents.

The detection packs include signatures for known automated clients, and I'm treating this as an evolving part of the project because these identifiers can change.

This is also why I don't think User-Agent detection should be treated as a security boundary.

It's useful for classification.

It isn't proof of identity.

One small project, a few interesting problems

The actual parsing code isn't particularly glamorous.

The interesting engineering problems were elsewhere:

How should the model look?

How do you keep behavior consistent across platforms?

How do you make detection extensible?

How do you avoid shipping unnecessary detection logic to a JavaScript application?

How do you test the same behavior across different KMP targets?

And perhaps most importantly:

How much complexity should the library expose to its users?

I tried to keep the public API relatively small and push the ugly parts into the implementation.

That's usually a good trade for a utility library.

Where it is today

The project is open source and MIT licensed.

The currently published package is 0.2.0, with the shared implementation available across the supported platforms.

There are also newer detection packs in development, including bot and AI-agent detection, so not everything visible in the source/docs necessarily means it is already part of the published package.

The live documentation has examples for:

  • Browser / Node.js
  • React Native
  • Android
  • iOS
  • JVM

You can try the parser directly in the documentation:

https://user-agent.lempert.site/

Why I built it

This started as one of those utilities that seemed too small to deserve a library.

Then I realized that was exactly why I wanted to build it.

Small infrastructure problems tend to get copied into projects.

Then they get slightly modified.

Then somebody fixes a bug in one copy but not the others.

Then six months later nobody remembers why one regular expression looks completely ridiculous.

I'd rather have one implementation that can be reused.

That's the main idea behind kmp-user-agent.

Not trying to reinvent User-Agent parsing.

Just trying to make it something I don't have to implement again.

If you work with Kotlin Multiplatform and have a similar small utility problem, I'd be interested to hear what you've ended up sharing between platforms.

Top comments (0)