Skip to content

Commit 3f9a33a

Browse files
committed
refactor(core): UserManager abstraction added
1 parent 39d9d88 commit 3f9a33a

13 files changed

Lines changed: 347 additions & 40 deletions

packages/core/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
}
1818
},
1919
"scripts": {
20-
"build": "vite build"
20+
"build": "vite build",
21+
"test": "vitest run",
22+
"test:coverage": "vitest run --coverage"
2123
},
2224
"repository": {
2325
"type": "git",
@@ -34,7 +36,9 @@
3436
},
3537
"homepage": "https://github.com/codex-team/hawk.javascript#readme",
3638
"devDependencies": {
39+
"@vitest/coverage-v8": "^4.0.18",
3740
"vite": "^7.3.1",
38-
"vite-plugin-dts": "^4.2.4"
41+
"vite-plugin-dts": "^4.2.4",
42+
"vitest": "^4.0.18"
3943
}
4044
}

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export type { HawkStorage } from './types/storage';
2+
export type { UserManager } from './types/user-manager';
3+
export { StorageUserManager } from './types/storage-user-manager';
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { AffectedUser } from "@hawk.so/types";
2+
import { HawkStorage } from "./storage";
3+
import { UserManager } from "./user-manager";
4+
5+
/**
6+
* Storage key used to persist the user identifier.
7+
*/
8+
const HAWK_USER_STORAGE_KEY = 'hawk-user-id';
9+
10+
/**
11+
* {@link UserManager} implementation that persists the affected user
12+
* via an injected {@link HawkStorage} backend.
13+
*/
14+
export class StorageUserManager implements UserManager {
15+
16+
/**
17+
* Underlying storage used to read and write the user identifier.
18+
*/
19+
private readonly storage: HawkStorage;
20+
21+
/**
22+
* @param storage - Storage backend to use for persistence.
23+
*/
24+
constructor(storage: HawkStorage) {
25+
this.storage = storage;
26+
}
27+
28+
getUser(): AffectedUser | null {
29+
const storedId = this.storage.getItem(HAWK_USER_STORAGE_KEY);
30+
if (storedId) {
31+
return {
32+
id: storedId,
33+
};
34+
}
35+
36+
return null;
37+
}
38+
39+
setUser(user: AffectedUser): void {
40+
this.storage.setItem(HAWK_USER_STORAGE_KEY, user.id);
41+
}
42+
43+
clear(): void {
44+
this.storage.removeItem(HAWK_USER_STORAGE_KEY)
45+
}
46+
}

packages/core/src/types/storage.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Abstract key–value storage contract used by Hawk internals
3+
* (e.g. {@link StorageUserManager}) to persist data across sessions.
4+
*/
5+
export interface HawkStorage {
6+
/**
7+
* Returns the value associated with the given key, or `null` if none exists.
8+
*
9+
* @param key - Storage key to look up.
10+
*/
11+
getItem(key: string): string | null
12+
13+
/**
14+
* Persists a value under the given key.
15+
*
16+
* @param key - Storage key.
17+
* @param value - Value to store.
18+
*/
19+
setItem(key: string, value: string): void
20+
21+
/**
22+
* Removes the entry for the given key.
23+
*
24+
* @param key - Storage key to remove.
25+
*/
26+
removeItem(key: string): void
27+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { AffectedUser } from "@hawk.so/types";
2+
3+
/**
4+
* Contract for user identity managers.
5+
*
6+
* Implementations are responsible for persisting and retrieving the
7+
* {@link AffectedUser} that is attached to every error report sent by the catcher.
8+
*/
9+
export interface UserManager {
10+
/**
11+
* Returns the current affected user, or `null` if none has been set.
12+
*/
13+
getUser(): AffectedUser | null
14+
15+
/**
16+
* Replaces the stored user with the provided one.
17+
*
18+
* @param user - The affected user to persist.
19+
*/
20+
setUser(user: AffectedUser): void
21+
22+
/**
23+
* Removes any previously stored user data.
24+
*/
25+
clear(): void
26+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { StorageUserManager } from '../src';
3+
import type { HawkStorage } from '../src';
4+
5+
class InMemoryStorage implements HawkStorage {
6+
private data = new Map<string, string>();
7+
8+
public getItem(key: string): string | null {
9+
return this.data.get(key) ?? null;
10+
}
11+
12+
public setItem(key: string, value: string): void {
13+
this.data.set(key, value);
14+
}
15+
16+
public removeItem(key: string): void {
17+
this.data.delete(key);
18+
}
19+
}
20+
21+
describe('StorageUserManager', () => {
22+
let storage: InMemoryStorage;
23+
let manager: StorageUserManager;
24+
25+
beforeEach(() => {
26+
storage = new InMemoryStorage();
27+
manager = new StorageUserManager(storage);
28+
});
29+
30+
it('should return null when storage is empty', () => {
31+
expect(manager.getUser()).toBeNull();
32+
});
33+
34+
it('should return user when ID exists in storage', () => {
35+
storage.setItem('hawk-user-id', 'test-user-123');
36+
37+
expect(manager.getUser()).toEqual({id: 'test-user-123'});
38+
});
39+
40+
it('should persist user ID via setUser()', () => {
41+
manager.setUser({id: 'user-abc'});
42+
43+
expect(storage.getItem('hawk-user-id')).toBe('user-abc');
44+
expect(manager.getUser()).toEqual({id: 'user-abc'});
45+
});
46+
47+
it('should remove user ID via clear()', () => {
48+
manager.setUser({id: 'user-to-clear'});
49+
manager.clear();
50+
51+
expect(manager.getUser()).toBeNull();
52+
expect(storage.getItem('hawk-user-id')).toBeNull();
53+
});
54+
});

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/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
},
4040
"homepage": "https://github.com/codex-team/hawk.javascript#readme",
4141
"dependencies": {
42+
"@hawk.so/core": "workspace:^",
4243
"error-stack-parser": "^2.1.4"
4344
},
4445
"devDependencies": {

packages/javascript/src/catcher.ts

Lines changed: 24 additions & 35 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 { StorageUserManager, UserManager } from "@hawk.so/core";
22+
import { HawkLocalStorage } from "./modules/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: UserManager = new StorageUserManager(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
/**
@@ -533,7 +516,7 @@ export default class Catcher {
533516
private getIntegrationId(): string {
534517
try {
535518
const decodedIntegrationToken: DecodedIntegrationToken = JSON.parse(atob(this.token));
536-
const { integrationId } = decodedIntegrationToken;
519+
const {integrationId} = decodedIntegrationToken;
537520

538521
if (!integrationId || integrationId === '') {
539522
throw new Error();
@@ -565,10 +548,16 @@ export default class Catcher {
565548
}
566549

567550
/**
568-
* Current authenticated user
551+
* Returns the current user, creating and persisting a new one if none exists.
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 newUser: AffectedUser = { id: id() };
559+
this.userManager.setUser(newUser);
560+
return newUser;
572561
}
573562

574563
/**

0 commit comments

Comments
 (0)