Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ jobs:
CI_JOB_NUMBER: 2
steps:
- uses: actions/checkout@v1
with:
fetch-depth: 0
- name: Use Node.js from .nvmrc
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- run: corepack enable
- run: yarn install
- run: yarn workspace @hawk.so/javascript test
- run: yarn test:modified origin/${{ github.event.pull_request.base.ref }}

build:
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"dev": "yarn workspace @hawk.so/javascript dev",
"build:all": "yarn workspaces foreach -Apt run build",
"build:modified": "yarn workspaces foreach --since=\"$@\" -Rpt run build",
"test:all": "yarn workspaces foreach -Apt run test",
"test:modified": "yarn workspaces foreach --since=\"$@\" -Rpt run test",
"stats": "yarn workspace @hawk.so/javascript stats",
"lint": "eslint -c ./.eslintrc.cjs packages/*/src --ext .ts,.js --fix",
"lint-test": "eslint -c ./.eslintrc.cjs packages/*/src --ext .ts,.js"
Expand Down
12 changes: 10 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
}
},
"scripts": {
"build": "vite build"
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint --fix \"src/**/*.{js,ts}\""
},
"repository": {
"type": "git",
Expand All @@ -33,8 +36,13 @@
"url": "https://github.com/codex-team/hawk.javascript/issues"
},
"homepage": "https://github.com/codex-team/hawk.javascript#readme",
"dependencies": {
"@hawk.so/types": "0.5.8"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.0.18",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.2.4"
"vite-plugin-dts": "^4.2.4",
"vitest": "^4.0.18"
}
}
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export type { HawkStorage } from './storages/hawk-storage';
export type { RandomGenerator } from './utils/random';
export { HawkUserManager } from './users/hawk-user-manager';
88 changes: 88 additions & 0 deletions packages/core/src/users/hawk-user-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { AffectedUser } from '@hawk.so/types';
import type { HawkStorage } from '../storages/hawk-storage';
import { id } from '../utils/id';
import type { RandomGenerator } from '../utils/random';

/**
* Storage key used to persist the auto-generated user ID.
*/
const HAWK_USER_ID_KEY = 'hawk-user-id';

/**
* Manages the affected user identity.
*
* Manually provided users are kept in memory only (they don't change restarts).
* {@link HawkStorage} is used solely to persist the auto-generated ID
* so it survives across sessions.
*
* @remarks changes to user data in storage from outside manager are not tracked;

Check warning on line 18 in packages/core/src/users/hawk-user-manager.ts

View workflow job for this annotation

GitHub Actions / lint

Invalid JSDoc tag name "remarks"
* for changes to take effect call {@link clear}.
*/
export class HawkUserManager {
/**
* In-memory user set explicitly via {@link setUser}.

Check warning on line 23 in packages/core/src/users/hawk-user-manager.ts

View workflow job for this annotation

GitHub Actions / lint

The type 'setUser' is undefined
*/
private user: AffectedUser | null = null;

Check warning on line 25 in packages/core/src/users/hawk-user-manager.ts

View workflow job for this annotation

GitHub Actions / lint

The type 'clear' is undefined

/**
* Underlying storage used to persist auto-generated user ID.
*/
private readonly storage: HawkStorage;

/**
* Random generator used to produce anonymous user IDs.
*/
private readonly randomGenerator: RandomGenerator;

/**
* @param storage - storage backend to use for persistence
* @param randomGenerator - utilities related to RandomGenerator generated values
*/
constructor(
storage: HawkStorage,
randomGenerator: RandomGenerator
) {
this.storage = storage;
this.randomGenerator = randomGenerator;
}

/**
* Returns current affected user if set, otherwise generates and persists an anonymous ID.
*
* Priority: in-memory user > persisted user ID.
*
* @returns set affected user or user with generated ID

Check warning on line 54 in packages/core/src/users/hawk-user-manager.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @returns type
*/
public getUser(): AffectedUser {
if (this.user) {
return this.user;
}

let storedId = this.storage.getItem(HAWK_USER_ID_KEY);

if (!storedId) {
storedId = id(this.randomGenerator);
this.storage.setItem(HAWK_USER_ID_KEY, storedId);
}

this.user = { id: storedId };

return this.user!;
}

/**
* Sets the user explicitly (in memory only).
*
* @param user - The affected user provided by the application.
*/
public setUser(user: AffectedUser): void {
this.user = user;
}

/**
* Clears the explicitly set user, falling back to the persisted user ID.
*/
public clear(): void {
this.user = null;
}
}
16 changes: 16 additions & 0 deletions packages/core/src/utils/id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { RandomGenerator } from './random';

