Skip to content
Open
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'

Check failure on line 2 in packages/core/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Missing semicolon
export { HawkUserManager } from './users/hawk-user-manager';
87 changes: 87 additions & 0 deletions packages/core/src/users/hawk-user-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { AffectedUser } from '@hawk.so/types';
import type { HawkStorage } from '../storages/hawk-storage';
import { id } from "../utils/id";

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

View workflow job for this annotation

GitHub Actions / lint

Strings must use singlequote
import { RandomGenerator } from "../utils/random";

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

View workflow job for this annotation

GitHub Actions / lint

Strings must use singlequote

Check failure on line 4 in packages/core/src/users/hawk-user-manager.ts

View workflow job for this annotation

GitHub Actions / lint

All imports in the declaration are only used as types. Use `import type`

/**
* Storage key used to persist the auto-generated user ID.
*/
const SESSION_STORAGE_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,

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

View workflow job for this annotation

GitHub Actions / lint

Unexpected trailing comma
) {
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.
*
* @return 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

Invalid JSDoc tag (preference). Replace "return" JSDoc tag with "returns"
*/
public getUser(): AffectedUser {
if (this.user) {
return this.user;
}

let storedId = this.storage.getItem(SESSION_STORAGE_KEY);
if (!storedId) {

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

View workflow job for this annotation

GitHub Actions / lint

Expected blank line before this statement
storedId = id(this.randomGenerator)

Check failure on line 63 in packages/core/src/users/hawk-user-manager.ts

View workflow job for this annotation

GitHub Actions / lint

Missing semicolon
this.storage.setItem(SESSION_STORAGE_KEY, storedId);
}

this.user = { id: storedId }

Check failure on line 67 in packages/core/src/users/hawk-user-manager.ts

View workflow job for this annotation

GitHub Actions / lint

Missing semicolon

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;
}
}
14 changes: 14 additions & 0 deletions packages/core/src/utils/id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { RandomGenerator } from "./random";

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

View workflow job for this annotation

GitHub Actions / lint

Strings must use singlequote

Check failure on line 1 in packages/core/src/utils/id.ts

View workflow job for this annotation

GitHub Actions / lint

All imports in the declaration are only used as types. Use `import type`

/**

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

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @param "random" declaration
* Returns random string
*/
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
Loading