···1313 required: true
1414 attributes:
1515 label: 🛠️ To reproduce
1616- description: A reproduction of the bug via https://stackblitz.com/github/danielroe/package-name/tree/main/playground
1616+ description: A reproduction of the bug via https://stackblitz.com/github/danielroe/fnv1a-64/tree/main/playground
1717 placeholder: https://stackblitz.com/[...]
1818 - type: textarea
1919 validations:
+68-17
README.md
···11-# package-name
11+# fnv1a-64
2233[![npm version][npm-version-src]][npm-version-href]
44[![npm downloads][npm-downloads-src]][npm-downloads-href]
55[![Github Actions][github-actions-src]][github-actions-href]
66[![Codecov][codecov-src]][codecov-href]
7788-> Package description
88+> Tiny, fast, dependency-free 64-bit [FNV-1a](http://www.isthe.com/chongo/tech/comp/fnv/) string hash for Node and the browser.
99+1010+The core computes a true 64-bit hash in two 32-bit lanes with plain JS numbers. It's ~320B gzipped (minified) and hashes a short key in ~36ns.
1111+1212+## Why
1313+1414+I created this for [Nuxt](https://github.com/nuxt/nuxt) due to the following issues with other FNV packages. These are all good packages and you should consider them if you don't have the same constraints I did:
1515+1616+| Package | Width | Gzip | Notes |
1717+| --- | --- | --- | --- |
1818+| `fnv1a`, `object-code` | 32-bit | tiny | collides at ~50k distinct keys |
1919+| `@sindresorhus/fnv1a` | 64-bit | ~1KB | BigInt, ~20x slower |
2020+| `fnv-lite` | 128-bit | ~1.4KB | byte arrays, ~40x slower |
2121+| `fnv-plus` | multi | ~9KB | _excellent_ package, but contains lots of utilities |
2222+| `fnv-hash` | 64-bit | native | can't run in the browser |
9231024## Usage
11251212-Install package:
2626+```sh
2727+npm install fnv1a-64
2828+```
13291414-```sh
1515-# npm
1616-npm install package-name
3030+```js
3131+import { fnv1a64, fnv1a64Base36, fnv1a64BigInt, fnv1a64Hex } from 'fnv1a-64'
17321818-# pnpm
1919-pnpm install package-name
3333+fnv1a64Hex('hello world') // => '779a65e7023cd2e7' (16-char zero-padded hex)
3434+fnv1a64Base36('hello world') // => '1th7cxzlyc0dj' (shortest, good for cache keys)
3535+fnv1a64BigInt('hello world') // => 8618312879776256743n
3636+fnv1a64('hello world') // => { high: 2006607335, low: 37540583 } (fast core, no BigInt)
2037```
21383939+- **`fnv1a64Hex`** and **`fnv1a64Base36`** are what you usually want as a map or cache key.
4040+- **`fnv1a64`** returns the raw 32-bit lanes and is the fastest path if you never need a string.
4141+- **`fnv1a64BigInt`** composes the lanes into a `bigint` when you want a single comparable number.
4242+4343+All outputs are deterministic: the same input always produces the same result. Hex is always exactly 16 characters; base36 length varies with the value but is stable per value.
4444+4545+## Non-ASCII: UTF-16 code units, not UTF-8 bytes
4646+4747+The hash iterates `str.charCodeAt(i)`, so it hashes UTF-16 code units rather than UTF-8 bytes. For **ASCII input this is bit-for-bit identical to a canonical FNV-1a-64**. For non-ASCII (accents, CJK, emoji) the output is stable and collision-resistant but will **not** match an FNV-1a-64 computed over the UTF-8 encoding of the same string.
4848+4949+If you need to interoperate with a hash produced elsewhere over UTF-8 bytes, encode first:
5050+2251```js
2323-import {} from 'package-name'
5252+const bytes = new TextEncoder().encode(str)
5353+// then hash the bytes with a UTF-8-aware FNV-1a-64 implementation
2454```
25555656+> [!IMPORTANT]
5757+5858+> FNV-1a is a fast, **non-cryptographic** hash. It's useful for hash tables, cache keys, checksums, and bucketing, but do not use it for anything security-sensitive: it is not collision-resistant against an adversary and offers no preimage resistance.
5959+6060+## Benchmark
6161+6262+Run `pnpm bench` (uses [mitata](https://github.com/evanwashere/mitata)). Indicative numbers on Node 22 (Apple silicon), lower is better:
6363+6464+| | width | short key | 1 KB string |
6565+| --- | --- | --- | --- |
6666+| `fnv1a64` (lanes) | 64 | **~42 ns** | ~2.7 µs |
6767+| `fnv1a64Base36` | 64 | ~98 ns | ~2.8 µs |
6868+| `fnv1a64Hex` | 64 | ~339 ns | ~3.0 µs |
6969+| `fnv-plus` `fast1a64` | 64 | ~63 ns | **~1.7 µs** |
7070+| `murmurhash` v3 | 32 | ~257 ns | ~1.7 µs |
7171+| `@sindresorhus/fnv1a` | 64 | ~714 ns | ~29 µs |
7272+| `fnv-lite` `hex` | 128 | ~5.6 µs | ~297 µs |
7373+| `xxhashjs` `h64` | 64 | ~34 µs | ~59 µs |
7474+7575+The `fnv1a64` core wins short keys outright. `fnv-plus` is competitive (and faster on long strings) but ships ~9 KB gzipped for a whole multi-width toolkit rather than one function. `murmurhash` is faster than 64-bit alternatives but is 32-bit, so it collides. `fnv-lite` and `xxhashjs` pay a large constant cost for their byte-array / `cuint` internals.
7676+2677## 💻 Development
27782879- Clone this repository
···38893990<!-- Badges -->
40914141-[npm-version-src]: https://npmx.dev/api/registry/badge/version/package-name
4242-[npm-version-href]: https://npmx.dev/package/package-name
4343-[npm-downloads-src]: https://npmx.dev/api/registry/badge/downloads/package-name
4444-[npm-downloads-href]: https://npm.chart.dev/package-name
4545-[github-actions-src]: https://img.shields.io/github/actions/workflow/status/danielroe/package-name/ci.yml?branch=main&style=flat-square
4646-[github-actions-href]: https://github.com/danielroe/package-name/actions?query=workflow%3Aci
4747-[codecov-src]: https://img.shields.io/codecov/c/gh/danielroe/package-name/main?style=flat-square
4848-[codecov-href]: https://codecov.io/gh/danielroe/package-name
9292+[npm-version-src]: https://npmx.dev/api/registry/badge/version/fnv1a-64
9393+[npm-version-href]: https://npmx.dev/package/fnv1a-64
9494+[npm-downloads-src]: https://npmx.dev/api/registry/badge/downloads/fnv1a-64
9595+[npm-downloads-href]: https://npm.chart.dev/fnv1a-64
9696+[github-actions-src]: https://img.shields.io/github/actions/workflow/status/danielroe/fnv1a-64/ci.yml?branch=main&style=flat-square
9797+[github-actions-href]: https://github.com/danielroe/fnv1a-64/actions?query=workflow%3Aci
9898+[codecov-src]: https://img.shields.io/codecov/c/gh/danielroe/fnv1a-64/main?style=flat-square
9999+[codecov-href]: https://codecov.io/gh/danielroe/fnv1a-64
···11-export const welcome = () => 'hello world'
11+/**
22+ * The two 32-bit lanes of a 64-bit FNV-1a hash.
33+ *
44+ * `high` is the most-significant 32 bits, `low` the least-significant. Both are
55+ * unsigned integers in the range `0` to `2^32 - 1`.
66+ */
77+export interface Fnv1a64Lanes {
88+ high: number
99+ low: number
1010+}
1111+1212+/**
1313+ * Compute the 64-bit FNV-1a hash of a string as two 32-bit lanes.
1414+ *
1515+ * This is the fast core: no BigInt, no allocations, plain `Math.imul`-free
1616+ * 32-bit arithmetic. Prefer {@link fnv1a64Hex} or {@link fnv1a64Base36} for a
1717+ * usable key; use this directly only when you want to avoid string formatting.
1818+ *
1919+ * The hash is computed over UTF-16 code units (`str.charCodeAt(i)`), not UTF-8
2020+ * bytes. For ASCII input this matches a canonical FNV-1a-64; for non-ASCII it
2121+ * does not. See the README for details.
2222+ *
2323+ * @param str - The string to hash.
2424+ * @returns The `{ high, low }` 32-bit lanes of the 64-bit hash.
2525+ */
2626+export function fnv1a64(str: string): Fnv1a64Lanes {
2727+ let low = 0x84222325
2828+ let high = 0xCBF29CE4
2929+ for (let i = 0; i < str.length; i++) {
3030+ low ^= str.charCodeAt(i)
3131+ const lowByLow = (low & 0xFFFF) * 0x1B3
3232+ const highOfLow = (low >>> 16) * 0x1B3
3333+ const highByHigh = (high & 0xFFFF) * 0x1B3 + ((high >>> 16) * 0x1B3 << 16)
3434+ const carry = (lowByLow >>> 16) + highOfLow
3535+ // The 64-bit prime 0x100000001B3's 2^40 bit folds `low * 2^8` into the high lane.
3636+ high = (highByHigh + (carry >>> 16) + low * 0x100) >>> 0
3737+ low = ((lowByLow & 0xFFFF) | ((carry & 0xFFFF) << 16)) >>> 0
3838+ }
3939+ return { high: high >>> 0, low: low >>> 0 }
4040+}
4141+4242+/**
4343+ * Compute the 64-bit FNV-1a hash of a string as a `bigint`.
4444+ *
4545+ * Ergonomic and comparable, at the cost of composing the two lanes into a
4646+ * `bigint`. For a compact string key, prefer {@link fnv1a64Base36}.
4747+ *
4848+ * @param str - The string to hash.
4949+ * @returns The 64-bit hash as an unsigned `bigint`.
5050+ */
5151+export function fnv1a64BigInt(str: string): bigint {
5252+ const { high, low } = fnv1a64(str)
5353+ return (BigInt(high) << 32n) | BigInt(low)
5454+}
5555+5656+/**
5757+ * Compute the 64-bit FNV-1a hash of a string as a 16-character zero-padded
5858+ * lowercase hex string.
5959+ *
6060+ * The output is always exactly 16 characters, so equal-length comparison and
6161+ * fixed-width storage are safe.
6262+ *
6363+ * @param str - The string to hash.
6464+ * @returns A 16-character hex string.
6565+ */
6666+export function fnv1a64Hex(str: string): string {
6767+ const { high, low } = fnv1a64(str)
6868+ return high.toString(16).padStart(8, '0') + low.toString(16).padStart(8, '0')
6969+}
7070+7171+/**
7272+ * Compute the 64-bit FNV-1a hash of a string as a base36 string.
7373+ *
7474+ * This is the shortest textual form (up to 13 characters) and is ideal for
7575+ * cache keys. The length varies with the value; it is not zero-padded. Equal
7676+ * inputs always produce identical strings.
7777+ *
7878+ * @param str - The string to hash.
7979+ * @returns A base36 string of the 64-bit hash.
8080+ */
8181+export function fnv1a64Base36(str: string): string {
8282+ return fnv1a64BigInt(str).toString(36)
8383+}
+111-4
test/index.test.ts
···11import { describe, expect, it } from 'vitest'
22-import { welcome } from '../src'
22+import { fnv1a64, fnv1a64Base36, fnv1a64BigInt, fnv1a64Hex } from '../src'
33+44+function reference(str: string): { high: number, low: number, value: bigint } {
55+ let h = 0xCBF29CE484222325n
66+ const mask = (1n << 64n) - 1n
77+ for (let i = 0; i < str.length; i++) {
88+ h ^= BigInt(str.charCodeAt(i))
99+ h = (h * 0x100000001B3n) & mask
1010+ }
1111+ return { high: Number(h >> 32n), low: Number(h & 0xFFFFFFFFn), value: h }
1212+}
1313+1414+const cases = ['', 'a', 'abc', 'hello world', 'x'.repeat(200)]
1515+1616+describe('fnv1a64', () => {
1717+ it('matches a bigint reference bit-for-bit', () => {
1818+ for (const input of cases) {
1919+ const ref = reference(input)
2020+ const { high, low } = fnv1a64(input)
2121+ expect({ high, low }, `lanes for ${JSON.stringify(input)}`).toEqual({
2222+ high: ref.high,
2323+ low: ref.low,
2424+ })
2525+ }
2626+ })
2727+2828+ it('returns the offset basis for the empty string', () => {
2929+ expect(fnv1a64('')).toEqual({ high: 0xCBF29CE4, low: 0x84222325 })
3030+ })
3131+3232+ it('returns unsigned lanes', () => {
3333+ for (const input of cases) {
3434+ const { high, low } = fnv1a64(input)
3535+ expect(high).toBeGreaterThanOrEqual(0)
3636+ expect(low).toBeGreaterThanOrEqual(0)
3737+ expect(high).toBeLessThanOrEqual(0xFFFFFFFF)
3838+ expect(low).toBeLessThanOrEqual(0xFFFFFFFF)
3939+ }
4040+ })
4141+4242+ it('is stable across calls', () => {
4343+ for (const input of cases) {
4444+ expect(fnv1a64(input)).toEqual(fnv1a64(input))
4545+ }
4646+ })
4747+})
4848+4949+describe('fnv1a64BigInt', () => {
5050+ it('matches a bigint reference', () => {
5151+ for (const input of cases) {
5252+ expect(fnv1a64BigInt(input)).toBe(reference(input).value)
5353+ }
5454+ })
5555+})
5656+5757+describe('fnv1a64Hex', () => {
5858+ it('is 16 zero-padded characters', () => {
5959+ for (const input of cases) {
6060+ const hex = fnv1a64Hex(input)
6161+ expect(hex).toHaveLength(16)
6262+ expect(hex).toMatch(/^[0-9a-f]{16}$/)
6363+ expect(BigInt(`0x${hex}`)).toBe(reference(input).value)
6464+ }
6565+ })
6666+6767+ it('matches known snapshots', () => {
6868+ expect(fnv1a64Hex('')).toBe('cbf29ce484222325')
6969+ expect(fnv1a64Hex('a')).toBe('af63dc4c8601ec8c')
7070+ expect(fnv1a64Hex('hello world')).toBe('779a65e7023cd2e7')
7171+ })
7272+})
7373+7474+describe('fnv1a64Base36', () => {
7575+ it('round-trips to the bigint value', () => {
7676+ for (const input of cases) {
7777+ expect(parseBase36(fnv1a64Base36(input))).toBe(reference(input).value)
7878+ }
7979+ })
8080+8181+ it('is stable and deterministic', () => {
8282+ expect(fnv1a64Base36('abc')).toBe(fnv1a64Base36('abc'))
8383+ })
8484+})
8585+8686+describe('non-ascii behaviour', () => {
8787+ it('hashes utf-16 code units, not utf-8 bytes', () => {
8888+ for (const input of ['café', '日本語', '\u{1F600}emoji']) {
8989+ const ref = reference(input)
9090+ expect(fnv1a64(input)).toEqual({ high: ref.high, low: ref.low })
9191+ }
9292+ })
9393+})
39444-describe('package-name', () => {
55- it('works', () => {
66- expect(welcome()).toMatchInlineSnapshot('"hello world"')
9595+describe('collision smoke test', () => {
9696+ it('has no collisions across 50k realistic keys', () => {
9797+ const seen = new Set<string>()
9898+ const prefixes = ['user', 'session', 'cache', 'route', 'asset', 'chunk', 'node', 'query']
9999+ let count = 0
100100+ for (let i = 0; i < 50_000; i++) {
101101+ const key = `${prefixes[i % prefixes.length]}:${i}:${(i * 2654435761 >>> 0).toString(36)}`
102102+ seen.add(fnv1a64Hex(key))
103103+ count++
104104+ }
105105+ expect(seen.size).toBe(count)
7106 })
8107})
108108+109109+function parseBase36(value: string): bigint {
110110+ let result = 0n
111111+ for (const char of value) {
112112+ result = result * 36n + BigInt(Number.parseInt(char, 36))
113113+ }
114114+ return result
115115+}