/**
* Returns random string
*
* @param random

Check warning on line 6 in packages/core/src/utils/id.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @param "random" description
*/
export function id(random: RandomGenerator): string {
const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

const randomSequence = random
.getRandomNumbers(40)
.map(x => validChars.charCodeAt(x % validChars.length));

return String.fromCharCode.apply(null, randomSequence);
}
13 changes: 13 additions & 0 deletions packages/core/src/utils/random.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Abstraction over random value generator.
* Allows platform-specific implementations to be injected wherever random values are needed.
*/
export interface RandomGenerator {
/**
* Generates sequence of random unsigned numbers.
*
* @param length - Length of generated sequence.
* @returns Array filled with random unsigned numbers.
*/
getRandomNumbers(length: number): Uint8Array<ArrayBuffer>;
}
76 changes: 76 additions & 0 deletions packages/core/tests/users/hawk-user-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { HawkUserManager } from '../../src';
import type { HawkStorage, RandomGenerator } from '../../src';

describe('HawkUserManager', () => {
let storage: HawkStorage;
let randomGenerator: RandomGenerator;
let manager: HawkUserManager;

beforeEach(() => {
storage = {
getItem: vi.fn().mockReturnValue(null),
setItem: vi.fn(),
removeItem: vi.fn(),
};
randomGenerator = {
getRandomNumbers: vi.fn().mockReturnValue(new Uint8Array(40).fill(42)),
};
manager = new HawkUserManager(storage, randomGenerator);
});

it('should return anonymous ID when no user is set and no ID is persisted', () => {
const user = manager.getUser();

expect(user.id).toBeTruthy();
expect(storage.setItem).toHaveBeenCalledWith('hawk-user-id', user.id);
});

it('should return in-memory user set via setUser()', () => {
const user = { id: 'user-1', name: 'Ryan Gosling', url: 'https://example.com', photo: 'https://example.com/photo.png' };

manager.setUser(user);

expect(manager.getUser()).toEqual(user);
expect(storage.setItem).not.toHaveBeenCalled();
});

it('should not affect storage when setUser() is called', () => {
manager.setUser({ id: 'user-1' });

expect(storage.setItem).not.toHaveBeenCalled();
expect(storage.removeItem).not.toHaveBeenCalled();
});

it('should return anonymous user from storage when no in-memory user is set', () => {
vi.mocked(storage.getItem).mockReturnValue('anon-123');

expect(manager.getUser()).toEqual({ id: 'anon-123' });
expect(storage.getItem).toHaveBeenCalledWith('hawk-user-id');
});

it('should prefer in-memory user over persisted anonymous ID', () => {
vi.mocked(storage.getItem).mockReturnValue('anon-123');
manager.setUser({ id: 'explicit-user' });

expect(manager.getUser()).toEqual({ id: 'explicit-user' });
});

it('should clear in-memory user and fall back to persisted anonymous ID', () => {
vi.mocked(storage.getItem).mockReturnValue('anon-123');
manager.setUser({ id: 'user-1' });
manager.clear();

expect(manager.getUser()).toEqual({ id: 'anon-123' });
});

it('should return new anonymous ID after clear() when no ID is persisted', () => {
manager.setUser({ id: 'user-1' });
manager.clear();

const user = manager.getUser();

expect(user.id).toBeTruthy();
expect(storage.setItem).toHaveBeenCalledWith('hawk-user-id', user.id);
});
});
13 changes: 13 additions & 0 deletions packages/core/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": null,
"declaration": false,
"types": ["vitest/globals"]
},
"include": [
"src/**/*",
"tests/**/*",
"vitest.config.ts"
]
}
15 changes: 15 additions & 0 deletions packages/core/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
globals: true,
include: ['tests/**/*.test.ts'],
typecheck: {
tsconfig: './tsconfig.test.json',
},
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
},
},
});
Loading