@scarlett-player/analytics 0.2.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.
package/dist/index.js ADDED
@@ -0,0 +1,582 @@
1
+ // src/helpers.ts
2
+ function generateId() {
3
+ const timestamp = Date.now();
4
+ const random = Math.random().toString(36).substring(2, 11);
5
+ return `${timestamp}-${random}`;
6
+ }
7
+ function getSessionId() {
8
+ const STORAGE_KEY = "sp_session_id";
9
+ try {
10
+ let sessionId = sessionStorage.getItem(STORAGE_KEY);
11
+ if (!sessionId) {
12
+ sessionId = generateId();
13
+ sessionStorage.setItem(STORAGE_KEY, sessionId);
14
+ }
15
+ return sessionId;
16
+ } catch (error) {
17
+ return generateId();
18
+ }
19
+ }
20
+ function getAnonymousViewerId() {
21
+ const STORAGE_KEY = "sp_viewer_id";
22
+ try {
23
+ let viewerId = localStorage.getItem(STORAGE_KEY);
24
+ if (!viewerId) {
25
+ viewerId = generateId();
26
+ localStorage.setItem(STORAGE_KEY, viewerId);
27
+ }
28
+ return viewerId;
29
+ } catch (error) {
30
+ try {
31
+ let viewerId = sessionStorage.getItem(STORAGE_KEY);
32
+ if (!viewerId) {
33
+ viewerId = generateId();
34
+ sessionStorage.setItem(STORAGE_KEY, viewerId);
35
+ }
36
+ return viewerId;
37
+ } catch {
38
+ return generateId();
39
+ }
40
+ }
41
+ }
42
+ function getBrowserInfo() {
43
+ const ua = navigator.userAgent;
44
+ if (ua.includes("Edg/")) {
45
+ const match = ua.match(/Edg\/(\d+)/);
46
+ return {
47
+ name: "Edge",
48
+ version: match ? match[1] : void 0
49
+ };
50
+ }
51
+ if (ua.includes("Chrome/") && !ua.includes("Edg/")) {
52
+ const match = ua.match(/Chrome\/(\d+)/);
53
+ return {
54
+ name: "Chrome",
55
+ version: match ? match[1] : void 0
56
+ };
57
+ }
58
+ if (ua.includes("Safari/") && !ua.includes("Chrome")) {
59
+ const match = ua.match(/Version\/(\d+)/);
60
+ return {
61
+ name: "Safari",
62
+ version: match ? match[1] : void 0
63
+ };
64
+ }
65
+ if (ua.includes("Firefox/")) {
66
+ const match = ua.match(/Firefox\/(\d+)/);
67
+ return {
68
+ name: "Firefox",
69
+ version: match ? match[1] : void 0
70
+ };
71
+ }
72
+ if (ua.includes("OPR/") || ua.includes("Opera/")) {
73
+ const match = ua.match(/(?:OPR|Opera)\/(\d+)/);
74
+ return {
75
+ name: "Opera",
76
+ version: match ? match[1] : void 0
77
+ };
78
+ }
79
+ return { name: "Unknown" };
80
+ }
81
+ function getOSInfo() {
82
+ const ua = navigator.userAgent;
83
+ const platform = navigator.platform || "";
84
+ if (ua.includes("Windows")) {
85
+ if (ua.includes("Windows NT 10.0")) return { name: "Windows", version: "10" };
86
+ if (ua.includes("Windows NT 6.3")) return { name: "Windows", version: "8.1" };
87
+ if (ua.includes("Windows NT 6.2")) return { name: "Windows", version: "8" };
88
+ if (ua.includes("Windows NT 6.1")) return { name: "Windows", version: "7" };
89
+ return { name: "Windows" };
90
+ }
91
+ if (ua.includes("Mac OS X")) {
92
+ const match = ua.match(/Mac OS X (\d+)[._](\d+)/);
93
+ return {
94
+ name: "macOS",
95
+ version: match ? `${match[1]}.${match[2]}` : void 0
96
+ };
97
+ }
98
+ if (ua.includes("iPhone") || ua.includes("iPad") || ua.includes("iPod")) {
99
+ const match = ua.match(/OS (\d+)[._](\d+)/);
100
+ return {
101
+ name: "iOS",
102
+ version: match ? `${match[1]}.${match[2]}` : void 0
103
+ };
104
+ }
105
+ if (ua.includes("Android")) {
106
+ const match = ua.match(/Android (\d+(?:\.\d+)?)/);
107
+ return {
108
+ name: "Android",
109
+ version: match ? match[1] : void 0
110
+ };
111
+ }
112
+ if (ua.includes("Linux") || platform.includes("Linux")) {
113
+ return { name: "Linux" };
114
+ }
115
+ if (ua.includes("CrOS")) {
116
+ return { name: "ChromeOS" };
117
+ }
118
+ return { name: "Unknown" };
119
+ }
120
+ function getDeviceType() {
121
+ const ua = navigator.userAgent;
122
+ if (ua.includes("TV") || ua.includes("PlayStation") || ua.includes("Xbox") || ua.includes("SmartTV")) {
123
+ return "tv";
124
+ }
125
+ if (ua.includes("iPad") || ua.includes("Android") && !ua.includes("Mobile") || ua.includes("Tablet")) {
126
+ return "tablet";
127
+ }
128
+ if (ua.includes("Mobile") || ua.includes("iPhone") || ua.includes("iPod") || ua.includes("Android") && ua.includes("Mobile")) {
129
+ return "mobile";
130
+ }
131
+ return "desktop";
132
+ }
133
+ function getScreenSize() {
134
+ return `${window.screen.width}x${window.screen.height}`;
135
+ }
136
+ function getPlayerSize(container) {
137
+ if (!container) {
138
+ return `${window.innerWidth}x${window.innerHeight}`;
139
+ }
140
+ const rect = container.getBoundingClientRect();
141
+ return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
142
+ }
143
+ function getConnectionType() {
144
+ const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
145
+ if (conn) {
146
+ return conn.effectiveType || conn.type || "unknown";
147
+ }
148
+ return "unknown";
149
+ }
150
+ function calculateQoEScore(params) {
151
+ const {
152
+ startupTime,
153
+ rebufferDuration,
154
+ watchTime,
155
+ maxBitrate,
156
+ exitType,
157
+ errorCount
158
+ } = params;
159
+ let startupScore = 100;
160
+ if (startupTime !== null) {
161
+ if (startupTime < 1e3) startupScore = 100;
162
+ else if (startupTime < 2e3) startupScore = 85;
163
+ else if (startupTime < 4e3) startupScore = 70;
164
+ else if (startupTime < 8e3) startupScore = 50;
165
+ else startupScore = 30;
166
+ }
167
+ let smoothnessScore = 100;
168
+ if (watchTime > 0) {
169
+ const rebufferRatio = rebufferDuration / watchTime * 100;
170
+ if (rebufferRatio < 0.1) smoothnessScore = 100;
171
+ else if (rebufferRatio < 1) smoothnessScore = 85;
172
+ else if (rebufferRatio < 2) smoothnessScore = 70;
173
+ else if (rebufferRatio < 5) smoothnessScore = 50;
174
+ else smoothnessScore = 30;
175
+ }
176
+ let successScore = 100;
177
+ if (exitType === "error") {
178
+ successScore = 0;
179
+ } else if (errorCount > 0) {
180
+ successScore = Math.max(0, 100 - errorCount * 10);
181
+ }
182
+ let qualityScore = 80;
183
+ if (maxBitrate > 4e6) qualityScore = 100;
184
+ else if (maxBitrate > 2e6) qualityScore = 90;
185
+ else if (maxBitrate > 1e6) qualityScore = 75;
186
+ else if (maxBitrate > 5e5) qualityScore = 60;
187
+ else if (maxBitrate > 0) qualityScore = 40;
188
+ const qoeScore = successScore * 0.3 + startupScore * 0.25 + smoothnessScore * 0.3 + qualityScore * 0.15;
189
+ return Math.round(qoeScore);
190
+ }
191
+ function isDevelopment() {
192
+ return window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" || window.location.hostname.includes(".local");
193
+ }
194
+ function safeStringify(data) {
195
+ try {
196
+ return JSON.stringify(data);
197
+ } catch (error) {
198
+ return "{}";
199
+ }
200
+ }
201
+
202
+ // src/index.ts
203
+ var PLUGIN_VERSION = "0.1.0";
204
+ var PLUGIN_NAME = "scarlett-player";
205
+ var DEFAULT_CONFIG = {
206
+ heartbeatInterval: 1e4,
207
+ errorSampleRate: 1,
208
+ disableInDev: false
209
+ };
210
+ function createAnalyticsPlugin(config) {
211
+ if (!config.beaconUrl) {
212
+ throw new Error("Analytics plugin requires beaconUrl");
213
+ }
214
+ if (!config.videoId) {
215
+ throw new Error("Analytics plugin requires videoId");
216
+ }
217
+ const mergedConfig = { ...DEFAULT_CONFIG, ...config };
218
+ let api = null;
219
+ let session;
220
+ let heartbeatTimer = null;
221
+ let lastHeartbeatTime = 0;
222
+ let isRebuffering = false;
223
+ let rebufferStartTime = null;
224
+ let pauseStartTime = null;
225
+ let cleanupFns = [];
226
+ function initSession() {
227
+ return {
228
+ viewId: generateId(),
229
+ sessionId: getSessionId(),
230
+ viewerId: mergedConfig.viewerId || getAnonymousViewerId(),
231
+ viewStart: Date.now(),
232
+ playRequestTime: null,
233
+ firstFrameTime: null,
234
+ viewEnd: null,
235
+ watchTime: 0,
236
+ playTime: 0,
237
+ pauseCount: 0,
238
+ pauseDuration: 0,
239
+ seekCount: 0,
240
+ startupTime: null,
241
+ rebufferCount: 0,
242
+ rebufferDuration: 0,
243
+ errorCount: 0,
244
+ errors: [],
245
+ bitrateHistory: [],
246
+ qualityChanges: 0,
247
+ maxBitrate: 0,
248
+ avgBitrate: 0,
249
+ playbackState: "loading",
250
+ exitType: null
251
+ };
252
+ }
253
+ function sendBeacon(eventType, data = {}) {
254
+ if (mergedConfig.disableInDev && isDevelopment()) {
255
+ return;
256
+ }
257
+ if (eventType === "error" && Math.random() > (mergedConfig.errorSampleRate ?? 1)) {
258
+ return;
259
+ }
260
+ const payload = {
261
+ // Event info
262
+ event: eventType,
263
+ timestamp: Date.now(),
264
+ // View context
265
+ viewId: session.viewId,
266
+ sessionId: session.sessionId,
267
+ viewerId: session.viewerId,
268
+ // Video context
269
+ videoId: mergedConfig.videoId,
270
+ videoTitle: mergedConfig.videoTitle,
271
+ isLive: mergedConfig.isLive ?? api?.getState("live") ?? false,
272
+ // Player context
273
+ playerVersion: PLUGIN_VERSION,
274
+ playerName: PLUGIN_NAME,
275
+ // Environment
276
+ browser: getBrowserInfo().name,
277
+ os: getOSInfo().name,
278
+ deviceType: getDeviceType(),
279
+ screenSize: getScreenSize(),
280
+ playerSize: getPlayerSize(api?.container ?? null),
281
+ connectionType: getConnectionType(),
282
+ // Custom dimensions
283
+ ...mergedConfig.customDimensions,
284
+ // Event-specific data
285
+ ...data
286
+ };
287
+ if (mergedConfig.customBeacon) {
288
+ mergedConfig.customBeacon(mergedConfig.beaconUrl, payload);
289
+ return;
290
+ }
291
+ if (navigator.sendBeacon) {
292
+ const blob = new Blob([safeStringify(payload)], {
293
+ type: "application/json"
294
+ });
295
+ navigator.sendBeacon(mergedConfig.beaconUrl, blob);
296
+ } else {
297
+ fetch(mergedConfig.beaconUrl, {
298
+ method: "POST",
299
+ headers: {
300
+ "Content-Type": "application/json",
301
+ ...mergedConfig.apiKey ? { "X-API-Key": mergedConfig.apiKey } : {}
302
+ },
303
+ body: safeStringify(payload),
304
+ keepalive: true
305
+ }).catch(() => {
306
+ });
307
+ }
308
+ }
309
+ function sendHeartbeat() {
310
+ if (!api) return;
311
+ const now = Date.now();
312
+ const timeSinceLastHeartbeat = now - lastHeartbeatTime;
313
+ session.watchTime += timeSinceLastHeartbeat;
314
+ if (session.playbackState === "playing" && !isRebuffering) {
315
+ session.playTime += timeSinceLastHeartbeat;
316
+ }
317
+ if (session.bitrateHistory.length > 0) {
318
+ const totalBitrateTime = session.bitrateHistory.reduce((sum, b, i, arr) => {
319
+ const nextTime = i < arr.length - 1 ? arr[i + 1]?.time : now;
320
+ const duration = nextTime - b.time;
321
+ return sum + b.bitrate * duration;
322
+ }, 0);
323
+ session.avgBitrate = Math.round(totalBitrateTime / session.watchTime);
324
+ }
325
+ const state = {
326
+ currentTime: api.getState("currentTime"),
327
+ duration: api.getState("duration")
328
+ };
329
+ sendBeacon("heartbeat", {
330
+ watchTime: session.watchTime,
331
+ playTime: session.playTime,
332
+ currentTime: state.currentTime,
333
+ duration: state.duration,
334
+ rebufferCount: session.rebufferCount,
335
+ rebufferDuration: session.rebufferDuration,
336
+ avgBitrate: session.avgBitrate,
337
+ qoeScore: getQoEScore()
338
+ });
339
+ lastHeartbeatTime = now;
340
+ }
341
+ function getQoEScore() {
342
+ return calculateQoEScore({
343
+ startupTime: session.startupTime,
344
+ rebufferDuration: session.rebufferDuration,
345
+ watchTime: session.watchTime,
346
+ maxBitrate: session.maxBitrate,
347
+ exitType: session.exitType,
348
+ errorCount: session.errorCount
349
+ });
350
+ }
351
+ function sendViewEnd() {
352
+ if (!api) return;
353
+ session.viewEnd = Date.now();
354
+ const state = {
355
+ currentTime: api.getState("currentTime"),
356
+ duration: api.getState("duration")
357
+ };
358
+ const completionRate = state.duration ? state.currentTime / state.duration * 100 : 0;
359
+ sendBeacon("viewEnd", {
360
+ watchTime: session.watchTime,
361
+ playTime: session.playTime,
362
+ startupTime: session.startupTime,
363
+ rebufferCount: session.rebufferCount,
364
+ rebufferDuration: session.rebufferDuration,
365
+ rebufferRatio: session.watchTime > 0 ? session.rebufferDuration / session.watchTime * 100 : 0,
366
+ avgBitrate: session.avgBitrate,
367
+ maxBitrate: session.maxBitrate,
368
+ qualityChanges: session.qualityChanges,
369
+ pauseCount: session.pauseCount,
370
+ pauseDuration: session.pauseDuration,
371
+ seekCount: session.seekCount,
372
+ errorCount: session.errorCount,
373
+ exitType: session.exitType,
374
+ qoeScore: getQoEScore(),
375
+ completionRate
376
+ });
377
+ }
378
+ function onPlayRequest() {
379
+ session.playRequestTime = Date.now();
380
+ sendBeacon("playRequest");
381
+ }
382
+ function onPlaying() {
383
+ const now = Date.now();
384
+ if (session.firstFrameTime === null) {
385
+ session.firstFrameTime = now;
386
+ session.startupTime = session.playRequestTime ? now - session.playRequestTime : null;
387
+ sendBeacon("videoStart", {
388
+ startupTime: session.startupTime
389
+ });
390
+ }
391
+ if (isRebuffering && rebufferStartTime) {
392
+ const rebufferDuration = now - rebufferStartTime;
393
+ session.rebufferDuration += rebufferDuration;
394
+ isRebuffering = false;
395
+ rebufferStartTime = null;
396
+ sendBeacon("rebufferEnd", {
397
+ duration: rebufferDuration,
398
+ totalRebufferTime: session.rebufferDuration
399
+ });
400
+ }
401
+ if (pauseStartTime) {
402
+ const pauseDuration = now - pauseStartTime;
403
+ session.pauseDuration += pauseDuration;
404
+ pauseStartTime = null;
405
+ }
406
+ session.playbackState = "playing";
407
+ }
408
+ function onPause() {
409
+ if (!api) return;
410
+ session.pauseCount++;
411
+ session.playbackState = "paused";
412
+ pauseStartTime = Date.now();
413
+ sendBeacon("pause", {
414
+ currentTime: api.getState("currentTime")
415
+ });
416
+ }
417
+ function onWaiting() {
418
+ if (!api) return;
419
+ if (session.firstFrameTime !== null && !isRebuffering) {
420
+ isRebuffering = true;
421
+ rebufferStartTime = Date.now();
422
+ session.rebufferCount++;
423
+ sendBeacon("rebufferStart", {
424
+ rebufferCount: session.rebufferCount,
425
+ currentTime: api.getState("currentTime")
426
+ });
427
+ }
428
+ }
429
+ function onSeeking() {
430
+ if (!api) return;
431
+ session.seekCount++;
432
+ sendBeacon("seeking", {
433
+ seekCount: session.seekCount,
434
+ seekTo: api.getState("currentTime")
435
+ });
436
+ }
437
+ function onEnded() {
438
+ session.playbackState = "ended";
439
+ session.exitType = "completed";
440
+ sendViewEnd();
441
+ }
442
+ function onError(payload) {
443
+ const error = payload.error;
444
+ session.errorCount++;
445
+ const errorEvent = {
446
+ time: Date.now(),
447
+ type: error.name || "Error",
448
+ message: error.message || "Unknown error",
449
+ fatal: error.fatal ?? false
450
+ };
451
+ session.errors.push(errorEvent);
452
+ sendBeacon("error", {
453
+ errorType: errorEvent.type,
454
+ errorMessage: errorEvent.message,
455
+ errorCode: error.code,
456
+ fatal: errorEvent.fatal
457
+ });
458
+ if (errorEvent.fatal) {
459
+ session.playbackState = "error";
460
+ session.exitType = "error";
461
+ sendViewEnd();
462
+ }
463
+ }
464
+ function onQualityChange(payload) {
465
+ if (!api) return;
466
+ const now = Date.now();
467
+ session.qualityChanges++;
468
+ const qualities = api.getState("qualities");
469
+ const currentQuality = qualities.find((q) => q.id === payload.quality);
470
+ if (currentQuality) {
471
+ const bitrateChange = {
472
+ time: now,
473
+ bitrate: currentQuality.bitrate,
474
+ width: currentQuality.width,
475
+ height: currentQuality.height
476
+ };
477
+ session.bitrateHistory.push(bitrateChange);
478
+ if (currentQuality.bitrate > session.maxBitrate) {
479
+ session.maxBitrate = currentQuality.bitrate;
480
+ }
481
+ sendBeacon("qualityChange", {
482
+ bitrate: currentQuality.bitrate,
483
+ width: currentQuality.width,
484
+ height: currentQuality.height,
485
+ auto: payload.auto
486
+ });
487
+ }
488
+ }
489
+ function onVisibilityChange() {
490
+ if (document.hidden) {
491
+ session.exitType = "background";
492
+ sendHeartbeat();
493
+ }
494
+ }
495
+ function onBeforeUnload() {
496
+ if (!session.exitType) {
497
+ session.exitType = "abandoned";
498
+ }
499
+ sendViewEnd();
500
+ }
501
+ return {
502
+ id: "analytics",
503
+ name: "Analytics",
504
+ version: PLUGIN_VERSION,
505
+ type: "analytics",
506
+ description: "Quality of Experience and engagement analytics",
507
+ async init(pluginApi) {
508
+ api = pluginApi;
509
+ session = initSession();
510
+ lastHeartbeatTime = Date.now();
511
+ sendBeacon("viewStart");
512
+ const unsubPlay = api.on("playback:play", () => {
513
+ onPlayRequest();
514
+ onPlaying();
515
+ });
516
+ const unsubPause = api.on("playback:pause", onPause);
517
+ const unsubWaiting = api.on("media:waiting", onWaiting);
518
+ const unsubSeeking = api.on("playback:seeking", onSeeking);
519
+ const unsubEnded = api.on("playback:ended", onEnded);
520
+ const unsubError = api.on("media:error", onError);
521
+ const unsubQuality = api.on("quality:change", onQualityChange);
522
+ cleanupFns.push(
523
+ unsubPlay,
524
+ unsubPause,
525
+ unsubWaiting,
526
+ unsubSeeking,
527
+ unsubEnded,
528
+ unsubError,
529
+ unsubQuality
530
+ );
531
+ document.addEventListener("visibilitychange", onVisibilityChange);
532
+ window.addEventListener("beforeunload", onBeforeUnload);
533
+ cleanupFns.push(() => {
534
+ document.removeEventListener("visibilitychange", onVisibilityChange);
535
+ window.removeEventListener("beforeunload", onBeforeUnload);
536
+ });
537
+ heartbeatTimer = setInterval(
538
+ sendHeartbeat,
539
+ mergedConfig.heartbeatInterval || 1e4
540
+ );
541
+ api.logger.info("Analytics plugin initialized", {
542
+ viewId: session.viewId,
543
+ videoId: mergedConfig.videoId
544
+ });
545
+ },
546
+ async destroy() {
547
+ if (heartbeatTimer) {
548
+ clearInterval(heartbeatTimer);
549
+ heartbeatTimer = null;
550
+ }
551
+ if (!session.viewEnd) {
552
+ session.exitType = session.exitType || "abandoned";
553
+ sendViewEnd();
554
+ }
555
+ cleanupFns.forEach((fn) => fn());
556
+ cleanupFns = [];
557
+ api?.logger.info("Analytics plugin destroyed");
558
+ api = null;
559
+ },
560
+ // === Public API ===
561
+ getViewId() {
562
+ return session.viewId;
563
+ },
564
+ getSessionId() {
565
+ return session.sessionId;
566
+ },
567
+ getQoEScore() {
568
+ return getQoEScore();
569
+ },
570
+ getMetrics() {
571
+ return { ...session };
572
+ },
573
+ trackEvent(name, data = {}) {
574
+ sendBeacon(`custom:${name}`, data);
575
+ }
576
+ };
577
+ }
578
+ var index_default = createAnalyticsPlugin;
579
+ export {
580
+ createAnalyticsPlugin,
581
+ index_default as default
582
+ };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@scarlett-player/analytics",
3
+ "version": "0.2.0",
4
+ "description": "Analytics Plugin for Scarlett Player - QoE metrics and engagement tracking",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "peerDependencies": {
25
+ "@scarlett-player/core": "^0.2.0"
26
+ },
27
+ "devDependencies": {
28
+ "@vitest/coverage-v8": "^1.6.0",
29
+ "jsdom": "^24.0.0",
30
+ "tsup": "^8.5.1",
31
+ "typescript": "^5.3.0",
32
+ "vitest": "^1.6.0",
33
+ "@scarlett-player/core": "0.2.0"
34
+ },
35
+ "keywords": [
36
+ "video",
37
+ "player",
38
+ "analytics",
39
+ "metrics",
40
+ "qoe",
41
+ "tracking",
42
+ "scarlett"
43
+ ],
44
+ "author": "The Stream Platform",
45
+ "license": "MIT",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/Hackney-Enterprises-Inc/scarlett-player.git",
49
+ "directory": "packages/plugins/analytics"
50
+ },
51
+ "bugs": {
52
+ "url": "https://github.com/Hackney-Enterprises-Inc/scarlett-player/issues"
53
+ },
54
+ "homepage": "https://scarlettplayer.com",
55
+ "scripts": {
56
+ "build": "tsup src/index.ts --format esm,cjs --dts",
57
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
58
+ "test": "vitest --run",
59
+ "test:watch": "vitest",
60
+ "test:coverage": "vitest --coverage"
61
+ }
62
+ }