UNPKG

12.3 kBPlain TextView Raw
1type NumberLike = number | bigint
2
3export default class Hashids {
4 private alphabet: string[]
5 private seps: string[]
6 private guards: string[]
7 private salt: string[]
8 private guardsRegExp: RegExp
9 private sepsRegExp: RegExp
10 private allowedCharsRegExp: RegExp
11
12 public constructor(
13 salt = '',
14 private minLength = 0,
15 alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890',
16 seps = 'cfhistuCFHISTU',
17 ) {
18 if (typeof minLength !== 'number') {
19 throw new TypeError(
20 `Hashids: Provided 'minLength' has to be a number (is ${typeof minLength})`,
21 )
22 }
23 if (typeof salt !== 'string') {
24 throw new TypeError(
25 `Hashids: Provided 'salt' has to be a string (is ${typeof salt})`,
26 )
27 }
28 if (typeof alphabet !== 'string') {
29 throw new TypeError(
30 `Hashids: Provided alphabet has to be a string (is ${typeof alphabet})`,
31 )
32 }
33
34 const saltChars = Array.from(salt)
35 const alphabetChars = Array.from(alphabet)
36 const sepsChars = Array.from(seps)
37
38 this.salt = saltChars
39
40 const uniqueAlphabet = keepUnique(alphabetChars)
41
42 if (uniqueAlphabet.length < minAlphabetLength) {
43 throw new Error(
44 `Hashids: alphabet must contain at least ${minAlphabetLength} unique characters, provided: ${uniqueAlphabet.join(
45 '',
46 )}`,
47 )
48 }
49
50 /** `alphabet` should not contains `seps` */
51 this.alphabet = withoutChars(uniqueAlphabet, sepsChars)
52 /** `seps` should contain only characters present in `alphabet` */
53 const filteredSeps = onlyChars(sepsChars, uniqueAlphabet)
54 this.seps = shuffle(filteredSeps, saltChars)
55
56 let sepsLength
57 let diff
58
59 if (
60 this.seps.length === 0 ||
61 this.alphabet.length / this.seps.length > sepDiv
62 ) {
63 sepsLength = Math.ceil(this.alphabet.length / sepDiv)
64
65 if (sepsLength > this.seps.length) {
66 diff = sepsLength - this.seps.length
67 this.seps.push(...this.alphabet.slice(0, diff))
68 this.alphabet = this.alphabet.slice(diff)
69 }
70 }
71
72 this.alphabet = shuffle(this.alphabet, saltChars)
73 const guardCount = Math.ceil(this.alphabet.length / guardDiv)
74
75 if (this.alphabet.length < 3) {
76 this.guards = this.seps.slice(0, guardCount)
77 this.seps = this.seps.slice(guardCount)
78 } else {
79 this.guards = this.alphabet.slice(0, guardCount)
80 this.alphabet = this.alphabet.slice(guardCount)
81 }
82
83 this.guardsRegExp = makeAnyOfCharsRegExp(this.guards)
84 this.sepsRegExp = makeAnyOfCharsRegExp(this.seps)
85 this.allowedCharsRegExp = makeAtLeastSomeCharRegExp([
86 ...this.alphabet,
87 ...this.guards,
88 ...this.seps,
89 ])
90 }
91
92 public encode(numbers: string): string
93 public encode(numbers: NumberLike[]): string
94 public encode(...numbers: NumberLike[]): string
95 public encode(numbers: string[]): string
96 public encode(...numbers: string[]): string
97 public encode<T extends string | NumberLike>(
98 first: T[] | T,
99 ...numbers: T[]
100 ): string {
101 const ret = ''
102
103 if (Array.isArray(first)) {
104 numbers = first
105 } else {
106 // eslint-disable-next-line eqeqeq
107 numbers = [...(first != null ? [first] : []), ...numbers]
108 }
109
110 if (!numbers.length) {
111 return ret
112 }
113
114 if (!numbers.every(isIntegerNumber)) {
115 numbers = numbers.map((n) =>
116 typeof n === 'bigint' || typeof n === 'number'
117 ? n
118 : safeParseInt10(String(n)),
119 ) as T[]
120 }
121
122 if (!(numbers as NumberLike[]).every(isPositiveAndFinite)) {
123 return ret
124 }
125
126 return this._encode(numbers as number[]).join('')
127 }
128
129 public decode(id: string): NumberLike[] {
130 if (!id || typeof id !== 'string' || id.length === 0) return []
131 return this._decode(id)
132 }
133
134 /**
135 * @description Splits a hex string into groups of 12-digit hexadecimal numbers,
136 * then prefixes each with '1' and encodes the resulting array of numbers
137 *
138 * Encoding '00000000000f00000000000f000f' would be the equivalent of:
139 * Hashids.encode([0x100000000000f, 0x100000000000f, 0x1000f])
140 *
141 * This means that if your environment supports BigInts,
142 * you will get different (shorter) results if you provide
143 * a BigInt representation of your hex and use `encode` directly, e.g.:
144 * Hashids.encode(BigInt(`0x${hex}`))
145 *
146 * To decode such a representation back to a hex string, use the following snippet:
147 * Hashids.decode(id)[0].toString(16)
148 */
149 public encodeHex(hex: string | bigint): string {
150 switch (typeof hex) {
151 case 'bigint':
152 hex = hex.toString(16)
153 break
154 case 'string':
155 if (!/^[0-9a-fA-F]+$/.test(hex)) return ''
156 break
157 default:
158 throw new Error(
159 `Hashids: The provided value is neither a string, nor a BigInt (got: ${typeof hex})`,
160 )
161 }
162
163 const numbers = splitAtIntervalAndMap(hex, 12, (part) =>
164 parseInt(`1${part}`, 16),
165 )
166 return this.encode(numbers)
167 }
168
169 public decodeHex(id: string): string {
170 return this.decode(id)
171 .map((number) => number.toString(16).slice(1))
172 .join('')
173 }
174
175 private _encode(numbers: NumberLike[]): string[] {
176 let alphabet = this.alphabet
177
178 const numbersIdInt = numbers.reduce<number>(
179 (last, number, i) =>
180 last +
181 (typeof number === 'bigint'
182 ? Number(number % BigInt(i + 100))
183 : number % (i + 100)),
184 0,
185 )
186
187 let ret: string[] = [alphabet[numbersIdInt % alphabet.length]]
188 const lottery = ret.slice()
189
190 const seps = this.seps
191 const guards = this.guards
192
193 numbers.forEach((number, i) => {
194 const buffer = lottery.concat(this.salt, alphabet)
195
196 alphabet = shuffle(alphabet, buffer)
197 const last = toAlphabet(number, alphabet)
198
199 ret.push(...last)
200
201 if (i + 1 < numbers.length) {
202 const charCode = last[0].codePointAt(0)! + i
203 const extraNumber =
204 typeof number === 'bigint'
205 ? Number(number % BigInt(charCode))
206 : number % charCode
207 ret.push(seps[extraNumber % seps.length])
208 }
209 })
210
211 if (ret.length < this.minLength) {
212 const prefixGuardIndex =
213 (numbersIdInt + ret[0].codePointAt(0)!) % guards.length
214 ret.unshift(guards[prefixGuardIndex])
215
216 if (ret.length < this.minLength) {
217 const suffixGuardIndex =
218 (numbersIdInt + ret[2].codePointAt(0)!) % guards.length
219 ret.push(guards[suffixGuardIndex])
220 }
221 }
222
223 const halfLength = Math.floor(alphabet.length / 2)
224 while (ret.length < this.minLength) {
225 alphabet = shuffle(alphabet, alphabet)
226 ret.unshift(...alphabet.slice(halfLength))
227 ret.push(...alphabet.slice(0, halfLength))
228
229 const excess = ret.length - this.minLength
230 if (excess > 0) {
231 const halfOfExcess = excess / 2
232 ret = ret.slice(halfOfExcess, halfOfExcess + this.minLength)
233 }
234 }
235
236 return ret
237 }
238
239 public isValidId(id: string): boolean {
240 return this.allowedCharsRegExp.test(id)
241 }
242
243 private _decode(id: string): NumberLike[] {
244 if (!this.isValidId(id)) {
245 throw new Error(
246 `The provided ID (${id}) is invalid, as it contains characters that do not exist in the alphabet (${this.guards.join(
247 '',
248 )}${this.seps.join('')}${this.alphabet.join('')})`,
249 )
250 }
251 const idGuardsArray = id.split(this.guardsRegExp)
252 const splitIndex =
253 idGuardsArray.length === 3 || idGuardsArray.length === 2 ? 1 : 0
254
255 const idBreakdown = idGuardsArray[splitIndex]
256 if (idBreakdown.length === 0) return []
257
258 const lotteryChar = idBreakdown[Symbol.iterator]().next().value as string
259 const idArray = idBreakdown.slice(lotteryChar.length).split(this.sepsRegExp)
260
261 let lastAlphabet: string[] = this.alphabet
262 const result: NumberLike[] = []
263
264 for (const subId of idArray) {
265 const buffer = [lotteryChar, ...this.salt, ...lastAlphabet]
266 const nextAlphabet = shuffle(
267 lastAlphabet,
268 buffer.slice(0, lastAlphabet.length),
269 )
270 result.push(fromAlphabet(Array.from(subId), nextAlphabet))
271 lastAlphabet = nextAlphabet
272 }
273
274 // if the result is different from what we'd expect, we return an empty result (malformed input):
275 if (this._encode(result).join('') !== id) return []
276 return result
277 }
278}
279
280const minAlphabetLength = 16
281const sepDiv = 3.5
282const guardDiv = 12
283
284export const keepUnique = <T>(content: Iterable<T>): T[] =>
285 Array.from(new Set(content))
286
287export const withoutChars = (
288 chars: string[],
289 withoutChars: string[],
290): string[] => chars.filter((char) => !withoutChars.includes(char))
291
292export const onlyChars = (chars: string[], keepChars: string[]): string[] =>
293 chars.filter((char) => keepChars.includes(char))
294
295const isIntegerNumber = (n: NumberLike | string) =>
296 typeof n === 'bigint' ||
297 (!Number.isNaN(Number(n)) && Math.floor(Number(n)) === n)
298
299const isPositiveAndFinite = (n: NumberLike) =>
300 typeof n === 'bigint' || (n >= 0 && Number.isSafeInteger(n))
301
302function shuffle(alphabetChars: string[], saltChars: string[]): string[] {
303 if (saltChars.length === 0) {
304 return alphabetChars
305 }
306
307 let integer: number
308 const transformed = alphabetChars.slice()
309
310 for (let i = transformed.length - 1, v = 0, p = 0; i > 0; i--, v++) {
311 v %= saltChars.length
312 p += integer = saltChars[v].codePointAt(0)!
313 const j = (integer + v + p) % i
314
315 // swap characters at positions i and j
316 const a = transformed[i]
317 const b = transformed[j]
318 transformed[j] = a
319 transformed[i] = b
320 }
321
322 return transformed
323}
324
325const toAlphabet = (input: NumberLike, alphabetChars: string[]): string[] => {
326 const id: string[] = []
327
328 if (typeof input === 'bigint') {
329 const alphabetLength = BigInt(alphabetChars.length)
330 do {
331 id.unshift(alphabetChars[Number(input % alphabetLength)])
332 input = input / alphabetLength
333 } while (input > BigInt(0))
334 } else {
335 do {
336 id.unshift(alphabetChars[input % alphabetChars.length])
337 input = Math.floor(input / alphabetChars.length)
338 } while (input > 0)
339 }
340
341 return id
342}
343
344const fromAlphabet = (
345 inputChars: string[],
346 alphabetChars: string[],
347): NumberLike =>
348 inputChars.reduce((carry, item) => {
349 const index = alphabetChars.indexOf(item)
350 if (index === -1) {
351 throw new Error(
352 `The provided ID (${inputChars.join(
353 '',
354 )}) is invalid, as it contains characters that do not exist in the alphabet (${alphabetChars.join(
355 '',
356 )})`,
357 )
358 }
359 if (typeof carry === 'bigint') {
360 return carry * BigInt(alphabetChars.length) + BigInt(index)
361 }
362 const value = carry * alphabetChars.length + index
363 const isSafeValue = Number.isSafeInteger(value)
364 if (isSafeValue) {
365 return value
366 } else {
367 if (typeof BigInt === 'function') {
368 return BigInt(carry) * BigInt(alphabetChars.length) + BigInt(index)
369 } else {
370 // we do not have support for BigInt:
371 throw new Error(
372 `Unable to decode the provided string, due to lack of support for BigInt numbers in the current environment`,
373 )
374 }
375 }
376 }, 0 as NumberLike)
377
378const safeToParseNumberRegExp = /^\+?[0-9]+$/
379const safeParseInt10 = (str: string) =>
380 safeToParseNumberRegExp.test(str) ? parseInt(str, 10) : NaN
381
382const splitAtIntervalAndMap = <T>(
383 str: string,
384 nth: number,
385 map: (n: string) => T,
386): T[] =>
387 Array.from<never, T>({length: Math.ceil(str.length / nth)}, (_, index) =>
388 map(str.slice(index * nth, (index + 1) * nth)),
389 )
390
391const makeAnyOfCharsRegExp = (chars: string[]) =>
392 new RegExp(
393 chars
394 .map((char) => escapeRegExp(char))
395 // we need to sort these from longest to shortest,
396 // as they may contain multibyte unicode characters (these should come first)
397 .sort((a, b) => b.length - a.length)
398 .join('|'),
399 )
400
401const makeAtLeastSomeCharRegExp = (chars: string[]) =>
402 new RegExp(
403 `^[${chars
404 .map((char) => escapeRegExp(char))
405 // we need to sort these from longest to shortest,
406 // as they may contain multibyte unicode characters (these should come first)
407 .sort((a, b) => b.length - a.length)
408 .join('')}]+$`,
409 )
410
411const escapeRegExp = (text: string) =>
412 text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')