Skip to content

Commit d0c4bff

Browse files
committed
refactor(core): HawkUserManager added
1 parent 591724a commit d0c4bff

File tree

12 files changed

+215
-37
lines changed

12 files changed

+215
-37
lines changed

.github/workflows/main.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ jobs:
2222
CI_JOB_NUMBER: 2
2323
steps:
2424
- uses: actions/checkout@v1
25+
with:
26+
fetch-depth: 0
2527
- name: Use Node.js from .nvmrc
2628
uses: actions/setup-node@v6
2729
with:
2830
node-version-file: '.nvmrc'
2931
- run: corepack enable
3032
- run: yarn install
31-
- run: yarn workspace @hawk.so/javascript test
33+
- run: yarn test:modified origin/${{ github.event.pull_request.base.ref }}
3234

3335
build:
3436
runs-on: ubuntu-latest

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"dev": "yarn workspace @hawk.so/javascript dev",
1515
"build:all": "yarn workspaces foreach -Apt run build",
1616
"build:modified": "yarn workspaces foreach --since=\"$@\" -Rpt run build",
17+
"test:all": "yarn workspaces foreach -Apt run test",
18+
"test:modified": "yarn workspaces foreach --since=\"$@\" -Rpt run test",
1719
"stats": "yarn workspace @hawk.so/javascript stats",
1820
"lint": "eslint -c ./.eslintrc.cjs packages/*/src --ext .ts,.js --fix",
1921
"lint-test": "eslint -c ./.eslintrc.cjs packages/*/src --ext .ts,.js"

