attune-sdk 0.4.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 (87) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +355 -0
  3. package/dist/action.d.ts +34 -0
  4. package/dist/action.d.ts.map +1 -0
  5. package/dist/action.js +62 -0
  6. package/dist/action.js.map +1 -0
  7. package/dist/api_client/client/client.gen.d.ts +3 -0
  8. package/dist/api_client/client/client.gen.d.ts.map +1 -0
  9. package/dist/api_client/client/client.gen.js +217 -0
  10. package/dist/api_client/client/client.gen.js.map +1 -0
  11. package/dist/api_client/client/index.d.ts +9 -0
  12. package/dist/api_client/client/index.d.ts.map +1 -0
  13. package/dist/api_client/client/index.js +7 -0
  14. package/dist/api_client/client/index.js.map +1 -0
  15. package/dist/api_client/client/types.gen.d.ts +121 -0
  16. package/dist/api_client/client/types.gen.d.ts.map +1 -0
  17. package/dist/api_client/client/types.gen.js +3 -0
  18. package/dist/api_client/client/types.gen.js.map +1 -0
  19. package/dist/api_client/client/utils.gen.d.ts +38 -0
  20. package/dist/api_client/client/utils.gen.d.ts.map +1 -0
  21. package/dist/api_client/client/utils.gen.js +229 -0
  22. package/dist/api_client/client/utils.gen.js.map +1 -0
  23. package/dist/api_client/client.gen.d.ts +13 -0
  24. package/dist/api_client/client.gen.d.ts.map +1 -0
  25. package/dist/api_client/client.gen.js +4 -0
  26. package/dist/api_client/client.gen.js.map +1 -0
  27. package/dist/api_client/core/auth.gen.d.ts +19 -0
  28. package/dist/api_client/core/auth.gen.d.ts.map +1 -0
  29. package/dist/api_client/core/auth.gen.js +15 -0
  30. package/dist/api_client/core/auth.gen.js.map +1 -0
  31. package/dist/api_client/core/bodySerializer.gen.d.ts +26 -0
  32. package/dist/api_client/core/bodySerializer.gen.d.ts.map +1 -0
  33. package/dist/api_client/core/bodySerializer.gen.js +58 -0
  34. package/dist/api_client/core/bodySerializer.gen.js.map +1 -0
  35. package/dist/api_client/core/params.gen.d.ts +44 -0
  36. package/dist/api_client/core/params.gen.d.ts.map +1 -0
  37. package/dist/api_client/core/params.gen.js +101 -0
  38. package/dist/api_client/core/params.gen.js.map +1 -0
  39. package/dist/api_client/core/pathSerializer.gen.d.ts +34 -0
  40. package/dist/api_client/core/pathSerializer.gen.d.ts.map +1 -0
  41. package/dist/api_client/core/pathSerializer.gen.js +107 -0
  42. package/dist/api_client/core/pathSerializer.gen.js.map +1 -0
  43. package/dist/api_client/core/queryKeySerializer.gen.d.ts +19 -0
  44. package/dist/api_client/core/queryKeySerializer.gen.d.ts.map +1 -0
  45. package/dist/api_client/core/queryKeySerializer.gen.js +93 -0
  46. package/dist/api_client/core/queryKeySerializer.gen.js.map +1 -0
  47. package/dist/api_client/core/serverSentEvents.gen.d.ts +72 -0
  48. package/dist/api_client/core/serverSentEvents.gen.d.ts.map +1 -0
  49. package/dist/api_client/core/serverSentEvents.gen.js +133 -0
  50. package/dist/api_client/core/serverSentEvents.gen.js.map +1 -0
  51. package/dist/api_client/core/types.gen.d.ts +79 -0
  52. package/dist/api_client/core/types.gen.d.ts.map +1 -0
  53. package/dist/api_client/core/types.gen.js +3 -0
  54. package/dist/api_client/core/types.gen.js.map +1 -0
  55. package/dist/api_client/core/utils.gen.d.ts +20 -0
  56. package/dist/api_client/core/utils.gen.d.ts.map +1 -0
  57. package/dist/api_client/core/utils.gen.js +88 -0
  58. package/dist/api_client/core/utils.gen.js.map +1 -0
  59. package/dist/api_client/index.d.ts +3 -0
  60. package/dist/api_client/index.d.ts.map +1 -0
  61. package/dist/api_client/index.js +3 -0
  62. package/dist/api_client/index.js.map +1 -0
  63. package/dist/api_client/sdk.gen.d.ts +1000 -0
  64. package/dist/api_client/sdk.gen.d.ts.map +1 -0
  65. package/dist/api_client/sdk.gen.js +2145 -0
  66. package/dist/api_client/sdk.gen.js.map +1 -0
  67. package/dist/api_client/types.gen.d.ts +19794 -0
  68. package/dist/api_client/types.gen.d.ts.map +1 -0
  69. package/dist/api_client/types.gen.js +3 -0
  70. package/dist/api_client/types.gen.js.map +1 -0
  71. package/dist/client.d.ts +42 -0
  72. package/dist/client.d.ts.map +1 -0
  73. package/dist/client.js +73 -0
  74. package/dist/client.js.map +1 -0
  75. package/dist/context.d.ts +112 -0
  76. package/dist/context.d.ts.map +1 -0
  77. package/dist/context.js +108 -0
  78. package/dist/context.js.map +1 -0
  79. package/dist/index.d.ts +43 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +38 -0
  82. package/dist/index.js.map +1 -0
  83. package/dist/sensor.d.ts +127 -0
  84. package/dist/sensor.d.ts.map +1 -0
  85. package/dist/sensor.js +880 -0
  86. package/dist/sensor.js.map +1 -0
  87. package/package.json +64 -0
