@mostly-good-metrics/javascript 0.1.0

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.
Files changed (54) hide show
  1. package/README.md +319 -0
  2. package/dist/cjs/client.js +416 -0
  3. package/dist/cjs/client.js.map +1 -0
  4. package/dist/cjs/index.js +65 -0
  5. package/dist/cjs/index.js.map +1 -0
  6. package/dist/cjs/logger.js +64 -0
  7. package/dist/cjs/logger.js.map +1 -0
  8. package/dist/cjs/network.js +192 -0
  9. package/dist/cjs/network.js.map +1 -0
  10. package/dist/cjs/storage.js +227 -0
  11. package/dist/cjs/storage.js.map +1 -0
  12. package/dist/cjs/types.js +70 -0
  13. package/dist/cjs/types.js.map +1 -0
  14. package/dist/cjs/utils.js +249 -0
  15. package/dist/cjs/utils.js.map +1 -0
  16. package/dist/esm/client.js +412 -0
  17. package/dist/esm/client.js.map +1 -0
  18. package/dist/esm/index.js +40 -0
  19. package/dist/esm/index.js.map +1 -0
  20. package/dist/esm/logger.js +55 -0
  21. package/dist/esm/logger.js.map +1 -0
  22. package/dist/esm/network.js +187 -0
  23. package/dist/esm/network.js.map +1 -0
  24. package/dist/esm/storage.js +221 -0
  25. package/dist/esm/storage.js.map +1 -0
  26. package/dist/esm/types.js +66 -0
  27. package/dist/esm/types.js.map +1 -0
  28. package/dist/esm/utils.js +236 -0
  29. package/dist/esm/utils.js.map +1 -0
  30. package/dist/types/client.d.ts +126 -0
  31. package/dist/types/client.d.ts.map +1 -0
  32. package/dist/types/index.d.ts +34 -0
  33. package/dist/types/index.d.ts.map +1 -0
  34. package/dist/types/logger.d.ts +37 -0
  35. package/dist/types/logger.d.ts.map +1 -0
  36. package/dist/types/network.d.ts +28 -0
  37. package/dist/types/network.d.ts.map +1 -0
  38. package/dist/types/storage.d.ts +76 -0
  39. package/dist/types/storage.d.ts.map +1 -0
  40. package/dist/types/types.d.ts +279 -0
  41. package/dist/types/types.d.ts.map +1 -0
  42. package/dist/types/utils.d.ts +48 -0
  43. package/dist/types/utils.d.ts.map +1 -0
  44. package/package.json +68 -0
  45. package/src/client.test.ts +346 -0
  46. package/src/client.ts +510 -0
  47. package/src/index.ts +79 -0
  48. package/src/logger.ts +63 -0
  49. package/src/network.ts +230 -0
  50. package/src/storage.test.ts +175 -0
  51. package/src/storage.ts +249 -0
  52. package/src/types.ts +347 -0
  53. package/src/utils.test.ts +239 -0
  54. package/src/utils.ts +315 -0
