livetap 0.1.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.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * MQTT Subscriber — connects to an MQTT broker and writes messages
3
+ * to a Redis Stream with rolling retention.
4
+ */
5
+
6
+ import mqtt from 'mqtt'
7
+ import type Redis from 'ioredis'
8
+ import type { MqttConnectionConfig, Subscriber, ConnectionStatus } from '../types.js'
9
+
10
+ export interface MqttSubscriberOpts {
11
+ config: MqttConnectionConfig
12
+ streamKey: string
13
+ redis: Redis
14
+ retentionMs?: number
15
+ onMessage?: () => void
16
+ }
17
+
18
+ export class MqttSubscriber implements Subscriber {
19
+ private client: mqtt.MqttClient | null = null
20
+ private config: MqttConnectionConfig
21
+ private streamKey: string
22
+ private redis: Redis
23
+ private retentionMs: number
24
+ private onMessage: (() => void) | undefined
25
+ private state: ConnectionStatus['runtimeState'] = 'disconnected'
26
+ private error?: string
27
+
28
+ constructor(opts: MqttSubscriberOpts) {
29
+ this.config = opts.config
30
+ this.streamKey = opts.streamKey
31
+ this.redis = opts.redis
32
+ this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
33
+ this.onMessage = opts.onMessage
34
+ }
35
+
36
+ async start(): Promise<void> {
37
+ const { broker, port, tls, credentials, topics } = this.config
38
+ const protocol = tls ? 'mqtts' : 'mqtt'
39
+ const url = `${protocol}://${broker}:${port}`
40
+
41
+ this.state = 'disconnected'
42
+
43
+ this.client = mqtt.connect(url, {
44
+ username: credentials.username || undefined,
45
+ password: credentials.password || undefined,
46
+ rejectUnauthorized: tls,
47
+ })
48
+
49
+ return new Promise((resolve, reject) => {
50
+ const timeout = setTimeout(() => {
51
+ this.state = 'error'
52
+ this.error = 'Connection timeout'
53
+ reject(new Error(`MQTT connection to ${url} timed out`))
54
+ }, 15_000)
55
+
56
+ this.client!.on('connect', () => {
57
+ clearTimeout(timeout)
58
+ this.state = 'connected'
59
+ this.error = undefined
60
+ this.client!.subscribe(topics, (err) => {
61
+ if (err) {
62
+ this.state = 'error'
63
+ this.error = err.message
64
+ reject(err)
65
+ } else {
66
+ resolve()
67
+ }
68
+ })
69
+ })
70
+
71
+ this.client!.on('error', (err) => {
72
+ this.state = 'error'
73
+ this.error = err.message
74
+ })
75
+
76
+ this.client!.on('reconnect', () => {
77
+ this.state = 'reconnecting'
78
+ })
79
+
80
+ this.client!.on('message', async (topic, payload) => {
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())
85
+ this.onMessage?.()
86
+ } catch {
87
+ // Redis write failure — log but don't crash
88
+ }
89
+ })
90
+ })
91
+ }
92
+
93
+ async stop(): Promise<void> {
94
+ if (this.client) {
95
+ await this.client.endAsync()
96
+ this.client = null
97
+ }
98
+ this.state = 'disconnected'
99
+ }
100
+
101
+ getStatus() {
102
+ return { runtimeState: this.state, error: this.error }
103
+ }
104
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Webhook Ingestor — HTTP endpoint that accepts POSTs and writes
3
+ * payloads to a Redis Stream with rolling retention.
4
+ */
5
+
6
+ import type Redis from 'ioredis'
7
+ import type { Subscriber, ConnectionStatus } from '../types.js'
8
+
9
+ export interface WebhookIngestorOpts {
10
+ streamKey: string
11
+ redis: Redis
12
+ retentionMs?: number
13
+ onMessage?: () => void
14
+ }
15
+
16
+ export class WebhookIngestor implements Subscriber {
17
+ private streamKey: string
18
+ private redis: Redis
19
+ private retentionMs: number
20
+ private onMessage: (() => void) | undefined
21
+ private state: ConnectionStatus['runtimeState'] = 'disconnected'
22
+
23
+ constructor(opts: WebhookIngestorOpts) {
24
+ this.streamKey = opts.streamKey
25
+ this.redis = opts.redis
26
+ this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
27
+ this.onMessage = opts.onMessage
28
+ }
29
+
30
+ async start(): Promise<void> {
31
+ this.state = 'connected'
32
+ }
33
+
34
+ async stop(): Promise<void> {
35
+ this.state = 'disconnected'
36
+ }
37
+
38
+ /**
39
+ * Ingest a webhook payload. Called by the HTTP API route handler.
40
+ */
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'])
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())
48
+ this.onMessage?.()
49
+ }
50
+
51
+ getStatus() {
52
+ return { runtimeState: this.state }
53
+ }
54
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * WebSocket Subscriber — connects to a remote WebSocket and writes
3
+ * messages to a Redis Stream with rolling retention + reconnection.
4
+ */
5
+
6
+ import type Redis from 'ioredis'
7
+ import type { WebSocketConnectionConfig, Subscriber, ConnectionStatus } from '../types.js'
8
+
9
+ export interface WsSubscriberOpts {
10
+ config: WebSocketConnectionConfig
11
+ streamKey: string
12
+ redis: Redis
13
+ retentionMs?: number
14
+ onMessage?: () => void
15
+ }
16
+
17
+ export class WsSubscriber implements Subscriber {
18
+ private ws: WebSocket | null = null
19
+ private config: WebSocketConnectionConfig
20
+ private streamKey: string
21
+ private redis: Redis
22
+ private retentionMs: number
23
+ private onMessage: (() => void) | undefined
24
+ private state: ConnectionStatus['runtimeState'] = 'disconnected'
25
+ private error?: string
26
+ private retryCount = 0
27
+ private retryTimer: ReturnType<typeof setTimeout> | null = null
28
+ private pingTimer: ReturnType<typeof setInterval> | null = null
29
+ private stopped = false
30
+
31
+ constructor(opts: WsSubscriberOpts) {
32
+ this.config = opts.config
33
+ this.streamKey = opts.streamKey
34
+ this.redis = opts.redis
35
+ this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
36
+ this.onMessage = opts.onMessage
37
+ }
38
+
39
+ async start(): Promise<void> {
40
+ this.stopped = false
41
+ this.connect()
42
+ }
43
+
44
+ private connect() {
45
+ this.state = this.retryCount === 0 ? 'disconnected' : 'reconnecting'
46
+
47
+ this.ws = new WebSocket(this.config.url)
48
+
49
+ this.ws.onopen = () => {
50
+ this.state = 'connected'
51
+ this.error = undefined
52
+ this.retryCount = 0
53
+
54
+ if (this.config.handshake) {
55
+ this.ws!.send(this.config.handshake)
56
+ }
57
+
58
+ const pingMs = this.config.pingIntervalMs ?? 30_000
59
+ if (pingMs > 0) {
60
+ this.pingTimer = setInterval(() => {
61
+ if (this.ws?.readyState === WebSocket.OPEN) {
62
+ this.ws.send('ping')
63
+ }
64
+ }, pingMs)
65
+ }
66
+ }
67
+
68
+ this.ws.onmessage = async (event) => {
69
+ try {
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())
74
+ this.onMessage?.()
75
+ } catch {
76
+ // Redis write failure
77
+ }
78
+ }
79
+
80
+ this.ws.onclose = () => {
81
+ this.cleanup()
82
+ if (!this.stopped && this.config.reconnect?.enabled !== false) {
83
+ this.scheduleReconnect()
84
+ } else {
85
+ this.state = 'disconnected'
86
+ }
87
+ }
88
+
89
+ this.ws.onerror = () => {
90
+ this.state = 'error'
91
+ }
92
+ }
93
+
94
+ private parseMessage(data: string | Buffer | ArrayBuffer): Record<string, string> {
95
+ if (typeof data === 'string') {
96
+ try {
97
+ JSON.parse(data) // validate JSON
98
+ return { payload: data, format: 'json' }
99
+ } catch {
100
+ return { payload: data, format: 'text' }
101
+ }
102
+ }
103
+ const buf = data instanceof ArrayBuffer ? Buffer.from(data) : data as Buffer
104
+ const fmt = this.config.binaryFormat ?? 'base64'
105
+ if (fmt === 'json') {
106
+ try {
107
+ const text = buf.toString('utf-8')
108
+ JSON.parse(text)
109
+ return { payload: text, format: 'json' }
110
+ } catch { /* fall through */ }
111
+ }
112
+ if (fmt === 'text') {
113
+ return { payload: buf.toString('utf-8'), format: 'text' }
114
+ }
115
+ return { payload: buf.toString('base64'), format: 'base64' }
116
+ }
117
+
118
+ private scheduleReconnect() {
119
+ const maxRetries = this.config.reconnect?.maxRetries ?? Infinity
120
+ if (this.retryCount >= maxRetries) {
121
+ this.state = 'error'
122
+ this.error = `Max retries (${maxRetries}) exceeded`
123
+ return
124
+ }
125
+
126
+ const base = this.config.reconnect?.initialDelayMs ?? 1000
127
+ const max = this.config.reconnect?.maxDelayMs ?? 30000
128
+ const delay = Math.min(base * Math.pow(2, this.retryCount), max)
129
+ const jitter = delay * 0.2 * Math.random()
130
+
131
+ this.retryCount++
132
+ this.state = 'reconnecting'
133
+ this.retryTimer = setTimeout(() => this.connect(), delay + jitter)
134
+ }
135
+
136
+ private cleanup() {
137
+ if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null }
138
+ }
139
+
140
+ async stop(): Promise<void> {
141
+ this.stopped = true
142
+ if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null }
143
+ this.cleanup()
144
+ if (this.ws) {
145
+ this.ws.close()
146
+ this.ws = null
147
+ }
148
+ this.state = 'disconnected'
149
+ }
150
+
151
+ getStatus() {
152
+ return { runtimeState: this.state, error: this.error }
153
+ }
154
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * livetap daemon — HTTP API on :8788
3
+ * Manages connections, streams, and watchers.
4
+ */
5
+
6
+ import { startRedis, type RedisManager } from './redis.js'
7
+ import { ConnectionManager } from './connection-manager.js'
8
+ import { WatcherManager } from './watchers/manager.js'
9
+ import type { WatcherAlert } from './watchers/types.js'
10
+ import type { StreamEntry } from './types.js'
11
+
12
+ const PORT = parseInt(process.env.LIVETAP_PORT || '8788')
13
+
14
+ let redis: RedisManager
15
+ let manager: ConnectionManager
16
+ let watchers: WatcherManager
17
+
18
+ // SSE clients for alert delivery
19
+ const sseClients = new Set<(chunk: string) => void>()
20
+
21
+ function broadcastSSE(alert: WatcherAlert) {
22
+ const data = `data: ${JSON.stringify(alert)}\n\n`
23
+ for (const emit of sseClients) {
24
+ try { emit(data) } catch { /* client gone */ }
25
+ }
26
+ }
27
+
28
+ async function boot() {
29
+ redis = await startRedis()
30
+ console.error(`[livetap] Redis started on port ${redis.port}`)
31
+
32
+ manager = new ConnectionManager(redis.client, redis.url)
33
+ watchers = new WatcherManager(redis.client, redis.url, broadcastSSE)
34
+
35
+ Bun.serve({
36
+ port: PORT,
37
+ hostname: '127.0.0.1',
38
+ async fetch(req) {
39
+ const url = new URL(req.url)
40
+ const method = req.method
41
+
42
+ // --- SSE events stream ---
43
+ if (method === 'GET' && url.pathname === '/events') {
44
+ const stream = new ReadableStream({
45
+ start(ctrl) {
46
+ const encoder = new TextEncoder()
47
+ ctrl.enqueue(encoder.encode(': connected\n\n'))
48
+ const emit = (chunk: string) => ctrl.enqueue(encoder.encode(chunk))
49
+ sseClients.add(emit)
50
+ req.signal.addEventListener('abort', () => sseClients.delete(emit))
51
+ },
52
+ })
53
+ return new Response(stream, {
54
+ headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
55
+ })
56
+ }
57
+
58
+ // --- Health ---
59
+ if (method === 'GET' && url.pathname === '/status') {
60
+ return json({
61
+ status: 'running',
62
+ port: PORT,
63
+ redisPort: redis.port,
64
+ connections: manager.list(),
65
+ uptime: process.uptime(),
66
+ })
67
+ }
68
+
69
+ // --- Create connection ---
70
+ if (method === 'POST' && url.pathname === '/connections') {
71
+ try {
72
+ const body = await req.json()
73
+ const record = await manager.create(body.config ?? body, body.name)
74
+ return json({
75
+ connectionId: record.id,
76
+ type: record.config.type,
77
+ streamKey: record.streamKey,
78
+ status: 'connected',
79
+ ...(record.config.type === 'webhook' && {
80
+ ingestUrl: `http://127.0.0.1:${PORT}/connections/${record.id}/ingest`,
81
+ }),
82
+ }, 201)
83
+ } catch (err) {
84
+ return json({ error: (err as Error).message }, 400)
85
+ }
86
+ }
87
+
88
+ // --- List connections ---
89
+ if (method === 'GET' && url.pathname === '/connections') {
90
+ return json(manager.list())
91
+ }
92
+
93
+ // --- Connection routes: /connections/:id ---
94
+ const connMatch = url.pathname.match(/^\/connections\/([^/]+)$/)
95
+ if (connMatch) {
96
+ const id = connMatch[1]
97
+ if (method === 'GET') {
98
+ const status = manager.getStatus(id)
99
+ if (!status) return json({ error: 'Connection not found' }, 404)
100
+ return json(status)
101
+ }
102
+ if (method === 'DELETE') {
103
+ const ok = await manager.destroy(id)
104
+ if (!ok) return json({ error: 'Connection not found' }, 404)
105
+ return json({ deleted: id })
106
+ }
107
+ }
108
+
109
+ // --- Webhook ingest: /connections/:id/ingest ---
110
+ const ingestMatch = url.pathname.match(/^\/connections\/([^/]+)\/ingest$/)
111
+ if (ingestMatch && method === 'POST') {
112
+ const id = ingestMatch[1]
113
+ const ingestor = manager.getWebhookIngestor(id)
114
+ if (!ingestor) return json({ error: 'Webhook connection not found' }, 404)
115
+ const body = await req.text()
116
+ await ingestor.ingest(body, Object.fromEntries(req.headers.entries()))
117
+ return json({ ok: true })
118
+ }
119
+
120
+ // --- Read stream: /connections/:id/stream ---
121
+ const streamMatch = url.pathname.match(/^\/connections\/([^/]+)\/stream$/)
122
+ if (streamMatch && method === 'GET') {
123
+ const id = streamMatch[1]
124
+ const record = manager.get(id)
125
+ if (!record) return json({ error: 'Connection not found' }, 404)
126
+
127
+ const backfillSeconds = parseInt(url.searchParams.get('backfillSeconds') ?? '300')
128
+ const maxEntries = parseInt(url.searchParams.get('maxEntries') ?? '50')
129
+
130
+ try {
131
+ const entries = await readBackfill(record.streamKey, backfillSeconds, maxEntries)
132
+ return json({ entries, totalEntries: entries.length })
133
+ } catch (err) {
134
+ return json({ error: (err as Error).message }, 500)
135
+ }
136
+ }
137
+
138
+ // --- Create watcher ---
139
+ if (method === 'POST' && url.pathname === '/watchers') {
140
+ try {
141
+ const body = await req.json()
142
+ const connId = body.connectionId
143
+ const record = manager.get(connId)
144
+ if (!record) return json({ error: 'Connection not found' }, 404)
145
+
146
+ const info = await watchers.create(
147
+ connId,
148
+ record.streamKey,
149
+ body.conditions,
150
+ body.match ?? 'all',
151
+ body.action ?? 'channel_alert',
152
+ body.cooldown ?? 60,
153
+ )
154
+ return json({ id: info.id, status: info.status, expression: `${info.conditions.map(c => `${c.field} ${c.op} ${c.value}`).join(info.match === 'all' ? ' AND ' : ' OR ')}` }, 201)
155
+ } catch (err) {
156
+ return json({ error: (err as Error).message }, 400)
157
+ }
158
+ }
159
+
160
+ // --- List watchers ---
161
+ if (method === 'GET' && url.pathname === '/watchers') {
162
+ const connId = url.searchParams.get('connectionId') || undefined
163
+ return json(await watchers.list(connId))
164
+ }
165
+
166
+ // --- Watcher routes: /watchers/:id ---
167
+ const watcherMatch = url.pathname.match(/^\/watchers\/([^/]+)$/)
168
+ if (watcherMatch) {
169
+ const id = watcherMatch[1]
170
+ if (method === 'GET') {
171
+ const info = await watchers.get(id)
172
+ if (!info) return json({ error: 'Watcher not found' }, 404)
173
+ return json(info)
174
+ }
175
+ if (method === 'PUT') {
176
+ const body = await req.json()
177
+ const info = await watchers.get(id)
178
+ if (!info) return json({ error: 'Watcher not found' }, 404)
179
+ const record = manager.get(info.connectionId)
180
+ const streamKey = record?.streamKey ?? null
181
+ try {
182
+ const updated = await watchers.update(id, streamKey, body)
183
+ return json(updated)
184
+ } catch (err) {
185
+ return json({ error: (err as Error).message }, 400)
186
+ }
187
+ }
188
+ if (method === 'DELETE') {
189
+ const ok = await watchers.delete(id)
190
+ if (!ok) return json({ error: 'Watcher not found' }, 404)
191
+ return json({ deleted: id })
192
+ }
193
+ }
194
+
195
+ // --- Watcher logs: /watchers/:id/logs ---
196
+ const logsMatch = url.pathname.match(/^\/watchers\/([^/]+)\/logs$/)
197
+ if (logsMatch && method === 'GET') {
198
+ const id = logsMatch[1]
199
+ const lines = parseInt(url.searchParams.get('lines') ?? '50')
200
+ const logs = await watchers.getLogs(id, lines)
201
+ return json({ id, logs })
202
+ }
203
+
204
+ // --- Restart watcher: /watchers/:id/restart ---
205
+ const restartMatch = url.pathname.match(/^\/watchers\/([^/]+)\/restart$/)
206
+ if (restartMatch && method === 'POST') {
207
+ const id = restartMatch[1]
208
+ const info = await watchers.get(id)
209
+ if (!info) return json({ error: 'Watcher not found' }, 404)
210
+ const record = manager.get(info.connectionId)
211
+ const streamKey = record?.streamKey ?? null
212
+ const ok = await watchers.restart(id, streamKey)
213
+ if (!ok) return json({ error: 'Watcher not found' }, 404)
214
+ return json({ restarted: id })
215
+ }
216
+
217
+ return json({ error: 'Not found' }, 404)
218
+ },
219
+ })
220
+
221
+ console.error(`[livetap] daemon listening on http://127.0.0.1:${PORT}`)
222
+ }
223
+
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
+ })
234
+ }
235
+
236
+ function json(data: unknown, status = 200) {
237
+ return new Response(JSON.stringify(data, null, 2), {
238
+ status,
239
+ headers: { 'Content-Type': 'application/json' },
240
+ })
241
+ }
242
+
243
+ // Graceful shutdown
244
+ async function shutdown() {
245
+ console.error('[livetap] shutting down...')
246
+ await watchers.stopAll()
247
+ await manager.destroyAll()
248
+ await redis.stop()
249
+ process.exit(0)
250
+ }
251
+
252
+ process.on('SIGTERM', shutdown)
253
+ process.on('SIGINT', shutdown)
254
+
255
+ boot()
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Embedded Redis lifecycle manager.
3
+ * Uses the `redis-server` npm package which bundles a Redis binary.
4
+ */
5
+
6
+ import RedisServer from 'redis-server'
7
+ import Redis from 'ioredis'
8
+
9
+ export interface RedisManager {
10
+ port: number
11
+ url: string
12
+ client: Redis
13
+ stop(): Promise<void>
14
+ }
15
+
16
+ /**
17
+ * Start an embedded redis-server on the given port (or random free port).
18
+ * Returns a manager with a connected ioredis client.
19
+ */
20
+ export async function startRedis(preferredPort?: number): Promise<RedisManager> {
21
+ const port = preferredPort ?? await findFreePort()
22
+
23
+ const server = new RedisServer({ port })
24
+
25
+ await new Promise<void>((resolve, reject) => {
26
+ server.open((err: Error | null) => {
27
+ if (err) reject(err)
28
+ else resolve()
29
+ })
30
+ })
31
+
32
+ const url = `redis://127.0.0.1:${port}`
33
+ const client = new Redis(url, { maxRetriesPerRequest: 3, lazyConnect: false })
34
+
35
+ await client.ping()
36
+
37
+ return {
38
+ port,
39
+ url,
40
+ client,
41
+ async stop() {
42
+ client.disconnect()
43
+ await new Promise<void>((resolve, reject) => {
44
+ server.close((err: Error | null) => {
45
+ if (err) reject(err)
46
+ else resolve()
47
+ })
48
+ })
49
+ },
50
+ }
51
+ }
52
+
53
+ async function findFreePort(): Promise<number> {
54
+ const server = Bun.listen({
55
+ hostname: '127.0.0.1',
56
+ port: 0,
57
+ socket: { data() {} },
58
+ })
59
+ const port = server.port
60
+ server.stop()
61
+ return port
62
+ }