@newrelic/video-core 4.1.8-beta → 5.0.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 (46) hide show
  1. package/CHANGELOG.md +65 -15
  2. package/README.md +628 -45
  3. package/dist/cjs/browser/index.js +3 -0
  4. package/dist/cjs/browser/index.js.LICENSE.txt +6 -0
  5. package/dist/cjs/browser/index.js.map +1 -0
  6. package/dist/cjs/index.js +1 -1
  7. package/dist/cjs/index.js.LICENSE.txt +1 -1
  8. package/dist/cjs/index.js.map +1 -1
  9. package/dist/cjs/vega/index.js +3 -0
  10. package/dist/cjs/vega/index.js.LICENSE.txt +6 -0
  11. package/dist/cjs/vega/index.js.map +1 -0
  12. package/dist/esm/browser/index.js +3 -0
  13. package/dist/esm/browser/index.js.LICENSE.txt +6 -0
  14. package/dist/esm/browser/index.js.map +1 -0
  15. package/dist/esm/index.js +1 -1
  16. package/dist/esm/index.js.LICENSE.txt +1 -1
  17. package/dist/esm/index.js.map +1 -1
  18. package/dist/esm/vega/index.js +3 -0
  19. package/dist/esm/vega/index.js.LICENSE.txt +6 -0
  20. package/dist/esm/vega/index.js.map +1 -0
  21. package/dist/umd/nrvideo.min.js +1 -1
  22. package/dist/umd/nrvideo.min.js.LICENSE.txt +1 -1
  23. package/dist/umd/nrvideo.min.js.map +1 -1
  24. package/package.json +31 -6
  25. package/src/{agent.js → browser/agent.js} +43 -8
  26. package/src/{harvestScheduler.js → browser/harvestScheduler.js} +87 -86
  27. package/src/browser/index.js +58 -0
  28. package/src/connectedDevice/connectedDeviceAgent.js +116 -0
  29. package/src/connectedDevice/connectedDeviceConstants.js +121 -0
  30. package/src/connectedDevice/connectedDeviceHarvester.js +522 -0
  31. package/src/connectedDevice/index.js +49 -0
  32. package/src/constants.js +8 -0
  33. package/src/core.js +4 -18
  34. package/src/eventAggregator.js +48 -0
  35. package/src/index.js +9 -10
  36. package/src/obfuscate.js +33 -0
  37. package/src/optimizedHttpClient.js +6 -2
  38. package/src/recordEvent.js +56 -49
  39. package/src/tracker.js +14 -4
  40. package/src/utils/eventBuilder.js +126 -0
  41. package/src/utils/harvestTimer.js +109 -0
  42. package/src/{utils.js → utils/index.js} +2 -2
  43. package/src/utils/qoeFilters.js +149 -0
  44. package/src/videoConfiguration.js +90 -7
  45. package/src/videotracker.js +100 -32
  46. package/src/videotrackerstate.js +156 -31
