diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94fcb2b..e18a89d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,6 +49,14 @@ jobs: id: bump_version run: | current_version=${{ steps.get_version.outputs.current_version }} + # A version set in package.json that is newer than the last release + # wins, so breaking changes can ship as a minor or major release. + package_version=$(node -p "require('./package.json').version") + newest=$(printf '%s\n%s\n' "$current_version" "$package_version" | sort -V | tail -1) + if [ "$package_version" != "$current_version" ] && [ "$newest" = "$package_version" ]; then + echo "new_version=$package_version" >> $GITHUB_OUTPUT + exit 0 + fi major=$(echo $current_version | cut -d '.' -f 1) minor=$(echo $current_version | cut -d '.' -f 2) patch=$(echo $current_version | cut -d '.' -f 3) diff --git a/README.md b/README.md index 9133a98..8c26355 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AB Testify -ABTestify is a versatile and user-friendly npm package for implementing A/B testing seamlessly into your JavaScript projects. Whether you're a developer looking to optimize user experiences or a product manager seeking data-driven insights, ABTestify empowers you to effortlessly create, manage, and analyze A/B tests. +Deterministic A/B test bucketing for JavaScript. Give it an experiment name and a user ID and it returns the same variant for that user every time, with no storage and no network calls. It runs anywhere JavaScript does: Node, browsers, React and Next.js (server and client), Remix, and edge runtimes. ## Test Coverage @@ -12,81 +12,92 @@ ABTestify is a versatile and user-friendly npm package for implementing A/B test ## Installation -You can install the package via npm: - ```bash npm install abtestify ``` -Or via yarn: +Or with yarn or pnpm: ```bash yarn add abtestify +pnpm add abtestify ``` -### Function Parameters -The function takes an object with the following properties: - -* `experimentName` (required): The name of the experiment. -* `uniqueId` (required): The unique identifier for the user. This can be an email, user ID, etc. -* `treatmentSplit` (optional, default: 50): The percentage of users to be bucketed into the treatment group. -* `exposure` (optional, default: 100): The percentage of users to be bucketed into the experiment. -* `controlVariantName` (optional, default: 'control'): The name of the control variant. -* `treatmentVariantName` (optional, default: 'treatment'): The name of the treatment variant. -* `ignoreVariantName` (optional, default: 'ignore'): The name of the variant to be ignored. - -### Function Output -The function returns an object with the following properties: - -* `experimentName`: The name of the experiment. -* `uniqueId`: The unique identifier for the user. -* `resolvedVariant`: The variant that the user is assigned to, or null if there was an error. -* `isError` (optional): Indicates if there was an error while assigning the variant. -* `error` (optional): The error object, if isError is true. - ## Usage -### React/Next.js Projects - ```javascript -// Import the function -import { getABExperimentBucket } from "abtestify"; +import { getABExperimentBucket } from 'abtestify'; +// or: const { getABExperimentBucket } = require('abtestify'); -// Use it in your component -const variant = getABExperimentBucket({ - experimentName: "exampleExperiment", - uniqueId: "user123", +const { resolvedVariant } = getABExperimentBucket({ + experimentName: 'checkout-cta', + uniqueId: 'user123', treatmentSplit: 50, exposure: 100, }); -console.log("Variant:", variant); +if (resolvedVariant === 'treatment') { + // show the new checkout button +} ``` -### Normal JavaScript Projects +The package ships ES modules and CommonJS, with TypeScript types for both. -```javascript -// Import the function -const { getABExperimentBucket } = require("abtestify"); +### Parameters -// Use it in your code -const variant = getABExperimentBucket({ - experimentName: "exampleExperiment", - uniqueId: "user123", - treatmentSplit: 50, - exposure: 100, -}); +- `experimentName` (required): the name of the experiment. It is part of the hash, so each experiment splits users independently. +- `uniqueId` (required): a stable identifier for the user, such as a user ID or a device ID. +- `treatmentSplit` (optional, default `50`): the percentage of users in the experiment who get the treatment. +- `exposure` (optional, default `100`): the percentage of all users who are in the experiment. Everyone else gets the ignore variant. +- `controlVariantName` (optional, default `'control'`): the name of the control variant. +- `treatmentVariantName` (optional, default `'treatment'`): the name of the treatment variant. +- `ignoreVariantName` (optional, default `'ignore'`): the name returned for users outside the experiment. +- `legacyHashing` (optional, default `false`): bucket by `uniqueId` alone, as 0.1.x did. See [Upgrading from 0.1.x](#upgrading-from-01x). + +Invalid input throws an `Error`: an empty or non-string `uniqueId` or `experimentName`, or a `treatmentSplit` or `exposure` that isn't a number from 0 to 100. + +### Output -console.log("Variant:", variant); +- `experimentName`: the experiment name you passed. +- `uniqueId`: the user ID you passed. +- `resolvedVariant`: the variant the user is in: the treatment, control or ignore name. + +### How bucketing works + +The user's bucket is the first 8 bytes of SHA-256 of `experimentName:uniqueId`, mapped to one of 10,000 buckets. Exposure takes the lowest buckets, and the split uses the bucket's last two digits, which means: + +- The same user gets the same variant in an experiment on every call, on every platform. +- Different experiments split users independently. +- Raising `exposure` (say from 10 to 50) only adds users. Nobody already in the experiment changes variant. +- Raising `treatmentSplit` only moves users from control to treatment. + +## Upgrading from 0.1.x + +0.1.x hashed only `uniqueId` and ignored `experimentName`. A user therefore got the same variant in every experiment: someone in treatment for one experiment was in treatment for all of them, so experiments running at the same time skewed each other's results. + +0.2.0 includes `experimentName` in the hash. **This reassigns users.** To keep the assignments of experiments that are already running, pass `legacyHashing: true` to them until they finish; it reproduces 0.1.x exactly. Start new experiments without it. + +```javascript +getABExperimentBucket({ + experimentName: 'running-since-0.1', + uniqueId: 'user123', + legacyHashing: true, +}); ``` +Other changes in 0.2.0: + +- Works in browsers and edge runtimes. 0.1.x imported Node's `crypto` and `Buffer`. +- Invalid input throws instead of returning `resolvedVariant: null` with `isError`. +- Ships CommonJS as well as ES modules. + [npm-coverage-image]: https://codecov.io/gh/pranav2012/abtestify/graph/badge.svg?token=CHCUS36FCG [npm-coverage-url]: https://codecov.io/gh/pranav2012/abtestify -[npm-install-size-image]:https://packagephobia.com/badge?p=abtestify +[npm-install-size-image]: https://packagephobia.com/badge?p=abtestify [npm-install-size-url]: https://packagephobia.com/result?p=abtestify [npm-downloads-image]: https://badgen.net/npm/dm/abtestify [npm-downloads-url]: https://npmcharts.com/compare/abtestify?minimal=true [npm-version-image]: https://badgen.net/npm/v/abtestify [npm-url]: https://npmjs.org/package/abtestify [npm-mit-image]: https://badges.frapsoft.com/os/mit/mit.svg?v=103 -[npm-mit-url]: https://opensource.org/licenses/mit-license.php \ No newline at end of file +[npm-mit-url]: https://opensource.org/licenses/mit-license.php diff --git a/package.json b/package.json index ad26148..9d36c42 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,28 @@ { "name": "abtestify", - "version": "0.0.1", + "version": "0.2.0", "type": "module", - "description": "abtestify is a package to do ab testing in your project in next.js react or any javascript frameworks", - "main": "./dist/index.js", - "module": "./dist/index.mjs", + "description": "Deterministic A/B test bucketing for Node, browsers and edge runtimes, with per-experiment hashing, exposure and split control", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", - "files": ["dist"], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist" + ], "repository": "https://github.com/pranav2012/abtestify.git", "author": "pranav2012 ", "license": "MIT", diff --git a/src/bucket.ts b/src/bucket.ts new file mode 100644 index 0000000..2d77565 --- /dev/null +++ b/src/bucket.ts @@ -0,0 +1,17 @@ +import { sha256 } from './sha256'; + +export const BUCKETS = 10000; + +/** + * Maps a key to one of 10,000 buckets from the first 8 bytes of its SHA-256. + * The arithmetic, including the float rounding, matches 0.1.x exactly, so + * `legacyHashing` reproduces earlier assignments. + */ +export const bucketOf = (key: string): number => { + const digest = sha256(key); + let top = BigInt(0); + for (let i = 0; i < 8; i++) top = (top << BigInt(8)) | BigInt(digest[i]); + return Math.floor( + (Number(top) / Number(BigInt('0xFFFFFFFFFFFFFFFF'))) * BUCKETS, + ); +}; diff --git a/src/index.ts b/src/index.ts index 062a961..c1d0ba2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,35 @@ -import * as crypto from 'crypto'; -import { IExperimentData, IParams } from '@types'; +import { bucketOf } from './bucket'; +import type { IExperimentData, IParams } from './types/interface'; +export type { IExperimentData, IParams } from './types/interface'; + +const assertPercentage = (name: string, value: unknown, meaning: string) => { + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + value < 0 || + value > 100 + ) { + throw new Error( + `${name} must be a number between 0 and 100 as it represents the percentage of users who will be bucketed into ${meaning}.`, + ); + } +}; + +const assertText = (name: string, value: unknown) => { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${name} must be a non-empty string.`); + } +}; + +/** + * Deterministically assigns a user to a variant of an experiment. + * + * The bucket comes from SHA-256 of `experimentName:uniqueId`, so the same + * user always gets the same variant within an experiment, and independent + * variants across experiments. Raising `exposure` only adds users; it never + * moves anyone between variants. + */ export const getABExperimentBucket = ({ experimentName, uniqueId, @@ -9,52 +38,22 @@ export const getABExperimentBucket = ({ treatmentVariantName = 'treatment', controlVariantName = 'control', ignoreVariantName = 'ignore', + legacyHashing = false, }: IParams): IExperimentData => { - if (treatmentSplit < 0 || treatmentSplit > 100) { - throw new Error( - 'treatmentSplit must be a number between 0 and 100 as it represents the percentage of users who will be bucketed into the treatment group.', - ); - } - if (exposure < 0 || exposure > 100) { - throw new Error( - 'exposure must be a number between 0 and 100 as it represents the percentage of users who will be bucketed into the experiment.', - ); - } + assertPercentage('treatmentSplit', treatmentSplit, 'the treatment group'); + assertPercentage('exposure', exposure, 'the experiment'); + assertText('uniqueId', uniqueId); + if (!legacyHashing) assertText('experimentName', experimentName); - try { - const digest = crypto.createHash('sha256'); - const hashBytes = digest.update(uniqueId).digest(); // Calculate the hash of the ID - const hashedId = BigInt( - `0x${Buffer.from(hashBytes).subarray(0, 8).toString('hex')}`, - ); // Convert the hashed ID to a 64-bit BigInt using subarray instead of slice - const normalizedHashedId = - Number(hashedId) / Number(BigInt('0xFFFFFFFFFFFFFFFF')); // Convert the hashed ID to a float between 0 and 1 - const randomNum = Math.floor(normalizedHashedId * 10000); // Multiply the normalized hashed ID by 10,000 to get a random number between 0 and 9,999 + const bucket = bucketOf( + legacyHashing ? uniqueId : `${experimentName}:${uniqueId}`, + ); - if (randomNum / 100 >= exposure) - return { - experimentName, - uniqueId, - resolvedVariant: ignoreVariantName, - }; // Check if user should be ignored based on exposure rate + let resolvedVariant: string; + if (bucket / 100 >= exposure) resolvedVariant = ignoreVariantName; + else if (bucket % 100 < treatmentSplit) + resolvedVariant = treatmentVariantName; + else resolvedVariant = controlVariantName; - const resolvedVariant = - randomNum % 100 < treatmentSplit - ? treatmentVariantName - : controlVariantName; - - return { - experimentName, - uniqueId, - resolvedVariant, - }; - } catch (error) { - return { - experimentName, - uniqueId, - resolvedVariant: null, - isError: true, - error, - }; - } + return { experimentName, uniqueId, resolvedVariant }; }; diff --git a/src/sha256.ts b/src/sha256.ts new file mode 100644 index 0000000..c4d2d43 --- /dev/null +++ b/src/sha256.ts @@ -0,0 +1,76 @@ +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); + +/** + * SHA-256 of a string's UTF-8 bytes (FIPS 180-4), the same digest as + * Node's `createHash('sha256').update(text)`. Synchronous and dependency-free, + * so bucketing works in browsers and edge runtimes as well as Node. + */ +export const sha256 = (text: string): Uint8Array => { + const data = new TextEncoder().encode(text); + // Message, a 0x80 byte, zero padding, then the bit length as 64 bits. + const padded = new Uint8Array(((data.length + 72) >> 6) << 6); + padded.set(data); + padded[data.length] = 0x80; + const view = new DataView(padded.buffer); + const bits = data.length * 8; + view.setUint32(padded.length - 8, Math.floor(bits / 2 ** 32)); + view.setUint32(padded.length - 4, bits >>> 0); + + const hash = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19, + ]); + const w = new Uint32Array(64); + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4); + for (let i = 16; i < 64; i++) { + const s0 = + rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = + rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + let [a, b, c, d, e, f, g, h] = hash; + for (let i = 0; i < 64; i++) { + const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const t1 = (h + s1 + ((e & f) ^ (~e & g)) + K[i] + w[i]) | 0; + const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const t2 = (s0 + ((a & b) ^ (a & c) ^ (b & c))) | 0; + h = g; + g = f; + f = e; + e = (d + t1) | 0; + d = c; + c = b; + b = a; + a = (t1 + t2) | 0; + } + hash[0] += a; + hash[1] += b; + hash[2] += c; + hash[3] += d; + hash[4] += e; + hash[5] += f; + hash[6] += g; + hash[7] += h; + } + + const digest = new Uint8Array(32); + const out = new DataView(digest.buffer); + hash.forEach((word, i) => out.setUint32(i * 4, word)); + return digest; +}; diff --git a/src/tests/bucketing.test.ts b/src/tests/bucketing.test.ts new file mode 100644 index 0000000..7358bfa --- /dev/null +++ b/src/tests/bucketing.test.ts @@ -0,0 +1,176 @@ +import { createHash } from 'crypto'; +import { getABExperimentBucket } from '../index'; +import { bucketOf } from '../bucket'; +import { sha256 } from '../sha256'; + +// 0.1.x's bucketing, verbatim, as the reference for legacyHashing. +const legacyBucket = (uniqueId: string) => { + const hashBytes = createHash('sha256').update(uniqueId).digest(); + const hashedId = BigInt( + `0x${Buffer.from(hashBytes).subarray(0, 8).toString('hex')}`, + ); + return Math.floor( + (Number(hashedId) / Number(BigInt('0xFFFFFFFFFFFFFFFF'))) * 10000, + ); +}; + +const ids = Array.from({ length: 20000 }, (_, i) => `user-${i}`); + +describe('sha256', () => { + test('matches Node crypto, including padding boundaries and non-ASCII', () => { + const inputs = [ + '', + 'abc', + 'a'.repeat(55), + 'a'.repeat(56), + 'a'.repeat(63), + 'a'.repeat(64), + 'a'.repeat(1000), + 'ユーザー', + '😀 emoji', + ]; + for (const input of inputs) { + expect(Buffer.from(sha256(input)).toString('hex')).toBe( + createHash('sha256').update(input).digest('hex'), + ); + } + }); +}); + +describe('legacyHashing', () => { + test('reproduces 0.1.x buckets exactly', () => { + for (const id of ids) expect(bucketOf(id)).toBe(legacyBucket(id)); + }); + + test('ignores experimentName, as 0.1.x did', () => { + const a = getABExperimentBucket({ + experimentName: 'a', + uniqueId: 'user-7', + legacyHashing: true, + }); + const b = getABExperimentBucket({ + experimentName: 'b', + uniqueId: 'user-7', + legacyHashing: true, + }); + expect(a.resolvedVariant).toBe(b.resolvedVariant); + }); +}); + +describe('per-experiment bucketing', () => { + test('is deterministic and pinned', () => { + const users = ['user-1', 'user-2', 'user-3', 'user-4', 'user-5']; + expect(users.map((id) => bucketOf(`checkout-cta:${id}`))).toEqual([ + 452, 4576, 8877, 7670, 4422, + ]); + expect( + users.map( + (uniqueId) => + getABExperimentBucket({ + experimentName: 'checkout-cta', + uniqueId, + }).resolvedVariant, + ), + ).toEqual(['control', 'control', 'control', 'control', 'treatment']); + }); + + test('assigns experiments independently', () => { + let both = 0; + let same = 0; + for (const uniqueId of ids) { + const a = getABExperimentBucket({ + experimentName: 'checkout-cta', + uniqueId, + }); + const b = getABExperimentBucket({ + experimentName: 'new-search', + uniqueId, + }); + if (a.resolvedVariant === b.resolvedVariant) same++; + if ( + a.resolvedVariant === 'treatment' && + b.resolvedVariant === 'treatment' + ) + both++; + } + expect(Math.abs((both / ids.length) * 100 - 25)).toBeLessThan(1.5); + expect(Math.abs((same / ids.length) * 100 - 50)).toBeLessThan(1.5); + }); + + test('raising exposure never moves a user between variants', () => { + for (const uniqueId of ids) { + const before = getABExperimentBucket({ + experimentName: 'ramp', + uniqueId, + exposure: 10, + }).resolvedVariant; + if (before === 'ignore') continue; + const after = getABExperimentBucket({ + experimentName: 'ramp', + uniqueId, + exposure: 50, + }).resolvedVariant; + expect(after).toBe(before); + } + }); + + test('raising treatmentSplit only moves users from control to treatment', () => { + for (const uniqueId of ids) { + const before = getABExperimentBucket({ + experimentName: 'split', + uniqueId, + treatmentSplit: 20, + }).resolvedVariant; + const after = getABExperimentBucket({ + experimentName: 'split', + uniqueId, + treatmentSplit: 40, + }).resolvedVariant; + if (before === 'treatment') expect(after).toBe('treatment'); + } + }); +}); + +describe('invalid input throws', () => { + const base = { experimentName: 'exp', uniqueId: 'user-1' }; + + test.each([ + ['empty uniqueId', { ...base, uniqueId: '' }, 'uniqueId'], + [ + 'non-string uniqueId', + { ...base, uniqueId: 42 as unknown as string }, + 'uniqueId', + ], + [ + 'empty experimentName', + { ...base, experimentName: '' }, + 'experimentName', + ], + [ + 'NaN treatmentSplit', + { ...base, treatmentSplit: NaN }, + 'treatmentSplit', + ], + [ + 'string exposure', + { ...base, exposure: '50' as unknown as number }, + 'exposure', + ], + ['infinite exposure', { ...base, exposure: Infinity }, 'exposure'], + ])('%s', (_, params, field) => { + expect(() => getABExperimentBucket(params)).toThrow(field); + }); + + test('legacyHashing allows an empty experimentName', () => { + expect( + getABExperimentBucket({ + ...base, + experimentName: '', + legacyHashing: true, + }).resolvedVariant, + ).toBe( + getABExperimentBucket({ ...base, legacyHashing: true }) + .resolvedVariant, + ); + }); +}); diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index 8038f67..2cadda6 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -1,5 +1,5 @@ import { getABExperimentBucket } from '../index'; -import { IParams } from 'src/types/interface'; +import { IParams } from '../types/interface'; const generateUniqueId = () => { return Math.random().toString(36).substring(2); @@ -131,33 +131,6 @@ describe('getABExperimentBucket function split accuracy', () => { }); describe('getABExperimentBucket function edge cases and error handling', () => { - // Test error handling - test('Error handling', () => { - // Mocking a function that throws an error - const mockCryptoCreateHash = jest.spyOn( - require('crypto'), - 'createHash', - ); - mockCryptoCreateHash.mockImplementation(() => { - throw new Error('Crypto error'); - }); - const mockParams: IParams = { - experimentName: 'test_experiment', - uniqueId: 'test_user_id', // Generate unique ID for each iteration - }; - const result = getABExperimentBucket(mockParams); - expect(result).toEqual({ - experimentName: 'test_experiment', - uniqueId: 'test_user_id', - resolvedVariant: null, - isError: true, - error: new Error('Crypto error'), - }); - - // Restore the original implementation - mockCryptoCreateHash.mockRestore(); - }); - // Test edge cases test('Edge cases: treatmentSplit and exposure at boundaries', () => { const mockParams: IParams = { diff --git a/src/types/interface.ts b/src/types/interface.ts index dfa08c2..dd43c26 100644 --- a/src/types/interface.ts +++ b/src/types/interface.ts @@ -2,13 +2,19 @@ * Interface for the parameters required by the getABExperimentBucket function. */ export interface IParams { - experimentName: string; // The name of the experiment + experimentName: string; // The name of the experiment; part of the hash uniqueId: string; // The unique identifier for the user can be email, user ID, etc. treatmentSplit?: number; // The percentage of users to be bucketed into the treatment group (default: 50) exposure?: number; // The percentage of users to be bucketed into the experiment (default: 100) controlVariantName?: string; // The name of the control variant (default: 'control') treatmentVariantName?: string; // The name of the treatment variant (default: 'treatment') ignoreVariantName?: string; // The name of the variant to be ignored (default: 'ignore') + /** + * Bucket by `uniqueId` alone, as 0.1.x did, so experiments started on + * 0.1.x keep their assignments. Every experiment then shares one split, + * so use it only to finish those experiments (default: false). + */ + legacyHashing?: boolean; } /** @@ -17,7 +23,9 @@ export interface IParams { export interface IExperimentData { experimentName: string; // The name of the experiment uniqueId: string; // The unique identifier for the user - resolvedVariant: string | null; // The variant that the user is assigned to, or null if there was an error - isError?: boolean; // Indicates if there was an error while assigning the variant - error?: unknown; // The error object, if isError is true + resolvedVariant: string; // The variant that the user is assigned to + /** @deprecated Invalid input now throws; this is never set. */ + isError?: boolean; + /** @deprecated Invalid input now throws; this is never set. */ + error?: unknown; } diff --git a/tsup.config.ts b/tsup.config.ts index 64ce5bb..2548c34 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,10 +1,9 @@ -import { defineConfig } from "tsup"; +import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ["src/index.ts"], - format: ["esm"], // Build for commonJS and ESmodules - dts: true, // Generate declaration file (.d.ts) - splitting: true, - sourcemap: false, - clean: true, -}); \ No newline at end of file + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: false, + clean: true, +});