livetap 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  * Connection Manager — creates, tracks, and destroys data source connections.
3
3
  */
4
4
 
5
- import type Redis from 'ioredis'
5
+ import type { StreamStore } from './stream-store.js'
6
6
  import type { ConnectionConfig, ConnectionRecord, ConnectionStatus, Subscriber } from './types.js'
7
7
  import { MqttSubscriber } from './connections/mqtt.js'
8
8
  import { WebhookIngestor } from './connections/webhook.js'
@@ -18,12 +18,10 @@ function generateId(): string {
18
18
 
19
19
  export class ConnectionManager {
20
20
  private connections = new Map<string, ConnectionRecord>()
21
- private redis: Redis
22
- private redisUrl: string
21
+ private store: StreamStore
23
22
 
24
- constructor(redis: Redis, redisUrl: string) {
25
- this.redis = redis
26
- this.redisUrl = redisUrl
23
+ constructor(store: StreamStore) {
24
+ this.store = store
27
25
  }
28
26
 
29
27
  async create(config: ConnectionConfig, name?: string): Promise<ConnectionRecord> {
@@ -51,27 +49,27 @@ export class ConnectionManager {
51
49
  record.subscriber = new MqttSubscriber({
52
50
  config,
53
51
  streamKey,
54
- redis: this.redis,
52
+ store: this.store,
55
53
  onMessage,
56
54
  })
57
55
  } else if (config.type === 'webhook') {
58
56
  record.subscriber = new WebhookIngestor({
59
57
  streamKey,
60
- redis: this.redis,
58
+ store: this.store,
61
59
  onMessage,
62
60
  })
63
61
  } else if (config.type === 'websocket') {
64
62
  record.subscriber = new WsSubscriber({
65
63
  config,
66
64
  streamKey,
67
- redis: this.redis,
65
+ store: this.store,
68
66
  onMessage,
69
67
  })
70
68
  } else if (config.type === 'file') {
71
69
  record.subscriber = new FileSubscriber({
72
70
  config,
73
71
  streamKey,
74
- redis: this.redis,
72
+ store: this.store,
75
73
  onMessage,
76
74
  })
77
75
  } else {
@@ -88,10 +86,8 @@ export class ConnectionManager {
88
86
  }, 5000)
89
87
 
90
88
  // Buffered count tracking (every 5s)
91
- record.bufferedInterval = setInterval(async () => {
92
- try {
93
- record.bufferedCount = await this.redis.xlen(streamKey)
94
- } catch { /* ignore */ }
89
+ record.bufferedInterval = setInterval(() => {
90
+ record.bufferedCount = this.store.len(streamKey)
95
91
  }, 5000)
96
92
 
97
93
  this.connections.set(id, record)
@@ -119,7 +115,7 @@ export class ConnectionManager {
119
115
  if (record.bufferedInterval) clearInterval(record.bufferedInterval)
120
116
 
121
117
  await record.subscriber.stop()
122
- await this.redis.del(record.streamKey)
118
+ this.store.del(record.streamKey)
123
119
  this.connections.delete(id)
124
120
  return true
125
121
  }
@@ -1,18 +1,18 @@
1
1
  /**
2
2
  * File Tailing Subscriber — watches a file for new lines (tail -f behavior)
3
- * and writes each line to a Redis Stream with rolling retention.
3
+ * and writes each line to a stream with rolling retention.
4
4
  * Auto-detects JSON vs plain text per line.
5
5
  */
6
6
 
7
7
  import { watch, type FSWatcher } from 'fs'
8
8
  import { open, stat, type FileHandle } from 'fs/promises'
9
- import type Redis from 'ioredis'
9
+ import type { StreamStore } from '../stream-store.js'
10
10
  import type { FileConnectionConfig, Subscriber, ConnectionStatus } from '../types.js'
11
11
 
12
12
  export interface FileSubscriberOpts {
13
13
  config: FileConnectionConfig
14
14
  streamKey: string
15
- redis: Redis
15
+ store: StreamStore
16
16
  retentionMs?: number
17
17
  onMessage?: () => void
18
18
  }
@@ -20,7 +20,7 @@ export interface FileSubscriberOpts {
20
20
  export class FileSubscriber implements Subscriber {
21
21
  private config: FileConnectionConfig
22
22
  private streamKey: string
23
- private redis: Redis
23
+ private store: StreamStore
24
24
  private retentionMs: number
25
25
  private onMessage: (() => void) | undefined
26
26
  private state: ConnectionStatus['runtimeState'] = 'disconnected'
@@ -35,7 +35,7 @@ export class FileSubscriber implements Subscriber {
35
35
  constructor(opts: FileSubscriberOpts) {
36
36
  this.config = opts.config
37
37
  this.streamKey = opts.streamKey
38
- this.redis = opts.redis
38
+ this.store = opts.store
39
39
  this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
40
40
  this.onMessage = opts.onMessage
41
41
  }
@@ -98,9 +98,8 @@ export class FileSubscriber implements Subscriber {
98
98
  fields = { payload: line, format: 'text' }
99
99
  }
100
100
 
101
- await this.redis.xadd(this.streamKey, '*', ...Object.entries(fields).flat())
102
- const minId = Date.now() - this.retentionMs
103
- await this.redis.xtrim(this.streamKey, 'MINID', '~', minId.toString())
101
+ this.store.append(this.streamKey, fields)
102
+ this.store.trim(this.streamKey, Date.now() - this.retentionMs)
104
103
  this.onMessage?.()
105
104
  }
106
105
  } catch (err) {
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * MQTT Subscriber — connects to an MQTT broker and writes messages
3
- * to a Redis Stream with rolling retention.
3
+ * to a stream with rolling retention.
4
4
  */
5
5
 
6
6
  import mqtt from 'mqtt'
7
- import type Redis from 'ioredis'
7
+ import type { StreamStore } from '../stream-store.js'
8
8
  import type { MqttConnectionConfig, Subscriber, ConnectionStatus } from '../types.js'
9
9
 
10
10
  export interface MqttSubscriberOpts {
11
11
  config: MqttConnectionConfig
12
12
  streamKey: string
13
- redis: Redis
13
+ store: StreamStore
14
14
  retentionMs?: number
15
15
  onMessage?: () => void
16
16
  }
@@ -19,7 +19,7 @@ export class MqttSubscriber implements Subscriber {
19
19
  private client: mqtt.MqttClient | null = null
20
20
  private config: MqttConnectionConfig
21
21
  private streamKey: string
22
- private redis: Redis
22
+ private store: StreamStore
23
23
  private retentionMs: number
24
24
  private onMessage: (() => void) | undefined
25
25
  private state: ConnectionStatus['runtimeState'] = 'disconnected'
@@ -28,7 +28,7 @@ export class MqttSubscriber implements Subscriber {
28
28
  constructor(opts: MqttSubscriberOpts) {
29
29
  this.config = opts.config
30
30
  this.streamKey = opts.streamKey
31
- this.redis = opts.redis
31
+ this.store = opts.store
32
32
  this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
33
33
  this.onMessage = opts.onMessage
34
34
  }
@@ -77,14 +77,13 @@ export class MqttSubscriber implements Subscriber {
77
77
  this.state = 'reconnecting'
78
78
  })
79
79
 
80
- this.client!.on('message', async (topic, payload) => {
80
+ this.client!.on('message', (topic, payload) => {
81
81
  try {
82
- await this.redis.xadd(this.streamKey, '*', 'topic', topic, 'payload', payload.toString())
83
- const minId = Date.now() - this.retentionMs
84
- await this.redis.xtrim(this.streamKey, 'MINID', '~', minId.toString())
82
+ this.store.append(this.streamKey, { topic, payload: payload.toString() })
83
+ this.store.trim(this.streamKey, Date.now() - this.retentionMs)
85
84
  this.onMessage?.()
86
85
  } catch {
87
- // Redis write failure — log but don't crash
86
+ // Write failure — log but don't crash
88
87
  }
89
88
  })
90
89
  })
@@ -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