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.
- package/LICENSE +190 -0
- package/README.md +355 -0
- package/dist/action.d.ts +34 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +62 -0
- package/dist/action.js.map +1 -0
- package/dist/api_client/client/client.gen.d.ts +3 -0
- package/dist/api_client/client/client.gen.d.ts.map +1 -0
- package/dist/api_client/client/client.gen.js +217 -0
- package/dist/api_client/client/client.gen.js.map +1 -0
- package/dist/api_client/client/index.d.ts +9 -0
- package/dist/api_client/client/index.d.ts.map +1 -0
- package/dist/api_client/client/index.js +7 -0
- package/dist/api_client/client/index.js.map +1 -0
- package/dist/api_client/client/types.gen.d.ts +121 -0
- package/dist/api_client/client/types.gen.d.ts.map +1 -0
- package/dist/api_client/client/types.gen.js +3 -0
- package/dist/api_client/client/types.gen.js.map +1 -0
- package/dist/api_client/client/utils.gen.d.ts +38 -0
- package/dist/api_client/client/utils.gen.d.ts.map +1 -0
- package/dist/api_client/client/utils.gen.js +229 -0
- package/dist/api_client/client/utils.gen.js.map +1 -0
- package/dist/api_client/client.gen.d.ts +13 -0
- package/dist/api_client/client.gen.d.ts.map +1 -0
- package/dist/api_client/client.gen.js +4 -0
- package/dist/api_client/client.gen.js.map +1 -0
- package/dist/api_client/core/auth.gen.d.ts +19 -0
- package/dist/api_client/core/auth.gen.d.ts.map +1 -0
- package/dist/api_client/core/auth.gen.js +15 -0
- package/dist/api_client/core/auth.gen.js.map +1 -0
- package/dist/api_client/core/bodySerializer.gen.d.ts +26 -0
- package/dist/api_client/core/bodySerializer.gen.d.ts.map +1 -0
- package/dist/api_client/core/bodySerializer.gen.js +58 -0
- package/dist/api_client/core/bodySerializer.gen.js.map +1 -0
- package/dist/api_client/core/params.gen.d.ts +44 -0
- package/dist/api_client/core/params.gen.d.ts.map +1 -0
- package/dist/api_client/core/params.gen.js +101 -0
- package/dist/api_client/core/params.gen.js.map +1 -0
- package/dist/api_client/core/pathSerializer.gen.d.ts +34 -0
- package/dist/api_client/core/pathSerializer.gen.d.ts.map +1 -0
- package/dist/api_client/core/pathSerializer.gen.js +107 -0
- package/dist/api_client/core/pathSerializer.gen.js.map +1 -0
- package/dist/api_client/core/queryKeySerializer.gen.d.ts +19 -0
- package/dist/api_client/core/queryKeySerializer.gen.d.ts.map +1 -0
- package/dist/api_client/core/queryKeySerializer.gen.js +93 -0
- package/dist/api_client/core/queryKeySerializer.gen.js.map +1 -0
- package/dist/api_client/core/serverSentEvents.gen.d.ts +72 -0
- package/dist/api_client/core/serverSentEvents.gen.d.ts.map +1 -0
- package/dist/api_client/core/serverSentEvents.gen.js +133 -0
- package/dist/api_client/core/serverSentEvents.gen.js.map +1 -0
- package/dist/api_client/core/types.gen.d.ts +79 -0
- package/dist/api_client/core/types.gen.d.ts.map +1 -0
- package/dist/api_client/core/types.gen.js +3 -0
- package/dist/api_client/core/types.gen.js.map +1 -0
- package/dist/api_client/core/utils.gen.d.ts +20 -0
- package/dist/api_client/core/utils.gen.d.ts.map +1 -0
- package/dist/api_client/core/utils.gen.js +88 -0
- package/dist/api_client/core/utils.gen.js.map +1 -0
- package/dist/api_client/index.d.ts +3 -0
- package/dist/api_client/index.d.ts.map +1 -0
- package/dist/api_client/index.js +3 -0
- package/dist/api_client/index.js.map +1 -0
- package/dist/api_client/sdk.gen.d.ts +1000 -0
- package/dist/api_client/sdk.gen.d.ts.map +1 -0
- package/dist/api_client/sdk.gen.js +2145 -0
- package/dist/api_client/sdk.gen.js.map +1 -0
- package/dist/api_client/types.gen.d.ts +19794 -0
- package/dist/api_client/types.gen.d.ts.map +1 -0
- package/dist/api_client/types.gen.js +3 -0
- package/dist/api_client/types.gen.js.map +1 -0
- package/dist/client.d.ts +42 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +73 -0
- package/dist/client.js.map +1 -0
- package/dist/context.d.ts +112 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +108 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/sensor.d.ts +127 -0
- package/dist/sensor.d.ts.map +1 -0
- package/dist/sensor.js +880 -0
- package/dist/sensor.js.map +1 -0
- 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
|