Skip to content

Commit

Permalink
feat: split code into files and add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
yamiteru committed May 11, 2024
1 parent 61d0598 commit 724cdab
Show file tree
Hide file tree
Showing 21 changed files with 499 additions and 217 deletions.
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
14 changes: 14 additions & 0 deletions .release-it.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"git": {
"changelog": "npx auto-changelog --stdout --commit-limit false -u --template https://raw.githubusercontent.com/release-it/release-it/master/templates/changelog-compact.hbs"
},
"npm": {
"publish": true
},
"github": {
"release": true
},
"hooks": {
"after:bump": "npx auto-changelog -p"
}
}
62 changes: 61 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,61 @@
# id
![Id image](https://github.com/the-minimal/id/blob/main/docs/the-minimal-id.jpg?raw=true)

# Highlights

- Small (~ 700 bytes)
- Low runtime overhead
- Cryptographically correct `random`
- Multiple entropy sources
- Fingerprint
- Uses [@the-minimal/fingerprint](https://github.com/the-minimal/fingerprint)
- Hashed using SHA-512
- Random 32 byte slice
- Counter
- Random start value
- Maximum of `4_294_967_295` values
- Timestamp
- 64 byte timestamp with millisecond precision
- Salt
- 52 bytes of random 8-bit values
- External data
- Data not provided
- 32 bytes of random 8-bit values
- Data is provided
- Hashed using SHA-512
- Random 32 byte slice
- Variable length
- Entropy sources are saved into 128 byte array
- The byte array is hashed using SHA-512
- The hash is converted into Base36 string
- The Base36 string is randomly sliced into desired length
- Default length is 24
- Collision-resistant
- `5.58e18` until 50% chance of collision
- Uniform and URL-friendly output
- `~0.005%` character variance
- Async / Non-blocking
- 100% test coverage

# API

## `init`

Initializes a new instance of ID generator.

```ts
const createId8 = init({ length: 8 });
```

## `createId`

Creates a random ID of length 24.

It accepts optional external data which is used as another source of entropy.

```ts
const userId = createId(userEmail);
```

# Credits

This library is directly based on Cuid2.
Binary file added docs/the-minimal-id.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 64 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,73 @@
{
"name": "id",
"module": "index.ts",
"name": "@the-minimal/id",
"type": "module",
"version": "0.0.0",
"license": "MIT",
"author": "Miroslav Vršecký <yamiteru@icloud.com>",
"description": "Minimal, secure and collision-resistant random IDs in TypeScript",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"sideEffects": false,
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "https://github.com/the-minimal/id.git",
"directory": "src"
},
"keywords": [
"uuid",
"guid",
"cuid",
"unique",
"id",
"ids",
"identifier",
"identifiers",
"javascript",
"typescript"
],
"homepage": "https://github.com/the-minimal/id",
"bugs": {
"url": "https://github.com/the-minimal/id/issues"
},
"scripts": {
"test": "bun test",
"build": "bun build src/index.ts --outdir ./dist --format esm --sourcemap=external --minify",
"prepublishOnly": "bun run check && bun run build && bun run test",
"release": "release-it",
"build": "bun run build:tsup && bun run build:stats",
"build:tsup": "tsup",
"build:stats": "bun run scripts/stats.ts",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"check": "bunx @biomejs/biome check --apply ./"
},
"devDependencies": {
"@types/bun": "latest"
"@biomejs/biome": "1.7.2",
"@types/bun": "latest",
"@vitest/coverage-v8": "1.6.0",
"release-it": "17.2.1",
"tsup": "8.0.2",
"vite-tsconfig-paths": "4.3.2",
"vitest": "1.6.0"
},
"dependencies": {
"@the-minimal/fingerprint": "0.0.1"
},
"peerDependencies": {
"typescript": "5.4.5"
Expand Down
20 changes: 20 additions & 0 deletions scripts/stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { readdir } from "node:fs/promises";
import { file, gzipSync } from "bun";

(async () => {
const outdir ="./dist";
const outFiles = await readdir(outdir);
const filesLength = outFiles.length;

for (let i = 0; i < filesLength; ++i) {
const fileName = outFiles[i];

if(fileName.endsWith("js")) {
const fileHandler = file(`${outdir}/${fileName}`);
const arrBuffer = await fileHandler.arrayBuffer();
const gzip = gzipSync(arrBuffer);

console.log(`${fileName} - ${gzip.byteLength} B`);
}
}
})();
14 changes: 14 additions & 0 deletions src/asciiToArray/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { asciiToArray } from "./index.js";

describe("asciiToArray", () => {
it("should convert ASCII string to Uint8Array", () => {
expect(asciiToArray("hello")).toEqual(
Uint8Array.from([104, 101, 108, 108, 111]),
);
});

it("should throw if string is not valid ASCII", () => {
expect(() => asciiToArray("你好")).toThrow();
});
});
24 changes: 24 additions & 0 deletions src/asciiToArray/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Transforms string into Uint8Array.
* It also asserts that string is ASCII.
*
* @param {string} value - Value to be transformed
*
* @return {Uint8Array} Array containing char codes of the input value
*/
export const asciiToArray = (value: string): Uint8Array => {
const length = value.length;
const buffer = new Uint8Array(length);

for (let i = 0; i < length; ++i) {
const code = value.charCodeAt(i);

if (code > 0x7f) {
throw Error("Value has to be ASCII string");
}

buffer[i] = code;
}

return buffer;
};
18 changes: 18 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const BUFFER_SIZE = 128;

export const COUNT_SIZE = 4;
export const TIMESTAMP_SIZE = 8;
export const SALT_SIZE = 52;
export const FINGERPRINT_SIZE = 32;
export const EXTERNAL_SIZE = 32;

export const SALT_START = COUNT_SIZE + TIMESTAMP_SIZE;
export const SALT_END = SALT_START + SALT_SIZE;

export const FINGERPRINT_START = SALT_END;
export const FINGERPRINT_END = FINGERPRINT_START + FINGERPRINT_SIZE;

export const EXTERNAL_START = FINGERPRINT_END;
export const EXTERNAL_END = EXTERNAL_START + EXTERNAL_SIZE;

export const DEFAULT_LENGTH = 24;
33 changes: 33 additions & 0 deletions src/getRandomSlice/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { getRandomSlice } from "./index.js";

describe("getRandomSlice", () => {
it("should return random slice from string", () => {
const string = "abcdefghijklmnopqrstuvwxyz";
const slice = getRandomSlice(string, 6);

expect(slice).toHaveLength(6);

const start = string.indexOf(slice[0]);

for (let i = 0; i < 6; ++i) {
expect(slice[i]).toBe(string[start + i]);
}
});

it("should return random slice from Uint8Array", () => {
const array = Uint8Array.from([
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, 26,
]);
const slice = getRandomSlice(array, 6);

expect(slice).toHaveLength(6);

const start = array.indexOf(slice[0]);

for (let i = 0; i < 6; ++i) {
expect(slice[i]).toBe(array[start + i]);
}
});
});
19 changes: 19 additions & 0 deletions src/getRandomSlice/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Slices the original value at a random start position.
*
* @param {string | Uint8Array} value - Value to be sliced
* @param {number} size - Desired size of the slice
*
* @return {string | Uint8Array} Sliced value
*/
export const getRandomSlice = <$Value extends string | Uint8Array>(
value: $Value,
size: number,
): $Value => {
const position = new Uint8Array(1);
crypto.getRandomValues(position);

const start = Math.round((position[0] / 255) * (value.length - size));

return value.slice(start, start + size) as $Value;
};
18 changes: 18 additions & 0 deletions src/hashBufferToArray/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { hashBufferToArray } from "./index.js";

describe("hashBufferToArray", () => {
it("should hash BufferSource to Uint8Array", async () => {
expect(
await hashBufferToArray(Uint8Array.from([104, 101, 108, 108, 111])),
).toEqual(
Uint8Array.from([
155, 113, 210, 36, 189, 98, 243, 120, 93, 150, 212, 106, 211, 234, 61,
115, 49, 155, 251, 194, 137, 12, 170, 218, 226, 223, 247, 37, 25, 103,
60, 167, 35, 35, 195, 217, 155, 165, 193, 29, 124, 122, 204, 110, 20,
184, 197, 218, 12, 70, 99, 71, 92, 46, 92, 58, 222, 244, 111, 115, 188,
222, 192, 67,
]),
);
});
});
12 changes: 12 additions & 0 deletions src/hashBufferToArray/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Hashes buffer to Uint8Array using SHA-512
*
* @param {BufferSource} buffer - Buffer to be hashed
*
* @return {Promise<Uint8Array>} Promise that resolves with Uint8Array
*/
export const hashBufferToArray = async (
buffer: BufferSource,
): Promise<Uint8Array> => {
return new Uint8Array(await crypto.subtle.digest("SHA-512", buffer));
};
12 changes: 12 additions & 0 deletions src/hashBufferToBase36/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { hashBufferToBase36 } from "./index.js";

describe("hashBufferToBase36", () => {
it("should hash BufferSource to Base36", async () => {
expect(
await hashBufferToBase36(Uint8Array.from([104, 101, 108, 108, 111])),
).toBe(
"opx46l6bobogoghc7ym8wqtjw4ef6kvy1gu0i2tiatlhl6wfm5jj1wxb2ut8oeie2s35yqj08zqjmc1o945mstusp7rlzl4aber",
);
});
});
23 changes: 23 additions & 0 deletions src/hashBufferToBase36/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { hashBufferToArray } from "../hashBufferToArray/index.js";

/**
* Hashes buffer to base36 using SHA-512.
*
* @param {BufferSource} buffer - Buffer to be hashed
*
* @return {Promise<string>} Promise that resolves with base36 string
*/
export const hashBufferToBase36 = async (
buffer: BufferSource,
): Promise<string> => {
const hashed = await hashBufferToArray(buffer);

// append bytes to BigInt
let bigint = BigInt(0);
for (let i = 0; i < hashed.length; ++i) {
bigint = (bigint << BigInt(8)) + BigInt(hashed[i]);
}

// return base36 converted from BigInt
return bigint.toString(36);
};
12 changes: 0 additions & 12 deletions src/index.test.ts

This file was deleted.

Loading

0 comments on commit 724cdab

Please sign in to comment.