npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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/cdn
import { 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 | Asiimov

Works 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

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.json

Querying

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 link

It 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-is

A 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 rows

Both 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 flags

The 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.souvenir does not mean a Souvenir version exists. It is true on 1,456 rows including AK-47 | Asiimov and M4A4 | Howl, and it contradicts stattrak on 698 of them - no CS2 item is both. canBeSouvenir uses 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?.name

Every 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 up

Fetching

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/*.json keeps the same filename across every export and the origin serves it max-age=60, stale-while-revalidate=300, so a plain fetch can hand back a heuristically-fresh copy and never see a new export. no-cache forces a conditional request and reuses the cached body on a 304 - one round trip, no payload. Override with init: { cache: 'default' }.
  • Concurrent calls for the same URL share one request. Three components asking for skins.json on 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: false if 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_bayonet and friends) have paint_index: null;
  • the 35 vanilla guns (skin-vanilla-weapon_deagle, …) have paint_index: '0' and rarity.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 present

readInspectUrl 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 through u32 - truncated, clamped, unsigned. The WeaponPaints plugin parses these with uint.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 protobuf floats.
  • a sticker_id of 0 normalises 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 slot

Floats 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 in

Only 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.customnames holds every name in wire order, only when the link carries more than one. customname keeps its old meaning - the last name - so a one-name link decodes to exactly the object it always did, and cs2-inspect-lib still agrees with it byte for byte.
  • EconItem.pet_food_expiration_date (uint32) and EconItem.blobdata (Uint8Array) appear when present. Nobody has published what blobdata holds.

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 model

What 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 pet

Integers 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.json

Two 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:live

The 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-run

Bumps 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