@licenseseat/js 0.3.1 → 0.4.2

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.
@@ -0,0 +1,518 @@
1
+ /**
2
+ * LicenseSeat SDK Telemetry Collection
3
+ *
4
+ * Collects non-PII platform information for analytics.
5
+ * All fields use snake_case keys to match the server API.
6
+ *
7
+ * @module telemetry
8
+ */
9
+
10
+ /**
11
+ * Detect the current OS name
12
+ * @returns {string} OS name ("Windows", "macOS", "Linux", "iOS", "Android", or "Unknown")
13
+ * @private
14
+ */
15
+ function detectOSName() {
16
+ // Node.js environment
17
+ if (typeof process !== "undefined" && process.platform) {
18
+ const map = {
19
+ darwin: "macOS",
20
+ win32: "Windows",
21
+ linux: "Linux",
22
+ freebsd: "FreeBSD",
23
+ sunos: "SunOS",
24
+ };
25
+ return map[process.platform] || process.platform;
26
+ }
27
+
28
+ // Browser: prefer userAgentData when available
29
+ if (typeof navigator !== "undefined") {
30
+ if (navigator.userAgentData && navigator.userAgentData.platform) {
31
+ return navigator.userAgentData.platform;
32
+ }
33
+
34
+ const ua = navigator.userAgent || "";
35
+ if (/Android/i.test(ua)) return "Android";
36
+ if (/iPhone|iPad|iPod/i.test(ua)) return "iOS";
37
+ if (/Mac/i.test(ua)) return "macOS";
38
+ if (/Win/i.test(ua)) return "Windows";
39
+ if (/Linux/i.test(ua)) return "Linux";
40
+ }
41
+
42
+ return "Unknown";
43
+ }
44
+
45
+ /**
46
+ * Detect the current OS version
47
+ * @returns {string|null} OS version string or null
48
+ * @private
49
+ */
50
+ function detectOSVersion() {
51
+ // Node.js
52
+ if (typeof process !== "undefined" && process.version) {
53
+ try {
54
+ const os = await_free_os_release();
55
+ if (os) return os;
56
+ } catch (_) {
57
+ // fall through
58
+ }
59
+ return process.version; // e.g. "v20.10.0"
60
+ }
61
+
62
+ // Browser
63
+ if (typeof navigator !== "undefined") {
64
+ const ua = navigator.userAgent || "";
65
+
66
+ // macOS: "Mac OS X 10_15_7" or "Mac OS X 10.15.7"
67
+ const macMatch = ua.match(/Mac OS X\s+([\d._]+)/);
68
+ if (macMatch) return macMatch[1].replace(/_/g, ".");
69
+
70
+ // Windows: "Windows NT 10.0"
71
+ const winMatch = ua.match(/Windows NT\s+([\d.]+)/);
72
+ if (winMatch) return winMatch[1];
73
+
74
+ // Android: "Android 14"
75
+ const androidMatch = ua.match(/Android\s+([\d.]+)/);
76
+ if (androidMatch) return androidMatch[1];
77
+
78
+ // iOS: "OS 17_1_1" or "OS 17.1.1"
79
+ const iosMatch = ua.match(/OS\s+([\d._]+)/);
80
+ if (iosMatch) return iosMatch[1].replace(/_/g, ".");
81
+
82
+ // Linux: usually no version in UA
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Synchronous OS release helper (avoids top-level await)
90
+ * @returns {string|null}
91
+ * @private
92
+ */
93
+ function await_free_os_release() {
94
+ try {
95
+ // Dynamic require for Node.js (won't execute in browser bundles)
96
+ // eslint-disable-next-line no-new-func
97
+ const os = new Function("try { return require('os') } catch(e) { return null }")();
98
+ if (os && os.release) return os.release();
99
+ } catch (_) {
100
+ // not available
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Dynamic require helper for Node.js modules (won't execute in browser bundles)
107
+ * @param {string} moduleName - The module to require
108
+ * @returns {Object|null}
109
+ * @private
110
+ */
111
+ function dynamicRequire(moduleName) {
112
+ try {
113
+ // eslint-disable-next-line no-new-func
114
+ return new Function("m", "try { return require(m) } catch(e) { return null }")(moduleName);
115
+ } catch (_) {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Detect the runtime platform
122
+ * @returns {string} "node", "electron", "react-native", "deno", "bun", or "browser"
123
+ * @private
124
+ */
125
+ function detectPlatform() {
126
+ if (typeof process !== "undefined") {
127
+ if (process.versions && process.versions.electron) return "electron";
128
+ if (process.versions && process.versions.bun) return "bun";
129
+ if (process.versions && process.versions.node) return "node";
130
+ }
131
+ // @ts-ignore
132
+ if (typeof Deno !== "undefined") return "deno";
133
+ if (typeof navigator !== "undefined" && navigator.product === "ReactNative") return "react-native";
134
+ if (typeof window !== "undefined") return "browser";
135
+ return "unknown";
136
+ }
137
+
138
+ /**
139
+ * Detect device model (browser only, via userAgentData)
140
+ * @returns {string|null}
141
+ * @private
142
+ */
143
+ function detectDeviceModel() {
144
+ try {
145
+ if (typeof navigator !== "undefined" && navigator.userAgentData) {
146
+ return navigator.userAgentData.model || null;
147
+ }
148
+ } catch (_) {
149
+ // not available
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * Detect the user's locale
156
+ * @returns {string|null}
157
+ * @private
158
+ */
159
+ function detectLocale() {
160
+ if (typeof navigator !== "undefined" && navigator.language) {
161
+ return navigator.language;
162
+ }
163
+ if (typeof Intl !== "undefined") {
164
+ try {
165
+ return Intl.DateTimeFormat().resolvedOptions().locale || null;
166
+ } catch (_) {
167
+ // fall through
168
+ }
169
+ }
170
+ if (typeof process !== "undefined" && process.env) {
171
+ return process.env.LANG || process.env.LC_ALL || null;
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * Detect the user's timezone
178
+ * @returns {string|null}
179
+ * @private
180
+ */
181
+ function detectTimezone() {
182
+ if (typeof Intl !== "undefined") {
183
+ try {
184
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
185
+ } catch (_) {
186
+ // fall through
187
+ }
188
+ }
189
+ return null;
190
+ }
191
+
192
+ /**
193
+ * Detect device type
194
+ * @returns {string} "phone", "tablet", "desktop", "server", or "unknown"
195
+ * @private
196
+ */
197
+ function detectDeviceType() {
198
+ try {
199
+ const platform = detectPlatform();
200
+
201
+ // Node.js server (but not Electron)
202
+ if (platform === "node" || platform === "bun" || platform === "deno") return "server";
203
+ if (platform === "electron") return "desktop";
204
+
205
+ // React Native: check screen dimensions
206
+ if (platform === "react-native") {
207
+ if (typeof screen !== "undefined" && screen.width) {
208
+ return screen.width < 768 ? "phone" : "tablet";
209
+ }
210
+ return "phone";
211
+ }
212
+
213
+ // Browser
214
+ if (typeof navigator !== "undefined") {
215
+ // Check userAgentData.mobile first (Chromium)
216
+ if (navigator.userAgentData && typeof navigator.userAgentData.mobile === "boolean") {
217
+ if (navigator.userAgentData.mobile) {
218
+ // Distinguish phone vs tablet by screen width
219
+ if (typeof screen !== "undefined" && screen.width >= 768) return "tablet";
220
+ return "phone";
221
+ }
222
+ return "desktop";
223
+ }
224
+
225
+ // Fallback: touch points heuristic
226
+ if (navigator.maxTouchPoints > 0) {
227
+ if (typeof screen !== "undefined" && screen.width >= 768) return "tablet";
228
+ return "phone";
229
+ }
230
+
231
+ return "desktop";
232
+ }
233
+ } catch (_) {
234
+ // fall through
235
+ }
236
+ return "unknown";
237
+ }
238
+
239
+ /**
240
+ * Detect CPU architecture
241
+ * @returns {string|null} "arm64", "x64", "x86", "arm", or null
242
+ * @private
243
+ */
244
+ function detectArchitecture() {
245
+ try {
246
+ // Node.js / Bun
247
+ if (typeof process !== "undefined" && process.arch) {
248
+ const map = { ia32: "x86", x64: "x64", arm: "arm", arm64: "arm64" };
249
+ return map[process.arch] || process.arch;
250
+ }
251
+
252
+ // Browser: userAgentData.architecture (sync access, may be empty string)
253
+ if (typeof navigator !== "undefined" && navigator.userAgentData) {
254
+ // architecture may not be available synchronously; getHighEntropyValues is async
255
+ // We can only get it if it's already populated
256
+ if (navigator.userAgentData.architecture) {
257
+ return navigator.userAgentData.architecture;
258
+ }
259
+ }
260
+ } catch (_) {
261
+ // not available
262
+ }
263
+ return null;
264
+ }
265
+
266
+ /**
267
+ * Detect number of CPU cores
268
+ * @returns {number|null}
269
+ * @private
270
+ */
271
+ function detectCpuCores() {
272
+ try {
273
+ // Browser
274
+ if (typeof navigator !== "undefined" && navigator.hardwareConcurrency) {
275
+ return navigator.hardwareConcurrency;
276
+ }
277
+
278
+ // Node.js
279
+ if (typeof process !== "undefined" && process.versions && process.versions.node) {
280
+ const os = dynamicRequire("os");
281
+ if (os && os.cpus) {
282
+ const cpus = os.cpus();
283
+ if (cpus && cpus.length) return cpus.length;
284
+ }
285
+ }
286
+ } catch (_) {
287
+ // not available
288
+ }
289
+ return null;
290
+ }
291
+
292
+ /**
293
+ * Detect approximate RAM in GB
294
+ * @returns {number|null}
295
+ * @private
296
+ */
297
+ function detectMemoryGb() {
298
+ try {
299
+ // Browser (Chrome only)
300
+ if (typeof navigator !== "undefined" && navigator.deviceMemory) {
301
+ return navigator.deviceMemory;
302
+ }
303
+
304
+ // Node.js
305
+ if (typeof process !== "undefined" && process.versions && process.versions.node) {
306
+ const os = dynamicRequire("os");
307
+ if (os && os.totalmem) {
308
+ return Math.round(os.totalmem() / (1024 * 1024 * 1024));
309
+ }
310
+ }
311
+ } catch (_) {
312
+ // not available
313
+ }
314
+ return null;
315
+ }
316
+
317
+ /**
318
+ * Detect 2-letter language code
319
+ * @returns {string|null} e.g. "en", "pt", "es"
320
+ * @private
321
+ */
322
+ function detectLanguage() {
323
+ try {
324
+ const locale = detectLocale();
325
+ if (locale) {
326
+ const lang = locale.split(/[-_]/)[0];
327
+ if (lang && lang.length >= 2) return lang.toLowerCase();
328
+ }
329
+ } catch (_) {
330
+ // fall through
331
+ }
332
+ return null;
333
+ }
334
+
335
+ /**
336
+ * Detect screen resolution
337
+ * @returns {string|null} e.g. "1920x1080"
338
+ * @private
339
+ */
340
+ function detectScreenResolution() {
341
+ try {
342
+ if (typeof screen !== "undefined" && screen.width && screen.height) {
343
+ return `${screen.width}x${screen.height}`;
344
+ }
345
+ } catch (_) {
346
+ // not available
347
+ }
348
+ return null;
349
+ }
350
+
351
+ /**
352
+ * Detect display pixel ratio
353
+ * @returns {number|null}
354
+ * @private
355
+ */
356
+ function detectDisplayScale() {
357
+ try {
358
+ if (typeof window !== "undefined" && window.devicePixelRatio) {
359
+ return window.devicePixelRatio;
360
+ }
361
+ } catch (_) {
362
+ // not available
363
+ }
364
+ return null;
365
+ }
366
+
367
+ /**
368
+ * Detect browser name
369
+ * @returns {string|null} e.g. "Chrome", "Safari", "Firefox", "Edge"
370
+ * @private
371
+ */
372
+ function detectBrowserName() {
373
+ try {
374
+ if (typeof navigator === "undefined") return null;
375
+
376
+ // Prefer userAgentData brands (Chromium-based browsers)
377
+ if (navigator.userAgentData && navigator.userAgentData.brands) {
378
+ const brands = navigator.userAgentData.brands;
379
+ // Look for specific browser brands, skip "Chromium" and "Not" brands
380
+ for (const b of brands) {
381
+ const name = b.brand || "";
382
+ if (/^(Google Chrome|Microsoft Edge|Opera|Brave|Vivaldi|Samsung Internet)$/i.test(name)) {
383
+ return name;
384
+ }
385
+ }
386
+ // Fallback to Chromium if that's all we have
387
+ for (const b of brands) {
388
+ if ((b.brand || "").toLowerCase() === "chromium") return "Chrome";
389
+ }
390
+ }
391
+
392
+ // Fallback: parse user agent string
393
+ const ua = navigator.userAgent || "";
394
+ if (/Edg\//i.test(ua)) return "Edge";
395
+ if (/OPR\//i.test(ua) || /Opera/i.test(ua)) return "Opera";
396
+ if (/Brave/i.test(ua)) return "Brave";
397
+ if (/Vivaldi/i.test(ua)) return "Vivaldi";
398
+ if (/Firefox/i.test(ua)) return "Firefox";
399
+ if (/SamsungBrowser/i.test(ua)) return "Samsung Internet";
400
+ if (/CriOS/i.test(ua)) return "Chrome"; // Chrome on iOS
401
+ if (/Chrome/i.test(ua)) return "Chrome";
402
+ if (/Safari/i.test(ua)) return "Safari";
403
+ } catch (_) {
404
+ // not available
405
+ }
406
+ return null;
407
+ }
408
+
409
+ /**
410
+ * Detect browser version
411
+ * @returns {string|null} e.g. "123.0"
412
+ * @private
413
+ */
414
+ function detectBrowserVersion() {
415
+ try {
416
+ if (typeof navigator === "undefined") return null;
417
+
418
+ // Prefer userAgentData brands
419
+ if (navigator.userAgentData && navigator.userAgentData.brands) {
420
+ const brands = navigator.userAgentData.brands;
421
+ for (const b of brands) {
422
+ const name = b.brand || "";
423
+ if (/^(Google Chrome|Microsoft Edge|Opera|Brave|Vivaldi|Samsung Internet)$/i.test(name)) {
424
+ return b.version || null;
425
+ }
426
+ }
427
+ for (const b of brands) {
428
+ if ((b.brand || "").toLowerCase() === "chromium") return b.version || null;
429
+ }
430
+ }
431
+
432
+ // Fallback: parse user agent
433
+ const ua = navigator.userAgent || "";
434
+ const patterns = [
435
+ /Edg\/([\d.]+)/,
436
+ /OPR\/([\d.]+)/,
437
+ /Firefox\/([\d.]+)/,
438
+ /SamsungBrowser\/([\d.]+)/,
439
+ /CriOS\/([\d.]+)/,
440
+ /Chrome\/([\d.]+)/,
441
+ /Version\/([\d.]+).*Safari/,
442
+ ];
443
+ for (const re of patterns) {
444
+ const m = ua.match(re);
445
+ if (m) return m[1];
446
+ }
447
+ } catch (_) {
448
+ // not available
449
+ }
450
+ return null;
451
+ }
452
+
453
+ /**
454
+ * Detect runtime version
455
+ * @returns {string|null} e.g. "20.11.0" for Node, "1.40.0" for Deno
456
+ * @private
457
+ */
458
+ function detectRuntimeVersion() {
459
+ try {
460
+ if (typeof process !== "undefined" && process.versions) {
461
+ if (process.versions.bun) return process.versions.bun;
462
+ if (process.versions.electron) return process.versions.electron;
463
+ if (process.versions.node) return process.versions.node;
464
+ }
465
+ // @ts-ignore
466
+ if (typeof Deno !== "undefined" && Deno.version) return Deno.version.deno;
467
+ } catch (_) {
468
+ // not available
469
+ }
470
+ return null;
471
+ }
472
+
473
+ /**
474
+ * Collect telemetry data for the current environment.
475
+ * Returns a plain object with snake_case keys matching the server schema.
476
+ * Null/undefined values are filtered out.
477
+ *
478
+ * @param {string} sdkVersion - The SDK version string
479
+ * @param {Object} [options] - Additional options
480
+ * @param {string} [options.appVersion] - User-provided app version
481
+ * @param {string} [options.appBuild] - User-provided app build
482
+ * @returns {Object} Telemetry data object
483
+ */
484
+ export function collectTelemetry(sdkVersion, options) {
485
+ const locale = detectLocale();
486
+
487
+ const raw = {
488
+ sdk_version: sdkVersion,
489
+ sdk_name: 'js',
490
+ os_name: detectOSName(),
491
+ os_version: detectOSVersion(),
492
+ platform: detectPlatform(),
493
+ device_model: detectDeviceModel(),
494
+ device_type: detectDeviceType(),
495
+ locale: locale,
496
+ timezone: detectTimezone(),
497
+ language: detectLanguage(),
498
+ architecture: detectArchitecture(),
499
+ cpu_cores: detectCpuCores(),
500
+ memory_gb: detectMemoryGb(),
501
+ screen_resolution: detectScreenResolution(),
502
+ display_scale: detectDisplayScale(),
503
+ browser_name: detectBrowserName(),
504
+ browser_version: detectBrowserVersion(),
505
+ runtime_version: detectRuntimeVersion(),
506
+ app_version: (options && options.appVersion) || null,
507
+ app_build: (options && options.appBuild) || null,
508
+ };
509
+
510
+ // Filter out null/undefined values
511
+ const result = {};
512
+ for (const [key, value] of Object.entries(raw)) {
513
+ if (value != null) {
514
+ result[key] = value;
515
+ }
516
+ }
517
+ return result;
518
+ }
package/src/types.js CHANGED
@@ -21,6 +21,10 @@
21
21
  * @property {number} [maxOfflineDays=0] - Maximum days a license can be used offline (0 = disabled)
22
22
  * @property {number} [maxClockSkewMs=300000] - Maximum allowed clock skew in ms for offline validation (default: 5 minutes)
23
23
  * @property {boolean} [autoInitialize=true] - Automatically initialize and validate cached license on construction
24
+ * @property {boolean} [telemetryEnabled=true] - Enable telemetry collection on POST requests (set false for GDPR compliance)
25
+ * @property {number} [heartbeatInterval=300000] - Interval in ms between automatic heartbeats (default: 5 minutes, set 0 to disable)
26
+ * @property {string} [appVersion] - User-provided app version string, sent as app_version in telemetry
27
+ * @property {string} [appBuild] - User-provided app build identifier, sent as app_build in telemetry
24
28
  */
25
29
 
26
30
  /**
@@ -198,6 +202,14 @@
198
202
  * @property {string} status - Key status ("active", "revoked")
199
203
  */
200
204
 
205
+ /**
206
+ * Heartbeat response from the API
207
+ * @typedef {Object} HeartbeatResponse
208
+ * @property {string} object - Object type ("heartbeat")
209
+ * @property {string} received_at - ISO8601 timestamp of when the heartbeat was received
210
+ * @property {LicenseObject} license - The license object
211
+ */
212
+
201
213
  /**
202
214
  * Health check response
203
215
  * @typedef {Object} HealthResponse