├── .prettierignore ├── .gitignore ├── .gitattributes ├── .prettierrc ├── tests ├── internal │ ├── README.md │ ├── uniques.test.ts │ └── shuffle.test.ts ├── alphabet.test.ts ├── encoding.test.ts ├── minlength.test.ts └── blocklist.test.ts ├── .eslintrc.cjs ├── .github └── workflows │ └── tests.yml ├── package.json ├── README.md └── src ├── blocklist.json └── index.ts /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | docs 4 | coverage -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | package-lock.json binary 2 | src/blocklist.json binary -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /tests/internal/README.md: -------------------------------------------------------------------------------- 1 | Tests in this folder are not necessary for individual implementations to implement 2 | since they test the algorithm, and not the implementations themselves 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint'], 6 | parserOptions: { 7 | sourceType: 'module', 8 | ecmaVersion: 2020 9 | }, 10 | env: { 11 | browser: true, 12 | es2017: true, 13 | node: true 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [18.x, 20.x] 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm ci 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sqids-spec", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "test": "vitest run --coverage.enabled --coverage.reporter='text-summary'", 7 | "lint": "eslint .", 8 | "format": "prettier --write ." 9 | }, 10 | "devDependencies": { 11 | "@typescript-eslint/eslint-plugin": "6.16.0", 12 | "@typescript-eslint/parser": "6.16.0", 13 | "@vitest/coverage-v8": "2.1.8", 14 | "eslint": "8.56.0", 15 | "eslint-config-prettier": "9.1.0", 16 | "prettier": "3.4.2", 17 | "typescript": "5.7.2", 18 | "vitest": "2.1.8" 19 | }, 20 | "type": "module" 21 | } 22 | -------------------------------------------------------------------------------- /tests/alphabet.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import Sqids from '../src/index.ts'; 3 | 4 | test('simple', () => { 5 | const sqids = new Sqids({ 6 | alphabet: '0123456789abcdef' 7 | }); 8 | 9 | const numbers = [1, 2, 3]; 10 | const id = '489158'; 11 | 12 | expect.soft(sqids.encode(numbers)).toBe(id); 13 | expect.soft(sqids.decode(id)).toEqual(numbers); 14 | }); 15 | 16 | test('short alphabet', () => { 17 | const sqids = new Sqids({ 18 | alphabet: 'abc' 19 | }); 20 | 21 | const numbers = [1, 2, 3]; 22 | expect.soft(sqids.decode(sqids.encode(numbers))).toEqual(numbers); 23 | }); 24 | 25 | test('long alphabet', () => { 26 | const sqids = new Sqids({ 27 | alphabet: 28 | 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_+|{}[];:\'"/?.>,<`~' 29 | }); 30 | 31 | const numbers = [1, 2, 3]; 32 | expect.soft(sqids.decode(sqids.encode(numbers))).toEqual(numbers); 33 | }); 34 | 35 | test('multibyte characters', async () => { 36 | await expect( 37 | async () => 38 | new Sqids({ 39 | alphabet: 'ë1092' 40 | }) 41 | ).rejects.toThrow('Alphabet cannot contain multibyte characters'); 42 | }); 43 | 44 | test('repeating alphabet characters', async () => { 45 | await expect( 46 | async () => 47 | new Sqids({ 48 | alphabet: 'aabcdefg' 49 | }) 50 | ).rejects.toThrow('Alphabet must contain unique characters'); 51 | }); 52 | 53 | test('too short of an alphabet', async () => { 54 | await expect( 55 | async () => 56 | new Sqids({ 57 | alphabet: 'ab' 58 | }) 59 | ).rejects.toThrow('Alphabet length must be at least 3'); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/internal/uniques.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import Sqids, { defaultOptions } from '../../src/index.ts'; 3 | 4 | // @NOTE: "uniques, with blocked words" is auto-tested since a lot of these big ids 5 | // will match some words on the blocklist and will be re-generated anyway 6 | 7 | const upTo = 6_000_000; 8 | 9 | test('uniques', () => { 10 | const sqids = new Sqids(); 11 | 12 | const idSet = new Set(); 13 | const numbersSet = new Set(); 14 | 15 | for (let i = 0; i != upTo; i++) { 16 | const numbers = [i]; 17 | 18 | const id = sqids.encode(numbers); 19 | 20 | const decodedNumbers = sqids.decode(id); 21 | expect.soft(decodedNumbers).toEqual(numbers); 22 | 23 | idSet.add(id); 24 | numbersSet.add(decodedNumbers.join(',')); 25 | } 26 | 27 | expect.soft(idSet.size).toBe(upTo); 28 | expect.soft(numbersSet.size).toBe(upTo); 29 | }); 30 | 31 | test('uniques, with padding', () => { 32 | const sqids = new Sqids({ 33 | minLength: defaultOptions.alphabet.length 34 | }); 35 | 36 | const idSet = new Set(); 37 | const numbersSet = new Set(); 38 | 39 | for (let i = 0; i != upTo; i++) { 40 | const numbers = [i]; 41 | 42 | const id = sqids.encode(numbers); 43 | 44 | const decodedNumbers = sqids.decode(id); 45 | expect.soft(decodedNumbers).toEqual(numbers); 46 | 47 | idSet.add(id); 48 | numbersSet.add(decodedNumbers.join(',')); 49 | } 50 | 51 | expect.soft(idSet.size).toBe(upTo); 52 | expect.soft(numbersSet.size).toBe(upTo); 53 | }); 54 | 55 | test('uniques, with multiple/random numbers', () => { 56 | const sqids = new Sqids(); 57 | 58 | const idSet = new Set(); 59 | const numbersSet = new Set(); 60 | 61 | for (let i = 0; i != upTo; i++) { 62 | const numbers = [0, i, i + 1, Math.floor(Math.random() * 1_000), 999999999]; 63 | 64 | const id = sqids.encode(numbers); 65 | 66 | const decodedNumbers = sqids.decode(id); 67 | expect.soft(decodedNumbers).toEqual(numbers); 68 | 69 | idSet.add(id); 70 | numbersSet.add(decodedNumbers.join(',')); 71 | } 72 | 73 | expect.soft(idSet.size).toBe(upTo); 74 | expect.soft(numbersSet.size).toBe(upTo); 75 | }); 76 | -------------------------------------------------------------------------------- /tests/internal/shuffle.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { defaultOptions } from '../../src/index.ts'; 3 | 4 | const shuffle = (alphabet: string): string => { 5 | const chars = alphabet.split(''); 6 | 7 | for (let i = 0, j = chars.length - 1; j > 0; i++, j--) { 8 | const r = (i * j + chars[i].codePointAt(0) + chars[j].codePointAt(0)) % chars.length; 9 | [chars[i], chars[r]] = [chars[r], chars[i]]; 10 | } 11 | 12 | return chars.join(''); 13 | }; 14 | 15 | test('default shuffle, checking for randomness', () => { 16 | expect 17 | .soft(shuffle(defaultOptions.alphabet)) 18 | .toBe('fwjBhEY2uczNPDiloxmvISCrytaJO4d71T0W3qnMZbXVHg6eR8sAQ5KkpLUGF9'); 19 | }); 20 | 21 | test('numbers in the front, another check for randomness', () => { 22 | const i = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 23 | const o = 'ec38UaynYXvoxSK7RV9uZ1D2HEPw6isrdzAmBNGT5OCJLk0jlFbtqWQ4hIpMgf'; 24 | 25 | expect.soft(shuffle(i)).toBe(o); 26 | }); 27 | 28 | test('swapping front 2 characters', () => { 29 | const i1 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 30 | const i2 = '1023456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 31 | 32 | const o1 = 'ec38UaynYXvoxSK7RV9uZ1D2HEPw6isrdzAmBNGT5OCJLk0jlFbtqWQ4hIpMgf'; 33 | const o2 = 'xI3RUayk1MSolQK7e09zYmFpVXPwHiNrdfBJ6ZAT5uCWbntgcDsEqjv4hLG28O'; 34 | 35 | expect.soft(shuffle(i1)).toBe(o1); 36 | expect.soft(shuffle(i2)).toBe(o2); 37 | }); 38 | 39 | test('swapping last 2 characters', () => { 40 | const i1 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 41 | const i2 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXZY'; 42 | 43 | const o1 = 'ec38UaynYXvoxSK7RV9uZ1D2HEPw6isrdzAmBNGT5OCJLk0jlFbtqWQ4hIpMgf'; 44 | const o2 = 'x038UaykZMSolIK7RzcbYmFpgXEPHiNr1d2VfGAT5uJWQetjvDswqn94hLC6BO'; 45 | 46 | expect.soft(shuffle(i1)).toBe(o1); 47 | expect.soft(shuffle(i2)).toBe(o2); 48 | }); 49 | 50 | test('short alphabet', () => { 51 | expect.soft(shuffle('0123456789')).toBe('4086517392'); 52 | }); 53 | 54 | test('really short alphabet', () => { 55 | expect.soft(shuffle('12345')).toBe('24135'); 56 | }); 57 | 58 | test('lowercase alphabet', () => { 59 | const i = 'abcdefghijklmnopqrstuvwxyz'; 60 | const o = 'lbfziqvscptmyxrekguohwjand'; 61 | 62 | expect.soft(shuffle(i)).toBe(o); 63 | }); 64 | 65 | test('uppercase alphabet', () => { 66 | const i = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 67 | const o = 'ZXBNSIJQEDMCTKOHVWFYUPLRGA'; 68 | 69 | expect.soft(shuffle(i)).toBe(o); 70 | }); 71 | -------------------------------------------------------------------------------- /tests/encoding.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import Sqids from '../src/index.ts'; 3 | 4 | test('simple', () => { 5 | const sqids = new Sqids(); 6 | 7 | const numbers = [1, 2, 3]; 8 | const id = '86Rf07'; 9 | 10 | expect.soft(sqids.encode(numbers)).toBe(id); 11 | expect.soft(sqids.decode(id)).toEqual(numbers); 12 | }); 13 | 14 | test('different inputs', () => { 15 | const sqids = new Sqids(); 16 | 17 | const numbers = [0, 0, 0, 1, 2, 3, 100, 1_000, 100_000, 1_000_000, Number.MAX_SAFE_INTEGER]; 18 | expect.soft(sqids.decode(sqids.encode(numbers))).toEqual(numbers); 19 | }); 20 | 21 | test('incremental numbers', () => { 22 | const sqids = new Sqids(); 23 | 24 | const ids = { 25 | bM: [0], 26 | Uk: [1], 27 | gb: [2], 28 | Ef: [3], 29 | Vq: [4], 30 | uw: [5], 31 | OI: [6], 32 | AX: [7], 33 | p6: [8], 34 | nJ: [9] 35 | }; 36 | 37 | for (const [id, numbers] of Object.entries(ids)) { 38 | expect.soft(sqids.encode(numbers)).toBe(id); 39 | expect.soft(sqids.decode(id)).toEqual(numbers); 40 | } 41 | }); 42 | 43 | test('incremental numbers, same index 0', () => { 44 | const sqids = new Sqids(); 45 | 46 | const ids = { 47 | SvIz: [0, 0], 48 | n3qa: [0, 1], 49 | tryF: [0, 2], 50 | eg6q: [0, 3], 51 | rSCF: [0, 4], 52 | sR8x: [0, 5], 53 | uY2M: [0, 6], 54 | '74dI': [0, 7], 55 | '30WX': [0, 8], 56 | moxr: [0, 9] 57 | }; 58 | 59 | for (const [id, numbers] of Object.entries(ids)) { 60 | expect.soft(sqids.encode(numbers)).toBe(id); 61 | expect.soft(sqids.decode(id)).toEqual(numbers); 62 | } 63 | }); 64 | 65 | test('incremental numbers, same index 1', () => { 66 | const sqids = new Sqids(); 67 | 68 | const ids = { 69 | SvIz: [0, 0], 70 | nWqP: [1, 0], 71 | tSyw: [2, 0], 72 | eX68: [3, 0], 73 | rxCY: [4, 0], 74 | sV8a: [5, 0], 75 | uf2K: [6, 0], 76 | '7Cdk': [7, 0], 77 | '3aWP': [8, 0], 78 | m2xn: [9, 0] 79 | }; 80 | 81 | for (const [id, numbers] of Object.entries(ids)) { 82 | expect.soft(sqids.encode(numbers)).toBe(id); 83 | expect.soft(sqids.decode(id)).toEqual(numbers); 84 | } 85 | }); 86 | 87 | test('multi input', () => { 88 | const sqids = new Sqids(); 89 | 90 | const numbers = [ 91 | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 92 | 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 93 | 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 94 | 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 95 | 98, 99 96 | ]; 97 | const output = sqids.decode(sqids.encode(numbers)); 98 | expect.soft(numbers).toEqual(output); 99 | }); 100 | 101 | test('encoding no numbers', () => { 102 | const sqids = new Sqids(); 103 | expect.soft(sqids.encode([])).toEqual(''); 104 | }); 105 | 106 | test('decoding empty string', () => { 107 | const sqids = new Sqids(); 108 | expect.soft(sqids.decode('')).toEqual([]); 109 | }); 110 | 111 | test('decoding an ID with an invalid character', () => { 112 | const sqids = new Sqids(); 113 | expect.soft(sqids.decode('*')).toEqual([]); 114 | }); 115 | 116 | test('encode out-of-range numbers', async () => { 117 | const encodingError = `Encoding supports numbers between 0 and ${Number.MAX_SAFE_INTEGER}`; 118 | 119 | const sqids = new Sqids(); 120 | await expect(async () => sqids.encode([-1])).rejects.toThrow(encodingError); 121 | await expect(async () => sqids.encode([Number.MAX_SAFE_INTEGER + 1])).rejects.toThrow( 122 | encodingError 123 | ); 124 | }); 125 | -------------------------------------------------------------------------------- /tests/minlength.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import Sqids, { defaultOptions } from '../src/index.ts'; 3 | 4 | test('simple', () => { 5 | const sqids = new Sqids({ 6 | minLength: defaultOptions.alphabet.length 7 | }); 8 | 9 | const numbers = [1, 2, 3]; 10 | const id = '86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTM'; 11 | 12 | expect.soft(sqids.encode(numbers)).toBe(id); 13 | expect.soft(sqids.decode(id)).toEqual(numbers); 14 | }); 15 | 16 | test('incremental', () => { 17 | const numbers = [1, 2, 3]; 18 | 19 | const map = { 20 | 6: '86Rf07', 21 | 7: '86Rf07x', 22 | 8: '86Rf07xd', 23 | 9: '86Rf07xd4', 24 | 10: '86Rf07xd4z', 25 | 11: '86Rf07xd4zB', 26 | 12: '86Rf07xd4zBm', 27 | 13: '86Rf07xd4zBmi', 28 | [defaultOptions.alphabet.length + 0]: 29 | '86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTM', 30 | [defaultOptions.alphabet.length + 1]: 31 | '86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMy', 32 | [defaultOptions.alphabet.length + 2]: 33 | '86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMyf', 34 | [defaultOptions.alphabet.length + 3]: 35 | '86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMyf1' 36 | }; 37 | 38 | for (const [minLength, id] of Object.entries(map)) { 39 | const sqids = new Sqids({ 40 | minLength: +minLength 41 | }); 42 | 43 | expect.soft(sqids.encode(numbers)).toBe(id); 44 | expect.soft(sqids.encode(numbers).length).toBe(+minLength); 45 | expect.soft(sqids.decode(id)).toEqual(numbers); 46 | } 47 | }); 48 | 49 | test('incremental numbers', () => { 50 | const sqids = new Sqids({ 51 | minLength: defaultOptions.alphabet.length 52 | }); 53 | 54 | const ids = { 55 | SvIzsqYMyQwI3GWgJAe17URxX8V924Co0DaTZLtFjHriEn5bPhcSkfmvOslpBu: [0, 0], 56 | n3qafPOLKdfHpuNw3M61r95svbeJGk7aAEgYn4WlSjXURmF8IDqZBy0CT2VxQc: [0, 1], 57 | tryFJbWcFMiYPg8sASm51uIV93GXTnvRzyfLleh06CpodJD42B7OraKtkQNxUZ: [0, 2], 58 | eg6ql0A3XmvPoCzMlB6DraNGcWSIy5VR8iYup2Qk4tjZFKe1hbwfgHdUTsnLqE: [0, 3], 59 | rSCFlp0rB2inEljaRdxKt7FkIbODSf8wYgTsZM1HL9JzN35cyoqueUvVWCm4hX: [0, 4], 60 | sR8xjC8WQkOwo74PnglH1YFdTI0eaf56RGVSitzbjuZ3shNUXBrqLxEJyAmKv2: [0, 5], 61 | uY2MYFqCLpgx5XQcjdtZK286AwWV7IBGEfuS9yTmbJvkzoUPeYRHr4iDs3naN0: [0, 6], 62 | '74dID7X28VLQhBlnGmjZrec5wTA1fqpWtK4YkaoEIM9SRNiC3gUJH0OFvsPDdy': [0, 7], 63 | '30WXpesPhgKiEI5RHTY7xbB1GnytJvXOl2p0AcUjdF6waZDo9Qk8VLzMuWrqCS': [0, 8], 64 | moxr3HqLAK0GsTND6jowfZz3SUx7cQ8aC54Pl1RbIvFXmEJuBMYVeW9yrdOtin: [0, 9] 65 | }; 66 | 67 | for (const [id, numbers] of Object.entries(ids)) { 68 | expect.soft(sqids.encode(numbers)).toBe(id); 69 | expect.soft(sqids.decode(id)).toEqual(numbers); 70 | } 71 | }); 72 | 73 | test('min lengths', () => { 74 | for (const minLength of [0, 1, 5, 10, defaultOptions.alphabet.length]) { 75 | for (const numbers of [ 76 | [0], 77 | [0, 0, 0, 0, 0], 78 | [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 79 | [100, 200, 300], 80 | [1_000, 2_000, 3_000], 81 | [1_000_000], 82 | [Number.MAX_SAFE_INTEGER] 83 | ]) { 84 | const sqids = new Sqids({ 85 | minLength 86 | }); 87 | 88 | const id = sqids.encode(numbers); 89 | expect.soft(id.length).toBeGreaterThanOrEqual(minLength); 90 | expect.soft(sqids.decode(id)).toEqual(numbers); 91 | } 92 | } 93 | }); 94 | 95 | // for those langs that don't support `u8` 96 | test('out-of-range invalid min length', async () => { 97 | const minLengthLimit = 255; 98 | const minLengthError = `Minimum length has to be between 0 and ${minLengthLimit}`; 99 | 100 | await expect( 101 | async () => 102 | new Sqids({ 103 | minLength: -1 104 | }) 105 | ).rejects.toThrow(minLengthError); 106 | 107 | await expect( 108 | async () => 109 | new Sqids({ 110 | minLength: minLengthLimit + 1 111 | }) 112 | ).rejects.toThrow(minLengthError); 113 | }); 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Sqids](https://sqids.org) Specification 2 | 3 | [![Github Actions](https://img.shields.io/github/actions/workflow/status/sqids/sqids/tests.yml?style=flat-square)](https://github.com/sqids/sqids/actions) 4 | 5 | This is the main repository for Sqids specification. It is meant to be the guide for future ports of different languages. 6 | 7 | **The code is optimized for readability and clarity**; _individual implementations should optimize for performance as needed_. 8 | 9 | All unit tests should have matching results. 10 | 11 | ## 👩‍💻 Get started 12 | 13 | ```bash 14 | npm install 15 | npm test 16 | ``` 17 | 18 | The main Sqids library is in [src/index.ts](src/index.ts); unit tests are in [src/tests](src/tests). 19 | 20 | Use the following to format & check changes: 21 | 22 | ```bash 23 | npm run format 24 | npm run lint 25 | ``` 26 | 27 | ## 🚧 Improvements (over Hashids) 28 | 29 | 1. The user is not required to provide randomized input anymore (there's still support for custom IDs). 30 | 1. Better internal alphabet shuffling function. 31 | 1. With default alphabet - Hashids is using base 49 for encoding-only, whereas Sqids is using base 61. 32 | 1. Safer public IDs, with support for custom blocklist of words. 33 | 1. Separators are no longer limited to characters "c, s, f, h, u, i, t". Instead, it's one rotating separator assigned on the fly. 34 | 1. Simpler & smaller implementation: only "encode" & "decode" functions. 35 | 36 | ## 🔬 How it works 37 | 38 | Sqids is basically a decimal to hexademical conversion, but with a few extra features. The alphabet is larger, it supports encoding several numbers into a single ID, and it makes sure generated IDs are URL-safe (no common profanity). 39 | 40 | Here's how encoding works: 41 | 42 | 1. An `offset` index is chosen from the given input 43 | 1. Alphabet is split into two pieces using that offset and those two halfs are swapped 44 | 1. Alphabet is reversed 45 | 1. For each input number: 46 | 1. The first character from the alphabet is reserved to be used as `separator` 47 | 1. The rest of the alphabet is used to encode the number into an ID 48 | 1. If this is not the last number in the input array, the `separator` character is appended 49 | 1. The alphabet is shuffled 50 | 1. If the generated ID does not meet the `minLength` requirement: 51 | - The `separator` character is appended 52 | - If still does not meet requirement: 53 | - Another shuffle occurs 54 | - The `separator` character is again appended to the remaining id + however many characters needed to meet the requirement 55 | 1. If the blocklist function matches the generated ID: 56 | - `offset` index is incremented by 1, but never more than the length of the alphabet (in that case throw error) 57 | - Re-encode (repeat the whole procedure again) 58 | 59 | Decoding is the same process but in reverse. A few things worth noting: 60 | 61 | - If two separators are right next to each other within the ID, that's fine - it just means the rest of the ID are junk characters used to satisfy the `minLength` requirement 62 | - The decoding function does not check if ID is valid/canonical, because we want blocked IDs to still be decodable (the user can check for this stuff themselves by re-encoding decoded numbers) 63 | 64 | ## 📦 How to port Sqids to another language? 65 | 66 | Implementations of new languages are [more than welcome](https://sqids.org/faq#contribute-lang)! To start: 67 | 68 | 1. Make sure the language is not already implemented. At this point, if you see a Hashids implementation, but not a Sqids implementation: _we could use your help on converting it_. 69 | 1. The main spec is here: . It's ~300 lines of code and heavily commented. Comments are there for clarity, they don't have to exist in your own implementation. 70 | 1. **Fork the repository/language you'd like to implement to your own Github account.** If the repository/language does not exist under the Sqids Github account, [open a new issue](https://github.com/sqids/sqids-spec/issues) under the spec repo so we can create a blank repo first. 71 | 1. Implement the main library + unit tests + Github Actions (if applicable). You **do not need to port tests in the `internal` folder**; they are there to test the algorithm itself. 72 | 1. Add a `README.md` -- you can re-use any of the [existing ones](https://raw.githubusercontent.com/sqids/sqids-javascript/main/README.md). 73 | 1. Please use the blocklist from . It will contain the most up-to-date list. Do not copy and paste the blocklist from other implementations, as they might not be up-to-date. 74 | 1. **Create a pull request, so we can review & merge it.** 75 | 1. If the repo has no active maintainers, we'll invite you to manage it (and maybe even merge your own PR). 76 | 1. Once the library is ready, we'll update the website. 77 | 78 | ## 📋 Notes 79 | 80 | - The reason `prefix` character is used is to randomize sequential inputs (eg: [0, 1], [0, 2], [0, 3]). Without the extra `prefix` character embedded into the ID, the output would start with the same characters. 81 | - Internal shuffle function does not use random input. It consistently produces the same output. 82 | - If new words are blocked (or removed from the blocklist), the `encode()` function might produce new IDs, but the `decode()` function would still work for old/blocked IDs, plus new IDs. So, there's more than one ID that can be produced for same numbers. 83 | - FAQ section is here: 84 | 85 | ## 🍻 License 86 | 87 | Every official Sqids library is MIT-licensed. 88 | -------------------------------------------------------------------------------- /tests/blocklist.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import Sqids from '../src/index.ts'; 3 | 4 | test('if no custom blocklist param, use the default blocklist', () => { 5 | const sqids = new Sqids(); 6 | 7 | expect.soft(sqids.decode('aho1e')).toEqual([4572721]); 8 | expect.soft(sqids.encode([4572721])).toBe('JExTR'); 9 | }); 10 | 11 | test(`if an empty blocklist param passed, don't use any blocklist`, () => { 12 | const sqids = new Sqids({ 13 | blocklist: new Set([]) 14 | }); 15 | 16 | expect.soft(sqids.decode('aho1e')).toEqual([4572721]); 17 | expect.soft(sqids.encode([4572721])).toBe('aho1e'); 18 | }); 19 | 20 | test('if a non-empty blocklist param passed, use only that', () => { 21 | const sqids = new Sqids({ 22 | blocklist: new Set([ 23 | 'ArUO' // originally encoded [100000] 24 | ]) 25 | }); 26 | 27 | // make sure we don't use the default blocklist 28 | expect.soft(sqids.decode('aho1e')).toEqual([4572721]); 29 | expect.soft(sqids.encode([4572721])).toBe('aho1e'); 30 | 31 | // make sure we are using the passed blocklist 32 | expect.soft(sqids.decode('ArUO')).toEqual([100000]); 33 | expect.soft(sqids.encode([100000])).toBe('QyG4'); 34 | expect.soft(sqids.decode('QyG4')).toEqual([100000]); 35 | }); 36 | 37 | test('blocklist', () => { 38 | const sqids = new Sqids({ 39 | blocklist: new Set([ 40 | 'JSwXFaosAN', // normal result of 1st encoding, let's block that word on purpose 41 | 'OCjV9JK64o', // result of 2nd encoding 42 | 'rBHf', // result of 3rd encoding is `4rBHfOiqd3`, let's block a substring 43 | '79SM', // result of 4th encoding is `dyhgw479SM`, let's block the postfix 44 | '7tE6' // result of 4th encoding is `7tE6jdAHLe`, let's block the prefix 45 | ]) 46 | }); 47 | 48 | expect.soft(sqids.encode([1_000_000, 2_000_000])).toBe('1aYeB7bRUt'); 49 | expect.soft(sqids.decode('1aYeB7bRUt')).toEqual([1_000_000, 2_000_000]); 50 | }); 51 | 52 | test('decoding blocklist words should still work', () => { 53 | const sqids = new Sqids({ 54 | blocklist: new Set(['86Rf07', 'se8ojk', 'ARsz1p', 'Q8AI49', '5sQRZO']) 55 | }); 56 | 57 | expect.soft(sqids.decode('86Rf07')).toEqual([1, 2, 3]); 58 | expect.soft(sqids.decode('se8ojk')).toEqual([1, 2, 3]); 59 | expect.soft(sqids.decode('ARsz1p')).toEqual([1, 2, 3]); 60 | expect.soft(sqids.decode('Q8AI49')).toEqual([1, 2, 3]); 61 | expect.soft(sqids.decode('5sQRZO')).toEqual([1, 2, 3]); 62 | }); 63 | 64 | test('match against a short blocklist word', () => { 65 | const sqids = new Sqids({ 66 | blocklist: new Set(['pnd']) 67 | }); 68 | 69 | expect.soft(sqids.decode(sqids.encode([1000]))).toEqual([1000]); 70 | }); 71 | 72 | test('blocklist filtering in constructor', () => { 73 | const sqids = new Sqids({ 74 | alphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 75 | blocklist: new Set(['sxnzkl']) // lowercase blocklist in only-uppercase alphabet 76 | }); 77 | 78 | const id = sqids.encode([1, 2, 3]); 79 | const numbers = sqids.decode(id); 80 | 81 | expect.soft(id).toEqual('IBSHOZ'); // without blocklist, would've been "SXNZKL" 82 | expect.soft(numbers).toEqual([1, 2, 3]); 83 | }); 84 | 85 | test('max encoding attempts', async () => { 86 | const alphabet = 'abc'; 87 | const minLength = 3; 88 | const blocklist = new Set(['cab', 'abc', 'bca']); 89 | 90 | const sqids = new Sqids({ 91 | alphabet, 92 | minLength, 93 | blocklist 94 | }); 95 | 96 | expect.soft(alphabet.length).toEqual(minLength); 97 | expect.soft(blocklist.size).toEqual(minLength); 98 | 99 | await expect(async () => sqids.encode([0])).rejects.toThrow( 100 | 'Reached max attempts to re-generate the ID' 101 | ); 102 | }); 103 | 104 | // "id" == generated sqids uid; "word" == blocklist word 105 | test('specific isBlockedId scenarios', async () => { 106 | // id or word less than 3 chars should match exactly 107 | // normally: [100] -> "86u" 108 | let sqids = new Sqids({ 109 | blocklist: new Set(['hey']) // should *not* regenerate id 110 | }); 111 | expect.soft(sqids.encode([100])).toEqual('86u'); 112 | 113 | // id or word less than 3 chars should match exactly 114 | // normally: [100] -> "86u" 115 | sqids = new Sqids({ 116 | blocklist: new Set(['86u']) // should regenerate id 117 | }); 118 | expect.soft(sqids.encode([100])).toEqual('sec'); 119 | 120 | // id or word less than 3 chars should match exactly 121 | // normally: [1_000_000] -> "gMvFo" 122 | sqids = new Sqids({ 123 | blocklist: new Set(['vFo']) // should *not* regenerate id 124 | }); 125 | expect.soft(sqids.encode([1_000_000])).toEqual('gMvFo'); 126 | 127 | // word with ints should match id at the beginning 128 | // normally: [100, 202, 303, 404] -> "lP3iIcG1HkYs" 129 | sqids = new Sqids({ 130 | blocklist: new Set(['lP3i']) // should regenerate id 131 | }); 132 | expect.soft(sqids.encode([100, 202, 303, 404])).toEqual('oDqljxrokxRt'); 133 | 134 | // word with ints should match id at the end 135 | // normally: [100, 202, 303, 404] -> "lP3iIcG1HkYs" 136 | sqids = new Sqids({ 137 | blocklist: new Set(['1HkYs']) // should regenerate id 138 | }); 139 | expect.soft(sqids.encode([100, 202, 303, 404])).toEqual('oDqljxrokxRt'); 140 | 141 | // word with ints should *not* match id in the middle 142 | // normally: [101, 202, 303, 404, 505, 606, 707] -> "862REt0hfxXVdsLG8vGWD" 143 | sqids = new Sqids({ 144 | blocklist: new Set(['0hfxX']) // should *not* regenerate id 145 | }); 146 | expect.soft(sqids.encode([101, 202, 303, 404, 505, 606, 707])).toEqual('862REt0hfxXVdsLG8vGWD'); 147 | 148 | // word *without* ints should match id in the middle 149 | // normally: [101, 202, 303, 404, 505, 606, 707] -> "862REt0hfxXVdsLG8vGWD" 150 | sqids = new Sqids({ 151 | blocklist: new Set(['hfxX']) // should regenerate id 152 | }); 153 | expect.soft(sqids.encode([101, 202, 303, 404, 505, 606, 707])).toEqual('seu8n1jO9C4KQQDxdOxsK'); 154 | }); 155 | -------------------------------------------------------------------------------- /src/blocklist.json: -------------------------------------------------------------------------------- 1 | [ 2 | "0rgasm", 3 | "1d10t", 4 | "1d1ot", 5 | "1di0t", 6 | "1diot", 7 | "1eccacu10", 8 | "1eccacu1o", 9 | "1eccacul0", 10 | "1eccaculo", 11 | "1mbec11e", 12 | "1mbec1le", 13 | "1mbeci1e", 14 | "1mbecile", 15 | "a11upat0", 16 | "a11upato", 17 | "a1lupat0", 18 | "a1lupato", 19 | "aand", 20 | "ah01e", 21 | "ah0le", 22 | "aho1e", 23 | "ahole", 24 | "al1upat0", 25 | "al1upato", 26 | "allupat0", 27 | "allupato", 28 | "ana1", 29 | "ana1e", 30 | "anal", 31 | "anale", 32 | "anus", 33 | "arrapat0", 34 | "arrapato", 35 | "arsch", 36 | "arse", 37 | "ass", 38 | "b00b", 39 | "b00be", 40 | "b01ata", 41 | "b0ceta", 42 | "b0iata", 43 | "b0ob", 44 | "b0obe", 45 | "b0sta", 46 | "b1tch", 47 | "b1te", 48 | "b1tte", 49 | "ba1atkar", 50 | "balatkar", 51 | "bastard0", 52 | "bastardo", 53 | "batt0na", 54 | "battona", 55 | "bitch", 56 | "bite", 57 | "bitte", 58 | "bo0b", 59 | "bo0be", 60 | "bo1ata", 61 | "boceta", 62 | "boiata", 63 | "boob", 64 | "boobe", 65 | "bosta", 66 | "bran1age", 67 | "bran1er", 68 | "bran1ette", 69 | "bran1eur", 70 | "bran1euse", 71 | "branlage", 72 | "branler", 73 | "branlette", 74 | "branleur", 75 | "branleuse", 76 | "c0ck", 77 | "c0g110ne", 78 | "c0g11one", 79 | "c0g1i0ne", 80 | "c0g1ione", 81 | "c0gl10ne", 82 | "c0gl1one", 83 | "c0gli0ne", 84 | "c0glione", 85 | "c0na", 86 | "c0nnard", 87 | "c0nnasse", 88 | "c0nne", 89 | "c0u111es", 90 | "c0u11les", 91 | "c0u1l1es", 92 | "c0u1lles", 93 | "c0ui11es", 94 | "c0ui1les", 95 | "c0uil1es", 96 | "c0uilles", 97 | "c11t", 98 | "c11t0", 99 | "c11to", 100 | "c1it", 101 | "c1it0", 102 | "c1ito", 103 | "cabr0n", 104 | "cabra0", 105 | "cabrao", 106 | "cabron", 107 | "caca", 108 | "cacca", 109 | "cacete", 110 | "cagante", 111 | "cagar", 112 | "cagare", 113 | "cagna", 114 | "cara1h0", 115 | "cara1ho", 116 | "caracu10", 117 | "caracu1o", 118 | "caracul0", 119 | "caraculo", 120 | "caralh0", 121 | "caralho", 122 | "cazz0", 123 | "cazz1mma", 124 | "cazzata", 125 | "cazzimma", 126 | "cazzo", 127 | "ch00t1a", 128 | "ch00t1ya", 129 | "ch00tia", 130 | "ch00tiya", 131 | "ch0d", 132 | "ch0ot1a", 133 | "ch0ot1ya", 134 | "ch0otia", 135 | "ch0otiya", 136 | "ch1asse", 137 | "ch1avata", 138 | "ch1er", 139 | "ch1ng0", 140 | "ch1ngadaz0s", 141 | "ch1ngadazos", 142 | "ch1ngader1ta", 143 | "ch1ngaderita", 144 | "ch1ngar", 145 | "ch1ngo", 146 | "ch1ngues", 147 | "ch1nk", 148 | "chatte", 149 | "chiasse", 150 | "chiavata", 151 | "chier", 152 | "ching0", 153 | "chingadaz0s", 154 | "chingadazos", 155 | "chingader1ta", 156 | "chingaderita", 157 | "chingar", 158 | "chingo", 159 | "chingues", 160 | "chink", 161 | "cho0t1a", 162 | "cho0t1ya", 163 | "cho0tia", 164 | "cho0tiya", 165 | "chod", 166 | "choot1a", 167 | "choot1ya", 168 | "chootia", 169 | "chootiya", 170 | "cl1t", 171 | "cl1t0", 172 | "cl1to", 173 | "clit", 174 | "clit0", 175 | "clito", 176 | "cock", 177 | "cog110ne", 178 | "cog11one", 179 | "cog1i0ne", 180 | "cog1ione", 181 | "cogl10ne", 182 | "cogl1one", 183 | "cogli0ne", 184 | "coglione", 185 | "cona", 186 | "connard", 187 | "connasse", 188 | "conne", 189 | "cou111es", 190 | "cou11les", 191 | "cou1l1es", 192 | "cou1lles", 193 | "coui11es", 194 | "coui1les", 195 | "couil1es", 196 | "couilles", 197 | "cracker", 198 | "crap", 199 | "cu10", 200 | "cu1att0ne", 201 | "cu1attone", 202 | "cu1er0", 203 | "cu1ero", 204 | "cu1o", 205 | "cul0", 206 | "culatt0ne", 207 | "culattone", 208 | "culer0", 209 | "culero", 210 | "culo", 211 | "cum", 212 | "cunt", 213 | "d11d0", 214 | "d11do", 215 | "d1ck", 216 | "d1ld0", 217 | "d1ldo", 218 | "damn", 219 | "de1ch", 220 | "deich", 221 | "depp", 222 | "di1d0", 223 | "di1do", 224 | "dick", 225 | "dild0", 226 | "dildo", 227 | "dyke", 228 | "encu1e", 229 | "encule", 230 | "enema", 231 | "enf01re", 232 | "enf0ire", 233 | "enfo1re", 234 | "enfoire", 235 | "estup1d0", 236 | "estup1do", 237 | "estupid0", 238 | "estupido", 239 | "etr0n", 240 | "etron", 241 | "f0da", 242 | "f0der", 243 | "f0ttere", 244 | "f0tters1", 245 | "f0ttersi", 246 | "f0tze", 247 | "f0utre", 248 | "f1ca", 249 | "f1cker", 250 | "f1ga", 251 | "fag", 252 | "fica", 253 | "ficker", 254 | "figa", 255 | "foda", 256 | "foder", 257 | "fottere", 258 | "fotters1", 259 | "fottersi", 260 | "fotze", 261 | "foutre", 262 | "fr0c10", 263 | "fr0c1o", 264 | "fr0ci0", 265 | "fr0cio", 266 | "fr0sc10", 267 | "fr0sc1o", 268 | "fr0sci0", 269 | "fr0scio", 270 | "froc10", 271 | "froc1o", 272 | "froci0", 273 | "frocio", 274 | "frosc10", 275 | "frosc1o", 276 | "frosci0", 277 | "froscio", 278 | "fuck", 279 | "g00", 280 | "g0o", 281 | "g0u1ne", 282 | "g0uine", 283 | "gandu", 284 | "go0", 285 | "goo", 286 | "gou1ne", 287 | "gouine", 288 | "gr0gnasse", 289 | "grognasse", 290 | "haram1", 291 | "harami", 292 | "haramzade", 293 | "hund1n", 294 | "hundin", 295 | "id10t", 296 | "id1ot", 297 | "idi0t", 298 | "idiot", 299 | "imbec11e", 300 | "imbec1le", 301 | "imbeci1e", 302 | "imbecile", 303 | "j1zz", 304 | "jerk", 305 | "jizz", 306 | "k1ke", 307 | "kam1ne", 308 | "kamine", 309 | "kike", 310 | "leccacu10", 311 | "leccacu1o", 312 | "leccacul0", 313 | "leccaculo", 314 | "m1erda", 315 | "m1gn0tta", 316 | "m1gnotta", 317 | "m1nch1a", 318 | "m1nchia", 319 | "m1st", 320 | "mam0n", 321 | "mamahuev0", 322 | "mamahuevo", 323 | "mamon", 324 | "masturbat10n", 325 | "masturbat1on", 326 | "masturbate", 327 | "masturbati0n", 328 | "masturbation", 329 | "merd0s0", 330 | "merd0so", 331 | "merda", 332 | "merde", 333 | "merdos0", 334 | "merdoso", 335 | "mierda", 336 | "mign0tta", 337 | "mignotta", 338 | "minch1a", 339 | "minchia", 340 | "mist", 341 | "musch1", 342 | "muschi", 343 | "n1gger", 344 | "neger", 345 | "negr0", 346 | "negre", 347 | "negro", 348 | "nerch1a", 349 | "nerchia", 350 | "nigger", 351 | "orgasm", 352 | "p00p", 353 | "p011a", 354 | "p01la", 355 | "p0l1a", 356 | "p0lla", 357 | "p0mp1n0", 358 | "p0mp1no", 359 | "p0mpin0", 360 | "p0mpino", 361 | "p0op", 362 | "p0rca", 363 | "p0rn", 364 | "p0rra", 365 | "p0uff1asse", 366 | "p0uffiasse", 367 | "p1p1", 368 | "p1pi", 369 | "p1r1a", 370 | "p1rla", 371 | "p1sc10", 372 | "p1sc1o", 373 | "p1sci0", 374 | "p1scio", 375 | "p1sser", 376 | "pa11e", 377 | "pa1le", 378 | "pal1e", 379 | "palle", 380 | "pane1e1r0", 381 | "pane1e1ro", 382 | "pane1eir0", 383 | "pane1eiro", 384 | "panele1r0", 385 | "panele1ro", 386 | "paneleir0", 387 | "paneleiro", 388 | "patakha", 389 | "pec0r1na", 390 | "pec0rina", 391 | "pecor1na", 392 | "pecorina", 393 | "pen1s", 394 | "pendej0", 395 | "pendejo", 396 | "penis", 397 | "pip1", 398 | "pipi", 399 | "pir1a", 400 | "pirla", 401 | "pisc10", 402 | "pisc1o", 403 | "pisci0", 404 | "piscio", 405 | "pisser", 406 | "po0p", 407 | "po11a", 408 | "po1la", 409 | "pol1a", 410 | "polla", 411 | "pomp1n0", 412 | "pomp1no", 413 | "pompin0", 414 | "pompino", 415 | "poop", 416 | "porca", 417 | "porn", 418 | "porra", 419 | "pouff1asse", 420 | "pouffiasse", 421 | "pr1ck", 422 | "prick", 423 | "pussy", 424 | "put1za", 425 | "puta", 426 | "puta1n", 427 | "putain", 428 | "pute", 429 | "putiza", 430 | "puttana", 431 | "queca", 432 | "r0mp1ba11e", 433 | "r0mp1ba1le", 434 | "r0mp1bal1e", 435 | "r0mp1balle", 436 | "r0mpiba11e", 437 | "r0mpiba1le", 438 | "r0mpibal1e", 439 | "r0mpiballe", 440 | "rand1", 441 | "randi", 442 | "rape", 443 | "recch10ne", 444 | "recch1one", 445 | "recchi0ne", 446 | "recchione", 447 | "retard", 448 | "romp1ba11e", 449 | "romp1ba1le", 450 | "romp1bal1e", 451 | "romp1balle", 452 | "rompiba11e", 453 | "rompiba1le", 454 | "rompibal1e", 455 | "rompiballe", 456 | "ruff1an0", 457 | "ruff1ano", 458 | "ruffian0", 459 | "ruffiano", 460 | "s1ut", 461 | "sa10pe", 462 | "sa1aud", 463 | "sa1ope", 464 | "sacanagem", 465 | "sal0pe", 466 | "salaud", 467 | "salope", 468 | "saugnapf", 469 | "sb0rr0ne", 470 | "sb0rra", 471 | "sb0rrone", 472 | "sbattere", 473 | "sbatters1", 474 | "sbattersi", 475 | "sborr0ne", 476 | "sborra", 477 | "sborrone", 478 | "sc0pare", 479 | "sc0pata", 480 | "sch1ampe", 481 | "sche1se", 482 | "sche1sse", 483 | "scheise", 484 | "scheisse", 485 | "schlampe", 486 | "schwachs1nn1g", 487 | "schwachs1nnig", 488 | "schwachsinn1g", 489 | "schwachsinnig", 490 | "schwanz", 491 | "scopare", 492 | "scopata", 493 | "sexy", 494 | "sh1t", 495 | "shit", 496 | "slut", 497 | "sp0mp1nare", 498 | "sp0mpinare", 499 | "spomp1nare", 500 | "spompinare", 501 | "str0nz0", 502 | "str0nza", 503 | "str0nzo", 504 | "stronz0", 505 | "stronza", 506 | "stronzo", 507 | "stup1d", 508 | "stupid", 509 | "succh1am1", 510 | "succh1ami", 511 | "succhiam1", 512 | "succhiami", 513 | "sucker", 514 | "t0pa", 515 | "tapette", 516 | "test1c1e", 517 | "test1cle", 518 | "testic1e", 519 | "testicle", 520 | "tette", 521 | "topa", 522 | "tr01a", 523 | "tr0ia", 524 | "tr0mbare", 525 | "tr1ng1er", 526 | "tr1ngler", 527 | "tring1er", 528 | "tringler", 529 | "tro1a", 530 | "troia", 531 | "trombare", 532 | "turd", 533 | "twat", 534 | "vaffancu10", 535 | "vaffancu1o", 536 | "vaffancul0", 537 | "vaffanculo", 538 | "vag1na", 539 | "vagina", 540 | "verdammt", 541 | "verga", 542 | "w1chsen", 543 | "wank", 544 | "wichsen", 545 | "x0ch0ta", 546 | "x0chota", 547 | "xana", 548 | "xoch0ta", 549 | "xochota", 550 | "z0cc01a", 551 | "z0cc0la", 552 | "z0cco1a", 553 | "z0ccola", 554 | "z1z1", 555 | "z1zi", 556 | "ziz1", 557 | "zizi", 558 | "zocc01a", 559 | "zocc0la", 560 | "zocco1a", 561 | "zoccola" 562 | ] 563 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import defaultBlocklist from './blocklist.json'; 2 | 3 | type SqidsOptions = { 4 | alphabet?: string; 5 | minLength?: number; // u8 6 | blocklist?: Set; 7 | }; 8 | 9 | export const defaultOptions = { 10 | // url-safe characters 11 | alphabet: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 12 | // `minLength` is the minimum length IDs should be (`u8` type) 13 | minLength: 0, 14 | // a list of words that should not appear anywhere in the IDs 15 | blocklist: new Set(defaultBlocklist) 16 | }; 17 | 18 | export default class Sqids { 19 | private alphabet: string; 20 | private minLength: number; 21 | private blocklist: Set; 22 | 23 | constructor(options?: SqidsOptions) { 24 | const alphabet = options?.alphabet ?? defaultOptions.alphabet; 25 | const minLength = options?.minLength ?? defaultOptions.minLength; 26 | const blocklist = options?.blocklist ?? defaultOptions.blocklist; 27 | 28 | // alphabet cannot contain multibyte characters 29 | if (new Blob([alphabet]).size != alphabet.length) { 30 | throw new Error('Alphabet cannot contain multibyte characters'); 31 | } 32 | 33 | // check the length of the alphabet 34 | if (alphabet.length < 3) { 35 | throw new Error('Alphabet length must be at least 3'); 36 | } 37 | 38 | // check that the alphabet has only unique characters 39 | if (new Set(alphabet).size != alphabet.length) { 40 | throw new Error('Alphabet must contain unique characters'); 41 | } 42 | 43 | // test min length (type [might be lang-specific] + min length + max length) 44 | // if lang supports `u8` type, you can just omit this check altogether 45 | const minLengthLimit = 255; 46 | if (typeof minLength != 'number' || minLength < 0 || minLength > minLengthLimit) { 47 | throw new Error(`Minimum length has to be between 0 and ${minLengthLimit}`); 48 | } 49 | 50 | // clean up blocklist: 51 | // 1. all blocklist words should be lowercase 52 | // 2. no words less than 3 chars 53 | // 3. if some words contain chars that are not in the alphabet, remove those 54 | const filteredBlocklist = new Set(); 55 | const alphabetChars = alphabet.toLowerCase().split(''); 56 | for (const word of blocklist) { 57 | if (word.length >= 3) { 58 | const wordLowercased = word.toLowerCase(); 59 | const wordChars = wordLowercased.split(''); 60 | const intersection = wordChars.filter((c) => alphabetChars.includes(c)); 61 | if (intersection.length == wordChars.length) { 62 | filteredBlocklist.add(wordLowercased); 63 | } 64 | } 65 | } 66 | 67 | this.alphabet = this.shuffle(alphabet); 68 | this.minLength = minLength; 69 | this.blocklist = filteredBlocklist; 70 | } 71 | 72 | /** 73 | * Encodes an array of unsigned integers into an ID 74 | * 75 | * These are the cases where encoding might fail: 76 | * - One of the numbers passed is smaller than 0 or greater than `maxValue()` 77 | * - An n-number of attempts has been made to re-generated the ID, where n is alphabet length + 1 78 | * 79 | * @param {array.} numbers Non-negative integers to encode into an ID 80 | * @returns {string} Generated ID 81 | */ 82 | encode(numbers: number[]): string { 83 | // if no numbers passed, return an empty string 84 | if (numbers.length == 0) { 85 | return ''; 86 | } 87 | 88 | // don't allow out-of-range numbers [might be lang-specific] 89 | const inRangeNumbers = numbers.filter((n) => n >= 0 && n <= this.maxValue()); 90 | if (inRangeNumbers.length != numbers.length) { 91 | throw new Error(`Encoding supports numbers between 0 and ${this.maxValue()}`); 92 | } 93 | 94 | return this.encodeNumbers(numbers); 95 | } 96 | 97 | /** 98 | * Internal function that encodes an array of unsigned integers into an ID 99 | * 100 | * @param {array.} numbers Non-negative integers to encode into an ID 101 | * @param {number} increment An internal number used to modify the `offset` variable in order to re-generate the ID 102 | * @returns {string} Generated ID 103 | */ 104 | private encodeNumbers(numbers: number[], increment = 0): string { 105 | // if increment is greater than alphabet length, we've reached max attempts 106 | if (increment > this.alphabet.length) { 107 | throw new Error('Reached max attempts to re-generate the ID'); 108 | } 109 | 110 | // get a semi-random offset from input numbers 111 | let offset = 112 | numbers.reduce((a, v, i) => { 113 | return this.alphabet[v % this.alphabet.length].codePointAt(0) + i + a; 114 | }, numbers.length) % this.alphabet.length; 115 | 116 | // if there is a non-zero `increment`, it's an internal attempt to re-generated the ID 117 | offset = (offset + increment) % this.alphabet.length; 118 | 119 | // re-arrange alphabet so that second-half goes in front of the first-half 120 | let alphabet = this.alphabet.slice(offset) + this.alphabet.slice(0, offset); 121 | 122 | // `prefix` is the first character in the generated ID, used for randomization 123 | const prefix = alphabet.charAt(0); 124 | 125 | // reverse alphabet (otherwise for [0, x] `offset` and `separator` will be the same char) 126 | alphabet = alphabet.split('').reverse().join(''); 127 | 128 | // final ID will always have the `prefix` character at the beginning 129 | const ret = [prefix]; 130 | 131 | // encode input array 132 | for (let i = 0; i != numbers.length; i++) { 133 | const num = numbers[i]; 134 | 135 | // the first character of the alphabet is going to be reserved for the `separator` 136 | const alphabetWithoutSeparator = alphabet.slice(1); 137 | ret.push(this.toId(num, alphabetWithoutSeparator)); 138 | 139 | // if not the last number 140 | if (i < numbers.length - 1) { 141 | // `separator` character is used to isolate numbers within the ID 142 | ret.push(alphabet.slice(0, 1)); 143 | 144 | // shuffle on every iteration 145 | alphabet = this.shuffle(alphabet); 146 | } 147 | } 148 | 149 | // join all the parts to form an ID 150 | let id = ret.join(''); 151 | 152 | // handle `minLength` requirement, if the ID is too short 153 | if (this.minLength > id.length) { 154 | // append a separator 155 | id += alphabet.slice(0, 1); 156 | 157 | // keep appending `separator` + however much alphabet is needed 158 | // for decoding: two separators next to each other is what tells us the rest are junk characters 159 | while (this.minLength - id.length > 0) { 160 | alphabet = this.shuffle(alphabet); 161 | id += alphabet.slice(0, Math.min(this.minLength - id.length, alphabet.length)); 162 | } 163 | } 164 | 165 | // if ID has a blocked word anywhere, restart with a +1 increment 166 | if (this.isBlockedId(id)) { 167 | id = this.encodeNumbers(numbers, increment + 1); 168 | } 169 | 170 | return id; 171 | } 172 | 173 | /** 174 | * Decodes an ID back into an array of unsigned integers 175 | * 176 | * These are the cases where the return value might be an empty array: 177 | * - Empty ID / empty string 178 | * - Non-alphabet character is found within ID 179 | * 180 | * @param {string} id Encoded ID 181 | * @returns {array.} Array of unsigned integers 182 | */ 183 | decode(id: string): number[] { 184 | const ret: number[] = []; 185 | 186 | // if an empty string, return an empty array 187 | if (id == '') { 188 | return ret; 189 | } 190 | 191 | // if a character is not in the alphabet, return an empty array 192 | const alphabetChars = this.alphabet.split(''); 193 | for (const c of id.split('')) { 194 | if (!alphabetChars.includes(c)) { 195 | return ret; 196 | } 197 | } 198 | 199 | // first character is always the `prefix` 200 | const prefix = id.charAt(0); 201 | 202 | // `offset` is the semi-random position that was generated during encoding 203 | const offset = this.alphabet.indexOf(prefix); 204 | 205 | // re-arrange alphabet back into it's original form 206 | let alphabet = this.alphabet.slice(offset) + this.alphabet.slice(0, offset); 207 | 208 | // reverse alphabet 209 | alphabet = alphabet.split('').reverse().join(''); 210 | 211 | // now it's safe to remove the prefix character from ID, it's not needed anymore 212 | id = id.slice(1); 213 | 214 | // decode 215 | while (id.length) { 216 | const separator = alphabet.slice(0, 1); 217 | 218 | // we need the first part to the left of the separator to decode the number 219 | const chunks = id.split(separator); 220 | if (chunks.length) { 221 | // if chunk is empty, we are done (the rest are junk characters) 222 | if (chunks[0] == '') { 223 | return ret; 224 | } 225 | 226 | // decode the number without using the `separator` character 227 | const alphabetWithoutSeparator = alphabet.slice(1); 228 | ret.push(this.toNumber(chunks[0], alphabetWithoutSeparator)); 229 | 230 | // if this ID has multiple numbers, shuffle the alphabet because that's what encoding function did 231 | if (chunks.length > 1) { 232 | alphabet = this.shuffle(alphabet); 233 | } 234 | } 235 | 236 | // `id` is now going to be everything to the right of the `separator` 237 | id = chunks.slice(1).join(separator); 238 | } 239 | 240 | return ret; 241 | } 242 | 243 | // consistent shuffle (always produces the same result given the input) 244 | private shuffle(alphabet: string): string { 245 | const chars = alphabet.split(''); 246 | 247 | for (let i = 0, j = chars.length - 1; j > 0; i++, j--) { 248 | const r = (i * j + chars[i].codePointAt(0) + chars[j].codePointAt(0)) % chars.length; 249 | [chars[i], chars[r]] = [chars[r], chars[i]]; 250 | } 251 | 252 | return chars.join(''); 253 | } 254 | 255 | private toId(num: number, alphabet: string): string { 256 | const id = []; 257 | const chars = alphabet.split(''); 258 | 259 | let result = num; 260 | 261 | do { 262 | id.unshift(chars[result % chars.length]); 263 | result = Math.floor(result / chars.length); 264 | } while (result > 0); 265 | 266 | return id.join(''); 267 | } 268 | 269 | private toNumber(id: string, alphabet: string): number { 270 | const chars = alphabet.split(''); 271 | return id.split('').reduce((a, v) => a * chars.length + chars.indexOf(v), 0); 272 | } 273 | 274 | private isBlockedId(id: string): boolean { 275 | id = id.toLowerCase(); 276 | 277 | for (const word of this.blocklist) { 278 | // no point in checking words that are longer than the ID 279 | if (word.length <= id.length) { 280 | if (id.length <= 3 || word.length <= 3) { 281 | // short words have to match completely; otherwise, too many matches 282 | if (id == word) { 283 | return true; 284 | } 285 | } else if (/\d/.test(word)) { 286 | // words with leet speak replacements are visible mostly on the ends of the ID 287 | if (id.startsWith(word) || id.endsWith(word)) { 288 | return true; 289 | } 290 | } else if (id.includes(word)) { 291 | // otherwise, check for blocked word anywhere in the string 292 | return true; 293 | } 294 | } 295 | } 296 | 297 | return false; 298 | } 299 | 300 | // this should be the biggest unsigned integer that the language can safely/mathematically support 301 | // the spec does not specify the upper integer limit - so it's up to the individual programming languages 302 | // examples as of 2023-09-24: 303 | // golang: uint64 304 | // rust: u128 305 | // php: PHP_INT_MAX 306 | private maxValue() { 307 | return Number.MAX_SAFE_INTEGER; 308 | } 309 | } 310 | --------------------------------------------------------------------------------