@skinhub/cdn
v0.4.0
Published
Typed data layer over the SkinHub CS2 CDN - skins, stickers, gloves, agents, music kits, keychains, collectibles, pets and items_game, fetched at runtime with pluggable caching. Plus the CS2 inspect-link codec and the WeaponPaints row formats.
Maintainers
Readme
@skinhub/cdn
Typed data layer over the CS2 game data published to the SkinHub CDN - skins, stickers, gloves,
agents, music kits, charms, collectibles, chicken pets and Valve's own items_game - plus the CS2
inspect-link codec and the WeaponPaints row formats.
The data is fetched at runtime, never bundled. The files are about 16 MB; shipping them inside a dependency would be worse than the problem they solve. If you want an offline copy, you supply it (see Fallbacks).
bun add @skinhub/cdnimport { fetchSkins } from '@skinhub/cdn'
import { listKnifeTypes, skinsForWeapon, findSkin } from '@skinhub/cdn/query'
const skins = await fetchSkins()
skins.length // 2161 - guns, knives and gloves, one file
listKnifeTypes(skins) // the 20 knife types
skinsForWeapon(skins, 'AK-47') // every AK-47 finish
findSkin(skins, { defindex: 7, paintindex: 801 }) // AK-47 | AsiimovWorks on a server and in a browser - the inspect codec included. No runtime dependencies, no Node
built-ins, no top-level await on import, no global state you did not ask for.
Contents
- What it serves
- Querying
- Configuring the origin
- Fetching
- Caching
- Fallbacks
- Errors
- Entry points and bundle size
- The types
- Inspect links and placement
- Pets
- The C4
- API reference
- Development
What it serves
Ten files under data/ on the CDN. Row counts are from the export current at the time of writing.
| helper | file | rows | size | shape |
|---|---|---:|---:|---|
| fetchSkins | skins.json | 2,161 | 4.2 MB | Skin[] |
| fetchStickers | stickers.json | 11,788 | 5.5 MB | Sticker[] |
| fetchCollectibles | collectibles.json | 715 | 212 KB | Collectible[] |
| fetchKeychains | keychains.json | 143 | 40 KB | Keychain[] |
| fetchMusicKits | music.json | 101 | 40 KB | MusicKit[] |
| fetchGloves | gloves.json | 95 | 24 KB | Glove[] |
| fetchAgents | agents.json | 81 | 52 KB | Agent[] |
| fetchItemsGame | items_game.json | - | 6.5 MB | { items_game: … } |
| fetchPets | pets.json | 5 pets | small | PetsJson - an object, see Pets |
| fetchPetVariants | petVariants.json | 5 models | - | PetVariantsJson - render data, see Pets |
Anything else on the CDN - manifest.json, a file added after this release - is reachable with
fetchCdnJson(path) and fetchCdnData(file), so you are never blocked on a release here.
import { fetchCdnJson, fetchCdnData } from '@skinhub/cdn'
await fetchCdnJson<Manifest>('manifest.json') // <origin>/manifest.json
await fetchCdnData<Row[]>('something-new.json') // <origin>/data/something-new.jsonQuerying
skins.json is the whole weapon catalogue. Counted on the current export: 1,483 gun rows, 576
melee, 94 glove and 8 Zeus. Knives and gloves are already in it, so answering "what exists" is one
4.2 MB fetch, not three. From CS2 1.41.8.2 the export adds one vanilla row for the C4 (in
equipment, beside the Zeus), which moves each count it lands in up by one - see The C4.
@skinhub/cdn/query is what you ask it with. Every function takes the rows as its first
argument and none of them fetch. That is deliberate: skinsForWeapon(skins, 'AK-47') is honest
about the fact that the download already happened, where a fetchSkinsForWeapon('AK-47') would hide
4.2 MB behind something spelled like a filter and invite you to call it once per row of a picker.
The subpath imports nothing from the fetch, cache or config layers - a browser build of
listKnifeTypes is 1.7 KB with no origin string in it, and test/bundle.test.ts checks the built
bundle rather than the source.
Enumerate
import { fetchSkins } from '@skinhub/cdn'
import { listCategories, listWeaponTypes, listKnifeTypes, listGloveTypes } from '@skinhub/cdn/query'
const skins = await fetchSkins()
listCategories(skins)
// [{ key: 'rifles', name: 'Rifles', skinCount: 500, weaponCount: 11 }, … 7 in all]
listWeaponTypes(skins) // 63 - every weapon, ascending by defindex
listWeaponTypes(skins, 'pistols') // 10
listKnifeTypes(skins) // 20
listGloveTypes(skins) // 8
// { defindex: 500, id: 'weapon_bayonet', name: 'Bayonet', category: 'knives', skinCount: 35, hasVanilla: true }SkinCategoryKey is a closed union - 'rifles' | 'pistols' | 'smgs' | 'heavy' | 'knives' | 'gloves'
| 'equipment' - so a switch over it is exhaustively checked. It is the only closed union in the
package; the ones describing Valve's own tokens stay open.
A weapon type is keyed on weapon.weapon_id, never on weapon.id. There are 83 distinct
weapon.id values against 63 defindexes, because each vanilla knife row carries a sfui_wpnhud_*
alias instead of the item name. Group a picker by weapon.id and every knife splits in two.
That alias survives a lookup, because the lookups hand back the exporter's row untouched. So when
you need the weapon's identity - a model path, a route, a group key - read it through weaponOf
rather than off the row:
import { findSkin, weaponOf } from '@skinhub/cdn/query'
const bayonet = findSkin(skins, { defindex: 500, paintindex: 0 })
bayonet?.weapon.id // 'sfui_wpnhud_knifebayonet' - a HUD string. No model has this name.
weaponOf(skins, bayonet!).id // 'weapon_bayonet' ✔
resolveItem(placement, { skins }).weapon?.id // same, straight off a decoded inspect linkIt bites on the 20 vanilla knives and nowhere else. weaponOf also reports aliased: true in the
one case it cannot resolve - a list you have already filtered down to just the vanilla row - so
"resolved" and "nothing to resolve with" stay distinguishable.
Weapon name ↔ defindex
The defindex is the key, but a large family of consumers does not hold one. The CS2 WeaponPaints
plugin schema - what a community server's website
reads and writes - stores wp_player_knife.knife as the item name (weapon_bayonet) while
wp_player_skins.weapon_defindex beside it is the number. Both directions are derived from the
rows:
import { weaponDefindexes, defindexForWeaponId, weaponIdForDefindex, normalizeWeaponId } from '@skinhub/cdn/query'
weaponDefindexes(skins) // { weapon_ak47: 7, weapon_bayonet: 500, sporty_gloves: 5030, … }
defindexForWeaponId(skins, 'weapon_bayonet') // 500
weaponIdForDefindex(skins, 500) // 'weapon_bayonet'
normalizeWeaponId(skins, 'sfui_wpnhud_knifebayonet') // 'weapon_bayonet'weaponDefindexes has 63 entries and never keys on an alias; weaponIdsByDefindex is its inverse.
defindexForWeaponId and normalizeWeaponId accept an alias, because a string that arrived from
a database column may well be one and failing to find it would be the wrong answer. The 8 glove ids
are in there too - skins.json carries the glove rows, so nothing here needs gloves.json.
There is no WEAPON_IDS constant on purpose. A table baked into this package is stale the moment
Valve ships a knife; these are functions of the rows you fetched, like everything else here. With an
index, index.byWeaponId and index.weaponById(id) are the O(1) forms.
Query within one
import { skinsForWeapon, skinsInCategory, knifeSkins, gloveSkins, statTrakSkins } from '@skinhub/cdn/query'
skinsForWeapon(skins, 7) // by defindex - the key
skinsForWeapon(skins, 'weapon_ak47') // by item id
skinsForWeapon(skins, 'AK-47') // by display name, case-insensitively
skinsForWeapon(skins, decodedLink) // by anything with a `defindex`
skinsInCategory(skins, 'knives') // 576
knifeSkins(skins) // the same 576
gloveSkins(skins) // 94 - "all gloves", no second fetch
statTrakSkins(skins) // 1274
listCollections(skins) // 94, with counts
listCrates(skins) // 196
skinsInCrate(skins, 'crate-4089')Look up one thing
Return types say what was measured. Single where the key is unique, an array where it is not.
| you hold | call | returns | why |
|---|---|---|---|
| defindex + paint index | findSkin(skins, ref) | Skin \| undefined | the pair is unique across all 2,161 rows |
| the export's row id | findSkinById(skins, id) | Skin \| undefined | 2,161 distinct ids |
| a paint index alone | skinsByPaintIndex(skins, i) | Skin[] | 113 of 1,480 are worn by more than one weapon |
| a display name | skinsByName(skins, name) | Skin[] | 29 names cover 181 rows - the Doppler phases |
| a Steam market_hash_name | skinsByMarketHashName(skins, n) | Skin[] | same 29, for the same reason |
findSkin(skins, { defindex: 7, paintindex: 801 }) // AK-47 | Asiimov
findSkin(skins, readInspectUrl(link)) // a decoded link satisfies the shape as-isA defindex is not an item id and a paint index is not either. Only the pair is. That matters because it is the one identity every source agrees on - an inspect link, a Steam inventory row and a WeaponPaints database row all carry both numbers.
Sorting by grade
Skin['rarity'] is { id, name, color } with no ordinal and the other six lists carry a bare word,
so neither sorts on its own. rarityRank puts both on Valve's own ladder - the value field of
items_game.rarities, 0 (default) through 7 (immortal, which the UI calls Contraband).
import { compareByRarity, rarityRank } from '@skinhub/cdn/query'
skins.sort(compareByRarity) // commonest first
skins.sort((a, b) => compareByRarity(b, a)) // rarest first
stickers.sort(compareByRarity) // same call, different list
rarityRank(skin) // reads skin.rarity for you
rarityRank(skin.rarity) // identical
rarityRank(musicKit) // undefined - music.json has rarity: null on all 101 rowsBoth take the row or the rarity. undefined rather than -1 for an unranked value, so "no
rarity" stays distinguishable from "the lowest rarity".
market_hash_name, the Steam join key
Steam publishes no defindex on a listing and skins.json has no market_hash_name column, so the
join has to be built - exactly, because a name assembled in the wrong order returns no listings,
which looks precisely like an item nobody is selling.
import { marketHashName, marketHashNames, parseMarketHashName } from '@skinhub/cdn/query'
marketHashName(asiimov, { wear: 'Field-Tested' }) // 'AK-47 | Asiimov (Field-Tested)'
marketHashName(asiimov, { wear: 'FT', stattrak: true }) // 'StatTrak™ AK-47 | Asiimov (Field-Tested)'
marketHashName(karambit, { wear: 'FN', stattrak: true }) // '★ StatTrak™ Karambit | Doppler (Factory New)'
marketHashName(vanillaBayonet) // '★ Bayonet'
marketHashName(defaultDeagle, { wear: 'FT' }) // null - a vanilla gun has no listing
marketHashNames(asiimov) // all 10 keys this row sells under, with their wear/quality flagsThe star comes before StatTrak™, and it is already in skin.name - all 670 melee and glove rows
start with ★ . null comes back for any variant that does not exist rather than a string that
would find nothing: a StatTrak glove, a Souvenir AK, an exterior below the finish's min_float.
skin.souvenirdoes not mean a Souvenir version exists. It istrueon 1,456 rows includingAK-47 | AsiimovandM4A4 | Howl, and it contradictsstattrakon 698 of them - no CS2 item is both.canBeSouveniruses the drop source instead: 319 rows drop from a… Souvenir Package, and exactly 0 of those are StatTrak-able. Enumerating from the raw flag would emit roughly 1,100 keys that match nothing on Steam.
Inspect link in, renderable item out
import { readInspectUrl } from '@skinhub/cdn/inspect'
import { fetchSkins, fetchStickers } from '@skinhub/cdn'
import { resolveItem, hasStickers } from '@skinhub/cdn/query'
const placement = readInspectUrl(link)
const skins = await fetchSkins()
const stickers = hasStickers(placement) ? await fetchStickers() : undefined
const item = resolveItem(placement, { skins, stickers })
item.name // 'AK-47 | Asiimov'
item.category // 'rifles'
item.float // clamped into the finish's own [min_float, max_float]
item.rawFloat // what the wire actually said, so the clamp is visible
item.wear.name // 'Field-Tested'
item.marketHashName // 'StatTrak™ AK-47 | Asiimov (Field-Tested)'
item.stickers[0]?.sticker?.image
item.keychain?.keychain?.nameEvery catalogue is optional. resolveItem(placement, {}) still gives you the wire values and the
wear tier. hasStickers/hasKeychain let you decide whether the 5.5 MB stickers.json is worth
fetching before you fetch it.
If you are resolving more than a handful
import { loadSkinIndex } from '@skinhub/cdn/catalog'
const index = await loadSkinIndex() // fetch + build, memoised on the fetched array
index.weaponTypes('knives')
index.forWeapon(7)
index.find({ defindex: 7, paintindex: 801 })
index.findByMarketHashName('AK-47 | Asiimov (Field-Tested)')
index.resolve(readInspectUrl(link))createSkinIndex(skins) is the same thing without the fetch. No index is ever shipped in the
tarball - one would be stale the moment the exporter runs, and the failure would be silent: a new
finish live on the CDN, missing from your picker, with nothing to show for it. The index is built
from the rows you fetched and lives exactly as long as they do.
Configuring the origin
Four sources, highest priority first:
| # | source | example |
|---|---|---|
| 1 | the call | fetchSkins({ origin: 'http://localhost:8787' }) |
| 2 | configureCdn | configureCdn({ origin: 'https://cdn.example' }) |
| 3 | environment | SKINHUB_CDN_URL=https://cdn.example |
| 4 | default | https://cdn.skinhub.gg |
On the client, use (1) or (2). Bundlers only inline the environment variables they are told to -
Next.js inlines NEXT_PUBLIC_*, Vite inlines VITE_* - so a browser bundle relying on
SKINHUB_CDN_URL silently falls through to the default. This is not a limitation to work around;
configureCdn is the supported path.
// app/providers.tsx - once, at startup
import { configureCdn } from '@skinhub/cdn'
configureCdn({ origin: process.env.NEXT_PUBLIC_CDN_URL })configureCdn({ origin: undefined }) clears it again. The environment is read off
globalThis.process?.env, so nothing throws in a browser that has no process at all.
URL builders, if you need to point an <img> or a <link rel="preload"> at the CDN yourself:
import { cdnUrl, dataUrl, resolveCdnOrigin } from '@skinhub/cdn'
resolveCdnOrigin() // 'https://cdn.skinhub.gg'
cdnUrl('manifest.json') // 'https://cdn.skinhub.gg/manifest.json'
dataUrl('skins.json') // 'https://cdn.skinhub.gg/data/skins.json'
cdnUrl('x.png', 'https://a.test/') // 'https://a.test/x.png' - slashes never double upFetching
Every dataset helper takes the same options, all optional:
await fetchSkins({
origin: 'https://cdn.example', // this call only
cache: myCache, // or `false` to disable; default is a shared in-memory cache
ttlMs: 5 * 60_000, // default 1 hour
fallback: bundledSkins, // returned instead of throwing
onError: err => log(err), // called when a fallback absorbs an error
fetch: instrumentedFetch, // inject your own
signal: controller.signal,
init: { headers: { … } }, // merged into the RequestInit, wins over our defaults
})Two behaviours worth knowing:
- Requests are sent with
cache: 'no-cache'.data/*.jsonkeeps the same filename across every export and the origin serves itmax-age=60, stale-while-revalidate=300, so a plainfetchcan hand back a heuristically-fresh copy and never see a new export.no-cacheforces a conditional request and reuses the cached body on a304- one round trip, no payload. Override withinit: { cache: 'default' }. - Concurrent calls for the same URL share one request. Three components asking for
skins.jsonon the same tick cost one 4.2 MB download.
Caching
Caching is yours, not ours. The interface is two methods:
interface CdnCache {
get(key: string): unknown | undefined | Promise<unknown | undefined>
set(key: string, value: unknown, ttlMs: number): void | Promise<void>
}The default is a shared in-memory cache with a 1-hour TTL and a 32-entry cap, created lazily. Good enough for a browser tab or a single-process server, and it needs no configuration.
Redis:
import Redis from 'ioredis'
import { fetchSkins, type CdnCache } from '@skinhub/cdn'
const redis = new Redis(process.env.REDIS_URL!)
const redisCache: CdnCache = {
async get(key) {
const raw = await redis.get(key)
return raw === null ? undefined : JSON.parse(raw)
},
async set(key, value, ttlMs) {
await redis.set(key, JSON.stringify(value), 'PX', ttlMs)
},
}
const skins = await fetchSkins({ cache: redisCache, ttlMs: 24 * 60 * 60 * 1000 })Next.js, letting the framework cache instead:
import { unstable_cache } from 'next/cache'
import { fetchSkins } from '@skinhub/cdn'
export const getSkins = unstable_cache(() => fetchSkins({ cache: false }), ['skins'], {
revalidate: 3600,
})None at all: fetchSkins({ cache: false }).
Other knobs: createMemoryCache({ ttlMs, max }) for an isolated instance, getDefaultCache() and
clearDefaultCache() for the shared one - the latter is what you call after an export lands.
A cached value is the parsed array, shared by reference between callers. Treat it as read-only, or pass
cache: falseif you intend to mutate.
Fallbacks
Pass fallback and a failure returns it instead of throwing. This is how you keep the CDN off your
startup critical path:
import bundledGloves from './gloves.snapshot.json'
import { fetchGloves } from '@skinhub/cdn'
const gloves = await fetchGloves({
fallback: bundledGloves,
onError: err => logger.warn({ err }, 'gloves: serving the bundled snapshot'),
})Without onError the error goes to console.warn. A fallback is not cached, so the next call
retries the CDN.
Errors
One error class. CdnError carries the URL, the HTTP status, and the response's content type.
import { fetchSkins, isCdnError } from '@skinhub/cdn'
try {
await fetchSkins()
} catch (error) {
if (isCdnError(error)) {
error.url // 'https://cdn.skinhub.gg/data/skins.json'
error.status // 404, or undefined if the request never completed
error.contentType // 'text/html'
}
}contentType is on there for a reason: the origin is behind Cloudflare, and a missing key returns
a 27 KB HTML page, not JSON. Code that goes straight to response.json() reports that as
SyntaxError: Unexpected token '<', which sends you debugging your parser instead of reading the
404. This package checks the status first and tells you what actually happened.
Entry points and bundle size
sideEffects: false, ESM, and a subpath per dataset. Import one list and you get one list.
| import | contents |
|---|---|
| @skinhub/cdn | everything: config, cache, errors, fetch, every dataset helper, the query layer, the inspect codec and the placement layer |
| @skinhub/cdn/skins | fetchSkins + the skin types |
| @skinhub/cdn/stickers …/gloves …/agents …/music …/keychains …/collectibles …/items-game | one dataset each |
| @skinhub/cdn/pets | fetchPets / fetchPetVariants, the pet types, the stage table and the wp_player_pets row codec |
| @skinhub/cdn/query | every filter, lookup and market-hash-name helper - pure, fetches nothing |
| @skinhub/cdn/catalog | loadSkinIndex / loadCatalog - fetch and index in one call |
| @skinhub/cdn/placement | placement types, normalisation, the WeaponPaints row formats (skins and pets), the C4 constants |
| @skinhub/cdn/inspect | inspect-link encode/decode - works in a browser |
This package has no runtime dependencies. Nothing to audit, nothing to resolve, and nothing that needs a Node built-in.
Measured with a real bundler against dist:
| a consumer that imports | target | result |
|---|---|---|
| fetchGloves from @skinhub/cdn/gloves | browser | 4.5 KB, and no trace of the other seven datasets |
| fetchGloves from @skinhub/cdn | browser | 4.5 KB - a named import off the barrel costs the same |
| formatStickerRow from @skinhub/cdn/placement | browser | 2.3 KB |
| formatPetRow from @skinhub/cdn/placement | browser | 2.2 KB, with no dataset file name or origin in it |
| fetchPets from @skinhub/cdn/pets | browser | 4.5 KB |
| listKnifeTypes from @skinhub/cdn/query | browser | 1.7 KB, with no origin, fetch or cache in it |
| listKnifeTypes from @skinhub/cdn | browser | 1.7 KB - again the same through the barrel |
| marketHashName from @skinhub/cdn/query | browser | 2.5 KB |
| resolveItem from @skinhub/cdn/query | browser | 5.5 KB |
| import * as query from @skinhub/cdn/query | browser | 19.3 KB - the whole surface, still network-free |
| loadSkinIndex from @skinhub/cdn/catalog | browser | 14.8 KB - this one does fetch, by design |
| buildInspectUrl from @skinhub/cdn/inspect | browser | 18.3 KB |
| buildInspectUrl from @skinhub/cdn/inspect | node | 18.3 KB |
| import * as cdn from @skinhub/cdn | browser | 75.6 KB - everything, because a namespace import keeps everything |
The inspect codec used to be server-only. It is not any more.
Earlier versions wrapped cs2-inspect-lib, whose
dependency list includes steam-user and node-cs2 for a Game Coordinator round trip this package
never makes. Installing this package used to put 89 MB across 60 packages in your node_modules;
it now puts 512 KB across one. And because cs2-inspect-lib reached its Steam transports through
an await import() inside a method, which bundlers still follow, bundling the codec for a browser
failed on tls, dns and readline, while bundling it for Node produced about 29 MB - an
entire Steam client, for a protobuf encode that never talks to Steam. That is why the codec was kept
out of the root export.
The two functions actually needed from it, createInspectUrl and decodeMaskedUrl, are pure maths -
a protobuf message and a CRC, no network and no Steam anywhere in them. They are now written natively
in one module with no imports at all, cs2-inspect-lib is gone from the dependency list, and the
same consumer that would not build now bundles to 16.7 KB for a browser. So the codec is in the root
export too; @skinhub/cdn/inspect stays as a subpath for anyone who wants the guarantee without
relying on a bundler.
Because a wrong byte in an inspect link does not throw - it produces a link that resolves to the
wrong skin, which reads as a data problem rather than a codec problem - the port is held to a corpus
rather than to an example. cs2-inspect-lib is kept as a devDependency, and 2,326 items plus 41
URL forms are encoded and decoded by both implementations on every test run and asserted identical
byte for byte, including the ones both must refuse. The corpus is built from the real export: every
[defindex, paintindex] pair in skins.json, real sticker and charm ids, and deliberate edges -
float32 extremes, every varint width boundary, seed 0 and 4294967295, all five sticker slots, StatTrak
on and off, nametags in Hebrew, CJK and emoji, and the 20 vanilla rows where paint_index is null.
A second test perturbs one token of the codec at a time and requires the corpus to catch every
perturbation, so the comparison cannot pass by accident.
@skinhub/cdn/placement is still the smallest useful piece - placement types, normalisation and the
WeaponPaints row format, no protobuf, 2 KB - for a server that stores placement but never encodes a
link.
The types
Derived from the real exported files and checked against them by the test suite, which validates every row and fails on a field the types do not describe. Some consequences you should know about, because each one is a bug waiting in code written against a looser type:
skins.json has 55 "vanilla" rows, in two different spellings. On all 55, pattern,
min_float and max_float are null, and souvenir, wears and collections are absent
keys, not null. The difference is what "no finish" looks like:
- the 20 vanilla knives (
skin-vanilla-weapon_bayonetand friends) havepaint_index: null; - the 35 vanilla guns (
skin-vanilla-weapon_deagle, …) havepaint_index: '0'andrarity.id === 'rarity_default_weapon'.
That second group is why skin.wears.length is the most likely crash in code written against the
raw type - it throws on 55 of 2,161 rows.
import { isVanilla, wearsOf, paintIndexOf } from '@skinhub/cdn/query'
const skins = await fetchSkins()
for (const skin of skins) {
wearsOf(skin) // [] on all 55, instead of throwing on a missing key
paintIndexOf(skin) // 0 for both spellings, matching what a decoded link carries
isVanilla(skin) // true for both
}phase is on 181 of 2,161 rows, and absent - never null - on the rest. It is
'Phase 1' | … | 'Black Pearl' widened so a new phase is not a compile error. Those 181 rows are
also the only reason name is not unique: 29 names cover all of them.
souvenir is not "a Souvenir version exists". See Querying - use canBeSouvenir.
gloves.json has one row where paint is a number (the Gloves | Default row); the other 94
are strings. Join it against Skin['paint_index'] through String().
agents.json has two rows where model is the four-character string "null", not null and
not ''. Check for it before building a model path.
music.json has rarity: null on every row. The field exists for shape compatibility; do not
branch on it.
Images can be the empty string. '' means the export has no icon for that row and is
deliberate - a URL that 404s would be worse. It is common: 643 of 11,788 stickers, 190 of 715
collectibles, and some collection and crate icons.
{sticker.image ? <img src={sticker.image} /> : <Placeholder />}color is the icon's own colour, #rrggbb, or null exactly when there is no icon. On skins,
stickers, collectibles, keychains and agents (not music or gloves). It is measured off the image, so
it is a good placeholder tint behind a loading <img> - not the rarity colour, which is
skin.rarity.color.
pets.json and petVariants.json are not arrays either - see Pets.
items_game.json is not an array. It is { items_game: { …33 sections… } } - Valve's KeyValues
converted to JSON. The section names are typed so data.items_game.paint_kits autocompletes; the
values are unknown, because writing an interface for a file that changes with every CS2 update
would be inventing structure the file does not guarantee.
Rarity tokens ('rare' | 'mythical' | …) and other string unions are open - they autocomplete
to the values in the current export but still accept a new one, so a CS2 update does not become a
compile error in your app.
Inspect links and placement
Encode and decode CS2 inspect links, and read/write the placement format the CS2 WeaponPaints plugin stores.
// or from '@skinhub/cdn' - same functions, and a named import tree-shakes to the same bytes
import { buildInspectUrl, readInspectUrl, toGameCommand, isLegacyInspectUrl } from '@skinhub/cdn/inspect'
const url = buildInspectUrl({
defindex: 7, // AK-47
paintindex: 44, // Case Hardened
paintseed: 661,
paintwear: 0.154,
stattrak: true,
stattrak_count: 1337,
nametag: 'blue gem',
stickers: [{ slot: 0, sticker_id: 7691, wear: 0.25, scale: 0.8, rotation: 12, offset_x: 0.1, offset_y: -0.2 }],
keychain: { slot: 0, sticker_id: 21, offset_x: 1.5, offset_y: -2.25, offset_z: 0.125, pattern: 41 },
})
// 'steam://rungame/730/…/+csgo_econ_action_preview%2000180720…'
toGameCommand(url) // 'csgo_econ_action_preview 00180720…' - paste into the CS2 console
readInspectUrl(url) // back to a SkinPlacement, all five sticker slots presentreadInspectUrl handles masked links only - the +csgo_econ_action_preview <hex> form that
carries the item data. The unmasked S…A…D… / M…A…D… market and inventory links needed a Game
Coordinator round trip Valve has shut down, so there is nothing to read out of them. Check first:
if (isLegacyInspectUrl(input)) {
// no item data in this link - ask the user for a masked one
}Placement is stored in the game's own field names
slot, sticker_id, wear, scale, rotation, offset_x, offset_y, offset_z, pattern -
CEconItemPreviewDataBlock.Sticker verbatim, nothing renamed or negated. Offsets are UV space
centred on the slot anchor, -0.5 … 0.5, which is the shader's own g_vStickerNOffset range. If
your UI works in 0 … 1, convert with offsetFromNormalized / normalizedFromOffset.
Everything is quantised at the boundary
makeSkinPlacement runs on the way into toEconItem, so a caller cannot hand the wire a value the
game will reject:
- ids and seeds (
defindex,paintindex,paintseed,sticker_id,pattern,stattrak_count) go throughu32- truncated, clamped, unsigned. The WeaponPaints plugin parses these withuint.TryParse, which silently skips an item whose id carries a sign, a decimal point or an exponent: the sticker just never appears in game, with no error anywhere. - floats go through
Math.fround, because they are protobuffloats. - a
sticker_idof0normalises the whole slot to empty, dropping offsets left behind by a removed sticker - a state no inspect link can represent.
A practical consequence: paintwear: 0.154 is a float64, and the wire holds float32. Normalise once
and the value is stable forever after:
import { makeSkinPlacement } from '@skinhub/cdn/placement'
const item = makeSkinPlacement(fromYourForm) // paintwear becomes 0.15399999916553497
readInspectUrl(buildInspectUrl(item)) // deep-equals `item`WeaponPaints database rows
wp_player_skins column formats, id;schema;x;y;wear;scale;rotation and id;x;y;z;seed:
import { formatStickerRow, parseStickerRow, formatKeychainRow, parseKeychainRow } from '@skinhub/cdn/placement'
formatStickerRow(placement) // '7691;0;0.1;-0.2;0.25;0.8;12'
parseStickerRow(row, slot) // -> StickerPlacement; malformed or null input gives an empty slotFloats are written at the shortest decimal that reads back as the same float32 - 0.3, not
0.30000001192092896 - because seven of the latter overflow the plugin's varchar(128) column and
the extra digits carry no information.
The fifth slot's anchor
The column's second field is not always 0. On 29 of the 69 weapon+mesh variants the model authors
no fifth StickerMarkup home, so the WeaponPaints plugin reads that field as an ANCHOR - which of
the weapon's other homes slot 4 hangs off - and a caller that never resolves one for those variants
gets a fifth sticker that saves fine and renders nowhere. stickerAnchorFor / stickerAnchorLookup
resolve it; formatStickerRow and parseStickerRow take it as an extra argument and no-op without
one, so existing callers are unaffected:
import { formatStickerRow, parseStickerRow, stickerAnchorLookup } from '@skinhub/cdn/placement'
const anchorFor = stickerAnchorLookup(skinsCatalogue) // from fetchSkins(), once
const anchor = anchorFor(weapon_defindex, weapon_paint_id, slot) // null for the other 40 variants
formatStickerRow(fifthSlotPlacement, anchor) // '60;1;0.14699425;0.028994253;0;1;0' on an AK-47
parseStickerRow(row, 4, anchor) // -> the placement in the frame the viewer draws inOnly the fifth slot ever resolves to a non-null anchor, and only a row whose own second field is already non-zero is un-shifted on read - a row saved before this table existed keeps its 0 and is never touched, which is what makes this safe with no migration.
migrateLegacyKeychainRow(row) rewrites charm rows written by the pre-2026 schema, which stored
id;-x;1;-y;seed into an id;x;y;z;seed column. It returns null for rows that are already
correct, so a migration using it is safe to re-run.
What CS2 1.41.8.2 changed in the link
The pets update (2026-09-22) changed CEconItemPreviewDataBlock: field 11 went from
optional string customname to repeated string customnames (one name per pet life stage), and
fields 24 pet_food_expiration_date and 25 blobdata are new. The codec reads all three:
EconItem.customnamesholds every name in wire order, only when the link carries more than one.customnamekeeps its old meaning - the last name - so a one-name link decodes to exactly the object it always did, andcs2-inspect-libstill agrees with it byte for byte.EconItem.pet_food_expiration_date(uint32) andEconItem.blobdata(Uint8Array) appear when present. Nobody has published whatblobdataholds.
A pet goes through the same SkinPlacement as a weapon - see Pets.
Pets
CS2 1.41.8.2 added chicken pets. One item definition, 4681 pet, carries the pet id (which
pet_definitions row: 1 egg, 2 chick, 3 Catalana, 4 Silkie, 5 Polish), the upgrade level (0 egg,
1 chick, 2 pullet, 3 hen) and a pet seed the client turns into a colour and body shape. Eggs
(4948) and feed (4949) are their own item definitions.
import { cdnUrl } from '@skinhub/cdn'
import { fetchPets, fetchPetVariants, findPet, petModelVariants, petKindForStage } from '@skinhub/cdn/pets'
const pets = await fetchPets() // data/pets.json - small, an object
const silkie = findPet(pets, 4) // { name: 'chicken_silkie_01', breed: 'silkie', kind: 'adult', … }
cdnUrl(silkie.glb) // paths in both files are CDN paths - resolve them
const variants = await fetchPetVariants() // data/petVariants.json - render data, fetch when you draw
petModelVariants(variants, silkie)?.materialGroups // the colours, by authored group name
petKindForStage('pullet') // 'adult' - the pullet and the hen share the breed modelWhat is not known yet, and so is not guessed at here: how the seed becomes a look (that code
is client-only), whether pet id changes when the egg hatches, and where the seed rides in a real
pet's inspect link. Nothing in this package interprets the seed.
In an inspect link
SkinPlacement carries a pet with four optional fields, absent on every weapon so no existing
placement changes shape:
| field | wire | meaning |
|---|---|---|
| petindex | 19 | pet id - the pet_definitions row |
| upgrade_level | 23 | the stage, 0..3 |
| nametag2 | 11 (second entry) | the pullet's name (custom name attr 2) |
| nametag3 | 11 (third entry) | the hen's name (custom name attr 3) |
nametag is the chick's name, and paintseed carries the pet seed - the natural carrier, but an
inference until a real pet link is decoded. A pet with only one name uses the plain single-name
encoding, so its link is byte-identical to what 0.3 wrote; with a second or third name the names go
out as the repeated field in stage order, an empty stage as "".
buildInspectUrl({
defindex: 4681, // the pet item - not a skins.json row
paintindex: 0,
paintwear: 0,
paintseed: 3141592653, // the pet seed
petindex: 4, // Silkie
upgrade_level: 3, // hen
nametag: 'Nugget',
nametag2: 'Drumstick',
nametag3: 'Hen Solo',
stickers: [],
keychain: null,
})WeaponPaints: wp_player_pets
One row per player (pets are noteam), keyed by steamid, which the row codec leaves to you:
CREATE TABLE IF NOT EXISTS wp_player_pets (
steamid VARCHAR(18) NOT NULL PRIMARY KEY,
pet_id INT NOT NULL,
pet_stage TINYINT NOT NULL DEFAULT 3, -- 0 egg, 1 chick, 2 pullet, 3 hen
pet_variant INT NULL, -- material group override; NULL = the seed decides
pet_seed INT UNSIGNED NOT NULL DEFAULT 0,
pet_name VARCHAR(32) NULL
)import { formatPetRow, parsePetRow, PET_ROW_COLUMNS, WP_PETS_TABLE } from '@skinhub/cdn/placement'
formatPetRow({ petId: 4, stage: 'pullet', variant: 7, petSeed: 3141592653, name: 'Drumstick' })
// { pet_id: 4, pet_stage: 2, pet_variant: 7, pet_seed: 3141592653, pet_name: 'Drumstick' }
parsePetRow(rowFromMysql) // numbers, numeric strings or bigints; null when there is no petIntegers are quantised onto each column's range - non-negative and whole, at most 2147483647 for
the signed INT columns pet_id and pet_variant, 4294967295 for pet_seed - an unknown stage
falls back to the column default (hen), and a missing or negative variant is NULL. A name goes
through normalizePetName, a port of the plugin's own sanitiser, so the site stores the string the
game would: control characters and { } < > removed, trimmed, and cut to 32 code points on a
whole-character boundary (an emoji sequence or a flag is never split).
No pet is no row. formatPetRow(null) returns null, which means delete the row - the plugin
does the same on !pet off - and parsePetRow answers null for a missing row, so the pair
round-trips "no pet" too:
const row = formatPetRow(selection) // selection: PetSelectionInput | null
row ? upsert(steamid, row) : remove(steamid)formatPetRow throws for a petId below 1: a selection that claims a pet and names none is a
bug to surface, not a row to write. parsePetRow(formatPetRow(x)) is x, and a load-and-save
leaves a row untouched.
A name the column accepts can still be too long for an inspect link to read back. The codec checks
a name in UTF-16 units (100) when it writes a link and in UTF-8 bytes (100) when it reads one -
cs2-inspect-lib's asymmetry, kept on purpose so the same links stay readable - so 32 four-byte
emoji (128 bytes) build a link that readInspectUrl refuses. The in-game rename box stops at 20
characters, so only a pasted name gets there.
The C4
CS2 1.41.8.2 lets the C4 take stickers (it could already take a charm). It is defindex 49,
weapon_c4, paint 0. From that update the export carries one vanilla row for it
(skin-vanilla-weapon_c4, C4 Explosive | Default, category equipment), so the row-derived query
functions see it like the Zeus. A skins.json older than that has no C4 row at all, and for that
case - a fallback copy, a database mirror - the C4 also exists as constants that agree with the row:
import { C4_DEFINDEX, C4_WEAPON, stickerSlotsFor } from '@skinhub/cdn/placement'
C4_WEAPON // { defindex: 49, id: 'weapon_c4', name: 'C4 Explosive', category: 'equipment' }
stickerSlotsFor(49) // [0, 1, 2, 3, 4] - four authored homes plus a borrowed fifth
stickerSlotsFor(7) // [0, 1, 2, 3, 4]Everything else already works on a C4 unchanged: makeSkinPlacement, the inspect link, and the
wp_player_skins sticker and charm columns (nothing here ever filtered by a weapon list). The C4's
model authors four sticker homes; the SkinHub viewer derives a fifth on the side of the bomb, and the
WeaponPaints plugin anchors slot 4 there itself, so write the fifth with anchor 0 like any other slot
(STICKER_ANCHORS has no C4 row on purpose). resolveItem names a C4 (weapon_c4, C4 Explosive, equipment, vanilla: true)
with or without skins; when the list has the exporter's row, that row is what it returns.
API reference
Config
configureCdn · resolveCdnOrigin · getConfiguredOrigin · normalizeOrigin · cdnUrl ·
dataUrl · SKINHUB_CDN_DEFAULT_ORIGIN · SKINHUB_CDN_ENV_VAR
Fetching
fetchSkins · fetchStickers · fetchGloves · fetchAgents · fetchMusicKits ·
fetchKeychains · fetchCollectibles · fetchItemsGame · fetchPets · fetchPetVariants ·
fetchCdnData · fetchCdnJson · inFlightCount
File-name constants, if you are keying a cache or a preload by them: SKINS_FILE, STICKERS_FILE,
GLOVES_FILE, AGENTS_FILE, MUSIC_FILE, KEYCHAINS_FILE, COLLECTIBLES_FILE,
ITEMS_GAME_FILE, PETS_FILE, PET_VARIANTS_FILE.
Caching
createMemoryCache · getDefaultCache · clearDefaultCache · DEFAULT_TTL_MS · CdnCache
Errors
CdnError · isCdnError
Types
Skin · Skins · SkinPhase · SkinWeapon · SkinCategory · SkinPattern · SkinRarity ·
SkinWear · SkinCollection · SkinCrate · SkinTeam · Sticker · Stickers · Glove ·
Gloves · Agent · Agents · AgentTeam · MusicKit · MusicKits · Keychain · Keychains ·
Collectible · Collectibles · ItemsGame · ItemsGameSection · RarityToken · ImageUrl ·
IconColor · CdnFetchOptions · DatasetOptions · FetchLike
@skinhub/cdn/pets
fetchPets · fetchPetVariants · findPet · petModelVariants · PET_STAGES ·
petStageForLevel · petLevelForStage · isPetStage · petKindForStage · PET_ITEM_DEFINDEX ·
CHICKEN_EGG_DEFINDEX · CHICKEN_FEED_DEFINDEX · LOADOUT_SLOT_PET · PetsJson · PetRow ·
PetStage · PetBreed · PetKind · PetVariantsJson · PetModelVariants · PetMaterialGroup ·
PetMaterial · PetBoneRange - plus the wp_player_pets row codec listed under /placement.
@skinhub/cdn/query
Taxonomy - listCategories · listWeaponTypes · listKnifeTypes · listGloveTypes ·
listGunTypes · skinCategory · isKnife · isGlove · isGun · isEquipment · isVanilla ·
SKIN_CATEGORIES · SKIN_CATEGORY_IDS
Weapon ids - weaponDefindexes · weaponIdsByDefindex · defindexForWeaponId ·
weaponIdForDefindex · normalizeWeaponId
Filters - skinsForWeapon · skinsInCategory · knifeSkins · gloveSkins · gunSkins ·
vanillaSkins · statTrakSkins · souvenirSkins · skinsWithWear · skinsInCollection ·
skinsInCrate · listCollections · listCrates · phasesOf
Lookups - findSkin · findSkinById · skinsByName · skinsByPaintIndex ·
skinsByMarketHashName · paintIndexForMarketHashName · createSkinIndex
Fields - weaponOf · paintIndexOf · wearsOf · floatRangeOf · clampFloat
Market - marketHashName · marketHashNames · marketHashNameIndex · parseMarketHashName ·
canBeStatTrak · canBeSouvenir · isUntradable · STATTRAK_PREFIX · SOUVENIR_PREFIX ·
STAR_PREFIX
Wear and rarity - WEAR_TIERS · wearTier · wearTierForFloat · rarityRank ·
compareByRarity · RARITY_RANKS · WEAPON_RARITY_RANKS
Resolve - resolveItem · resolveItemWith · hasStickers · hasKeychain
The C4, for a skins.json older than 1.41.8.2 that has no row for it - C4_DEFINDEX · C4_WEAPON_ID · C4_NAME · C4_WEAPON ·
C4_STICKER_SLOTS · isC4 · stickerSlotsFor
Types - SkinCategoryKey · WeaponRef · WeaponType · ResolvedWeapon · WeaponSelector ·
CategorySummary · NamedGroup · SkinRef · SkinIndex · MarketEntry · MarketVariant ·
MarketHashNameOptions · ParsedMarketHashName · ResolvedItem · ResolvedSticker ·
ResolvedKeychain · ItemCatalogs · ItemFinders · WearTier · WearTierId · WearName ·
WearShort · WearLike · RarityRank · RarityLike · RarityObject · RarityBearer
@skinhub/cdn/catalog
loadSkinIndex · loadCatalog · LoadSkinIndexOptions · Catalog
@skinhub/cdn/placement
makeSkinPlacement · makeStickerPlacement · makeKeychainPlacement · emptySticker ·
emptyKeychain · formatStickerRow · parseStickerRow · formatKeychainRow · parseKeychainRow ·
migrateLegacyKeychainRow · offsetFromNormalized · normalizedFromOffset · f32 · u32 ·
clamp · clampStickerOffset · shortFloat · STICKER_SLOTS · SkinPlacement ·
StickerPlacement · KeychainPlacement
The fifth slot's anchor - STICKER_ANCHORS · stickerAnchorFor · stickerAnchorLookup ·
FIFTH_STICKER_SLOT · NO_STICKER_ANCHOR · StickerAnchor · AnchorCatalogSkin
Pets (wp_player_pets) - formatPetRow · parsePetRow · normalizePetName · WP_PETS_TABLE ·
PET_ROW_COLUMNS · PET_NAME_MAX_LENGTH · PET_STAGES · DEFAULT_PET_STAGE ·
petStageForLevel · petLevelForStage · isPetStage · PetSelection · PetSelectionInput ·
WeaponPaintsPetRow · PetStage
The C4 - C4_DEFINDEX · C4_WEAPON_ID · C4_NAME · C4_PAINT_INDEX · C4_WEAPON ·
C4_STICKER_SLOTS · isC4 · stickerSlotsFor
@skinhub/cdn/inspect
buildInspectUrl · readInspectUrl · toEconItem · fromEconItem · toGameCommand ·
isLegacyInspectUrl · EconItem - plus everything from /placement, re-exported.
Everything in this section and the one above it is also on the root @skinhub/cdn export.
Development
bun install
bun run typecheck # tsc --noEmit, over src + test + scripts
bun test # offline; fixtures + unit + bundle tests
bun run build # rm -rf dist && tsc -p tsconfig.build.jsonTwo extra tiers, both opt-in because they need something the repo does not carry:
# Validate the types against the full 16 MB export rather than the committed fixtures
SKINHUB_CDN_FIXTURES=/path/to/asset-export/out/data bun test
# Hit the real CDN (or SKINHUB_CDN_URL=<origin> bun run test:live for another one)
bun run test:liveThe live tier sends Origin: https://skinhub.gg on every request. cdn.skinhub.gg varies on
Origin but its edge cache does not, so a request without one warms a copy with no CORS header that
browsers then fail to fetch. Anything else you point at the real CDN from a server should do the same.
test/fixtures/ holds a small sample of each real file, chosen so every edge case documented above
appears in it - the vanilla skins, the numeric glove paint, the "null" agent models, an empty
image, every nullable field null at least once. test/types.test.ts asserts both that the fixtures
validate and that those edge cases are actually present, so the validator cannot pass by being fed
easy rows. pets.json and petVariants.json are newer than some exports on disk, so the full-export
tier skips them when the directory predates CS2 1.41.8.2 rather than failing.
test/fixtures/inspect-corpus.json is the other kind of fixture: the real ids the inspect corpus is
generated from - every [defindex, paintindex] pair in skins.json, a sample of sticker ids across
the whole range, and every charm id. test/corpus.ts crosses those with a seeded PRNG and a list of
named edge cases; test/codec.test.ts runs the result through src/codec.ts and through
cs2-inspect-lib and asserts the hex matches byte for byte; test/codec-mutation.test.ts perturbs
one token of the codec at a time and requires the corpus to catch every perturbation, plus two
controls it must not flag. That is what makes cs2-inspect-lib worth keeping as a devDependency:
remove it and the equivalence stops being checkable.
Releasing
bun run release # patch
bun run release minor
bun run release 1.2.0
bun run release patch --dry-runBumps the version, publishes, then commits and tags - and deliberately does not push; it prints
the command. It refuses to run on a dirty tree, refuses a version already on the registry, and
restores the previous version if typecheck, build or publish fails. prepublishOnly runs typecheck
and a clean build, so npm publish by hand cannot ship a broken package either.
License
MIT