packages/core/package.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
}
1818
},
1919
"scripts": {
20-
"build": "vite build"
20+
"build": "vite build",
21+
"test": "vitest run",
22+
"test:coverage": "vitest run --coverage",
23+
"lint": "eslint --fix \"src/**/*.{js,ts}\""
2124
},
2225
"repository": {
2326
"type": "git",
@@ -33,8 +36,13 @@
3336
"url": "https://github.com/codex-team/hawk.javascript/issues"
3437
},
3538
"homepage": "https://github.com/codex-team/hawk.javascript#readme",
39+
"dependencies": {
40+
"@hawk.so/types": "0.5.8"
41+
},
3642
"devDependencies": {
43+
"@vitest/coverage-v8": "^4.0.18",
3744
"vite": "^7.3.1",
38-
"vite-plugin-dts": "^4.2.4"
45+
"vite-plugin-dts": "^4.2.4",
46+
"vitest": "^4.0.18"
3947
}
4048
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export type { HawkStorage } from './storages/hawk-storage';
2+
export { HawkUserManager } from './users/hawk-user-manager';
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { AffectedUser } from '@hawk.so/types';
2+
import type { HawkStorage } from '../storages/hawk-storage';
3+
4+
/**
5+
* Storage key used to persist the auto-generated user ID.
6+
*/
7+
export const HAWK_USER_ID_KEY = 'hawk-user-id';
8+
9+
/**
10+
* Manages the affected user identity.
11+
*
12+
* Manually provided users are kept in memory only (they don't change restarts).
13+
* {@link HawkStorage} is used solely to persist the auto-generated ID
14+
* so it survives across sessions.
15+
*/
16+
export class HawkUserManager {
17+
/**
18+
* In-memory user set explicitly via {@link setUser}.
19+
*/
20+
private user: AffectedUser | null = null;
21+
22+
/**
23+
* Underlying storage used to persist auto-generated user ID.
24+
*/
25+
private readonly storage: HawkStorage;
26+
27+
/**
28+
* @param storage - Storage backend to use for persistence.
29+
*/
30+
constructor(storage: HawkStorage) {
31+
this.storage = storage;
32+
}
33+
34+
/**
35+
* Returns the current affected user, or `null` if none is available.
36+
*
37+
* Priority: in-memory user > persisted user ID.
38+
*/
39+
public getUser(): AffectedUser | null {
40+
if (this.user) {
41+
return this.user;
42+
}
43+
const storedId = this.storage.getItem(HAWK_USER_ID_KEY);
44+
return storedId ? { id: storedId } : null;
45+
}
46+
47+
/**
48+
* Sets the user explicitly (in memory only).
49+
*
50+
* @param user - The affected user provided by the application.
51+
*/
52+
public setUser(user: AffectedUser): void {
53+
this.user = user;
54+
}
55+
56+
/**
57+
* Persists an auto-generated user ID to storage.
58+
*
59+
* @param id - The generated ID to persist.
60+
*/
61+
public persistGeneratedId(id: string): void {
62+
this.storage.setItem(HAWK_USER_ID_KEY, id);
63+
}
64+
65+
/**
66+
* Clears the explicitly set user, falling back to the persisted user ID.
67+
*/
68+
public clear(): void {
69+
this.user = null;
70+
}
71+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { HawkUserManager } from '../../src';
3+
import type { HawkStorage } from '../../src';
4+
5+
describe('HawkUserManager', () => {
6+
let storage: HawkStorage;
7+
let manager: HawkUserManager;
8+
9+
beforeEach(() => {
10+
storage = {
11+
getItem: vi.fn().mockReturnValue(null),
12+
setItem: vi.fn(),
13+
removeItem: vi.fn(),
14+
};
15+
manager = new HawkUserManager(storage);
16+
});
17+
18+
it('should return null when no user is set and storage is empty', () => {
19+
expect(manager.getUser()).toBeNull();
20+
});
21+
22+
it('should return in-memory user set via setUser()', () => {
23+
const user = { id: 'user-1', name: 'Ryan Gosling', url: 'https://example.com', photo: 'https://example.com/photo.png' };
24+
25+
manager.setUser(user);
26+
27+
expect(manager.getUser()).toEqual(user);
28+
expect(storage.setItem).not.toHaveBeenCalled();
29+
});
30+
31+
it('should not touch storage when setUser() is called', () => {
32+
manager.setUser({ id: 'user-1' });
33+
34+
expect(storage.setItem).not.toHaveBeenCalled();
35+
expect(storage.removeItem).not.toHaveBeenCalled();
36+
});
37+
38+
it('should return anonymous user from storage when no in-memory user is set', () => {
39+
vi.mocked(storage.getItem).mockReturnValue('anon-123');
40+
41+
expect(manager.getUser()).toEqual({ id: 'anon-123' });
42+
expect(storage.getItem).toHaveBeenCalledWith('hawk-user-id');
43+
});
44+
45+
it('should prefer in-memory user over persisted anonymous ID', () => {
46+
vi.mocked(storage.getItem).mockReturnValue('anon-123');
47+
manager.setUser({ id: 'explicit-user' });
48+
49+
expect(manager.getUser()).toEqual({ id: 'explicit-user' });
50+
});
51+
52+
it('should persist anonymous ID via persistGeneratedId()', () => {
53+
manager.persistGeneratedId('anon-456');
54+
55+
expect(storage.setItem).toHaveBeenCalledWith('hawk-user-id', 'anon-456');
56+
});
57+
58+
it('should clear in-memory user and fall back to persisted anonymous ID', () => {
59+
vi.mocked(storage.getItem).mockReturnValue('anon-123');
60+
manager.setUser({ id: 'user-1' });
61+
manager.clear();
62+
63+
expect(manager.getUser()).toEqual({ id: 'anon-123' });
64+
});
65+
66+
it('should return null after clear() when no anonymous ID is persisted', () => {
67+
manager.setUser({ id: 'user-1' });
68+
manager.clear();
69+
70+
expect(manager.getUser()).toBeNull();
71+
});
72+
});

packages/core/tsconfig.test.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": null,
5+
"declaration": false,
6+
"types": ["vitest/globals"]
7+
},
8+
"include": [
9+
"src/**/*",
10+
"tests/**/*",
11+
"vitest.config.ts"
12+
]
13+
}

packages/core/vitest.config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: {
5+
globals: true,
6+
include: ['tests/**/*.test.ts'],
7+
typecheck: {
8+
tsconfig: './tsconfig.test.json',
9+
},
10+
coverage: {
11+
provider: 'v8',
12+
include: ['src/**/*.ts'],
13+
},
14+
},
15+
});

packages/javascript/src/catcher.ts