package/src/utils.ts ADDED
@@ -0,0 +1,315 @@
1
+ import { logger } from './logger';
2
+ import {
3
+ Constraints,
4
+ DefaultConfiguration,
5
+ DeviceType,
6
+ EVENT_NAME_REGEX,
7
+ EventProperties,
8
+ EventPropertyValue,
9
+ MGMConfiguration,
10
+ MGMError,
11
+ Platform,
12
+ ResolvedConfiguration,
13
+ } from './types';
14
+
15
+ /**
16
+ * Generate a UUID v4 string.
17
+ */
18
+ export function generateUUID(): string {
19
+ // Use crypto.randomUUID if available (modern browsers and Node.js 19+)
20
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
21
+ return crypto.randomUUID();
22
+ }
23
+
24
+ // Fallback implementation
25
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
26
+ const r = (Math.random() * 16) | 0;
27
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
28
+ return v.toString(16);
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Get the current timestamp in ISO8601 format.
34
+ */
35
+ export function getISOTimestamp(): string {
36
+ return new Date().toISOString();
37
+ }
38
+
39
+ /**
40
+ * Validate an event name.
41
+ * Must match pattern: ^$?[a-zA-Z][a-zA-Z0-9_]*$
42
+ * Max 255 characters.
43
+ */
44
+ export function isValidEventName(name: string): boolean {
45
+ if (!name || name.length > Constraints.MAX_EVENT_NAME_LENGTH) {
46
+ return false;
47
+ }
48
+ return EVENT_NAME_REGEX.test(name);
49
+ }
50
+
51
+ /**
52
+ * Validate an event name and throw if invalid.
53
+ */
54
+ export function validateEventName(name: string): void {
55
+ if (!name) {
56
+ throw new MGMError('INVALID_EVENT_NAME', 'Event name is required');
57
+ }
58
+
59
+ if (name.length > Constraints.MAX_EVENT_NAME_LENGTH) {
60
+ throw new MGMError(
61
+ 'INVALID_EVENT_NAME',
62
+ `Event name must be ${Constraints.MAX_EVENT_NAME_LENGTH} characters or less`
63
+ );
64
+ }
65
+
66
+ if (!EVENT_NAME_REGEX.test(name)) {
67
+ throw new MGMError(
68
+ 'INVALID_EVENT_NAME',
69
+ 'Event name must start with a letter (or $ for system events) and contain only alphanumeric characters and underscores'
70
+ );
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Sanitize event properties by truncating strings and limiting depth.
76
+ */
77
+ export function sanitizeProperties(
78
+ properties: EventProperties | undefined,
79
+ maxDepth: number = Constraints.MAX_PROPERTY_DEPTH
80
+ ): EventProperties | undefined {
81
+ if (!properties || typeof properties !== 'object') {
82
+ return undefined;
83
+ }
84
+
85
+ const sanitized = sanitizeValue(properties, 0, maxDepth);
86
+ if (typeof sanitized === 'object' && sanitized !== null && !Array.isArray(sanitized)) {
87
+ return sanitized as EventProperties;
88
+ }
89
+
90
+ return undefined;
91
+ }
92
+
93
+ /**
94
+ * Recursively sanitize a property value.
95
+ */
96
+ function sanitizeValue(
97
+ value: EventPropertyValue,
98
+ depth: number,
99
+ maxDepth: number
100
+ ): EventPropertyValue {
101
+ // Null is valid
102
+ if (value === null) {
103
+ return null;
104
+ }
105
+
106
+ // Primitives
107
+ if (typeof value === 'boolean' || typeof value === 'number') {
108
+ return value;
109
+ }
110
+
111
+ // Strings - truncate if needed
112
+ if (typeof value === 'string') {
113
+ if (value.length > Constraints.MAX_STRING_PROPERTY_LENGTH) {
114
+ logger.debug(
115
+ `Truncating string property from ${value.length} to ${Constraints.MAX_STRING_PROPERTY_LENGTH} characters`
116
+ );
117
+ return value.substring(0, Constraints.MAX_STRING_PROPERTY_LENGTH);
118
+ }
119
+ return value;
120
+ }
121
+
122
+ // Arrays
123
+ if (Array.isArray(value)) {
124
+ if (depth >= maxDepth) {
125
+ logger.debug(`Max property depth reached, omitting nested array`);
126
+ return null;
127
+ }
128
+ return value.map((item) => sanitizeValue(item, depth + 1, maxDepth));
129
+ }
130
+
131
+ // Objects
132
+ if (typeof value === 'object') {
133
+ if (depth >= maxDepth) {
134
+ logger.debug(`Max property depth reached, omitting nested object`);
135
+ return null;
136
+ }
137
+
138
+ const result: Record<string, EventPropertyValue> = {};
139
+ for (const [key, val] of Object.entries(value)) {
140
+ result[key] = sanitizeValue(val, depth + 1, maxDepth);
141
+ }
142
+ return result;
143
+ }
144
+
145
+ // Unknown type - convert to null
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Resolve configuration with defaults.
151
+ */
152
+ export function resolveConfiguration(config: MGMConfiguration): ResolvedConfiguration {
153
+ const maxBatchSize = Math.min(
154
+ Math.max(config.maxBatchSize ?? DefaultConfiguration.maxBatchSize, Constraints.MIN_BATCH_SIZE),
155
+ Constraints.MAX_BATCH_SIZE
156
+ );
157
+
158
+ const flushInterval = Math.max(
159
+ config.flushInterval ?? DefaultConfiguration.flushInterval,
160
+ Constraints.MIN_FLUSH_INTERVAL
161
+ );
162
+
163
+ const maxStoredEvents = Math.max(
164
+ config.maxStoredEvents ?? DefaultConfiguration.maxStoredEvents,
165
+ Constraints.MIN_STORED_EVENTS
166
+ );
167
+
168
+ return {
169
+ apiKey: config.apiKey,
170
+ baseURL: config.baseURL ?? DefaultConfiguration.baseURL,
171
+ environment: config.environment ?? DefaultConfiguration.environment,
172
+ maxBatchSize,
173
+ flushInterval,
174
+ maxStoredEvents,
175
+ enableDebugLogging: config.enableDebugLogging ?? DefaultConfiguration.enableDebugLogging,
176
+ trackAppLifecycleEvents:
177
+ config.trackAppLifecycleEvents ?? DefaultConfiguration.trackAppLifecycleEvents,
178
+ bundleId: config.bundleId ?? detectBundleId(),
179
+ appVersion: config.appVersion ?? '',
180
+ osVersion: config.osVersion ?? '',
181
+ storage: config.storage,
182
+ networkClient: config.networkClient,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Detect the bundle ID from the current environment.
188
+ */
189
+ function detectBundleId(): string {
190
+ // In browser, use the hostname
191
+ if (typeof window !== 'undefined' && window.location) {
192
+ return window.location.hostname;
193
+ }
194
+
195
+ // In Node.js, could use package.json name but that requires fs access
196
+ return '';
197
+ }
198
+
199
+ /**
200
+ * Detect the current platform.
201
+ */
202
+ export function detectPlatform(): Platform {
203
+ // Check for React Native / Expo
204
+ if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
205
+ // Could check for Expo-specific globals here
206
+ return 'react-native';
207
+ }
208
+
209
+ // Check for Node.js
210
+ if (typeof process !== 'undefined' && process.versions?.node) {
211
+ return 'node';
212
+ }
213
+
214
+ // Default to web for browser environments
215
+ return 'web';
216
+ }
217
+
218
+ /**
219
+ * Detect the device type from user agent.
220
+ */
221
+ export function detectDeviceType(): DeviceType {
222
+ if (typeof navigator === 'undefined' || !navigator.userAgent) {
223
+ return 'unknown';
224
+ }
225
+
226
+ const ua = navigator.userAgent.toLowerCase();
227
+
228
+ // Check for specific device types
229
+ if (/tablet|ipad|playbook|silk/i.test(ua)) {
230
+ return 'tablet';
231
+ }
232
+
233
+ if (/mobile|iphone|ipod|android.*mobile|blackberry|opera mini|opera mobi/i.test(ua)) {
234
+ return 'phone';
235
+ }
236
+
237
+ if (/smart-tv|smarttv|googletv|appletv|hbbtv|pov_tv|netcast.tv/i.test(ua)) {
238
+ return 'tv';
239
+ }
240
+
241
+ // Default to desktop for other browsers
242
+ if (typeof window !== 'undefined') {
243
+ return 'desktop';
244
+ }
245
+
246
+ return 'unknown';
247
+ }
248
+
249
+ /**
250
+ * Get the OS version string.
251
+ */
252
+ export function getOSVersion(): string {
253
+ if (typeof navigator === 'undefined' || !navigator.userAgent) {
254
+ return '';
255
+ }
256
+
257
+ const ua = navigator.userAgent;
258
+
259
+ // Try to extract OS version from user agent
260
+ const patterns: [RegExp, string][] = [
261
+ [/Windows NT ([\d.]+)/i, 'Windows'],
262
+ [/Mac OS X ([\d_.]+)/i, 'macOS'],
263
+ [/iPhone OS ([\d_]+)/i, 'iOS'],
264
+ [/iPad.*OS ([\d_]+)/i, 'iPadOS'],
265
+ [/Android ([\d.]+)/i, 'Android'],
266
+ [/Linux/i, 'Linux'],
267
+ ];
268
+
269
+ for (const [pattern, osName] of patterns) {
270
+ const match = ua.match(pattern);
271
+ if (match) {
272
+ const version = match[1]?.replace(/_/g, '.') ?? '';
273
+ return version ? `${osName} ${version}` : osName;
274
+ }
275
+ }
276
+
277
+ return '';
278
+ }
279
+
280
+ /**
281
+ * Get the browser/device model.
282
+ */
283
+ export function getDeviceModel(): string {
284
+ if (typeof navigator === 'undefined' || !navigator.userAgent) {
285
+ return '';
286
+ }
287
+
288
+ const ua = navigator.userAgent;
289
+
290
+ // Try to extract browser name and version
291
+ const patterns: [RegExp, string][] = [
292
+ [/Chrome\/([\d.]+)/i, 'Chrome'],
293
+ [/Firefox\/([\d.]+)/i, 'Firefox'],
294
+ [/Safari\/([\d.]+)/i, 'Safari'],
295
+ [/Edge\/([\d.]+)/i, 'Edge'],
296
+ [/MSIE ([\d.]+)/i, 'IE'],
297
+ [/Trident.*rv:([\d.]+)/i, 'IE'],
298
+ ];
299
+
300
+ for (const [pattern, browserName] of patterns) {
301
+ const match = ua.match(pattern);
302
+ if (match) {
303
+ return `${browserName} ${match[1]}`;
304
+ }
305
+ }
306
+
307
+ return '';
308
+ }
309
+
310
+ /**
311
+ * Delay execution for a specified number of milliseconds.
312
+ */
313
+ export function delay(ms: number): Promise<void> {
314
+ return new Promise((resolve) => setTimeout(resolve, ms));
315
+ }