@nextsparkjs/plugin-amplitude 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CODE_REVIEW_REPORT.md +462 -0
- package/README.md +619 -0
- package/__tests__/amplitude-core.test.ts +279 -0
- package/__tests__/hooks.test.ts +478 -0
- package/__tests__/validation.test.ts +393 -0
- package/components/AnalyticsDashboard.tsx +339 -0
- package/components/ConsentManager.tsx +265 -0
- package/components/ExperimentWrapper.tsx +440 -0
- package/components/PerformanceMonitor.tsx +578 -0
- package/hooks/useAmplitude.ts +132 -0
- package/hooks/useAmplitudeEvents.ts +100 -0
- package/hooks/useExperiment.ts +195 -0
- package/hooks/useSessionReplay.ts +238 -0
- package/jest.setup.ts +276 -0
- package/lib/amplitude-core.ts +178 -0
- package/lib/cache.ts +181 -0
- package/lib/performance.ts +319 -0
- package/lib/queue.ts +389 -0
- package/lib/security.ts +188 -0
- package/package.json +15 -0
- package/plugin.config.ts +58 -0
- package/providers/AmplitudeProvider.tsx +113 -0
- package/styles/amplitude.css +593 -0
- package/translations/en.json +45 -0
- package/translations/es.json +45 -0
- package/tsconfig.json +47 -0
- package/types/amplitude.types.ts +105 -0
- package/utils/debounce.ts +133 -0
package/jest.setup.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jest setup file for Amplitude plugin tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import '@testing-library/jest-dom';
|
|
6
|
+
|
|
7
|
+
// Mock window and document objects
|
|
8
|
+
Object.defineProperty(window, 'location', {
|
|
9
|
+
value: {
|
|
10
|
+
href: 'https://example.com',
|
|
11
|
+
pathname: '/',
|
|
12
|
+
search: '',
|
|
13
|
+
hash: '',
|
|
14
|
+
origin: 'https://example.com',
|
|
15
|
+
protocol: 'https:',
|
|
16
|
+
host: 'example.com',
|
|
17
|
+
hostname: 'example.com',
|
|
18
|
+
port: '',
|
|
19
|
+
assign: jest.fn(),
|
|
20
|
+
replace: jest.fn(),
|
|
21
|
+
reload: jest.fn(),
|
|
22
|
+
},
|
|
23
|
+
writable: true,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
Object.defineProperty(window, 'navigator', {
|
|
27
|
+
value: {
|
|
28
|
+
userAgent: 'Mozilla/5.0 (compatible; Test)',
|
|
29
|
+
language: 'en-US',
|
|
30
|
+
onLine: true,
|
|
31
|
+
},
|
|
32
|
+
writable: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
Object.defineProperty(window, 'screen', {
|
|
36
|
+
value: {
|
|
37
|
+
width: 1920,
|
|
38
|
+
height: 1080,
|
|
39
|
+
},
|
|
40
|
+
writable: true,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
44
|
+
value: 1200,
|
|
45
|
+
writable: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
Object.defineProperty(window, 'innerHeight', {
|
|
49
|
+
value: 800,
|
|
50
|
+
writable: true,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
Object.defineProperty(window, 'scrollX', {
|
|
54
|
+
value: 0,
|
|
55
|
+
writable: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
Object.defineProperty(window, 'scrollY', {
|
|
59
|
+
value: 0,
|
|
60
|
+
writable: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
Object.defineProperty(window, 'devicePixelRatio', {
|
|
64
|
+
value: 2,
|
|
65
|
+
writable: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Mock performance API
|
|
69
|
+
Object.defineProperty(window, 'performance', {
|
|
70
|
+
value: {
|
|
71
|
+
now: jest.fn(() => Date.now()),
|
|
72
|
+
getEntriesByType: jest.fn(() => []),
|
|
73
|
+
getEntriesByName: jest.fn(() => []),
|
|
74
|
+
mark: jest.fn(),
|
|
75
|
+
measure: jest.fn(),
|
|
76
|
+
memory: {
|
|
77
|
+
usedJSHeapSize: 1024 * 1024,
|
|
78
|
+
totalJSHeapSize: 2 * 1024 * 1024,
|
|
79
|
+
jsHeapSizeLimit: 4 * 1024 * 1024,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
writable: true,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Mock document methods
|
|
86
|
+
Object.defineProperty(document, 'title', {
|
|
87
|
+
value: 'Test Page',
|
|
88
|
+
writable: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
Object.defineProperty(document, 'referrer', {
|
|
92
|
+
value: 'https://example.com',
|
|
93
|
+
writable: true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
Object.defineProperty(document, 'hidden', {
|
|
97
|
+
value: false,
|
|
98
|
+
writable: true,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
Object.defineProperty(document, 'visibilityState', {
|
|
102
|
+
value: 'visible',
|
|
103
|
+
writable: true,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Mock localStorage
|
|
107
|
+
const localStorageMock = {
|
|
108
|
+
getItem: jest.fn(),
|
|
109
|
+
setItem: jest.fn(),
|
|
110
|
+
removeItem: jest.fn(),
|
|
111
|
+
clear: jest.fn(),
|
|
112
|
+
length: 0,
|
|
113
|
+
key: jest.fn(),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
Object.defineProperty(window, 'localStorage', {
|
|
117
|
+
value: localStorageMock,
|
|
118
|
+
writable: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Mock sessionStorage
|
|
122
|
+
const sessionStorageMock = {
|
|
123
|
+
getItem: jest.fn(),
|
|
124
|
+
setItem: jest.fn(),
|
|
125
|
+
removeItem: jest.fn(),
|
|
126
|
+
clear: jest.fn(),
|
|
127
|
+
length: 0,
|
|
128
|
+
key: jest.fn(),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
Object.defineProperty(window, 'sessionStorage', {
|
|
132
|
+
value: sessionStorageMock,
|
|
133
|
+
writable: true,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Mock MutationObserver
|
|
137
|
+
class MockMutationObserver {
|
|
138
|
+
constructor(callback: MutationCallback) {
|
|
139
|
+
this.callback = callback;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private callback: MutationCallback;
|
|
143
|
+
|
|
144
|
+
observe = jest.fn();
|
|
145
|
+
disconnect = jest.fn();
|
|
146
|
+
takeRecords = jest.fn(() => []);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
Object.defineProperty(window, 'MutationObserver', {
|
|
150
|
+
value: MockMutationObserver,
|
|
151
|
+
writable: true,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Mock IntersectionObserver
|
|
155
|
+
class MockIntersectionObserver {
|
|
156
|
+
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
|
157
|
+
this.callback = callback;
|
|
158
|
+
this.options = options;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private callback: IntersectionObserverCallback;
|
|
162
|
+
private options?: IntersectionObserverInit;
|
|
163
|
+
|
|
164
|
+
observe = jest.fn();
|
|
165
|
+
unobserve = jest.fn();
|
|
166
|
+
disconnect = jest.fn();
|
|
167
|
+
takeRecords = jest.fn(() => []);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
Object.defineProperty(window, 'IntersectionObserver', {
|
|
171
|
+
value: MockIntersectionObserver,
|
|
172
|
+
writable: true,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Mock ResizeObserver
|
|
176
|
+
class MockResizeObserver {
|
|
177
|
+
constructor(callback: ResizeObserverCallback) {
|
|
178
|
+
this.callback = callback;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private callback: ResizeObserverCallback;
|
|
182
|
+
|
|
183
|
+
observe = jest.fn();
|
|
184
|
+
unobserve = jest.fn();
|
|
185
|
+
disconnect = jest.fn();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
Object.defineProperty(window, 'ResizeObserver', {
|
|
189
|
+
value: MockResizeObserver,
|
|
190
|
+
writable: true,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Mock setTimeout and setInterval for consistent testing
|
|
194
|
+
global.setTimeout = jest.fn((fn, delay) => {
|
|
195
|
+
if (typeof fn === 'function') {
|
|
196
|
+
return setTimeout(fn, delay);
|
|
197
|
+
}
|
|
198
|
+
return setTimeout(fn, delay);
|
|
199
|
+
}) as any;
|
|
200
|
+
|
|
201
|
+
global.setInterval = jest.fn((fn, delay) => {
|
|
202
|
+
if (typeof fn === 'function') {
|
|
203
|
+
return setInterval(fn, delay);
|
|
204
|
+
}
|
|
205
|
+
return setInterval(fn, delay);
|
|
206
|
+
}) as any;
|
|
207
|
+
|
|
208
|
+
global.clearTimeout = jest.fn(clearTimeout);
|
|
209
|
+
global.clearInterval = jest.fn(clearInterval);
|
|
210
|
+
|
|
211
|
+
// Mock console methods to reduce noise in tests
|
|
212
|
+
const originalConsole = global.console;
|
|
213
|
+
global.console = {
|
|
214
|
+
...originalConsole,
|
|
215
|
+
log: jest.fn(),
|
|
216
|
+
warn: jest.fn(),
|
|
217
|
+
error: jest.fn(),
|
|
218
|
+
info: jest.fn(),
|
|
219
|
+
debug: jest.fn(),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Mock fetch for API calls
|
|
223
|
+
global.fetch = jest.fn(() =>
|
|
224
|
+
Promise.resolve({
|
|
225
|
+
ok: true,
|
|
226
|
+
status: 200,
|
|
227
|
+
json: () => Promise.resolve({}),
|
|
228
|
+
text: () => Promise.resolve(''),
|
|
229
|
+
blob: () => Promise.resolve(new Blob()),
|
|
230
|
+
})
|
|
231
|
+
) as jest.Mock;
|
|
232
|
+
|
|
233
|
+
// Mock crypto for UUID generation
|
|
234
|
+
Object.defineProperty(global, 'crypto', {
|
|
235
|
+
value: {
|
|
236
|
+
randomUUID: jest.fn(() => 'test-uuid-1234-5678'),
|
|
237
|
+
getRandomValues: jest.fn((arr) => {
|
|
238
|
+
for (let i = 0; i < arr.length; i++) {
|
|
239
|
+
arr[i] = Math.floor(Math.random() * 256);
|
|
240
|
+
}
|
|
241
|
+
return arr;
|
|
242
|
+
}),
|
|
243
|
+
},
|
|
244
|
+
writable: true,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Error boundary for React components
|
|
248
|
+
const originalError = console.error;
|
|
249
|
+
beforeAll(() => {
|
|
250
|
+
console.error = (...args) => {
|
|
251
|
+
if (
|
|
252
|
+
typeof args[0] === 'string' &&
|
|
253
|
+
args[0].includes('Warning: ReactDOM.render is no longer supported')
|
|
254
|
+
) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
originalError.call(console, ...args);
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
afterAll(() => {
|
|
262
|
+
console.error = originalError;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Clean up after each test
|
|
266
|
+
afterEach(() => {
|
|
267
|
+
jest.clearAllMocks();
|
|
268
|
+
localStorageMock.getItem.mockClear();
|
|
269
|
+
localStorageMock.setItem.mockClear();
|
|
270
|
+
localStorageMock.removeItem.mockClear();
|
|
271
|
+
localStorageMock.clear.mockClear();
|
|
272
|
+
sessionStorageMock.getItem.mockClear();
|
|
273
|
+
sessionStorageMock.setItem.mockClear();
|
|
274
|
+
sessionStorageMock.removeItem.mockClear();
|
|
275
|
+
sessionStorageMock.clear.mockClear();
|
|
276
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { AmplitudeAPIKey, AmplitudePluginConfig, EventProperties, EventType, UserProperties, UserId } from '../types/amplitude.types';
|
|
2
|
+
import { trackPerformanceMetric, getPerformanceMetrics } from './performance';
|
|
3
|
+
import { EventQueue } from './queue';
|
|
4
|
+
import { DataSanitizer } from './security';
|
|
5
|
+
|
|
6
|
+
class AmplitudeCoreWrapper {
|
|
7
|
+
private initialized = false;
|
|
8
|
+
private config: AmplitudePluginConfig | null = null;
|
|
9
|
+
private eventQueue: EventQueue;
|
|
10
|
+
private healthCheckInterval: NodeJS.Timeout | null = null;
|
|
11
|
+
|
|
12
|
+
constructor() {
|
|
13
|
+
this.eventQueue = new EventQueue(this.sendEventsBatch.bind(this));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public async init(apiKey: AmplitudeAPIKey, config: AmplitudePluginConfig): Promise<void> {
|
|
17
|
+
try {
|
|
18
|
+
// Check for double initialization
|
|
19
|
+
if (this.initialized) {
|
|
20
|
+
throw new Error('Amplitude is already initialized');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Validate API key (must be at least 32 characters)
|
|
24
|
+
if (!apiKey || apiKey.length < 32) {
|
|
25
|
+
throw new Error('Invalid API key: Must be at least 32 characters');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.config = config;
|
|
29
|
+
|
|
30
|
+
// Initialize Amplitude SDK (mock for now)
|
|
31
|
+
console.log(`[Amplitude Core] Initializing with API key: ${apiKey.substring(0, 8)}...`);
|
|
32
|
+
|
|
33
|
+
// Simulate async initialization
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
35
|
+
|
|
36
|
+
this.initialized = true;
|
|
37
|
+
this.startHealthChecks();
|
|
38
|
+
|
|
39
|
+
trackPerformanceMetric('amplitude_init_success', 1, 'counter');
|
|
40
|
+
console.log('[Amplitude Core] Successfully initialized');
|
|
41
|
+
} catch (error) {
|
|
42
|
+
trackPerformanceMetric('amplitude_init_error', 1, 'counter');
|
|
43
|
+
throw new Error(`Failed to initialize Amplitude: ${error instanceof Error ? error.message : String(error)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public async track(eventType: EventType, properties?: EventProperties): Promise<void> {
|
|
48
|
+
if (!this.initialized) {
|
|
49
|
+
throw new Error('Amplitude not initialized');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const startTime = Date.now();
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Sanitize properties
|
|
56
|
+
const sanitizedProperties = this.config?.piiMaskingEnabled
|
|
57
|
+
? DataSanitizer.sanitizeEventProperties(properties, [])
|
|
58
|
+
: properties;
|
|
59
|
+
|
|
60
|
+
// Add to queue for batch processing
|
|
61
|
+
await this.eventQueue.enqueue(eventType, sanitizedProperties);
|
|
62
|
+
|
|
63
|
+
const latency = Date.now() - startTime;
|
|
64
|
+
trackPerformanceMetric('amplitude_track_latency', latency, 'timing');
|
|
65
|
+
} catch (error) {
|
|
66
|
+
trackPerformanceMetric('amplitude_track_error', 1, 'counter');
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async sendEventsBatch(events: Array<{ eventType: EventType; properties?: EventProperties; timestamp?: number }>): Promise<void> {
|
|
72
|
+
if (!this.initialized || events.length === 0) return;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
// Mock sending to Amplitude API
|
|
76
|
+
console.log(`[Amplitude Core] Sending batch of ${events.length} events`);
|
|
77
|
+
|
|
78
|
+
// Simulate API call
|
|
79
|
+
await new Promise(resolve => setTimeout(resolve, Math.random() * 100 + 50));
|
|
80
|
+
|
|
81
|
+
trackPerformanceMetric('amplitude_batch_sent', events.length, 'counter');
|
|
82
|
+
} catch (error) {
|
|
83
|
+
trackPerformanceMetric('amplitude_batch_error', 1, 'counter');
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public async identify(userId: UserId, properties?: UserProperties): Promise<void> {
|
|
89
|
+
if (!this.initialized) {
|
|
90
|
+
throw new Error('Amplitude not initialized');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// Sanitize user properties
|
|
95
|
+
const sanitizedProperties = this.config?.piiMaskingEnabled
|
|
96
|
+
? DataSanitizer.sanitizeUserProperties(properties, [])
|
|
97
|
+
: properties;
|
|
98
|
+
|
|
99
|
+
// Mock identify call
|
|
100
|
+
console.log(`[Amplitude Core] Identifying user: ${userId}`);
|
|
101
|
+
|
|
102
|
+
trackPerformanceMetric('amplitude_identify_success', 1, 'counter');
|
|
103
|
+
} catch (error) {
|
|
104
|
+
trackPerformanceMetric('amplitude_identify_error', 1, 'counter');
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public async setUserProperties(properties: UserProperties): Promise<void> {
|
|
110
|
+
if (!this.initialized) {
|
|
111
|
+
throw new Error('Amplitude not initialized');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// Sanitize user properties
|
|
116
|
+
const sanitizedProperties = this.config?.piiMaskingEnabled
|
|
117
|
+
? DataSanitizer.sanitizeUserProperties(properties, [])
|
|
118
|
+
: properties;
|
|
119
|
+
|
|
120
|
+
// Mock setUserProperties call
|
|
121
|
+
console.log('[Amplitude Core] Setting user properties');
|
|
122
|
+
|
|
123
|
+
trackPerformanceMetric('amplitude_user_properties_success', 1, 'counter');
|
|
124
|
+
} catch (error) {
|
|
125
|
+
trackPerformanceMetric('amplitude_user_properties_error', 1, 'counter');
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public reset(): void {
|
|
131
|
+
if (!this.initialized) {
|
|
132
|
+
throw new Error('Amplitude not initialized');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
// Mock reset call
|
|
137
|
+
console.log('[Amplitude Core] Resetting user session');
|
|
138
|
+
|
|
139
|
+
trackPerformanceMetric('amplitude_reset', 1, 'counter');
|
|
140
|
+
} catch (error) {
|
|
141
|
+
trackPerformanceMetric('amplitude_reset_error', 1, 'counter');
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public isInitialized(): boolean {
|
|
147
|
+
return this.initialized;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public shutdown(): void {
|
|
151
|
+
if (this.healthCheckInterval) {
|
|
152
|
+
clearInterval(this.healthCheckInterval);
|
|
153
|
+
this.healthCheckInterval = null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.eventQueue.shutdown();
|
|
157
|
+
this.initialized = false;
|
|
158
|
+
|
|
159
|
+
console.log('[Amplitude Core] Shutdown complete');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private startHealthChecks(): void {
|
|
163
|
+
this.healthCheckInterval = setInterval(() => {
|
|
164
|
+
const metrics = getPerformanceMetrics();
|
|
165
|
+
const memoryUsage = (performance as any).memory?.usedJSHeapSize || 0;
|
|
166
|
+
|
|
167
|
+
trackPerformanceMetric('amplitude_memory_usage', memoryUsage, 'gauge');
|
|
168
|
+
trackPerformanceMetric('amplitude_health_check', 1, 'counter');
|
|
169
|
+
|
|
170
|
+
// Check for performance issues
|
|
171
|
+
if (memoryUsage > 50 * 1024 * 1024) { // 50MB
|
|
172
|
+
console.warn('[Amplitude Core] High memory usage detected:', memoryUsage);
|
|
173
|
+
}
|
|
174
|
+
}, 30000); // Every 30 seconds
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const AmplitudeCore = new AmplitudeCoreWrapper();
|
package/lib/cache.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
interface CacheEntry<T> {
|
|
2
|
+
value: T;
|
|
3
|
+
timestamp: number;
|
|
4
|
+
ttl: number;
|
|
5
|
+
hits: number;
|
|
6
|
+
lastAccessed: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class LRUCache<K, V> {
|
|
10
|
+
private cache: Map<K, CacheEntry<V>> = new Map();
|
|
11
|
+
private maxSize: number;
|
|
12
|
+
private defaultTtl: number;
|
|
13
|
+
private hitCount = 0;
|
|
14
|
+
private missCount = 0;
|
|
15
|
+
|
|
16
|
+
constructor(maxSize: number = 1000, defaultTtl: number = 300000) { // 5 minutes default TTL
|
|
17
|
+
this.maxSize = maxSize;
|
|
18
|
+
this.defaultTtl = defaultTtl;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public get(key: K): V | undefined {
|
|
22
|
+
const entry = this.cache.get(key);
|
|
23
|
+
|
|
24
|
+
if (!entry) {
|
|
25
|
+
this.missCount++;
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check if expired
|
|
30
|
+
if (Date.now() > entry.timestamp + entry.ttl) {
|
|
31
|
+
this.cache.delete(key);
|
|
32
|
+
this.missCount++;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Update access stats
|
|
37
|
+
entry.hits++;
|
|
38
|
+
entry.lastAccessed = Date.now();
|
|
39
|
+
this.hitCount++;
|
|
40
|
+
|
|
41
|
+
// Move to end (most recently used)
|
|
42
|
+
this.cache.delete(key);
|
|
43
|
+
this.cache.set(key, entry);
|
|
44
|
+
|
|
45
|
+
return entry.value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public set(key: K, value: V, ttl?: number): void {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const entryTtl = ttl || this.defaultTtl;
|
|
51
|
+
|
|
52
|
+
// Remove existing entry if it exists
|
|
53
|
+
if (this.cache.has(key)) {
|
|
54
|
+
this.cache.delete(key);
|
|
55
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
56
|
+
// Remove least recently used item
|
|
57
|
+
this.evictLRU();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Add new entry
|
|
61
|
+
this.cache.set(key, {
|
|
62
|
+
value,
|
|
63
|
+
timestamp: now,
|
|
64
|
+
ttl: entryTtl,
|
|
65
|
+
hits: 0,
|
|
66
|
+
lastAccessed: now,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public delete(key: K): boolean {
|
|
71
|
+
return this.cache.delete(key);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public has(key: K): boolean {
|
|
75
|
+
const entry = this.cache.get(key);
|
|
76
|
+
if (!entry) return false;
|
|
77
|
+
|
|
78
|
+
// Check if expired
|
|
79
|
+
if (Date.now() > entry.timestamp + entry.ttl) {
|
|
80
|
+
this.cache.delete(key);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public clear(): void {
|
|
88
|
+
this.cache.clear();
|
|
89
|
+
this.hitCount = 0;
|
|
90
|
+
this.missCount = 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public size(): number {
|
|
94
|
+
this.cleanupExpired();
|
|
95
|
+
return this.cache.size;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public getStats(): {
|
|
99
|
+
size: number;
|
|
100
|
+
maxSize: number;
|
|
101
|
+
hitRate: number;
|
|
102
|
+
missRate: number;
|
|
103
|
+
totalHits: number;
|
|
104
|
+
totalMisses: number;
|
|
105
|
+
} {
|
|
106
|
+
this.cleanupExpired();
|
|
107
|
+
const total = this.hitCount + this.missCount;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
size: this.cache.size,
|
|
111
|
+
maxSize: this.maxSize,
|
|
112
|
+
hitRate: total > 0 ? this.hitCount / total : 0,
|
|
113
|
+
missRate: total > 0 ? this.missCount / total : 0,
|
|
114
|
+
totalHits: this.hitCount,
|
|
115
|
+
totalMisses: this.missCount,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public getEntryStats(key: K): {
|
|
120
|
+
hits: number;
|
|
121
|
+
age: number;
|
|
122
|
+
ttl: number;
|
|
123
|
+
lastAccessed: number;
|
|
124
|
+
} | null {
|
|
125
|
+
const entry = this.cache.get(key);
|
|
126
|
+
if (!entry) return null;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
hits: entry.hits,
|
|
130
|
+
age: Date.now() - entry.timestamp,
|
|
131
|
+
ttl: entry.ttl,
|
|
132
|
+
lastAccessed: entry.lastAccessed,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private evictLRU(): void {
|
|
137
|
+
// Find the least recently used entry
|
|
138
|
+
let lruKey: K | null = null;
|
|
139
|
+
let oldestAccess = Date.now();
|
|
140
|
+
|
|
141
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
142
|
+
if (entry.lastAccessed < oldestAccess) {
|
|
143
|
+
oldestAccess = entry.lastAccessed;
|
|
144
|
+
lruKey = key;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (lruKey !== null) {
|
|
149
|
+
this.cache.delete(lruKey);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private cleanupExpired(): void {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const expiredKeys: K[] = [];
|
|
156
|
+
|
|
157
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
158
|
+
if (now > entry.timestamp + entry.ttl) {
|
|
159
|
+
expiredKeys.push(key);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
expiredKeys.forEach(key => this.cache.delete(key));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
public keys(): K[] {
|
|
167
|
+
this.cleanupExpired();
|
|
168
|
+
return Array.from(this.cache.keys());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public values(): V[] {
|
|
172
|
+
this.cleanupExpired();
|
|
173
|
+
return Array.from(this.cache.values()).map(entry => entry.value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public entries(): [K, V][] {
|
|
177
|
+
this.cleanupExpired();
|
|
178
|
+
return Array.from(this.cache.entries()).map(([key, entry]) => [key, entry.value]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|