Lines changed: 23 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import log from './utils/log';
44
import StackParser from './modules/stackParser';
55
import type { CatcherMessage, HawkInitialSettings, BreadcrumbsAPI, Transport } from './types';
66
import { VueIntegration } from './integrations/vue';
7-
import { id } from './utils/id';
87
import type {
98
AffectedUser,
109
EventContext,
@@ -19,6 +18,9 @@ import { isErrorProcessed, markErrorAsProcessed } from './utils/event';
1918
import { ConsoleCatcher } from './addons/consoleCatcher';
2019
import { BreadcrumbManager } from './addons/breadcrumbs';
2120
import { validateUser, validateContext, isValidEventPayload } from './utils/validation';
21+
import { HawkUserManager } from '@hawk.so/core';
22+
import { HawkLocalStorage } from './storages/hawk-local-storage';
23+
import { id } from './utils/id';
2224

2325
/**
2426
* Allow to use global VERSION, that will be overwritten by Webpack
@@ -62,11 +64,6 @@ export default class Catcher {
6264
*/
6365
private readonly release: string | undefined;
6466

65-
/**
66-
* Current authenticated user
67-
*/
68-
private user: AffectedUser;
69-
7067
/**
7168
* Any additional data passed by user for sending with all messages
7269
*/
@@ -111,6 +108,11 @@ export default class Catcher {
111108
*/
112109
private readonly breadcrumbManager: BreadcrumbManager | null;
113110

111+
/**
112+
* Current authenticated user manager instance
113+
*/
114+
private readonly userManager: HawkUserManager = new HawkUserManager(new HawkLocalStorage());
115+
114116
/**
115117
* Catcher constructor
116118
*
@@ -126,7 +128,9 @@ export default class Catcher {
126128
this.token = settings.token;
127129
this.debug = settings.debug || false;
128130
this.release = settings.release !== undefined ? String(settings.release) : undefined;
129-
this.setUser(settings.user || Catcher.getGeneratedUser());
131+
if (settings.user) {
132+
this.setUser(settings.user);
133+
}
130134
this.setContext(settings.context || undefined);
131135
this.beforeSend = settings.beforeSend;
132136
this.disableVueErrorHandler =
@@ -189,27 +193,6 @@ export default class Catcher {
189193
}
190194
}
191195

192-
/**
193-
* Generates user if no one provided via HawkCatcher settings
194-
* After generating, stores user for feature requests
195-
*/
196-
private static getGeneratedUser(): AffectedUser {
197-
let userId: string;
198-
const LOCAL_STORAGE_KEY = 'hawk-user-id';
199-
const storedId = localStorage.getItem(LOCAL_STORAGE_KEY);
200-
201-
if (storedId) {
202-
userId = storedId;
203-
} else {
204-
userId = id();
205-
localStorage.setItem(LOCAL_STORAGE_KEY, userId);
206-
}
207-
208-
return {
209-
id: userId,
210-
};
211-
}
212-
213196
/**
214197
* Send test event from client
215198
*/
@@ -272,14 +255,14 @@ export default class Catcher {
272255
return;
273256
}
274257

275-
this.user = user;
258+
this.userManager.setUser(user);
276259
}
277260

278261
/**
279-
* Clear current user information (revert to generated user)
262+
* Clear current user information
280263
*/
281264
public clearUser(): void {
282-
this.user = Catcher.getGeneratedUser();
265+
this.userManager.clear();
283266
}
284267

285268
/**
@@ -565,10 +548,16 @@ export default class Catcher {
565548
}
566549

567550
/**
568-
* Current authenticated user
551+
* Returns the current user if set, otherwise generates and persists an anonymous ID.
569552
*/
570-
private getUser(): HawkJavaScriptEvent['user'] {
571-
return this.user || null;
553+
private getUser(): AffectedUser {
554+
const user = this.userManager.getUser();
555+
if (user) {
556+
return user;
557+
}
558+
const generatedId = id();
559+
this.userManager.persistGeneratedId(generatedId);
560+
return { id: generatedId };
572561
}
573562

574563
/**

packages/javascript/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default defineConfig(() => {
2626
fileName: 'hawk',
2727
},
2828
rollupOptions: {
29+
external: ['@hawk.so/core'],
2930
plugins: [
3031
license({
3132
thirdParty: {

0 commit comments

Comments
 (0)