UNPKG

hashids

Version:

Generate YouTube-like ids from numbers. Use Hashids when you do not want to expose your database ids to the user.

296 lines (251 loc) 8.67 kB
import type { NumberLike } from './util' import { fromAlphabet, isIntegerNumber, isPositiveAndFinite, keepUnique, makeAnyOfCharsRegExp, makeAtLeastSomeCharRegExp, onlyChars, safeParseInt10, shuffle, splitAtIntervalAndMap, toAlphabet, withoutChars, } from './util' const MIN_ALPHABET_LENGTH = 16 const SEPARATOR_DIV = 3.5 const GUARD_DIV = 12 const HEXADECIMAL = 16 const SPLIT_AT_EVERY_NTH = 12 const MODULO_PART = 100 export default class Hashids { private alphabet: string[] private seps: string[] private guards: string[] private salt: string[] private guardsRegExp: RegExp private sepsRegExp: RegExp private allowedCharsRegExp: RegExp constructor( salt = '', private minLength = 0, alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', seps = 'cfhistuCFHISTU', ) { if (typeof minLength !== 'number') { throw new TypeError( `Hashids: Provided 'minLength' has to be a number (is ${typeof minLength})`, ) } if (typeof salt !== 'string') { throw new TypeError( `Hashids: Provided 'salt' has to be a string (is ${typeof salt})`, ) } if (typeof alphabet !== 'string') { throw new TypeError( `Hashids: Provided alphabet has to be a string (is ${typeof alphabet})`, ) } const saltChars = Array.from(salt) const alphabetChars = Array.from(alphabet) const sepsChars = Array.from(seps) this.salt = saltChars const uniqueAlphabet = keepUnique(alphabetChars) if (uniqueAlphabet.length < MIN_ALPHABET_LENGTH) { throw new Error( `Hashids: alphabet must contain at least ${MIN_ALPHABET_LENGTH} unique characters, provided: ${uniqueAlphabet.join( '', )}`, ) } /** `alphabet` should not contains `seps` */ this.alphabet = withoutChars(uniqueAlphabet, sepsChars) /** `seps` should contain only characters present in `alphabet` */ const filteredSeps = onlyChars(sepsChars, uniqueAlphabet) this.seps = shuffle(filteredSeps, saltChars) let sepsLength let diff if ( this.seps.length === 0 || this.alphabet.length / this.seps.length > SEPARATOR_DIV ) { sepsLength = Math.ceil(this.alphabet.length / SEPARATOR_DIV) if (sepsLength > this.seps.length) { diff = sepsLength - this.seps.length this.seps.push(...this.alphabet.slice(0, diff)) this.alphabet = this.alphabet.slice(diff) } } this.alphabet = shuffle(this.alphabet, saltChars) const guardCount = Math.ceil(this.alphabet.length / GUARD_DIV) if (this.alphabet.length < 3) { this.guards = this.seps.slice(0, guardCount) this.seps = this.seps.slice(guardCount) } else { this.guards = this.alphabet.slice(0, guardCount) this.alphabet = this.alphabet.slice(guardCount) } this.guardsRegExp = makeAnyOfCharsRegExp(this.guards) this.sepsRegExp = makeAnyOfCharsRegExp(this.seps) this.allowedCharsRegExp = makeAtLeastSomeCharRegExp([ ...this.alphabet, ...this.guards, ...this.seps, ]) } encode(numbers: NumberLike[] | string[] | string): string encode(...numbers: NumberLike[]): string encode(...numbers: string[]): string encode<T extends NumberLike | string>( first: T | T[], ...inputNumbers: T[] ): string { const ret = '' let numbers: T[] = Array.isArray(first) ? first : [...(first != null ? [first] : []), ...inputNumbers] if (numbers.length === 0) { return ret } if (!numbers.every(isIntegerNumber)) { numbers = numbers.map((n) => typeof n === 'bigint' || typeof n === 'number' ? n : safeParseInt10(String(n)), ) as T[] } if (!(numbers as NumberLike[]).every(isPositiveAndFinite)) { return ret } return this._encode(numbers as number[]).join('') } decode(id: string): NumberLike[] { if (!id || typeof id !== 'string' || id.length === 0) return [] return this._decode(id) } /** * @description Splits a hex string into groups of 12-digit hexadecimal numbers, * then prefixes each with '1' and encodes the resulting array of numbers * * Encoding '00000000000f00000000000f000f' would be the equivalent of: * Hashids.encode([0x100000000000f, 0x100000000000f, 0x1000f]) * * This means that if your environment supports BigInts, * you will get different (shorter) results if you provide * a BigInt representation of your hex and use `encode` directly, e.g.: * Hashids.encode(BigInt(`0x${hex}`)) * * To decode such a representation back to a hex string, use the following snippet: * Hashids.decode(id)[0].toString(16) */ encodeHex(inputHex: bigint | string): string { let hex = inputHex switch (typeof hex) { case 'bigint': hex = hex.toString(HEXADECIMAL) break case 'string': if (!/^[\dA-Fa-f]+$/.test(hex)) return '' break default: throw new Error( `Hashids: The provided value is neither a string, nor a BigInt (got: ${typeof hex})`, ) } const numbers = splitAtIntervalAndMap(hex, SPLIT_AT_EVERY_NTH, (part) => Number.parseInt(`1${part}`, 16), ) return this.encode(numbers) } decodeHex(id: string): string { return this.decode(id) .map((number) => number.toString(HEXADECIMAL).slice(1)) .join('') } isValidId(id: string): boolean { return this.allowedCharsRegExp.test(id) } private _encode(numbers: NumberLike[]): string[] { let { alphabet } = this const numbersIdInt = numbers.reduce<number>( (last, number, i) => last + (typeof number === 'bigint' ? Number(number % BigInt(i + MODULO_PART)) : number % (i + MODULO_PART)), 0, ) let ret: string[] = [alphabet[numbersIdInt % alphabet.length]!] const lottery = [...ret] const { seps } = this const { guards } = this numbers.forEach((number, i) => { const buffer = lottery.concat(this.salt, alphabet) alphabet = shuffle(alphabet, buffer) const last = toAlphabet(number, alphabet) ret.push(...last) if (i + 1 < numbers.length) { const charCode = last[0]!.codePointAt(0)! + i const extraNumber = typeof number === 'bigint' ? Number(number % BigInt(charCode)) : number % charCode ret.push(seps[extraNumber % seps.length]!) } }) if (ret.length < this.minLength) { const prefixGuardIndex = (numbersIdInt + ret[0]!.codePointAt(0)!) % guards.length ret.unshift(guards[prefixGuardIndex]!) if (ret.length < this.minLength) { const suffixGuardIndex = (numbersIdInt + ret[2]!.codePointAt(0)!) % guards.length ret.push(guards[suffixGuardIndex]!) } } const halfLength = Math.floor(alphabet.length / 2) while (ret.length < this.minLength) { alphabet = shuffle(alphabet, alphabet) ret.unshift(...alphabet.slice(halfLength)) ret.push(...alphabet.slice(0, halfLength)) const excess = ret.length - this.minLength if (excess > 0) { const halfOfExcess = excess / 2 ret = ret.slice(halfOfExcess, halfOfExcess + this.minLength) } } return ret } private _decode(id: string): NumberLike[] { if (!this.isValidId(id)) { throw new Error( `The provided ID (${id}) is invalid, as it contains characters that do not exist in the alphabet (${this.guards.join( '', )}${this.seps.join('')}${this.alphabet.join('')})`, ) } const idGuardsArray = id.split(this.guardsRegExp) const splitIndex = idGuardsArray.length === 3 || idGuardsArray.length === 2 ? 1 : 0 const idBreakdown = idGuardsArray[splitIndex]! if (idBreakdown.length === 0) return [] const lotteryChar = idBreakdown[Symbol.iterator]().next().value as string const idArray = idBreakdown.slice(lotteryChar.length).split(this.sepsRegExp) let lastAlphabet: string[] = this.alphabet const result: NumberLike[] = [] for (const subId of idArray) { const buffer = [lotteryChar, ...this.salt, ...lastAlphabet] const nextAlphabet = shuffle( lastAlphabet, buffer.slice(0, lastAlphabet.length), ) result.push(fromAlphabet(Array.from(subId), nextAlphabet)) lastAlphabet = nextAlphabet } // if the result is different from what we'd expect, we return an empty result (malformed input): if (this._encode(result).join('') !== id) return [] return result } }