
Parses and generates User-Agent strings via composable, pack-based type tables (browser, engine, OS, device, bot, AI-agent). Modular, tree-shakeable packs; first-pack-wins merging and graceful failures.
This is a Kotlin Multiplatform project targeting Android, iOS, JVM, and Web with a
shared library module that parses and generates User-Agent strings.
Once published, the library is available on Maven Central under:
implementation("site.lempert:user-agent:0.2.0")For JS/Node consumers, the same library is published to npm separately:
npm install @lempert/user-agentThe library exposes two factory functions, each composed from a variadic list of type packs:
fun UserAgentParser(vararg packs: UserAgentTypePack): (String) -> UserAgentInfo
fun UserAgentGenerator(vararg packs: UserAgentTypePack): (UserAgentInfo) -> StringCall the factory with the packs you want, then call the function it returns:
val parse = UserAgentParser(UserAgentAllTypes)
val info = parse(userAgentString) // UserAgentInfo(browser = ..., engine = ..., os = ..., device = ...)
val generate = UserAgentGenerator(UserAgentAllTypes)
val userAgentString = generate(info)Built-in packs, each individually importable so a bundler can tree-shake out the ones you don't reference:
UserAgentBrowserTypes -- populates UserAgentInfo.browser
UserAgentEngineTypes -- populates UserAgentInfo.engine
UserAgentOsTypes -- populates UserAgentInfo.os
UserAgentDeviceTypes -- populates UserAgentInfo.device
UserAgentBotTypes -- populates UserAgentInfo.bot: 26 entries -- a mix of
search/SEO crawlers, monitoring tools, social/link-preview bots, API clients,
and scraping/extraction services -- see
UserAgentBotTypePack.kt
for the full entry list and sourcing notesUserAgentAIAgentTypes -- populates UserAgentInfo.aiAgent: 20 well-known AI/LLM
crawlers and agents -- see
UserAgentAIAgentTypePack.kt
for the full entry list and sourcing notesUserAgentAllTypes -- convenience bundle of all of the aboveEvery UserAgentBotTypes/UserAgentAIAgentTypes entry is hand-transcribed directly
from that bot/crawler operator's own public documentation, or -- only where no
first-party page could be found -- from multiple independent, clearly-attributed
corroborating sources with a known operator (see the two files linked above's KDoc
comments for the operators sourced from) -- never copied from a third-party
commercial bot-detection dataset. Both tables are intentionally non-exhaustive
starter lists; add your own
entries via a custom UserAgentTypePack (see below) if you need to detect something
not covered here.
Passing no packs returns an always-empty result -- every UserAgentInfo field
null on parse, or just the bare "Mozilla/5.0" base string on generate. There is
no implicit fallback to UserAgentAllTypes; a consumer wanting full detection
passes it explicitly. This keeps a single-pack import (e.g. only
UserAgentBrowserTypes) tree-shakeable in a JS build -- the unused packs' rule
tables and detection code are never bundled.
You can pass a subset of packs to only populate the fields you care about:
val parseBrowserOnly = UserAgentParser(UserAgentBrowserTypes)
val info = parseBrowserOnly(userAgentString) // only `browser` is populated; the rest are nullWhen more than one pack is passed, UserAgentParser merges their detect results
field-by-field: the first pack (in the order given) to produce a non-null value for
a field wins, and UserAgentInfo.custom entries merge by key with the same
first-pack-wins rule per key. UserAgentGenerator instead tries each pack's
applyToGenerate in order and uses the first non-null result, falling back to the
bare "Mozilla/5.0" base string if every pack returns null.
The same API is published to npm. Top-level pack constants are exported as getter
objects under Kotlin/JS's @JsExport lowering, so call .get() to retrieve the
actual UserAgentTypePack instance, and pass packs as a plain array rather than
varargs:
import { UserAgentBrowserTypes, UserAgentParser } from '@lempert/user-agent';
const parse = UserAgentParser([UserAgentBrowserTypes.get()]);
const info = parse(userAgentString); // only `browser` is populated; the rest are nullYou can also author your own pack -- a UserAgentTypePack is just an id, a
detect function, and an optional applyToGenerate function -- to add detection
or generation categories without forking the library:
val myPack = UserAgentTypePack(
id = "myThing",
detect = { userAgent -> UserAgentInfo(custom = mapOf("myThing" to Component("Found", null))) },
applyToGenerate = { info -> info.custom["myThing"]?.let { "MyThing/${it.name}" } },
)
val parse = UserAgentParser(UserAgentBrowserTypes, myPack)
val generate = UserAgentGenerator(UserAgentBrowserTypes, myPack)A pack that throws during detect/applyToGenerate degrades gracefully -- it
just contributes nothing for that call, and never crashes a composed
UserAgentParser/UserAgentGenerator call.
UserAgentInfo's bot/aiAgent fields are populated by UserAgentBotTypes/
UserAgentAIAgentTypes (or UserAgentAllTypes) above; they stay null when
those packs aren't passed, or when nothing in the passed packs' tables matches.
0.1.0's singleton API is gone in 0.2.0 -- replace it with the pack-based factory
functions above, passing UserAgentAllTypes to match the old, all-categories
behavior:
// 0.1.0
val info = UserAgentParser.parse(userAgentString)
val userAgentString = UserAgentGenerator.generate(info)
// 0.2.0
val info = UserAgentParser(UserAgentAllTypes)(userAgentString)
val userAgentString = UserAgentGenerator(UserAgentAllTypes)(info)UserAgentInfo also gained two new trailing fields in 0.2.0, bot: Component? and
aiAgent: Component? (both default to null, unused until a future release's
bot/AI-agent packs). This matters if you construct UserAgentInfo positionally
(as webApp's sample does from TypeScript) or serialize/deserialize it, since the
field count/order changed.
/library is the multiplatform library itself -- UserAgentParser,
UserAgentGenerator, the built-in type packs, and the shared data model live in
commonMain, with the shared cross-target test
corpus in commonTest. Every production source
set depends only on the Kotlin stdlib.
/androidApp, /iosApp, /jvmApp,
and /webApp are thin per-target sample apps -- one per MVP target
(Android, iOS, JVM, Web) -- that each depend on :library and call
UserAgentParser(UserAgentAllTypes)/UserAgentGenerator(UserAgentAllTypes) to
prove the library works as a consumed dependency. They are harnesses, not real
app experiences.
Use the run configurations provided by the run widget in your IDE's toolbar. You can also use these commands and options:
./gradlew :androidApp:assembleDebug
./gradlew :jvmApp:run
npm)npm run build:shared
npm install
npm run startUse the run button in your IDE's editor gutter, or run tests using Gradle tasks:
./gradlew :library:allTests
./gradlew :library:testAndroidHostTest
./gradlew :library:jvmTest
./gradlew :library:jsTest
./gradlew :library:iosSimulatorArm64Test
CI (.github/workflows/ci.yml) runs ./gradlew build on every push and pull
request, which exercises all four targets and compiles the sample apps.
Learn more about Kotlin Multiplatform…
This is a Kotlin Multiplatform project targeting Android, iOS, JVM, and Web with a
shared library module that parses and generates User-Agent strings.
Once published, the library is available on Maven Central under:
implementation("site.lempert:user-agent:0.2.0")For JS/Node consumers, the same library is published to npm separately:
npm install @lempert/user-agentThe library exposes two factory functions, each composed from a variadic list of type packs:
fun UserAgentParser(vararg packs: UserAgentTypePack): (String) -> UserAgentInfo
fun UserAgentGenerator(vararg packs: UserAgentTypePack): (UserAgentInfo) -> StringCall the factory with the packs you want, then call the function it returns:
val parse = UserAgentParser(UserAgentAllTypes)
val info = parse(userAgentString) // UserAgentInfo(browser = ..., engine = ..., os = ..., device = ...)
val generate = UserAgentGenerator(UserAgentAllTypes)
val userAgentString = generate(info)Built-in packs, each individually importable so a bundler can tree-shake out the ones you don't reference:
UserAgentBrowserTypes -- populates UserAgentInfo.browser
UserAgentEngineTypes -- populates UserAgentInfo.engine
UserAgentOsTypes -- populates UserAgentInfo.os
UserAgentDeviceTypes -- populates UserAgentInfo.device
UserAgentBotTypes -- populates UserAgentInfo.bot: 26 entries -- a mix of
search/SEO crawlers, monitoring tools, social/link-preview bots, API clients,
and scraping/extraction services -- see
UserAgentBotTypePack.kt
for the full entry list and sourcing notesUserAgentAIAgentTypes -- populates UserAgentInfo.aiAgent: 20 well-known AI/LLM
crawlers and agents -- see
UserAgentAIAgentTypePack.kt
for the full entry list and sourcing notesUserAgentAllTypes -- convenience bundle of all of the aboveEvery UserAgentBotTypes/UserAgentAIAgentTypes entry is hand-transcribed directly
from that bot/crawler operator's own public documentation, or -- only where no
first-party page could be found -- from multiple independent, clearly-attributed
corroborating sources with a known operator (see the two files linked above's KDoc
comments for the operators sourced from) -- never copied from a third-party
commercial bot-detection dataset. Both tables are intentionally non-exhaustive
starter lists; add your own
entries via a custom UserAgentTypePack (see below) if you need to detect something
not covered here.
Passing no packs returns an always-empty result -- every UserAgentInfo field
null on parse, or just the bare "Mozilla/5.0" base string on generate. There is
no implicit fallback to UserAgentAllTypes; a consumer wanting full detection
passes it explicitly. This keeps a single-pack import (e.g. only
UserAgentBrowserTypes) tree-shakeable in a JS build -- the unused packs' rule
tables and detection code are never bundled.
You can pass a subset of packs to only populate the fields you care about:
val parseBrowserOnly = UserAgentParser(UserAgentBrowserTypes)
val info = parseBrowserOnly(userAgentString) // only `browser` is populated; the rest are nullWhen more than one pack is passed, UserAgentParser merges their detect results
field-by-field: the first pack (in the order given) to produce a non-null value for
a field wins, and UserAgentInfo.custom entries merge by key with the same
first-pack-wins rule per key. UserAgentGenerator instead tries each pack's
applyToGenerate in order and uses the first non-null result, falling back to the
bare "Mozilla/5.0" base string if every pack returns null.
The same API is published to npm. Top-level pack constants are exported as getter
objects under Kotlin/JS's @JsExport lowering, so call .get() to retrieve the
actual UserAgentTypePack instance, and pass packs as a plain array rather than
varargs:
import { UserAgentBrowserTypes, UserAgentParser } from '@lempert/user-agent';
const parse = UserAgentParser([UserAgentBrowserTypes.get()]);
const info = parse(userAgentString); // only `browser` is populated; the rest are nullYou can also author your own pack -- a UserAgentTypePack is just an id, a
detect function, and an optional applyToGenerate function -- to add detection
or generation categories without forking the library:
val myPack = UserAgentTypePack(
id = "myThing",
detect = { userAgent -> UserAgentInfo(custom = mapOf("myThing" to Component("Found", null))) },
applyToGenerate = { info -> info.custom["myThing"]?.let { "MyThing/${it.name}" } },
)
val parse = UserAgentParser(UserAgentBrowserTypes, myPack)
val generate = UserAgentGenerator(UserAgentBrowserTypes, myPack)A pack that throws during detect/applyToGenerate degrades gracefully -- it
just contributes nothing for that call, and never crashes a composed
UserAgentParser/UserAgentGenerator call.
UserAgentInfo's bot/aiAgent fields are populated by UserAgentBotTypes/
UserAgentAIAgentTypes (or UserAgentAllTypes) above; they stay null when
those packs aren't passed, or when nothing in the passed packs' tables matches.
0.1.0's singleton API is gone in 0.2.0 -- replace it with the pack-based factory
functions above, passing UserAgentAllTypes to match the old, all-categories
behavior:
// 0.1.0
val info = UserAgentParser.parse(userAgentString)
val userAgentString = UserAgentGenerator.generate(info)
// 0.2.0
val info = UserAgentParser(UserAgentAllTypes)(userAgentString)
val userAgentString = UserAgentGenerator(UserAgentAllTypes)(info)UserAgentInfo also gained two new trailing fields in 0.2.0, bot: Component? and
aiAgent: Component? (both default to null, unused until a future release's
bot/AI-agent packs). This matters if you construct UserAgentInfo positionally
(as webApp's sample does from TypeScript) or serialize/deserialize it, since the
field count/order changed.
/library is the multiplatform library itself -- UserAgentParser,
UserAgentGenerator, the built-in type packs, and the shared data model live in
commonMain, with the shared cross-target test
corpus in commonTest. Every production source
set depends only on the Kotlin stdlib.
/androidApp, /iosApp, /jvmApp,
and /webApp are thin per-target sample apps -- one per MVP target
(Android, iOS, JVM, Web) -- that each depend on :library and call
UserAgentParser(UserAgentAllTypes)/UserAgentGenerator(UserAgentAllTypes) to
prove the library works as a consumed dependency. They are harnesses, not real
app experiences.
Use the run configurations provided by the run widget in your IDE's toolbar. You can also use these commands and options:
./gradlew :androidApp:assembleDebug
./gradlew :jvmApp:run
npm)npm run build:shared
npm install
npm run startUse the run button in your IDE's editor gutter, or run tests using Gradle tasks:
./gradlew :library:allTests
./gradlew :library:testAndroidHostTest
./gradlew :library:jvmTest
./gradlew :library:jsTest
./gradlew :library:iosSimulatorArm64Test
CI (.github/workflows/ci.yml) runs ./gradlew build on every push and pull
request, which exercises all four targets and compiles the sample apps.
Learn more about Kotlin Multiplatform…