package/src/constants.js CHANGED
@@ -25,6 +25,7 @@ Constants.AdPositions = {
25
25
  Constants.COLLECTOR = {
26
26
  US: ["bam.nr-data.net", "bam-cell.nr-data.net"],
27
27
  EU: "bam.eu01.nr-data.net",
28
+ JP: "bam.jp.nr-data.net",
28
29
  Staging: "staging-bam-cell.nr-data.net",
29
30
  GOV: "gov-bam.nr-data.net",
30
31
  };
@@ -41,6 +42,13 @@ Constants.MAX_PAYLOAD_SIZE = 1048576; // 1MB = 1024 × 1024 bytes
41
42
  Constants.MAX_BEACON_SIZE = 61440; // 60KB = 60 × 1024 bytes
42
43
  Constants.MAX_EVENTS_PER_BATCH = 1000;
43
44
  Constants.INTERVAL = 10000; //10 seconds
45
+ Constants.DEFAULT_QOE_INTERVAL_FACTOR = 2;
46
+
47
+ Constants.QOE_KPI_KEYS = [
48
+ "startupTime", "peakBitrate", "averageBitrate", "totalPlaytime",
49
+ "totalRebufferingTime", "rebufferingRatio", "hadStartupError",
50
+ "hadPlaybackError", "numberOfErrors"
51
+ ];
44
52
 
45
53
  Constants.QOE_AGGREGATE_KEYS = [
46
54
  "coreVersion", "instrumentation.name",
package/src/core.js CHANGED
@@ -14,9 +14,11 @@ class Core {
14
14
  * @param {object} options Configuration options including video analytics settings.
15
15
  */
16
16
  static addTracker(tracker, options) {
17
- // Set video analytics configuration
17
+ // Set video analytics configuration. The optional `options.src` field
18
+ // selects the pipeline: `'Vega'` writes globalThis.__NRVIDEO_CD__,
19
+ // anything else writes window.NRVIDEO.
18
20
  if (options?.info) {
19
- setVideoConfig(options.info, options?.config);
21
+ setVideoConfig(options.info, options?.config, options?.src);
20
22
  }
21
23
 
22
24
  if (tracker.on && tracker.emit) {
@@ -82,22 +84,6 @@ class Core {
82
84
  });
83
85
  }
84
86
 
85
-
86
-
87
-
88
- /**
89
- * Forces an immediate harvest of all pending events.
90
- * @returns {Promise<object>} Harvest result
91
- */
92
- static async forceHarvest() {
93
- try {
94
- const { videoAnalyticsHarvester } = require("./agent"); // lazy loading for dynamic import
95
- return await videoAnalyticsHarvester.forceHarvest();
96
- } catch (error) {
97
- Log.error("Failed to force harvest:", error.message);
98
- return { success: false, error: error.message };
99
- }
100
- }
101
87
  }
102
88
 
103
89
  let trackers = [];
@@ -55,6 +55,54 @@ export class NrVideoEventAggregator {
55
55
  }
56
56
  }
57
57
 
58
+ /**
59
+ * If an event with the specified actionName and viewId already exists in the buffer, it will be replaced.
60
+ * Otherwise, the event will be added as a new entry.
61
+ * @param {string} actionName - The actionName to search for in the buffer
62
+ * @param {string} viewId - The viewId to scope the lookup to
63
+ * @param {object} eventObject - The event object to add or use as replacement.
64
+ * @returns {boolean} True if the operation succeeded, false if an error occurred
65
+ */
66
+ addOrReplaceByActionNameAndViewId(actionName, viewId, eventObject) {
67
+ const i = this.buffer.findIndex(
68
+ e => e.actionName === actionName && e.viewId === viewId
69
+ );
70
+ try {
71
+ if (i === -1) {
72
+ this.add(eventObject);
73
+ } else {
74
+ this.add(eventObject, i);
75
+ }
76
+ return true;
77
+ } catch (error) {
78
+ Log.error("Failed to set or replace the event to buffer:", error.message);
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Returns the existing event in buffer matching the given actionName and viewId, or null.
85
+ * @param {string} actionName
86
+ * @param {string} viewId
87
+ * @returns {object|null}
88
+ */
89
+ findByActionNameAndViewId(actionName, viewId) {
90
+ const event = this.buffer.find(
91
+ e => e.actionName === actionName && e.viewId === viewId
92
+ );
93
+ return event || null;
94
+ }
95
+
96
+ /**
97
+ * Returns the existing event in buffer matching the given actionName, or null.
98
+ * @param {string} actionName
99
+ * @returns {object|null}
100
+ */
101
+ findByActionName(actionName) {
102
+ const event = this.buffer.find(e => e.actionName === actionName);
103
+ return event || null;
104
+ }
105
+
58
106
  /**
59
107
  * Adds an event to the unified buffer.
60
108
  * All events are treated equally in FIFO order.
package/src/index.js CHANGED
@@ -9,12 +9,14 @@ import VideoTrackerState from "./videotrackerstate";
9
9
  import { NrVideoEventAggregator } from "./eventAggregator";
10
10
  import { RetryQueueHandler } from "./retryQueueHandler";
11
11
  import { OptimizedHttpClient } from "./optimizedHttpClient";
12
- import { HarvestScheduler } from "./harvestScheduler";
13
- import { recordEvent } from "./recordEvent";
12
+ import { HarvestScheduler } from "./browser/harvestScheduler";
13
+ import { recordEvent, getRegisteredHarvester } from "./recordEvent";
14
14
  import { version } from "../package.json";
15
15
 
16
+ // Harvesters are exported as NAMED exports only — never added to the `nrvideo`
17
+ // default-namespace object. This is the load-bearing detail for tree-shaking
18
+
16
19
  const nrvideo = {
17
- // Core components (existing)
18
20
  Constants,
19
21
  Chrono,
20
22
  Log,
@@ -25,19 +27,16 @@ const nrvideo = {
25
27
  Core,
26
28
  version,
27
29
 
28
- // Enhanced video analytics components (new)
29
-
30
30
  NrVideoEventAggregator,
31
31
  RetryQueueHandler,
32
32
  OptimizedHttpClient,
33
33
  HarvestScheduler,
34
34
 
35
-
36
-
37
- // Enhanced event recording
38
35
  recordEvent,
39
-
40
-
41
36
  };
42
37
 
38
+ export { videoAnalyticsHarvester } from "./browser/agent";
39
+ export { connectedDeviceAnalyticsHarvester } from "./connectedDevice/connectedDeviceAgent";
40
+ export { getRegisteredHarvester } from "./recordEvent";
41
+
43
42
  export default nrvideo;
@@ -0,0 +1,33 @@
1
+ import Log from "./log";
2
+
3
+ /**
4
+ * Applies obfuscation rules to a JSON string before sending to the collector.
5
+ * Each rule replaces matches of `regex` with `replacement` in the string.
6
+ *
7
+ * @param {string} jsonString - Serialized JSON payload
8
+ * @param {Array<{regex: string|RegExp, replacement: string}>} rules - Obfuscation rules
9
+ * @returns {string} Obfuscated string
10
+ */
11
+ export function applyObfuscationRules(jsonString, rules) {
12
+ if (!rules || rules.length === 0) return jsonString;
13
+
14
+ return rules.reduce((str, rule) => {
15
+ let pattern;
16
+ try {
17
+ if (rule.regex instanceof RegExp) {
18
+ // Ensure global flag so all occurrences are replaced
19
+ const flags = rule.regex.flags.includes("g")
20
+ ? rule.regex.flags
21
+ : rule.regex.flags + "g";
22
+ pattern = new RegExp(rule.regex.source, flags);
23
+ } else {
24
+ pattern = new RegExp(rule.regex, "g");
25
+ }
26
+ } catch (e) {
27
+ Log.warn("applyObfuscationRules: invalid regex, skipping rule:", rule.regex, e.message);
28
+ return str;
29
+ }
30
+ let s = str.replace(pattern, rule.replacement);
31
+ return s;
32
+ }, jsonString);
33
+ }
@@ -1,5 +1,6 @@
1
- import { dataSize, shouldRetry } from "./utils";
1
+ import { shouldRetry } from "./utils";
2
2
  import Log from "./log";
3
+ import { applyObfuscationRules } from "./obfuscate";
3
4
 
4
5
  /**
5
6
  * Optimized HTTP client for video analytics data transmission with
@@ -50,7 +51,10 @@ export class OptimizedHttpClient {
50
51
  const startTime = Date.now();
51
52
 
52
53
  try {
53
- const requestBody = JSON.stringify(payload.body);
54
+ const requestBody = applyObfuscationRules(
55
+ JSON.stringify(payload.body),
56
+ window.NRVIDEO?.config?.obfuscate
57
+ );
54
58
 
55
59
  // Handle final harvest with sendBeacon
56
60
  if (options.isFinalHarvest && navigator.sendBeacon) {
@@ -1,68 +1,75 @@
1
- import { videoAnalyticsHarvester } from "./agent.js";
2
1
  import Constants from "./constants.js";
3
2
  import Log from "./log.js";
4
- import Tracker from "./tracker";
5
- import {getObjectEntriesForKeys} from "./utils";
3
+ import { dispatchRecordEvent } from "./utils/eventBuilder";
6
4
 
7
5
  /**
8
- * Enhanced record event function with validation, enrichment, and unified handling.
9
- * @param {string} eventType - Type of event to record
10
- * @param {object} attributes - Event attributes
6
+ * Registry-based recordEvent (split build).
7
+ *
8
+ * No static harvester imports. Each harvester self-registers under a key
9
+ * (`'Browser'` or `'Vega'`) at module load time via `registerHarvester`.
11
10
  */
11
+ const harvesters = Object.create(null);
12
+
13
+ /**
14
+ * Register a harvester implementation under a routing key.
15
+ * Called by `agent.js` (key `'Browser'`) and `connectedDeviceAgent.js`
16
+ * (key `'Vega'`) at module load time.
17
+ *
18
+ * @param {string} key Routing key matching `attributes.src`.
19
+ * @param {{ addEvent: function }} harvester
20
+ */
21
+ export function registerHarvester(key, harvester) {
22
+ harvesters[key] = harvester;
23
+ }
24
+
25
+ /**
26
+ * Look up a harvester registered under a routing key. Returns `undefined` if
27
+ * no module has registered for that key in this build (e.g. on the `/browser`
28
+ * subpath, the 'Vega' key is never registered because `connectedDeviceAgent.js`
29
+ * is unreachable from that entry's import graph).
30
+ *
31
+ * Trackers use this getter — instead of importing the harvester binding by
32
+ * name — so the same `tracker.js` / `vegaTracker.js` source files compile
33
+ * unchanged across all three core entry points (main, /browser, /vega) when
34
+ * the html5 webpack build aliases `@newrelic/video-core` to a specific
35
+ * subpath. Without this, parent-class file `tracker.js` would carry an
36
+ * unconditional `import { videoAnalyticsHarvester }` that fails to resolve
37
+ * against the `/vega` subpath.
38
+ *
39
+ * @param {string} key
40
+ * @returns {{ addEvent: function }|undefined}
41
+ */
42
+ export function getRegisteredHarvester(key) {
43
+ return harvesters[key];
44
+ }
45
+
12
46
  export function recordEvent(eventType, attributes = {}) {
13
47
  try {
14
- // Validate event type
15
48
  if (!Constants.VALID_EVENT_TYPES.includes(eventType)) {
16
49
  Log.warn("Invalid event type provided to recordEvent", { eventType });
17
50
  return false;
18
51
  }
19
52
 
20
- // Get app configuration
21
-
22
- if (!window?.NRVIDEO?.info) return;
53
+ const isVega = attributes.src === "Vega";
54
+ const routingKey = isVega ? "Vega" : "Browser";
55
+ const w = typeof window !== "undefined" ? window : undefined;
56
+ const info = isVega ? globalThis.__NRVIDEO_CD__?.info : w?.NRVIDEO?.info;
57
+ if (!info) return;
23
58
 
24
- const { appName, applicationID } = window.NRVIDEO.info;
25
-
26
- const { qoe, ...eventAttributes } = attributes;
27
- const qoeAttrs = qoe ? { ...qoe } : {};
28
-
29
- const otherAttrs = {
30
- ...(applicationID ? {} : { appName }), // Only include appName when no applicationID
31
- timestamp: Date.now(),
32
- timeSinceLoad: window.performance
33
- ? window.performance.now() / 1000
34
- : null,
35
- }
36
-
37
- const eventObject = {
38
- ...eventAttributes,
39
- eventType,
40
- ...otherAttrs,
41
- };
42
-
43
- const metadataAttributes = getObjectEntriesForKeys(Constants.QOE_AGGREGATE_KEYS, attributes)
44
-
45
- let qoeEventObject = null;
46
- if(eventType === "VideoAction") {
47
- qoeEventObject = {
48
- eventType: "VideoAction",
49
- actionName: Tracker.Events.QOE_AGGREGATE,
50
- qoeAggregateVersion: '1.0.0',
51
- ...qoeAttrs,
52
- ...metadataAttributes,
53
- ...otherAttrs,
54
- }
59
+ const harvester = harvesters[routingKey];
60
+ if (!harvester) {
61
+ Log.warn("No harvester registered for routing key", { routingKey });
62
+ return false;
55
63
  }
56
64
 
57
- // Send to video analytics harvester
58
- const success = videoAnalyticsHarvester.addEvent(eventObject);
59
-
60
- if(qoeEventObject && window?.NRVIDEO?.config?.qoeAggregate) {
61
- const successQoe = videoAnalyticsHarvester.addEvent(qoeEventObject);
62
- return success && successQoe;
63
- }
65
+ const qoeEnabled = isVega
66
+ ? globalThis.__NRVIDEO_CD__?.config?.qoeAggregate
67
+ : w?.NRVIDEO?.config?.qoeAggregate;
64
68
 
65
- return success;
69
+ return dispatchRecordEvent(
70
+ eventType, attributes, info, harvester, qoeEnabled,
71
+ { addTimeSinceLoad: !isVega }
72
+ );
66
73
  } catch (error) {
67
74
  Log.error("Failed to record event:", error.message);
68
75
  return false;
package/src/tracker.js CHANGED
@@ -2,7 +2,6 @@ import pkg from "../package.json";
2
2
  import Emitter from "./emitter";
3
3
  import Chrono from "./chrono";
4
4
  import Constants from "./constants";
5
- import { videoAnalyticsHarvester } from "./agent";
6
5
  import Log from "./log";
7
6
 
8
7
  /**
@@ -269,18 +268,29 @@ class Tracker extends Emitter {
269
268
  */
270
269
 
271
270
  setHarvestInterval(interval) {
272
- if (!videoAnalyticsHarvester) {
273
- Log.error("VideoAnalyticsHarvester is not available");
271
+ const harvester = this.getHarvester?.();
272
+ if (!harvester) {
273
+ Log.error("Tracker has no harvester; setHarvestInterval ignored");
274
274
  return;
275
275
  }
276
276
 
277
277
  try {
278
- videoAnalyticsHarvester.setHarvestInterval(interval);
278
+ harvester.setHarvestInterval(interval);
279
279
  } catch (error) {
280
280
  Log.error("Failed to set harvest interval:", error.message);
281
281
  return;
282
282
  }
283
283
  }
284
+
285
+ /**
286
+ * Default harvester accessor. Subclasses (Html5Tracker, VegaTracker, etc.)
287
+ * override this to point to their specific harvester. Returning `null` here
288
+ * means a tracker that doesn't override `getHarvester()` will silently no-op
289
+ * on harvester-bound calls (setHarvestInterval, QoE drain wiring).
290
+ */
291
+ getHarvester() {
292
+ return null;
293
+ }
284
294
  }
285
295
 
286
296
  /**
@@ -0,0 +1,126 @@
1
+ import Constants from "../constants";
2
+ import Tracker from "../tracker";
3
+ import { getObjectEntriesForKeys } from "./index";
4
+
5
+ /**
6
+ * Pure helper that constructs the wire-format event objects from `recordEvent`
7
+ * arguments. Used by `src/recordEvent.js`, which is the single shared
8
+ * implementation re-exported from all three subpath entry points:
9
+ *
10
+ * - `src/recordEvent.js` (registry dispatcher; runtime hot path
11
+ * and the only direct caller of this helper)
12
+ * - `src/browser/index.js` (Browser subpath re-export)
13
+ * - `src/connectedDevice/index.js` (Vega subpath re-export)
14
+ *
15
+ * Pipeline-specific concerns (which info global to read, which harvester to
16
+ * call, which qoeAggregate config to check) stay at the call site. This helper
17
+ * is purely about event-object shape.
18
+ *
19
+ * @param {string} eventType - One of `Constants.VALID_EVENT_TYPES`.
20
+ * @param {object} attributes - Caller-supplied attributes including optional `qoe`.
21
+ * @param {object} info - The pipeline's resolved `info` object (with `appName` /
22
+ * `applicationID` for namespacing).
23
+ * @param {{ addTimeSinceLoad?: boolean }} [opts] - When `true`, stamp
24
+ * `timeSinceLoad` on the event. Browser sets this; Vega does not.
25
+ *
26
+ * @returns {{ eventObject: object, qoeEventObject: object|null }}
27
+ */
28
+ export function buildEventObjects(
29
+ eventType,
30
+ attributes,
31
+ info,
32
+ { addTimeSinceLoad = false } = {}
33
+ ) {
34
+ const { appName, applicationID } = info;
35
+ const { qoe, ...eventAttributes } = attributes;
36
+ const qoeAttrs = qoe ? { ...qoe } : {};
37
+
38
+ const otherAttrs = {
39
+ ...(applicationID ? {} : { appName }),
40
+ timestamp: Date.now(),
41
+ ...(addTimeSinceLoad
42
+ ? {
43
+ timeSinceLoad:
44
+ typeof window !== "undefined" && window.performance
45
+ ? window.performance.now() / 1000
46
+ : null,
47
+ }
48
+ : {}),
49
+ };
50
+
51
+ const eventObject = {
52
+ ...eventAttributes,
53
+ eventType,
54
+ ...otherAttrs,
55
+ };
56
+
57
+ let qoeEventObject = null;
58
+ if (eventType === "VideoAction") {
59
+ const metadataAttributes = getObjectEntriesForKeys(
60
+ Constants.QOE_AGGREGATE_KEYS,
61
+ attributes
62
+ );
63
+ qoeEventObject = {
64
+ eventType: "VideoAction",
65
+ actionName: Tracker.Events.QOE_AGGREGATE,
66
+ qoeAggregateVersion: "1.0.0",
67
+ ...qoeAttrs,
68
+ ...metadataAttributes,
69
+ ...otherAttrs,
70
+ };
71
+ }
72
+
73
+ return { eventObject, qoeEventObject };
74
+ }
75
+
76
+ /**
77
+ * Build + dispatch a video analytics event to the given harvester.
78
+ *
79
+ * This is the full dispatch loop shared by all three `recordEvent` entry
80
+ * points:
81
+ * 1. Build `eventObject` (and optional `qoeEventObject`) via
82
+ * `buildEventObjects`.
83
+ * 2. Call `harvester.addEvent(eventObject)`.
84
+ * 3. If qoeEnabled and a QoE companion event was produced, also call
85
+ * `harvester.addEvent(qoeEventObject)`.
86
+ * 4. Return a boolean indicating overall success.
87
+ *
88
+ * Pipeline-specific concerns — which global to read for `info`, which
89
+ * harvester to use, whether qoeAggregate is on — are resolved by the caller.
90
+ * This function only handles the build + dispatch.
91
+ *
92
+ * @param {string} eventType
93
+ * @param {object} attributes
94
+ * @param {object} info - Resolved `info` from the pipeline's config global.
95
+ * @param {{ addEvent: function }} harvester - The registered harvester for
96
+ * the current pipeline. Returns false if null/undefined.
97
+ * @param {boolean} qoeEnabled
98
+ * @param {{ addTimeSinceLoad?: boolean }} [opts] - Forwarded to
99
+ * `buildEventObjects`.
100
+ * @returns {boolean}
101
+ */
102
+ export function dispatchRecordEvent(
103
+ eventType,
104
+ attributes,
105
+ info,
106
+ harvester,
107
+ qoeEnabled,
108
+ { addTimeSinceLoad = false } = {}
109
+ ) {
110
+ if (!harvester) return false;
111
+
112
+ const { eventObject, qoeEventObject } = buildEventObjects(
113
+ eventType,
114
+ attributes,
115
+ info,
116
+ { addTimeSinceLoad }
117
+ );
118
+
119
+ const success = harvester.addEvent(eventObject);
120
+
121
+ if (qoeEventObject && qoeEnabled) {
122
+ return success && harvester.addEvent(qoeEventObject);
123
+ }
124
+
125
+ return success;
126
+ }
@@ -0,0 +1,109 @@
1
+ import Log from "../log";
2
+
3
+ /**
4
+ * Periodic harvest timer using chained `setTimeout`. Each tick schedules the
5
+ * next one only after the current `onTick` callback resolves — by construction
6
+ * there can never be overlapping ticks, even if `onTick` is async and slow.
7
+ *
8
+ * Used by both pipelines:
9
+ * - `browser/harvestScheduler.js` — Browser pipeline drain orchestration
10
+ * - `connectedDevice/connectedDeviceHarvester.js` — Vega CAF pipeline
11
+ *
12
+ * Single source of truth for harvest timing. Future timer changes (drift
13
+ * compensation, jitter, exponential back-off, etc.) land here and apply to
14
+ * both pipelines automatically.
15
+ *
16
+ * @param {object} opts
17
+ * @param {number} opts.interval - Initial tick interval in ms.
18
+ * @param {() => Promise<void>|void} opts.onTick - Pipeline-specific drain
19
+ * callback. Awaited; the next tick is scheduled in `finally` so a slow or
20
+ * throwing tick can't overlap with the next one.
21
+ * @param {string} opts.errorLabel - Prefix for the error log when `onTick`
22
+ * throws (e.g. "HarvestScheduler", "ConnectedDeviceHarvester").
23
+ *
24
+ * @returns {{
25
+ * start: () => void,
26
+ * stop: () => void,
27
+ * cancelAndReschedule: () => void,
28
+ * updateInterval: (ms: number) => void,
29
+ * isRunning: () => boolean,
30
+ * }}
31
+ */
32
+ export function createHarvestTimer({ interval, onTick, errorLabel }) {
33
+ let isStarted = false;
34
+ let currentTimerId = null;
35
+ let currentInterval = interval;
36
+
37
+ function scheduleNext() {
38
+ if (!isStarted) return;
39
+ currentTimerId = setTimeout(async () => {
40
+ currentTimerId = null;
41
+ try {
42
+ await onTick();
43
+ } catch (error) {
44
+ Log.error(`${errorLabel}: scheduled tick failed:`, error.message);
45
+ } finally {
46
+ if (isStarted) scheduleNext();
47
+ }
48
+ }, currentInterval);
49
+ }
50
+
51
+ return {
52
+ /**
53
+ * Start the timer. Idempotent — repeated calls have no effect while
54
+ * already running.
55
+ */
56
+ start() {
57
+ if (isStarted) return;
58
+ isStarted = true;
59
+ scheduleNext();
60
+ },
61
+
62
+ /**
63
+ * Stop the timer. The pending tick (if any) is cancelled. The currently-
64
+ * executing tick (if any) will see `isStarted === false` in its `finally`
65
+ * and won't schedule a successor.
66
+ */
67
+ stop() {
68
+ isStarted = false;
69
+ if (currentTimerId) {
70
+ clearTimeout(currentTimerId);
71
+ currentTimerId = null;
72
+ }
73
+ },
74
+
75
+ /**
76
+ * Cancel any pending tick and reschedule from now. Used by smart-harvest /
77
+ * forceHarvest paths that drain the buffer immediately and want the
78
+ * periodic clock reset relative to that drain.
79
+ */
80
+ cancelAndReschedule() {
81
+ if (currentTimerId) {
82
+ clearTimeout(currentTimerId);
83
+ currentTimerId = null;
84
+ }
85
+ if (isStarted) scheduleNext();
86
+ },
87
+
88
+ /**
89
+ * Update the tick interval. Takes effect on the next scheduled tick.
90
+ * If the timer is currently running, the pending tick is cancelled and
91
+ * a new one is scheduled with the new interval.
92
+ *
93
+ * @param {number} ms - New interval in milliseconds. Must be a positive
94
+ * number; invalid values are silently ignored.
95
+ */
96
+ updateInterval(ms) {
97
+ if (typeof ms !== "number" || ms <= 0) return;
98
+ currentInterval = ms;
99
+ if (isStarted && currentTimerId) {
100
+ clearTimeout(currentTimerId);
101
+ currentTimerId = null;
102
+ scheduleNext();
103
+ }
104
+ },
105
+
106
+ /** @returns {boolean} True iff `start()` has been called and `stop()` has not. */
107
+ isRunning: () => isStarted,
108
+ };
109
+ }
@@ -1,5 +1,5 @@
1
- import pkg from "../package.json";
2
- import Log from "./log";
1
+ import pkg from "../../package.json";
2
+ import Log from "../log";
3
3
 
4
4
  /**
5
5
  * Builds the harvest URL with proper query parameters.