diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fade8851..1881dea1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/package.json b/package.json index 01c1ec1d..d59466ec 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/core/package.json b/packages/core/package.json index e06be1d4..6797c920 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", @@ -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" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6a701ebc..d63e1f56 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,3 @@ export type { HawkStorage } from './storages/hawk-storage'; +export type { RandomGenerator } from './utils/random'; +export { HawkUserManager } from './users/hawk-user-manager'; diff --git a/packages/core/src/users/hawk-user-manager.ts b/packages/core/src/users/hawk-user-manager.ts new file mode 100644 index 00000000..133b5bd7 --- /dev/null +++ b/packages/core/src/users/hawk-user-manager.ts @@ -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; + * for changes to take effect call {@link clear}. + */ +export class HawkUserManager { + /** + * In-memory user set explicitly via {@link setUser}. + */ + private user: AffectedUser | null = null; + + /** + * 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 + */ + 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; + } +} diff --git a/packages/core/src/utils/id.ts b/packages/core/src/utils/id.ts new file mode 100644 index 00000000..ee3823dd --- /dev/null +++ b/packages/core/src/utils/id.ts @@ -0,0 +1,16 @@ +import type { RandomGenerator } from './random'; + +/** + * Returns random string + * + * @param random + */ +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); +} diff --git a/packages/core/src/utils/random.ts b/packages/core/src/utils/random.ts new file mode 100644 index 00000000..e8abd358 --- /dev/null +++ b/packages/core/src/utils/random.ts @@ -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; +} diff --git a/packages/core/tests/users/hawk-user-manager.test.ts b/packages/core/tests/users/hawk-user-manager.test.ts new file mode 100644 index 00000000..3fc80231 --- /dev/null +++ b/packages/core/tests/users/hawk-user-manager.test.ts @@ -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); + }); +}); diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json new file mode 100644 index 00000000..38db19a8 --- /dev/null +++ b/packages/core/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": null, + "declaration": false, + "types": ["vitest/globals"] + }, + "include": [ + "src/**/*", + "tests/**/*", + "vitest.config.ts" + ] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 00000000..aee26683 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -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'], + }, + }, +}); diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index b18d8683..936f8d1f 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -2,23 +2,26 @@ import Socket from './modules/socket'; import Sanitizer from './modules/sanitizer'; import log from './utils/log'; import StackParser from './modules/stackParser'; -import type { CatcherMessage, HawkInitialSettings, BreadcrumbsAPI, Transport } from './types'; +import type { BreadcrumbsAPI, CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; import { VueIntegration } from './integrations/vue'; -import { id } from './utils/id'; import type { AffectedUser, + DecodedIntegrationToken, + EncodedIntegrationToken, EventContext, JavaScriptAddons, - VueIntegrationAddons, - Json, EncodedIntegrationToken, DecodedIntegrationToken + Json, + VueIntegrationAddons } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from './types/integrations'; import { EventRejectedError } from './errors'; -import type { HawkJavaScriptEvent } from './types'; import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; +import { BrowserRandomGenerator } from './utils/random'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; -import { validateUser, validateContext, isValidEventPayload } from './utils/validation'; +import { isValidEventPayload, validateContext, validateUser } from './utils/validation'; +import { HawkUserManager } from '@hawk.so/core'; +import { HawkLocalStorage } from './storages/hawk-local-storage'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -62,11 +65,6 @@ export default class Catcher { */ private readonly release: string | undefined; - /** - * Current authenticated user - */ - private user: AffectedUser; - /** * Any additional data passed by user for sending with all messages */ @@ -111,6 +109,14 @@ export default class Catcher { */ private readonly breadcrumbManager: BreadcrumbManager | null; + /** + * Manages currently authenticated user identity. + */ + private readonly userManager: HawkUserManager = new HawkUserManager( + new HawkLocalStorage(), + new BrowserRandomGenerator() + ); + /** * Catcher constructor * @@ -126,7 +132,9 @@ export default class Catcher { this.token = settings.token; this.debug = settings.debug || false; this.release = settings.release !== undefined ? String(settings.release) : undefined; - this.setUser(settings.user || Catcher.getGeneratedUser()); + if (settings.user) { + this.setUser(settings.user); + } this.setContext(settings.context || undefined); this.beforeSend = settings.beforeSend; this.disableVueErrorHandler = @@ -189,27 +197,6 @@ export default class Catcher { } } - /** - * Generates user if no one provided via HawkCatcher settings - * After generating, stores user for feature requests - */ - private static getGeneratedUser(): AffectedUser { - let userId: string; - const LOCAL_STORAGE_KEY = 'hawk-user-id'; - const storedId = localStorage.getItem(LOCAL_STORAGE_KEY); - - if (storedId) { - userId = storedId; - } else { - userId = id(); - localStorage.setItem(LOCAL_STORAGE_KEY, userId); - } - - return { - id: userId, - }; - } - /** * Send test event from client */ @@ -272,14 +259,14 @@ export default class Catcher { return; } - this.user = user; + this.userManager.setUser(user); } /** - * Clear current user information (revert to generated user) + * Clear current user information */ public clearUser(): void { - this.user = Catcher.getGeneratedUser(); + this.userManager.clear(); } /** @@ -565,10 +552,10 @@ export default class Catcher { } /** - * Current authenticated user + * Returns the current user if set, otherwise generates and persists an anonymous ID. */ - private getUser(): HawkJavaScriptEvent['user'] { - return this.user || null; + private getUser(): AffectedUser { + return this.userManager.getUser(); } /** diff --git a/packages/javascript/src/storages/hawk-local-storage.ts b/packages/javascript/src/storages/hawk-local-storage.ts index c0362ea9..1d7d9bad 100644 --- a/packages/javascript/src/storages/hawk-local-storage.ts +++ b/packages/javascript/src/storages/hawk-local-storage.ts @@ -1,5 +1,5 @@ import type { HawkStorage } from '@hawk.so/core'; -import log from '../utils/log.ts'; +import log from '../utils/log'; /** * {@link HawkStorage} implementation backed by the browser's {@linkcode localStorage}. @@ -11,6 +11,7 @@ export class HawkLocalStorage implements HawkStorage { return localStorage.getItem(key); } catch (e) { log('HawkLocalStorage: getItem failed', 'error', e); + return null; } } diff --git a/packages/javascript/src/utils/random.ts b/packages/javascript/src/utils/random.ts new file mode 100644 index 00000000..1907884c --- /dev/null +++ b/packages/javascript/src/utils/random.ts @@ -0,0 +1,19 @@ +import type { RandomGenerator } from '@hawk.so/core'; + +/** + * Browser implementation of {@link RandomGenerator} backed by the Web Crypto API. + */ +export class BrowserRandomGenerator implements RandomGenerator { + /** + * Generates a sequence of cryptographically secure random unsigned numbers. + * + * @param length - Length of the generated sequence. + * @returns Array filled with random unsigned numbers. + * @remarks Uses {@link Crypto.getRandomValues} under the hood. + */ + public getRandomNumbers(length: number): Uint8Array { + const array = new Uint8Array(length); + + return window.crypto.getRandomValues(array); + } +} diff --git a/packages/javascript/vite.config.ts b/packages/javascript/vite.config.ts index 47fe52e7..65b622e0 100644 --- a/packages/javascript/vite.config.ts +++ b/packages/javascript/vite.config.ts @@ -26,6 +26,7 @@ export default defineConfig(() => { fileName: 'hawk', }, rollupOptions: { + external: ['@hawk.so/core'], plugins: [ license({ thirdParty: { diff --git a/packages/javascript/vitest.config.ts b/packages/javascript/vitest.config.ts index 47ad6a2c..68e2cda4 100644 --- a/packages/javascript/vitest.config.ts +++ b/packages/javascript/vitest.config.ts @@ -14,5 +14,6 @@ export default defineConfig({ alias: { '@/types': path.resolve(__dirname, './src/types'), }, + conditions: ['source'], }, }); diff --git a/yarn.lock b/yarn.lock index a2aacdff..65b88ef1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -587,8 +587,11 @@ __metadata: version: 0.0.0-use.local resolution: "@hawk.so/core@workspace:packages/core" dependencies: + "@hawk.so/types": "npm:0.5.8" + "@vitest/coverage-v8": "npm:^4.0.18" vite: "npm:^7.3.1" vite-plugin-dts: "npm:^4.2.4" + vitest: "npm:^4.0.18" languageName: unknown linkType: soft