livetap 0.1.5 → 0.2.1

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.
@@ -1,28 +1,28 @@
1
1
  /**
2
2
  * Webhook Ingestor — HTTP endpoint that accepts POSTs and writes
3
- * payloads to a Redis Stream with rolling retention.
3
+ * payloads to a stream with rolling retention.
4
4
  */
5
5
 
6
- import type Redis from 'ioredis'
6
+ import type { StreamStore } from '../stream-store.js'
7
7
  import type { Subscriber, ConnectionStatus } from '../types.js'
8
8
 
9
9
  export interface WebhookIngestorOpts {
10
10
  streamKey: string
11
- redis: Redis
11
+ store: StreamStore
12
12
  retentionMs?: number
13
13
  onMessage?: () => void
14
14
  }
15
15
 
16
16
  export class WebhookIngestor implements Subscriber {
17
17
  private streamKey: string
18
- private redis: Redis
18
+ private store: StreamStore
19
19
  private retentionMs: number
20
20
  private onMessage: (() => void) | undefined
21
21
  private state: ConnectionStatus['runtimeState'] = 'disconnected'
22
22
 
23
23
  constructor(opts: WebhookIngestorOpts) {
24
24
  this.streamKey = opts.streamKey
25
- this.redis = opts.redis
25
+ this.store = opts.store
26
26
  this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
27
27
  this.onMessage = opts.onMessage
28
28
  }
@@ -39,12 +39,11 @@ export class WebhookIngestor implements Subscriber {
39
39
  * Ingest a webhook payload. Called by the HTTP API route handler.
40
40
  */
41
41
  async ingest(body: string, headers?: Record<string, string>): Promise<void> {
42
- const fields: string[] = ['payload', body]
43
- if (headers?.['content-type']) fields.push('content_type', headers['content-type'])
42
+ const fields: Record<string, string> = { payload: body }
43
+ if (headers?.['content-type']) fields.content_type = headers['content-type']
44
44
 
45
- await this.redis.xadd(this.streamKey, '*', ...fields)
46
- const minId = Date.now() - this.retentionMs
47
- await this.redis.xtrim(this.streamKey, 'MINID', '~', minId.toString())
45
+ this.store.append(this.streamKey, fields)
46
+ this.store.trim(this.streamKey, Date.now() - this.retentionMs)
48
47
  this.onMessage?.()
49
48
  }
50
49
 
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * WebSocket Subscriber — connects to a remote WebSocket and writes
3
- * messages to a Redis Stream with rolling retention + reconnection.
3
+ * messages to a stream with rolling retention + reconnection.
4
4
  */
5
5
 
6
- import type Redis from 'ioredis'
6
+ import type { StreamStore } from '../stream-store.js'
7
7
  import type { WebSocketConnectionConfig, Subscriber, ConnectionStatus } from '../types.js'
8
8
 
9
9
  export interface WsSubscriberOpts {
10
10
  config: WebSocketConnectionConfig
11
11
  streamKey: string
12
- redis: Redis
12
+ store: StreamStore
13
13
  retentionMs?: number
14
14
  onMessage?: () => void
15
15
  }
@@ -18,7 +18,7 @@ export class WsSubscriber implements Subscriber {
18
18
  private ws: WebSocket | null = null
19
19
  private config: WebSocketConnectionConfig
20
20
  private streamKey: string
21
- private redis: Redis
21
+ private store: StreamStore
22
22
  private retentionMs: number
23
23
  private onMessage: (() => void) | undefined
24
24
  private state: ConnectionStatus['runtimeState'] = 'disconnected'
@@ -31,7 +31,7 @@ export class WsSubscriber implements Subscriber {
31
31
  constructor(opts: WsSubscriberOpts) {
32
32
  this.config = opts.config
33
33
  this.streamKey = opts.streamKey
34
- this.redis = opts.redis
34
+ this.store = opts.store
35
35
  this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
36
36
  this.onMessage = opts.onMessage
37
37
  }
@@ -65,15 +65,14 @@ export class WsSubscriber implements Subscriber {
65
65
  }
66
66
  }
67
67
 
68
- this.ws.onmessage = async (event) => {
68
+ this.ws.onmessage = (event) => {
69
69
  try {
70
70
  const fields = this.parseMessage(event.data)
71
- await this.redis.xadd(this.streamKey, '*', ...Object.entries(fields).flat())
72
- const minId = Date.now() - this.retentionMs
73
- await this.redis.xtrim(this.streamKey, 'MINID', '~', minId.toString())
71
+ this.store.append(this.streamKey, fields)
72
+ this.store.trim(this.streamKey, Date.now() - this.retentionMs)
74
73
  this.onMessage?.()
75
74
  } catch {
76
- // Redis write failure
75
+ // Write failure
77
76
  }
78
77
  }
79
78
 
@@ -3,7 +3,7 @@
3
3
  * Manages connections, streams, and watchers.
4
4
  */
5
5
 
6
- import { startRedis, type RedisManager } from './redis.js'
6
+ import { StreamStore } from './stream-store.js'
7
7
  import { ConnectionManager } from './connection-manager.js'
8
8
  import { WatcherManager } from './watchers/manager.js'
9
9
  import type { WatcherAlert } from './watchers/types.js'
@@ -11,7 +11,7 @@ import type { StreamEntry } from './types.js'
11
11
 
12
12
  const PORT = parseInt(process.env.LIVETAP_PORT || '8788')
13
13
 
14
- let redis: RedisManager
14
+ let store: StreamStore
15
15
  let manager: ConnectionManager
16
16
  let watchers: WatcherManager
17
17
 
@@ -26,11 +26,10 @@ function broadcastSSE(alert: WatcherAlert) {
26
26
  }
27
27
 
28
28
  async function boot() {
29
- redis = await startRedis()
30
- console.error(`[livetap] Redis started on port ${redis.port}`)
29
+ store = new StreamStore()
31
30
 
32
- manager = new ConnectionManager(redis.client, redis.url)
33
- watchers = new WatcherManager(redis.client, redis.url, broadcastSSE)
31
+ manager = new ConnectionManager(store)
32
+ watchers = new WatcherManager(store, broadcastSSE)
34
33
 
35
34
  Bun.serve({
36
35
  port: PORT,
@@ -60,7 +59,6 @@ async function boot() {
60
59
  return json({
61
60
  status: 'running',
62
61
  port: PORT,
63
- redisPort: redis.port,
64
62
  connections: manager.list(),
65
63
  uptime: process.uptime(),
66
64
  })
@@ -128,7 +126,7 @@ async function boot() {
128
126
  const maxEntries = parseInt(url.searchParams.get('maxEntries') ?? '50')
129
127
 
130
128
  try {
131
- const entries = await readBackfill(record.streamKey, backfillSeconds, maxEntries)
129
+ const entries = readBackfill(record.streamKey, backfillSeconds, maxEntries)
132
130
  return json({ entries, totalEntries: entries.length })
133
131
  } catch (err) {
134
132
  return json({ error: (err as Error).message }, 500)
@@ -221,16 +219,13 @@ async function boot() {
221
219
  console.error(`[livetap] daemon listening on http://127.0.0.1:${PORT}`)
222
220
  }
223
221
 
224
- async function readBackfill(streamKey: string, backfillSeconds: number, maxEntries: number): Promise<StreamEntry[]> {
225
- const since = (Date.now() - backfillSeconds * 1000).toString()
226
- const raw = await redis.client.xrange(streamKey, since, '+', 'COUNT', maxEntries)
227
- return raw.map(([id, fieldArray]) => {
228
- const fields: Record<string, string> = {}
229
- for (let i = 0; i < fieldArray.length; i += 2) {
230
- fields[fieldArray[i]] = fieldArray[i + 1]
231
- }
232
- return { id, fields, ts: parseInt(id.split('-')[0]) }
233
- })
222
+ function readBackfill(streamKey: string, backfillSeconds: number, maxEntries: number): StreamEntry[] {
223
+ const sinceMs = Date.now() - backfillSeconds * 1000
224
+ return store.range(streamKey, sinceMs, maxEntries).map((e) => ({
225
+ id: e.id,
226
+ fields: e.fields,
227
+ ts: parseInt(e.id.split('-')[0]),
228
+ }))
234
229
  }
235
230
 
236
231
  function json(data: unknown, status = 200) {
@@ -245,7 +240,7 @@ async function shutdown() {
245
240
  console.error('[livetap] shutting down...')
246
241
  await watchers.stopAll()
247
242
  await manager.destroyAll()
248
- await redis.stop()
243
+ store.stop()
249
244
  process.exit(0)
250
245
  }
251
246
 
@@ -0,0 +1,128 @@
1
+ /**
2
+ * In-memory stream store — replaces Redis for stream buffering.
3
+ * Provides append, range, trim, len, del, and subscribe (EventEmitter).
4
+ */
5
+
6
+ import { EventEmitter } from 'events'
7
+
8
+ export interface StreamEntry {
9
+ id: string // "{timestamp}-{seq}"
10
+ fields: Record<string, string>
11
+ }
12
+
13
+ export class StreamStore {
14
+ private streams = new Map<string, StreamEntry[]>()
15
+ private seqCounters = new Map<string, number>()
16
+ private emitter = new EventEmitter()
17
+
18
+ constructor() {
19
+ this.emitter.setMaxListeners(1000)
20
+ }
21
+
22
+ /**
23
+ * Append an entry to a stream. Returns the generated ID.
24
+ * Equivalent to Redis XADD streamKey * field1 val1 field2 val2
25
+ */
26
+ append(streamKey: string, fields: Record<string, string>): string {
27
+ const ts = Date.now()
28
+ const seq = this.seqCounters.get(streamKey) ?? 0
29
+ this.seqCounters.set(streamKey, seq + 1)
30
+ const id = `${ts}-${seq}`
31
+
32
+ const entry: StreamEntry = { id, fields }
33
+
34
+ let stream = this.streams.get(streamKey)
35
+ if (!stream) {
36
+ stream = []
37
+ this.streams.set(streamKey, stream)
38
+ }
39
+ stream.push(entry)
40
+
41
+ this.emitter.emit(streamKey, entry)
42
+ return id
43
+ }
44
+
45
+ /**
46
+ * Read entries from a stream in a time range.
47
+ * Equivalent to Redis XRANGE streamKey since + COUNT count
48
+ */
49
+ range(streamKey: string, sinceMs: number, count?: number): StreamEntry[] {
50
+ const stream = this.streams.get(streamKey)
51
+ if (!stream) return []
52
+
53
+ const sinceStr = String(sinceMs)
54
+ const filtered = stream.filter((e) => {
55
+ const entryTs = e.id.split('-')[0]
56
+ return entryTs >= sinceStr
57
+ })
58
+
59
+ return count ? filtered.slice(0, count) : filtered
60
+ }
61
+
62
+ /**
63
+ * Trim entries older than minTs.
64
+ * Equivalent to Redis XTRIM streamKey MINID ~ minTs
65
+ */
66
+ trim(streamKey: string, minTs: number): void {
67
+ const stream = this.streams.get(streamKey)
68
+ if (!stream) return
69
+
70
+ const minStr = String(minTs)
71
+ // Find first entry that's >= minTs
72
+ let cutIndex = 0
73
+ for (let i = 0; i < stream.length; i++) {
74
+ const entryTs = stream[i].id.split('-')[0]
75
+ if (entryTs >= minStr) break
76
+ cutIndex = i + 1
77
+ }
78
+ if (cutIndex > 0) {
79
+ stream.splice(0, cutIndex)
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Get entry count for a stream.
85
+ * Equivalent to Redis XLEN streamKey
86
+ */
87
+ len(streamKey: string): number {
88
+ return this.streams.get(streamKey)?.length ?? 0
89
+ }
90
+
91
+ /**
92
+ * Delete a stream entirely.
93
+ * Equivalent to Redis DEL streamKey
94
+ */
95
+ del(streamKey: string): void {
96
+ this.streams.delete(streamKey)
97
+ this.seqCounters.delete(streamKey)
98
+ this.emitter.removeAllListeners(streamKey)
99
+ }
100
+
101
+ /**
102
+ * Subscribe to new entries on a stream.
103
+ * Replaces Redis XREAD BLOCK — zero-latency, event-driven.
104
+ * Returns an unsubscribe function.
105
+ */
106
+ subscribe(streamKey: string, callback: (entry: StreamEntry) => void): () => void {
107
+ this.emitter.on(streamKey, callback)
108
+ return () => {
109
+ this.emitter.off(streamKey, callback)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Check if a stream exists.
115
+ */
116
+ has(streamKey: string): boolean {
117
+ return this.streams.has(streamKey)
118
+ }
119
+
120
+ /**
121
+ * Stop the store — clean up all data and listeners.
122
+ */
123
+ stop(): void {
124
+ this.streams.clear()
125
+ this.seqCounters.clear()
126
+ this.emitter.removeAllListeners()
127
+ }
128
+ }
@@ -6,10 +6,15 @@
6
6
  import type { WatcherCondition, WatcherDefinition } from './types.js'
7
7
 
8
8
  /**
9
- * Resolve a dot-separated path into a nested object.
10
- * Returns undefined if any segment is missing.
9
+ * Resolve a field path into an object.
10
+ * First tries the path as a literal key (handles keys like "2.8.0"),
11
+ * then falls back to dot-separated nested access (handles "sensors.temperature.value").
11
12
  */
12
13
  export function resolveDotPath(obj: any, path: string): any {
14
+ if (obj == null) return undefined
15
+ // Try literal key first (handles OBIS codes like "2.8.0", "32.7.0")
16
+ if (path in obj) return obj[path]
17
+ // Fall back to dot-path traversal
13
18
  return path.split('.').reduce((o, k) => o?.[k], obj)
14
19
  }
15
20
 
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Watcher Manager — CRUD + evaluation loops for expression watchers.
3
- * Stores definitions in Redis hashes. Runs evaluator loops in-process.
3
+ * Stores definitions in-memory. Evaluates via StreamStore subscriptions.
4
4
  */
5
5
 
6
- import Redis from 'ioredis'
7
6
  import { mkdirSync, appendFileSync, statSync, writeFileSync, readFileSync, unlinkSync } from 'fs'
8
7
  import { resolve } from 'path'
9
8
  import { homedir } from 'os'
9
+ import type { StreamStore, StreamEntry } from '../stream-store.js'
10
10
  import type { WatcherCondition, WatcherDefinition, WatcherInfo, WatcherAlert, WatcherAction } from './types.js'
11
+ import { resolveDotPath } from './engine.js'
11
12
  import { VALID_OPS } from './types.js'
12
13
  import { evaluateWatcher, extractMatchedValues, formatExpression } from './engine.js'
13
14
 
@@ -24,15 +25,13 @@ const MAX_LOG_SIZE = 512 * 1024 // 512KB
24
25
  export type AlertCallback = (alert: WatcherAlert) => void
25
26
 
26
27
  export class WatcherManager {
27
- private redisUrl: string
28
- private loops = new Map<string, { abort: AbortController; reader: Redis }>()
28
+ private store: StreamStore
29
+ private subscriptions = new Map<string, () => void>() // watcherId → unsubscribe
29
30
  private info = new Map<string, WatcherInfo>()
30
31
  private onAlert: AlertCallback
31
- private redis: Redis
32
32
 
33
- constructor(redis: Redis, redisUrl: string, onAlert: AlertCallback) {
34
- this.redis = redis
35
- this.redisUrl = redisUrl
33
+ constructor(store: StreamStore, onAlert: AlertCallback) {
34
+ this.store = store
36
35
  this.onAlert = onAlert
37
36
  mkdirSync(LOG_DIR, { recursive: true })
38
37
  }
@@ -73,9 +72,6 @@ export class WatcherManager {
73
72
  updatedAt: now,
74
73
  }
75
74
 
76
- // Store in flat hash (globally unique watcher IDs)
77
- await this.redis.hset('livetap:watchers', id, JSON.stringify(def))
78
-
79
75
  const info: WatcherInfo = { ...def, matchCount: 0, entriesChecked: 0 }
80
76
  this.info.set(id, info)
81
77
 
@@ -135,10 +131,6 @@ export class WatcherManager {
135
131
  info.updatedAt = new Date().toISOString()
136
132
  info.status = 'running'
137
133
 
138
- // Persist
139
- const def: WatcherDefinition = { ...info }
140
- await this.redis.hset('livetap:watchers', watcherId, JSON.stringify(def))
141
-
142
134
  this.writeLog(watcherId, `UPDATED conditions=${formatExpression(info.conditions, info.match)} cooldown=${info.cooldown}s`)
143
135
  this.startLoop(watcherId, streamKey, info)
144
136
 
@@ -162,7 +154,6 @@ export class WatcherManager {
162
154
 
163
155
  this.stopLoop(watcherId)
164
156
  this.info.delete(watcherId)
165
- await this.redis.hdel('livetap:watchers', watcherId)
166
157
 
167
158
  // Delete log file
168
159
  try { unlinkSync(resolve(LOG_DIR, `${watcherId}.log`)) } catch { /* ok */ }
@@ -178,114 +169,93 @@ export class WatcherManager {
178
169
  }
179
170
 
180
171
  private startLoop(watcherId: string, streamKey: string, def: WatcherDefinition) {
181
- const abort = new AbortController()
182
- const reader = new Redis(this.redisUrl)
183
- this.loops.set(watcherId, { abort, reader })
184
-
185
172
  const info = this.info.get(watcherId)!
186
173
  let lastAlertTime = 0
187
174
  let checkpointTime = Date.now()
188
- const fieldNotFoundThrottle = new Map<string, number>() // field → last logged time
175
+ const fieldNotFoundThrottle = new Map<string, number>()
176
+
177
+ const onEntry = (entry: StreamEntry) => {
178
+ try {
179
+ info.entriesChecked++
180
+ info.lastChecked = new Date().toISOString()
181
+
182
+ const fields = entry.fields
189
183
 
190
- const run = async () => {
191
- let lastId = '$'
192
- while (!abort.signal.aborted) {
184
+ // Parse payload: use parsed JSON object if available, otherwise raw fields
185
+ let payload: any
193
186
  try {
194
- const result = await reader.xread('BLOCK', 2000, 'STREAMS', streamKey, lastId)
195
- if (!result || abort.signal.aborted) continue
196
-
197
- for (const [, entries] of result) {
198
- for (const [id, fieldArray] of entries) {
199
- lastId = id
200
- info.entriesChecked++
201
- info.lastChecked = new Date().toISOString()
202
-
203
- // Parse fields
204
- const fields: Record<string, string> = {}
205
- for (let i = 0; i < fieldArray.length; i += 2) {
206
- fields[fieldArray[i]] = fieldArray[i + 1]
207
- }
208
-
209
- // Parse payload: try JSON first, fall back to raw fields
210
- let payload: any
211
- try {
212
- payload = JSON.parse(fields.payload ?? '{}')
213
- } catch {
214
- // Plain text — use raw Redis fields as the payload
215
- // This allows conditions like { field: "payload", op: "contains", value: "ERROR" }
216
- payload = fields
217
- }
218
-
219
- // Log missing fields (throttled: once per field per minute)
220
- for (const c of def.conditions) {
221
- const val = resolveDotPathImport(payload, c.field)
222
- if (val === undefined) {
223
- const last = fieldNotFoundThrottle.get(c.field) ?? 0
224
- if (Date.now() - last > 60_000) {
225
- this.writeLog(watcherId, `FIELD_NOT_FOUND ${c.field} in entry ${id}`)
226
- fieldNotFoundThrottle.set(c.field, Date.now())
227
- }
228
- }
229
- }
230
-
231
- // Evaluate
232
- const matched = evaluateWatcher(payload, def)
233
- if (matched) {
234
- const now = Date.now()
235
- if (now - lastAlertTime >= def.cooldown * 1000) {
236
- lastAlertTime = now
237
- info.matchCount++
238
- info.lastMatch = new Date().toISOString()
239
-
240
- const matchedValues = extractMatchedValues(payload, def.conditions)
241
- const expression = formatExpression(def.conditions, def.match)
242
-
243
- this.writeLog(watcherId, `MATCH ${Object.entries(matchedValues).map(([k, v]) => `${k}=${v}`).join(' ')} action=${typeof def.action === 'string' ? def.action : JSON.stringify(def.action)}`)
244
-
245
- const alert: WatcherAlert = {
246
- watcherId,
247
- connectionId: def.connectionId,
248
- expression,
249
- matchedValues,
250
- entry: fields,
251
- ts: now,
252
- }
253
-
254
- // Execute action
255
- await this.executeAction(def.action, alert)
256
- this.onAlert(alert)
257
- } else {
258
- const remaining = Math.round((def.cooldown * 1000 - (Date.now() - lastAlertTime)) / 1000)
259
- this.writeLog(watcherId, `SUPPRESSED ${formatExpression(def.conditions, def.match)} (cooldown ${remaining}s remaining)`)
260
- }
261
- }
262
-
263
- // Checkpoint every 5 minutes
264
- if (Date.now() - checkpointTime > 5 * 60 * 1000) {
265
- this.writeLog(watcherId, `CHECKPOINT entries_checked=${info.entriesChecked} matches=${info.matchCount}`)
266
- checkpointTime = Date.now()
267
- }
187
+ const parsed = JSON.parse(fields.payload ?? '{}')
188
+ // Only use parsed result if it's an object (has addressable fields).
189
+ // Primitives (numbers, strings, booleans) fall through to raw fields
190
+ // so "payload" remains addressable for regex/contains/numeric conditions.
191
+ payload = (typeof parsed === 'object' && parsed !== null) ? parsed : fields
192
+ } catch {
193
+ // Not valid JSON — use raw fields as the payload
194
+ payload = fields
195
+ }
196
+
197
+ // Log missing fields (throttled: once per field per minute)
198
+ for (const c of def.conditions) {
199
+ const val = resolveDotPath(payload, c.field)
200
+ if (val === undefined) {
201
+ const last = fieldNotFoundThrottle.get(c.field) ?? 0
202
+ if (Date.now() - last > 60_000) {
203
+ this.writeLog(watcherId, `FIELD_NOT_FOUND ${c.field} in entry ${entry.id}`)
204
+ fieldNotFoundThrottle.set(c.field, Date.now())
268
205
  }
269
206
  }
270
- } catch (err) {
271
- if (!abort.signal.aborted) {
272
- this.writeLog(watcherId, `ERROR ${(err as Error).message}`)
273
- await new Promise((r) => setTimeout(r, 1000))
207
+ }
208
+
209
+ // Evaluate
210
+ const matched = evaluateWatcher(payload, def)
211
+ if (matched) {
212
+ const now = Date.now()
213
+ if (now - lastAlertTime >= def.cooldown * 1000) {
214
+ lastAlertTime = now
215
+ info.matchCount++
216
+ info.lastMatch = new Date().toISOString()
217
+
218
+ const matchedValues = extractMatchedValues(payload, def.conditions)
219
+ const expression = formatExpression(def.conditions, def.match)
220
+
221
+ this.writeLog(watcherId, `MATCH ${Object.entries(matchedValues).map(([k, v]) => `${k}=${v}`).join(' ')} action=${typeof def.action === 'string' ? def.action : JSON.stringify(def.action)}`)
222
+
223
+ const alert: WatcherAlert = {
224
+ watcherId,
225
+ connectionId: def.connectionId,
226
+ expression,
227
+ matchedValues,
228
+ entry: fields,
229
+ ts: now,
230
+ }
231
+
232
+ this.executeAction(def.action, alert)
233
+ this.onAlert(alert)
234
+ } else {
235
+ const remaining = Math.round((def.cooldown * 1000 - (Date.now() - lastAlertTime)) / 1000)
236
+ this.writeLog(watcherId, `SUPPRESSED ${formatExpression(def.conditions, def.match)} (cooldown ${remaining}s remaining)`)
274
237
  }
275
238
  }
239
+
240
+ // Checkpoint every 5 minutes
241
+ if (Date.now() - checkpointTime > 5 * 60 * 1000) {
242
+ this.writeLog(watcherId, `CHECKPOINT entries_checked=${info.entriesChecked} matches=${info.matchCount}`)
243
+ checkpointTime = Date.now()
244
+ }
245
+ } catch (err) {
246
+ this.writeLog(watcherId, `ERROR ${(err as Error).message}`)
276
247
  }
277
- reader.disconnect()
278
248
  }
279
249
 
280
- run()
250
+ const unsub = this.store.subscribe(streamKey, onEntry)
251
+ this.subscriptions.set(watcherId, unsub)
281
252
  }
282
253
 
283
254
  private stopLoop(watcherId: string) {
284
- const loop = this.loops.get(watcherId)
285
- if (loop) {
286
- loop.abort.abort()
287
- loop.reader.disconnect()
288
- this.loops.delete(watcherId)
255
+ const unsub = this.subscriptions.get(watcherId)
256
+ if (unsub) {
257
+ unsub()
258
+ this.subscriptions.delete(watcherId)
289
259
  }
290
260
  const info = this.info.get(watcherId)
291
261
  if (info) info.status = 'stopped'
@@ -342,13 +312,9 @@ export class WatcherManager {
342
312
  }
343
313
 
344
314
  async stopAll() {
345
- for (const id of this.loops.keys()) {
315
+ for (const id of this.subscriptions.keys()) {
346
316
  this.stopLoop(id)
347
317
  }
348
318
  }
349
319
  }
350
320
 
351
- // Import from engine to avoid circular
352
- function resolveDotPathImport(obj: any, path: string): any {
353
- return path.split('.').reduce((o, k) => o?.[k], obj)
354
- }