package/dist/sensor.js ADDED
@@ -0,0 +1,880 @@
1
+ /**
2
+ * Sensor base classes — lifecycle management, rule change hooks, event emission,
3
+ * signal handling, and polling helpers.
4
+ *
5
+ * Architecture:
6
+ *
7
+ * Sensor (base — custom event loops)
8
+ * ├── PollingSensor (synchronous polling with setInterval per rule)
9
+ * └── AsyncPollingSensor (async polling with per-rule loops)
10
+ *
11
+ * Quick start — polling:
12
+ *
13
+ * import { PollingSensor, runSensor } from "attune-sdk";
14
+ *
15
+ * class TempSensor extends PollingSensor {
16
+ * interval = 5000;
17
+ * async poll(rule: RuleState) {
18
+ * const temp = readTemp(rule.triggerParams.device);
19
+ * if (temp > 100) this.emit({ temperature: temp }, { rule });
20
+ * }
21
+ * }
22
+ *
23
+ * runSensor(TempSensor);
24
+ */
25
+ import { sensorContext, } from "./context.js";
26
+ import WebSocket from "ws";
27
+ const LOG_LEVELS = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
28
+ const DEFAULT_TOKEN_ROTATION_SKEW_MS = 30_000;
29
+ const PROACTIVE_ROTATION_CLOSE_CODE = 4001;
30
+ const PROACTIVE_ROTATION_REASON = "sensor token rotation";
31
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
32
+ const NOTIFIER_CONNECT_TIMEOUT_MS = 10_000;
33
+ class Logger {
34
+ level;
35
+ sensorRef;
36
+ constructor(sensorRef, level) {
37
+ this.sensorRef = sensorRef;
38
+ this.level = LOG_LEVELS[level] ?? LOG_LEVELS.INFO;
39
+ }
40
+ write(level, message, extra) {
41
+ if (LOG_LEVELS[level] < this.level)
42
+ return;
43
+ const entry = {
44
+ timestamp: new Date().toISOString(),
45
+ level,
46
+ logger: `attune.sensor.${this.sensorRef}`,
47
+ message,
48
+ sensor: this.sensorRef,
49
+ ...extra,
50
+ };
51
+ process.stderr.write(JSON.stringify(entry) + "\n");
52
+ }
53
+ debug(message, extra) { this.write("DEBUG", message, extra); }
54
+ info(message, extra) { this.write("INFO", message, extra); }
55
+ warn(message, extra) { this.write("WARN", message, extra); }
56
+ error(message, extra) { this.write("ERROR", message, extra); }
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Base Sensor
60
+ // ---------------------------------------------------------------------------
61
+ export class Sensor {
62
+ context = sensorContext;
63
+ logger;
64
+ _shutdownRequested = false;
65
+ _rules = new Map();
66
+ constructor() {
67
+ this.logger = new Logger(this.context.sensorRef || "unknown", this.context.logLevel);
68
+ }
69
+ // ------------------------------------------------------------------
70
+ // Properties
71
+ // ------------------------------------------------------------------
72
+ get isShuttingDown() {
73
+ return this._shutdownRequested;
74
+ }
75
+ get rules() {
76
+ return new Map(this._rules);
77
+ }
78
+ get config() {
79
+ return this.context.config;
80
+ }
81
+ // ------------------------------------------------------------------
82
+ // HTTP helpers
83
+ // ------------------------------------------------------------------
84
+ getCurrentTokenState() {
85
+ if (typeof this.context.getTokenState === "function") {
86
+ return this.context.getTokenState();
87
+ }
88
+ return {
89
+ token: this.context.apiToken ?? "",
90
+ expiresAt: null,
91
+ source: this.context.apiToken ? "env" : "none",
92
+ };
93
+ }
94
+ getHttpHeaders() {
95
+ const headers = {
96
+ "Content-Type": "application/json",
97
+ };
98
+ const token = this.getCurrentTokenState().token;
99
+ if (token.length > 0) {
100
+ headers.Authorization = `Bearer ${token}`;
101
+ }
102
+ return headers;
103
+ }
104
+ // ------------------------------------------------------------------
105
+ // Event emission
106
+ // ------------------------------------------------------------------
107
+ async emit(payload, options = {}) {
108
+ const { rule, triggerRef } = options;
109
+ const targetRule = rule !== undefined && options.targetRule !== false;
110
+ if (targetRule && !isValidRuleId(rule.ruleId)) {
111
+ this.logger.error("Cannot target event: rule ID must be a positive safe integer", {
112
+ rule_id: rule.ruleId,
113
+ });
114
+ return null;
115
+ }
116
+ const resolvedTriggerRef = triggerRef ?? rule?.triggerRef ?? this.context.sensorRef;
117
+ const body = {
118
+ trigger_ref: resolvedTriggerRef,
119
+ payload,
120
+ source: this.context.sensorRef,
121
+ };
122
+ if (rule && targetRule) {
123
+ body.trigger_instance_id = `rule_${rule.ruleId}`;
124
+ }
125
+ try {
126
+ const resp = await fetch(`${this.context.apiUrl}/api/v1/events`, {
127
+ method: "POST",
128
+ headers: this.getHttpHeaders(),
129
+ body: JSON.stringify(body),
130
+ signal: AbortSignal.timeout(10_000),
131
+ });
132
+ if (!resp.ok) {
133
+ this.logger.error(`Failed to emit event: HTTP ${resp.status}`);
134
+ return null;
135
+ }
136
+ const data = (await resp.json());
137
+ const eventId = data?.data?.id ?? null;
138
+ this.logger.debug("Event emitted", { trigger_ref: resolvedTriggerRef, event_id: eventId });
139
+ return eventId;
140
+ }
141
+ catch (err) {
142
+ // Retry once on connection errors
143
+ if (err instanceof TypeError || err?.code === "ECONNREFUSED") {
144
+ this.logger.warn(`Transport error, retrying: ${err}`);
145
+ try {
146
+ const resp = await fetch(`${this.context.apiUrl}/api/v1/events`, {
147
+ method: "POST",
148
+ headers: this.getHttpHeaders(),
149
+ body: JSON.stringify(body),
150
+ signal: AbortSignal.timeout(10_000),
151
+ });
152
+ if (!resp.ok) {
153
+ this.logger.error(`Failed to emit event after retry: HTTP ${resp.status}`);
154
+ return null;
155
+ }
156
+ const data = (await resp.json());
157
+ return data?.data?.id ?? null;
158
+ }
159
+ catch (retryErr) {
160
+ this.logger.error(`Failed to emit event after retry: ${retryErr}`);
161
+ return null;
162
+ }
163
+ }
164
+ this.logger.error(`Failed to emit event: ${err}`);
165
+ return null;
166
+ }
167
+ }
168
+ // ------------------------------------------------------------------
169
+ // Lifecycle hooks (override in subclasses)
170
+ // ------------------------------------------------------------------
171
+ /** Called once before the main loop starts. Override to initialize resources. */
172
+ async setup() { }
173
+ /** Called once during shutdown. Override to release resources. */
174
+ async cleanup() { }
175
+ /** Main sensor loop. Override for custom event-driven sensors. */
176
+ async run() {
177
+ while (!this._shutdownRequested) {
178
+ await sleep(500);
179
+ }
180
+ }
181
+ // ------------------------------------------------------------------
182
+ // Rule lifecycle hooks (override in subclasses)
183
+ // ------------------------------------------------------------------
184
+ onRuleCreated(_rule) { }
185
+ onRuleEnabled(rule) { this.onRuleCreated(rule); }
186
+ onRuleDisabled(_rule) { }
187
+ onRuleDeleted(rule) { this.onRuleDisabled(rule); }
188
+ onRuleUpdated(rule, _oldParams) {
189
+ this.onRuleDisabled(rule);
190
+ this.onRuleEnabled(rule);
191
+ }
192
+ // ------------------------------------------------------------------
193
+ // Rule management
194
+ // ------------------------------------------------------------------
195
+ async _handleRuleMessage(message) {
196
+ const eventType = normalizeRuleEventType(message.event_type);
197
+ if (!eventType)
198
+ return;
199
+ const rawRuleId = message.rule_id;
200
+ if (rawRuleId == null)
201
+ return;
202
+ const ruleId = parseRuleId(rawRuleId);
203
+ if (ruleId === null) {
204
+ this.logger.warn("Ignoring rule lifecycle message with invalid rule ID");
205
+ return;
206
+ }
207
+ const existing = this._rules.get(ruleId);
208
+ let ruleRef = typeof message.rule_ref === "string" && message.rule_ref.length > 0
209
+ ? message.rule_ref
210
+ : existing?.ruleRef ?? `rule_${ruleId}`;
211
+ let triggerRef = typeof message.trigger_ref === "string"
212
+ ? message.trigger_ref
213
+ : typeof message.trigger_type === "string"
214
+ ? message.trigger_type
215
+ : existing?.triggerRef ?? "";
216
+ const deferred = message.auth_mode === "deferred";
217
+ let suppliedTriggerParams = toRecord(message.trigger_params) ?? toRecord(message.config);
218
+ let active = typeof message.active === "boolean" ? message.active : undefined;
219
+ if (deferred &&
220
+ suppliedTriggerParams == null &&
221
+ eventType !== "disabled" &&
222
+ eventType !== "deleted") {
223
+ const fetched = await this._fetchCurrentRule(ruleRef, ruleId);
224
+ if (!fetched)
225
+ return;
226
+ ruleRef = fetched.ref;
227
+ triggerRef = fetched.trigger_ref;
228
+ suppliedTriggerParams = fetched.trigger_params;
229
+ active = fetched.enabled;
230
+ }
231
+ const triggerParams = suppliedTriggerParams ?? existing?.triggerParams ?? {};
232
+ if (eventType === "created" || eventType === "enabled") {
233
+ const rule = {
234
+ ruleId,
235
+ ruleRef,
236
+ triggerRef,
237
+ triggerParams,
238
+ enabled: active ?? true,
239
+ };
240
+ this._rules.set(ruleId, rule);
241
+ if (existing && JSON.stringify(existing.triggerParams) !== JSON.stringify(triggerParams)) {
242
+ this.onRuleUpdated(rule, existing.triggerParams);
243
+ }
244
+ else if (eventType === "enabled" && existing) {
245
+ this.onRuleEnabled(rule);
246
+ }
247
+ else {
248
+ this.onRuleCreated(rule);
249
+ }
250
+ }
251
+ else if (eventType === "disabled") {
252
+ const rule = this._rules.get(ruleId);
253
+ if (rule) {
254
+ rule.enabled = false;
255
+ this.onRuleDisabled(rule);
256
+ }
257
+ }
258
+ else if (eventType === "deleted") {
259
+ const rule = this._rules.get(ruleId);
260
+ this._rules.delete(ruleId);
261
+ if (rule)
262
+ this.onRuleDeleted(rule);
263
+ }
264
+ else if (eventType === "updated") {
265
+ if (existing) {
266
+ const oldParams = { ...existing.triggerParams };
267
+ existing.ruleRef = ruleRef;
268
+ existing.triggerRef = triggerRef;
269
+ existing.triggerParams = triggerParams;
270
+ existing.enabled = active ?? existing.enabled;
271
+ if (JSON.stringify(oldParams) !== JSON.stringify(triggerParams)) {
272
+ this.onRuleUpdated(existing, oldParams);
273
+ }
274
+ }
275
+ else {
276
+ const rule = {
277
+ ruleId,
278
+ ruleRef,
279
+ triggerRef,
280
+ triggerParams,
281
+ enabled: active ?? true,
282
+ };
283
+ this._rules.set(ruleId, rule);
284
+ this.onRuleCreated(rule);
285
+ }
286
+ }
287
+ }
288
+ async _fetchCurrentRule(ruleRef, ruleId) {
289
+ if (!ruleRef || ruleRef === `rule_${ruleId}`) {
290
+ this.logger.warn("Cannot refetch deferred rule lifecycle message without a rule reference", {
291
+ rule_id: ruleId,
292
+ });
293
+ return null;
294
+ }
295
+ const token = this.getCurrentTokenState().token;
296
+ if (!token) {
297
+ this.logger.warn("Cannot refetch deferred rule lifecycle message without an API token", {
298
+ rule_id: ruleId,
299
+ rule_ref: ruleRef,
300
+ });
301
+ return null;
302
+ }
303
+ try {
304
+ const response = await fetch(`${this.context.apiUrl}/api/v1/rules/${encodeURIComponent(ruleRef)}`, {
305
+ method: "GET",
306
+ headers: { Authorization: `Bearer ${token}` },
307
+ signal: AbortSignal.timeout(10_000),
308
+ });
309
+ if (!response.ok)
310
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
311
+ const payload = toRecord(await response.json());
312
+ const rule = toRecord(payload?.data);
313
+ if (!rule ||
314
+ rule.id !== ruleId ||
315
+ typeof rule.ref !== "string" ||
316
+ typeof rule.trigger_ref !== "string" ||
317
+ typeof rule.enabled !== "boolean" ||
318
+ !toRecord(rule.trigger_params)) {
319
+ this.logger.warn("Refetched deferred rule details did not match the lifecycle message", {
320
+ rule_id: ruleId,
321
+ rule_ref: ruleRef,
322
+ });
323
+ return null;
324
+ }
325
+ return rule;
326
+ }
327
+ catch (err) {
328
+ this.logger.warn("Failed to refetch deferred rule lifecycle details", {
329
+ rule_id: ruleId,
330
+ rule_ref: ruleRef,
331
+ error: String(err),
332
+ });
333
+ return null;
334
+ }
335
+ }
336
+ async _handleNotifierEnvelope(data) {
337
+ const message = extractRuleLifecycleMessage(data);
338
+ if (!message)
339
+ return;
340
+ await this._handleRuleMessage(message);
341
+ }
342
+ _getManagedTriggerFilters() {
343
+ const declaredTriggerRefs = parseStringArray(process.env.ATTUNE_SENSOR_TRIGGER_TYPES);
344
+ const triggerRefs = declaredTriggerRefs.length > 0
345
+ ? declaredTriggerRefs
346
+ : [...this._rules.values()].map((rule) => rule.triggerRef);
347
+ return [...new Set(triggerRefs
348
+ .map((triggerRef) => triggerRef.trim())
349
+ .filter((triggerRef) => triggerRef.length > 0)
350
+ .map((triggerRef) => `trigger_ref:${triggerRef}`))];
351
+ }
352
+ _bootstrapRules() {
353
+ const raw = process.env.ATTUNE_SENSOR_TRIGGERS ?? "[]";
354
+ let triggers;
355
+ try {
356
+ triggers = JSON.parse(raw);
357
+ }
358
+ catch {
359
+ triggers = [];
360
+ }
361
+ if (!Array.isArray(triggers))
362
+ triggers = [];
363
+ for (const item of triggers) {
364
+ if (typeof item !== "object" || item === null)
365
+ continue;
366
+ const obj = item;
367
+ const ruleId = obj.id ?? obj.rule_id;
368
+ if (ruleId == null)
369
+ continue;
370
+ this._handleRuleMessage({
371
+ event_type: "rule.created",
372
+ rule_id: ruleId,
373
+ rule_ref: obj.ref ?? obj.rule_ref ?? `rule_${ruleId}`,
374
+ trigger_ref: obj.trigger_ref ?? "",
375
+ trigger_params: (obj.config ?? obj.trigger_params ?? {}),
376
+ });
377
+ }
378
+ }
379
+ // ------------------------------------------------------------------
380
+ // Notifier WebSocket consumer (optional)
381
+ // ------------------------------------------------------------------
382
+ _notifierConnection = null;
383
+ _notifierLoop = null;
384
+ _notifierMessageQueue = Promise.resolve();
385
+ async _startNotifierConsumer() {
386
+ const notifierWsUrl = this.context.notifierWsUrl;
387
+ if (!notifierWsUrl) {
388
+ this.logger.info("Notifier WebSocket URL not configured; managed rule updates disabled");
389
+ return false;
390
+ }
391
+ try {
392
+ validateNotifierUrl(notifierWsUrl);
393
+ }
394
+ catch (err) {
395
+ this.logger.error(`Notifier WebSocket disabled: ${err}`);
396
+ return false;
397
+ }
398
+ this._notifierLoop = this._notifierConsumeLoop(notifierWsUrl).catch((err) => {
399
+ this.logger.warn(`Notifier loop exited: ${err}`);
400
+ });
401
+ return true;
402
+ }
403
+ async _notifierConsumeLoop(notifierWsUrl) {
404
+ while (!this._shutdownRequested) {
405
+ try {
406
+ await this._connectNotifier(notifierWsUrl);
407
+ }
408
+ catch (err) {
409
+ this.logger.warn(`Notifier connection error, retrying in 5s: ${err}`);
410
+ }
411
+ finally {
412
+ this._notifierConnection = null;
413
+ }
414
+ if (!this._shutdownRequested) {
415
+ await interruptibleShutdownSleep(5000, () => this._shutdownRequested);
416
+ }
417
+ }
418
+ }
419
+ async _connectNotifier(notifierWsUrl) {
420
+ const filters = this._getManagedTriggerFilters();
421
+ if (filters.length === 0) {
422
+ this.logger.info("No managed trigger refs available for notifier subscriptions");
423
+ return;
424
+ }
425
+ const tokenState = this.getCurrentTokenState();
426
+ if (!tokenState.token) {
427
+ this.logger.warn("Notifier token unavailable; skipping connection attempt");
428
+ return;
429
+ }
430
+ await new Promise((resolve, reject) => {
431
+ let connected = false;
432
+ let settled = false;
433
+ let proactiveRotationTimer = null;
434
+ let connectTimer = null;
435
+ const ws = new WebSocket(notifierWsUrl, {
436
+ headers: { Authorization: `Bearer ${tokenState.token}` },
437
+ });
438
+ this._notifierConnection = ws;
439
+ const finish = (error) => {
440
+ if (settled)
441
+ return;
442
+ settled = true;
443
+ if (proactiveRotationTimer) {
444
+ clearTimeout(proactiveRotationTimer);
445
+ proactiveRotationTimer = null;
446
+ }
447
+ if (connectTimer) {
448
+ clearTimeout(connectTimer);
449
+ connectTimer = null;
450
+ }
451
+ if (this._notifierConnection === ws) {
452
+ this._notifierConnection = null;
453
+ }
454
+ if (error && !connected) {
455
+ reject(error);
456
+ }
457
+ else {
458
+ resolve();
459
+ }
460
+ };
461
+ connectTimer = setTimeout(() => {
462
+ ws.terminate();
463
+ finish(new Error("notifier connection timed out"));
464
+ }, NOTIFIER_CONNECT_TIMEOUT_MS);
465
+ ws.on("open", () => {
466
+ connected = true;
467
+ if (connectTimer) {
468
+ clearTimeout(connectTimer);
469
+ connectTimer = null;
470
+ }
471
+ if (tokenState.expiresAt) {
472
+ const reconnectAt = tokenState.expiresAt.getTime() - DEFAULT_TOKEN_ROTATION_SKEW_MS;
473
+ const delay = Math.max(0, reconnectAt - Date.now());
474
+ proactiveRotationTimer = setTimeout(() => {
475
+ if (!this._shutdownRequested && ws.readyState === WebSocket.OPEN) {
476
+ this.logger.info("Reconnecting notifier before token expiry", {
477
+ token_source: tokenState.source,
478
+ expires_at: tokenState.expiresAt?.toISOString(),
479
+ });
480
+ ws.close(PROACTIVE_ROTATION_CLOSE_CODE, PROACTIVE_ROTATION_REASON);
481
+ }
482
+ }, delay);
483
+ }
484
+ for (const filter of filters) {
485
+ ws.send(JSON.stringify({ type: "subscribe", filter }));
486
+ }
487
+ this.logger.info("Notifier connected", { filters });
488
+ if (this._shutdownRequested) {
489
+ ws.close();
490
+ }
491
+ });
492
+ ws.on("message", (data) => {
493
+ this._notifierMessageQueue = this._notifierMessageQueue
494
+ .then(() => this._handleNotifierEnvelope(data))
495
+ .catch((err) => {
496
+ this.logger.warn(`Invalid notifier message: ${err}`);
497
+ });
498
+ });
499
+ ws.on("error", (err) => {
500
+ this.logger.warn(`Notifier socket error: ${err.message}`);
501
+ ws.terminate();
502
+ finish(err);
503
+ });
504
+ ws.on("close", (code, reason) => {
505
+ if (!this._shutdownRequested) {
506
+ const closeReason = decodeWebSocketReason(reason);
507
+ this.logger.warn("Notifier disconnected", {
508
+ code,
509
+ ...(closeReason ? { reason: closeReason } : {}),
510
+ });
511
+ if (code === 4401) {
512
+ this.logger.info("Notifier requested reconnect due to token expiry");
513
+ }
514
+ }
515
+ finish();
516
+ });
517
+ });
518
+ }
519
+ // ------------------------------------------------------------------
520
+ // Signal handling
521
+ // ------------------------------------------------------------------
522
+ _installSignalHandlers() {
523
+ const handler = (signal) => {
524
+ this.logger.info(`Received ${signal}, shutting down`);
525
+ this.shutdown();
526
+ };
527
+ const onSigterm = () => handler("SIGTERM");
528
+ const onSigint = () => handler("SIGINT");
529
+ process.on("SIGTERM", onSigterm);
530
+ process.on("SIGINT", onSigint);
531
+ return () => {
532
+ process.off("SIGTERM", onSigterm);
533
+ process.off("SIGINT", onSigint);
534
+ };
535
+ }
536
+ shutdown() {
537
+ this._shutdownRequested = true;
538
+ this._notifierConnection?.close();
539
+ }
540
+ // ------------------------------------------------------------------
541
+ // Main lifecycle
542
+ // ------------------------------------------------------------------
543
+ async _runLifecycle() {
544
+ const removeSignalHandlers = this._installSignalHandlers();
545
+ try {
546
+ this._bootstrapRules();
547
+ await this.setup();
548
+ await this._startNotifierConsumer();
549
+ this.logger.info("Sensor started", { active_rules: this._rules.size });
550
+ await this.run();
551
+ }
552
+ catch (err) {
553
+ this.logger.error(`Sensor error: ${err}`);
554
+ return 1;
555
+ }
556
+ finally {
557
+ this._shutdownRequested = true;
558
+ removeSignalHandlers();
559
+ const notifierConnection = this._notifierConnection;
560
+ notifierConnection?.close();
561
+ if (this._notifierLoop) {
562
+ await Promise.race([this._notifierLoop, sleep(1000)]);
563
+ this._notifierLoop = null;
564
+ }
565
+ if (notifierConnection && this._notifierConnection === notifierConnection && notifierConnection.readyState !== WebSocket.CLOSED) {
566
+ notifierConnection.terminate();
567
+ this._notifierConnection = null;
568
+ }
569
+ try {
570
+ await this.cleanup();
571
+ }
572
+ catch (err) {
573
+ this.logger.error(`Cleanup error: ${err}`);
574
+ }
575
+ this.logger.info("Sensor stopped");
576
+ }
577
+ return 0;
578
+ }
579
+ }
580
+ // ---------------------------------------------------------------------------
581
+ // PollingSensor — setInterval-based per-rule polling
582
+ // ---------------------------------------------------------------------------
583
+ export class PollingSensor extends Sensor {
584
+ /** Default polling interval in milliseconds. */
585
+ interval = 5000;
586
+ _pollTimers = new Map();
587
+ _running = false;
588
+ /** Called periodically for each active rule. Override to check for events. */
589
+ async poll(_rule) { }
590
+ _getRuleInterval(rule) {
591
+ return resolvePollingInterval(rule.triggerParams, this.interval);
592
+ }
593
+ _startPollTimer(rule) {
594
+ if (!this._running)
595
+ return;
596
+ this._stopPollTimer(rule.ruleId);
597
+ const interval = this._getRuleInterval(rule);
598
+ const timer = setInterval(async () => {
599
+ const currentRule = this._rules.get(rule.ruleId);
600
+ if (!currentRule || !currentRule.enabled || this.isShuttingDown) {
601
+ this._stopPollTimer(rule.ruleId);
602
+ return;
603
+ }
604
+ try {
605
+ await this.poll(currentRule);
606
+ }
607
+ catch (err) {
608
+ this.logger.error(`Poll error for rule ${currentRule.ruleRef}: ${err}`);
609
+ }
610
+ }, interval);
611
+ this._pollTimers.set(rule.ruleId, timer);
612
+ // Also run immediately
613
+ this.poll(rule).catch((err) => {
614
+ this.logger.error(`Poll error for rule ${rule.ruleRef}: ${err}`);
615
+ });
616
+ }
617
+ _stopPollTimer(ruleId) {
618
+ const timer = this._pollTimers.get(ruleId);
619
+ if (timer) {
620
+ clearInterval(timer);
621
+ this._pollTimers.delete(ruleId);
622
+ }
623
+ }
624
+ onRuleCreated(rule) { this._startPollTimer(rule); }
625
+ onRuleEnabled(rule) { this._startPollTimer(rule); }
626
+ onRuleDisabled(rule) { this._stopPollTimer(rule.ruleId); }
627
+ onRuleDeleted(rule) { this._stopPollTimer(rule.ruleId); }
628
+ onRuleUpdated(rule, _oldParams) {
629
+ this._startPollTimer(rule);
630
+ }
631
+ async run() {
632
+ this._running = true;
633
+ for (const rule of this._rules.values()) {
634
+ if (rule.enabled)
635
+ this._startPollTimer(rule);
636
+ }
637
+ while (!this.isShuttingDown) {
638
+ await sleep(500);
639
+ }
640
+ }
641
+ async cleanup() {
642
+ for (const ruleId of [...this._pollTimers.keys()]) {
643
+ this._stopPollTimer(ruleId);
644
+ }
645
+ }
646
+ }
647
+ // ---------------------------------------------------------------------------
648
+ // AsyncPollingSensor — async loop per rule (for async/await-heavy sensors)
649
+ // ---------------------------------------------------------------------------
650
+ export class AsyncPollingSensor extends Sensor {
651
+ /** Default polling interval in milliseconds. */
652
+ interval = 5000;
653
+ _pollAbortControllers = new Map();
654
+ _pollPromises = new Map();
655
+ _running = false;
656
+ /** Called periodically for each active rule (async). Override to check for events. */
657
+ async poll(_rule) { }
658
+ _getRuleInterval(rule) {
659
+ return resolvePollingInterval(rule.triggerParams, this.interval);
660
+ }
661
+ _startPollTask(rule) {
662
+ if (!this._running)
663
+ return;
664
+ this._cancelPollTask(rule.ruleId);
665
+ const controller = new AbortController();
666
+ this._pollAbortControllers.set(rule.ruleId, controller);
667
+ const promise = this._pollLoop(rule.ruleId, controller.signal);
668
+ this._pollPromises.set(rule.ruleId, promise);
669
+ }
670
+ async _pollLoop(ruleId, signal) {
671
+ while (!this.isShuttingDown && !signal.aborted) {
672
+ const rule = this._rules.get(ruleId);
673
+ if (!rule || !rule.enabled)
674
+ break;
675
+ try {
676
+ await this.poll(rule);
677
+ }
678
+ catch (err) {
679
+ if (signal.aborted)
680
+ break;
681
+ this.logger.error(`Poll error for rule ${rule.ruleRef}: ${err}`);
682
+ }
683
+ const interval = this._getRuleInterval(rule);
684
+ await interruptibleSleep(interval, signal);
685
+ }
686
+ }
687
+ _cancelPollTask(ruleId) {
688
+ const controller = this._pollAbortControllers.get(ruleId);
689
+ if (controller) {
690
+ controller.abort();
691
+ this._pollAbortControllers.delete(ruleId);
692
+ }
693
+ }
694
+ onRuleCreated(rule) { this._startPollTask(rule); }
695
+ onRuleEnabled(rule) { this._startPollTask(rule); }
696
+ onRuleDisabled(rule) { this._cancelPollTask(rule.ruleId); }
697
+ onRuleDeleted(rule) { this._cancelPollTask(rule.ruleId); }
698
+ onRuleUpdated(rule, _oldParams) {
699
+ this._startPollTask(rule);
700
+ }
701
+ async run() {
702
+ this._running = true;
703
+ // Start poll tasks for bootstrapped rules
704
+ for (const rule of this._rules.values()) {
705
+ if (rule.enabled)
706
+ this._startPollTask(rule);
707
+ }
708
+ while (!this.isShuttingDown) {
709
+ await sleep(1000);
710
+ }
711
+ }
712
+ async cleanup() {
713
+ for (const ruleId of [...this._pollAbortControllers.keys()]) {
714
+ this._cancelPollTask(ruleId);
715
+ }
716
+ // Wait for all tasks to finish
717
+ await Promise.allSettled([...this._pollPromises.values()]);
718
+ this._pollPromises.clear();
719
+ }
720
+ }
721
+ // ---------------------------------------------------------------------------
722
+ // Entry point
723
+ // ---------------------------------------------------------------------------
724
+ export async function runSensor(SensorClass) {
725
+ const sensor = new SensorClass();
726
+ const code = await sensor._runLifecycle();
727
+ process.exit(code);
728
+ }
729
+ // ---------------------------------------------------------------------------
730
+ // Helpers
731
+ // ---------------------------------------------------------------------------
732
+ function sleep(ms) {
733
+ return new Promise((resolve) => setTimeout(resolve, ms));
734
+ }
735
+ function interruptibleSleep(ms, signal) {
736
+ return new Promise((resolve) => {
737
+ if (signal.aborted) {
738
+ resolve();
739
+ return;
740
+ }
741
+ const timer = setTimeout(resolve, ms);
742
+ signal.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true });
743
+ });
744
+ }
745
+ function interruptibleShutdownSleep(ms, isShuttingDown) {
746
+ return new Promise((resolve) => {
747
+ const poll = setInterval(() => {
748
+ if (isShuttingDown()) {
749
+ clearInterval(poll);
750
+ clearTimeout(timer);
751
+ resolve();
752
+ }
753
+ }, Math.min(100, ms));
754
+ const timer = setTimeout(() => {
755
+ clearInterval(poll);
756
+ resolve();
757
+ }, ms);
758
+ });
759
+ }
760
+ function isValidRuleId(value) {
761
+ return Number.isSafeInteger(value) && value > 0;
762
+ }
763
+ function parseRuleId(value) {
764
+ if (typeof value !== "number" && (typeof value !== "string" || !/^\d+$/.test(value))) {
765
+ return null;
766
+ }
767
+ const parsed = Number(value);
768
+ return isValidRuleId(parsed) ? parsed : null;
769
+ }
770
+ function resolvePollingInterval(params, fallback) {
771
+ for (const [key, multiplier] of [["interval", 1], ["interval_seconds", 1000], ["poll_interval", 1]]) {
772
+ if (params[key] == null)
773
+ continue;
774
+ const interval = Number(params[key]) * multiplier;
775
+ return isValidTimerDelay(interval) ? interval : normalizeTimerFallback(fallback);
776
+ }
777
+ return normalizeTimerFallback(fallback);
778
+ }
779
+ function isValidTimerDelay(value) {
780
+ return Number.isFinite(value) && value > 0 && value <= MAX_TIMER_DELAY_MS;
781
+ }
782
+ function normalizeTimerFallback(value) {
783
+ return isValidTimerDelay(value) ? value : 5000;
784
+ }
785
+ function parseStringArray(raw) {
786
+ if (!raw)
787
+ return [];
788
+ try {
789
+ const parsed = JSON.parse(raw);
790
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
791
+ }
792
+ catch {
793
+ return [];
794
+ }
795
+ }
796
+ function validateNotifierUrl(rawUrl) {
797
+ let url;
798
+ try {
799
+ url = new URL(rawUrl);
800
+ }
801
+ catch {
802
+ throw new Error("ATTUNE_NOTIFIER_WS_URL must be a valid URL");
803
+ }
804
+ if (url.username || url.password) {
805
+ throw new Error("ATTUNE_NOTIFIER_WS_URL must not contain credentials");
806
+ }
807
+ if (url.protocol === "wss:")
808
+ return;
809
+ if (url.protocol !== "ws:") {
810
+ throw new Error("ATTUNE_NOTIFIER_WS_URL must use wss:// or ws://");
811
+ }
812
+ const allowInsecure = process.env.ATTUNE_ALLOW_INSECURE_NOTIFIER_WS === "true";
813
+ const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
814
+ const loopback = hostname === "localhost" || hostname === "::1" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
815
+ if (!allowInsecure && !loopback) {
816
+ throw new Error("non-loopback ws:// requires ATTUNE_ALLOW_INSECURE_NOTIFIER_WS=true");
817
+ }
818
+ }
819
+ function normalizeRuleEventType(eventType) {
820
+ if (typeof eventType !== "string")
821
+ return null;
822
+ switch (eventType) {
823
+ case "RuleCreated":
824
+ case "rule.created":
825
+ return "created";
826
+ case "RuleEnabled":
827
+ case "rule.enabled":
828
+ return "enabled";
829
+ case "RuleDisabled":
830
+ case "rule.disabled":
831
+ return "disabled";
832
+ case "RuleDeleted":
833
+ case "rule.deleted":
834
+ return "deleted";
835
+ case "RuleUpdated":
836
+ case "rule.updated":
837
+ return "updated";
838
+ default:
839
+ return null;
840
+ }
841
+ }
842
+ function extractRuleLifecycleMessage(data) {
843
+ const raw = decodeWebSocketMessage(data);
844
+ if (!raw)
845
+ return null;
846
+ const parsed = JSON.parse(raw);
847
+ const envelope = toRecord(parsed);
848
+ if (!envelope)
849
+ return null;
850
+ if (envelope.type === "welcome")
851
+ return null;
852
+ if (envelope.type === "error") {
853
+ throw new Error(typeof envelope.message === "string" ? envelope.message : "unknown notifier error");
854
+ }
855
+ const payload = toRecord(envelope.payload) ??
856
+ toRecord(envelope.data) ??
857
+ envelope;
858
+ return typeof payload.event_type === "string" ? payload : null;
859
+ }
860
+ function decodeWebSocketMessage(data) {
861
+ if (typeof data === "string")
862
+ return data;
863
+ if (data instanceof ArrayBuffer)
864
+ return Buffer.from(data).toString("utf8");
865
+ if (Array.isArray(data))
866
+ return Buffer.concat(data).toString("utf8");
867
+ if (ArrayBuffer.isView(data))
868
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
869
+ return null;
870
+ }
871
+ function decodeWebSocketReason(reason) {
872
+ const decoded = reason.toString("utf8").trim();
873
+ return decoded.length > 0 ? decoded : null;
874
+ }
875
+ function toRecord(value) {
876
+ return typeof value === "object" && value !== null && !Array.isArray(value)
877
+ ? value
878
+ : null;
879
+ }
880
+ //# sourceMappingURL=sensor.js.map