1 | type NumberLike = number | bigint
|
2 |
|
3 | export 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 |
|
51 | this.alphabet = withoutChars(uniqueAlphabet, sepsChars)
|
52 |
|
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 |
|
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 |
|
136 |
|
137 |
|
138 |
|
139 |
|
140 |
|
141 |
|
142 |
|
143 |
|
144 |
|
145 |
|
146 |
|
147 |
|
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 |
|
275 | if (this._encode(result).join('') !== id) return []
|
276 | return result
|
277 | }
|
278 | }
|
279 |
|
280 | const minAlphabetLength = 16
|
281 | const sepDiv = 3.5
|
282 | const guardDiv = 12
|
283 |
|
284 | export const keepUnique = <T>(content: Iterable<T>): T[] =>
|
285 | Array.from(new Set(content))
|
286 |
|
287 | export const withoutChars = (
|
288 | chars: string[],
|
289 | withoutChars: string[],
|
290 | ): string[] => chars.filter((char) => !withoutChars.includes(char))
|
291 |
|
292 | export const onlyChars = (chars: string[], keepChars: string[]): string[] =>
|
293 | chars.filter((char) => keepChars.includes(char))
|
294 |
|
295 | const isIntegerNumber = (n: NumberLike | string) =>
|
296 | typeof n === 'bigint' ||
|
297 | (!Number.isNaN(Number(n)) && Math.floor(Number(n)) === n)
|
298 |
|
299 | const isPositiveAndFinite = (n: NumberLike) =>
|
300 | typeof n === 'bigint' || (n >= 0 && Number.isSafeInteger(n))
|
301 |
|
302 | function 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 |
|
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 |
|
325 | const 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 |
|
344 | const 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 |
|
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 |
|
378 | const safeToParseNumberRegExp = /^\+?[0-9]+$/
|
379 | const safeParseInt10 = (str: string) =>
|
380 | safeToParseNumberRegExp.test(str) ? parseInt(str, 10) : NaN
|
381 |
|
382 | const 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 |
|
391 | const makeAnyOfCharsRegExp = (chars: string[]) =>
|
392 | new RegExp(
|
393 | chars
|
394 | .map((char) => escapeRegExp(char))
|
395 |
|
396 |
|
397 | .sort((a, b) => b.length - a.length)
|
398 | .join('|'),
|
399 | )
|
400 |
|
401 | const 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 |
|
411 | const escapeRegExp = (text: string) =>
|
412 | text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
|