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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/bin/livetap.ts +33 -0
- package/package.json +43 -0
- package/scripts/postinstall.ts +75 -0
- package/src/cli/daemon-client.ts +43 -0
- package/src/cli/help.ts +9 -0
- package/src/cli/sip.ts +63 -0
- package/src/cli/start.ts +75 -0
- package/src/cli/status.ts +45 -0
- package/src/cli/stop.ts +64 -0
- package/src/cli/tap.ts +94 -0
- package/src/cli/taps.ts +32 -0
- package/src/cli/untap.ts +23 -0
- package/src/cli/unwatch.ts +23 -0
- package/src/cli/watch.ts +91 -0
- package/src/cli/watchers.ts +116 -0
- package/src/mcp/channel.ts +121 -0
- package/src/mcp/tools.ts +314 -0
- package/src/server/connection-manager.ts +171 -0
- package/src/server/connections/file.ts +123 -0
- package/src/server/connections/mqtt.ts +104 -0
- package/src/server/connections/webhook.ts +54 -0
- package/src/server/connections/websocket.ts +154 -0
- package/src/server/index.ts +255 -0
- package/src/server/redis.ts +62 -0
- package/src/server/types.ts +94 -0
- package/src/server/watchers/engine.ts +70 -0
- package/src/server/watchers/manager.ts +354 -0
- package/src/server/watchers/types.ts +44 -0
- package/src/shared/catalog-generators.ts +125 -0
- package/src/shared/command-catalog.ts +143 -0
package/src/mcp/tools.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Tool definitions for livetap.
|
|
3
|
+
* Registers tools on an MCP Server that proxy to the daemon HTTP API.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
8
|
+
import {
|
|
9
|
+
ListToolsRequestSchema,
|
|
10
|
+
CallToolRequestSchema,
|
|
11
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
12
|
+
|
|
13
|
+
export const TOOLS = [
|
|
14
|
+
{
|
|
15
|
+
name: 'create_connection',
|
|
16
|
+
description: 'Create a data connection. MQTT: connects to a broker and subscribes to topics. WebSocket: connects to a remote WS URL. Webhook: creates an HTTP ingest endpoint.',
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: 'object' as const,
|
|
19
|
+
properties: {
|
|
20
|
+
type: { type: 'string', enum: ['mqtt', 'webhook', 'websocket', 'file'], description: 'Source type', default: 'mqtt' },
|
|
21
|
+
name: { type: 'string', description: 'Display name for the connection' },
|
|
22
|
+
broker: { type: 'string', description: 'MQTT broker hostname (required for mqtt)' },
|
|
23
|
+
port: { type: 'number', description: 'Broker port (default 1883 for mqtt)', default: 1883 },
|
|
24
|
+
tls: { type: 'boolean', description: 'Use TLS (default false)', default: false },
|
|
25
|
+
username: { type: 'string', description: 'MQTT username', default: '' },
|
|
26
|
+
password: { type: 'string', description: 'MQTT password', default: '' },
|
|
27
|
+
topics: { type: 'array', items: { type: 'string' }, description: 'MQTT topic filters (required for mqtt)' },
|
|
28
|
+
url: { type: 'string', description: 'WebSocket URL (required for websocket)' },
|
|
29
|
+
headers: { type: 'object', description: 'WebSocket auth headers' },
|
|
30
|
+
handshake: { type: 'string', description: 'Message to send after WS connect (e.g. subscription JSON)' },
|
|
31
|
+
path: { type: 'string', description: 'Absolute file path to tail (required for file type, e.g. "/var/log/app.log")' },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'list_connections',
|
|
37
|
+
description: 'List all active connections with their status, message rate, and buffered count.',
|
|
38
|
+
inputSchema: { type: 'object' as const, properties: {} },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'get_connection',
|
|
42
|
+
description: 'Get detailed status of a specific connection.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object' as const,
|
|
45
|
+
properties: {
|
|
46
|
+
connectionId: { type: 'string', description: 'The connection ID (e.g. "conn_a1b2c3d4")' },
|
|
47
|
+
},
|
|
48
|
+
required: ['connectionId'],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'destroy_connection',
|
|
53
|
+
description: 'Destroy a connection — stops the source subscriber, cleans up the Redis stream.',
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: 'object' as const,
|
|
56
|
+
properties: {
|
|
57
|
+
connectionId: { type: 'string', description: 'The connection ID to destroy' },
|
|
58
|
+
},
|
|
59
|
+
required: ['connectionId'],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'read_stream',
|
|
64
|
+
description: "Read recent entries from a connection's live stream. Use this to inspect what data is flowing through a connection and understand the payload structure before creating watchers.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object' as const,
|
|
67
|
+
properties: {
|
|
68
|
+
connectionId: { type: 'string', description: 'The connection ID to read from' },
|
|
69
|
+
backfillSeconds: { type: 'number', description: 'Include entries from the last N seconds (default 60)', default: 60 },
|
|
70
|
+
maxEntries: { type: 'number', description: 'Max entries to return (default 10)', default: 10 },
|
|
71
|
+
},
|
|
72
|
+
required: ['connectionId'],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'create_watcher',
|
|
77
|
+
description: 'Create an expression-based watcher on a connection. The watcher evaluates structured conditions against each stream entry and fires an alert when conditions match. ALWAYS use read_stream first to understand the data shape and field paths.',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object' as const,
|
|
80
|
+
properties: {
|
|
81
|
+
connectionId: { type: 'string', description: 'The connection ID to watch' },
|
|
82
|
+
conditions: {
|
|
83
|
+
type: 'array',
|
|
84
|
+
description: 'Array of conditions: [{field: "dot.path", op: ">", value: 50}]. Supported ops: >, <, >=, <=, ==, !=, contains, matches (regex)',
|
|
85
|
+
items: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
field: { type: 'string', description: 'Dot-path into the JSON payload (e.g. "sensors.temperature.value")' },
|
|
89
|
+
op: { type: 'string', enum: ['>', '<', '>=', '<=', '==', '!=', 'contains', 'matches'], description: 'Comparison operator. "matches" takes a regex pattern string.' },
|
|
90
|
+
value: { description: 'Value to compare against (number, string, or boolean)' },
|
|
91
|
+
},
|
|
92
|
+
required: ['field', 'op', 'value'],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
match: { type: 'string', enum: ['all', 'any'], description: 'How to combine conditions: "all" (AND) or "any" (OR). Default: "all"', default: 'all' },
|
|
96
|
+
action: { description: '"channel_alert" (default), or {webhook: "url"}, or {shell: "command"}', default: 'channel_alert' },
|
|
97
|
+
cooldown: { type: 'number', description: 'Seconds between repeated alerts. 0 for every match, 60 default. Use 0 for rare events (webhooks), 30-60 for sensors, 300+ for high-frequency streams.', default: 60 },
|
|
98
|
+
},
|
|
99
|
+
required: ['connectionId', 'conditions'],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'list_watchers',
|
|
104
|
+
description: 'List watchers. Optionally filter by connectionId. If omitted, lists all watchers.',
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: 'object' as const,
|
|
107
|
+
properties: {
|
|
108
|
+
connectionId: { type: 'string', description: 'Optional: filter by connection ID' },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'get_watcher',
|
|
114
|
+
description: 'Get details of a specific watcher including conditions, status, match count, and config.',
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: 'object' as const,
|
|
117
|
+
properties: {
|
|
118
|
+
watcherId: { type: 'string', description: 'The watcher ID' },
|
|
119
|
+
},
|
|
120
|
+
required: ['watcherId'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'get_watcher_logs',
|
|
125
|
+
description: 'Get evaluation logs from a watcher — shows MATCH, SUPPRESSED, FIELD_NOT_FOUND, and CHECKPOINT events.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: 'object' as const,
|
|
128
|
+
properties: {
|
|
129
|
+
watcherId: { type: 'string', description: 'The watcher ID' },
|
|
130
|
+
lines: { type: 'number', description: 'Number of log lines to return (default 50)', default: 50 },
|
|
131
|
+
},
|
|
132
|
+
required: ['watcherId'],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'update_watcher',
|
|
137
|
+
description: "Update a watcher's conditions, match mode, action, or cooldown. The watcher restarts with the new config.",
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: 'object' as const,
|
|
140
|
+
properties: {
|
|
141
|
+
watcherId: { type: 'string', description: 'The watcher ID' },
|
|
142
|
+
conditions: { type: 'array', description: 'New conditions array', items: { type: 'object' } },
|
|
143
|
+
match: { type: 'string', enum: ['all', 'any'] },
|
|
144
|
+
action: { description: 'New action' },
|
|
145
|
+
cooldown: { type: 'number', description: 'New cooldown in seconds' },
|
|
146
|
+
},
|
|
147
|
+
required: ['watcherId'],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'delete_watcher',
|
|
152
|
+
description: 'Stop and remove a watcher.',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object' as const,
|
|
155
|
+
properties: {
|
|
156
|
+
watcherId: { type: 'string', description: 'The watcher ID to delete' },
|
|
157
|
+
},
|
|
158
|
+
required: ['watcherId'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'restart_watcher',
|
|
163
|
+
description: 'Restart a stopped watcher.',
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object' as const,
|
|
166
|
+
properties: {
|
|
167
|
+
watcherId: { type: 'string', description: 'The watcher ID to restart' },
|
|
168
|
+
},
|
|
169
|
+
required: ['watcherId'],
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
function text(content: string) {
|
|
175
|
+
return { content: [{ type: 'text' as const, text: content }] }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function error(content: string) {
|
|
179
|
+
return { content: [{ type: 'text' as const, text: content }], isError: true }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Register all livetap MCP tools on the given server.
|
|
184
|
+
* Tools proxy to the daemon HTTP API at the given base URL.
|
|
185
|
+
*/
|
|
186
|
+
export function registerTools(server: Server, daemonUrl: string) {
|
|
187
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
188
|
+
tools: TOOLS,
|
|
189
|
+
}))
|
|
190
|
+
|
|
191
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
192
|
+
const { name, arguments: args } = req.params
|
|
193
|
+
try {
|
|
194
|
+
switch (name) {
|
|
195
|
+
case 'create_connection': {
|
|
196
|
+
const config: any = { type: args?.type ?? 'mqtt' }
|
|
197
|
+
if (config.type === 'mqtt') {
|
|
198
|
+
config.broker = args?.broker
|
|
199
|
+
config.port = args?.port ?? 1883
|
|
200
|
+
config.tls = args?.tls ?? false
|
|
201
|
+
config.credentials = { username: args?.username ?? '', password: args?.password ?? '' }
|
|
202
|
+
config.topics = args?.topics
|
|
203
|
+
if (!config.broker || !config.topics?.length) {
|
|
204
|
+
return error('Error: broker and topics are required for mqtt connections')
|
|
205
|
+
}
|
|
206
|
+
} else if (config.type === 'websocket') {
|
|
207
|
+
config.url = args?.url
|
|
208
|
+
if (args?.headers) config.headers = args.headers
|
|
209
|
+
if (args?.handshake) config.handshake = args.handshake
|
|
210
|
+
if (!config.url) return error('Error: url is required for websocket connections')
|
|
211
|
+
} else if (config.type === 'file') {
|
|
212
|
+
config.path = args?.path
|
|
213
|
+
if (!config.path) return error('Error: path is required for file connections (e.g. "/var/log/app.log")')
|
|
214
|
+
if (!config.path.startsWith('/')) return error('Error: file path must be absolute')
|
|
215
|
+
}
|
|
216
|
+
// webhook needs no extra params
|
|
217
|
+
const res = await fetch(`${daemonUrl}/connections`, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: { 'Content-Type': 'application/json' },
|
|
220
|
+
body: JSON.stringify({ ...config, name: args?.name }),
|
|
221
|
+
})
|
|
222
|
+
return text(await res.text())
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case 'list_connections': {
|
|
226
|
+
const res = await fetch(`${daemonUrl}/connections`)
|
|
227
|
+
return text(await res.text())
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
case 'get_connection': {
|
|
231
|
+
const res = await fetch(`${daemonUrl}/connections/${args?.connectionId}`)
|
|
232
|
+
if (!res.ok) return error('Connection not found')
|
|
233
|
+
return text(await res.text())
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
case 'destroy_connection': {
|
|
237
|
+
const res = await fetch(`${daemonUrl}/connections/${args?.connectionId}`, { method: 'DELETE' })
|
|
238
|
+
if (!res.ok) return error('Connection not found')
|
|
239
|
+
return text(await res.text())
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case 'read_stream': {
|
|
243
|
+
const params = new URLSearchParams()
|
|
244
|
+
if (args?.backfillSeconds) params.set('backfillSeconds', String(args.backfillSeconds))
|
|
245
|
+
if (args?.maxEntries) params.set('maxEntries', String(args.maxEntries))
|
|
246
|
+
const res = await fetch(`${daemonUrl}/connections/${args?.connectionId}/stream?${params}`)
|
|
247
|
+
if (!res.ok) return error('Connection not found')
|
|
248
|
+
return text(await res.text())
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case 'create_watcher': {
|
|
252
|
+
const res = await fetch(`${daemonUrl}/watchers`, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'Content-Type': 'application/json' },
|
|
255
|
+
body: JSON.stringify({
|
|
256
|
+
connectionId: args?.connectionId,
|
|
257
|
+
conditions: args?.conditions,
|
|
258
|
+
match: args?.match ?? 'all',
|
|
259
|
+
action: args?.action ?? 'channel_alert',
|
|
260
|
+
cooldown: args?.cooldown ?? 60,
|
|
261
|
+
}),
|
|
262
|
+
})
|
|
263
|
+
if (!res.ok) return error(await res.text())
|
|
264
|
+
return text(await res.text())
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
case 'list_watchers': {
|
|
268
|
+
const params = args?.connectionId ? `?connectionId=${args.connectionId}` : ''
|
|
269
|
+
const res = await fetch(`${daemonUrl}/watchers${params}`)
|
|
270
|
+
return text(await res.text())
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case 'get_watcher': {
|
|
274
|
+
const res = await fetch(`${daemonUrl}/watchers/${args?.watcherId}`)
|
|
275
|
+
if (!res.ok) return error('Watcher not found')
|
|
276
|
+
return text(await res.text())
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case 'get_watcher_logs': {
|
|
280
|
+
const res = await fetch(`${daemonUrl}/watchers/${args?.watcherId}/logs?lines=${args?.lines ?? 50}`)
|
|
281
|
+
return text(await res.text())
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
case 'update_watcher': {
|
|
285
|
+
const { watcherId, ...updates } = args as any
|
|
286
|
+
const res = await fetch(`${daemonUrl}/watchers/${watcherId}`, {
|
|
287
|
+
method: 'PUT',
|
|
288
|
+
headers: { 'Content-Type': 'application/json' },
|
|
289
|
+
body: JSON.stringify(updates),
|
|
290
|
+
})
|
|
291
|
+
if (!res.ok) return error(await res.text())
|
|
292
|
+
return text(await res.text())
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case 'delete_watcher': {
|
|
296
|
+
const res = await fetch(`${daemonUrl}/watchers/${args?.watcherId}`, { method: 'DELETE' })
|
|
297
|
+
if (!res.ok) return error('Watcher not found')
|
|
298
|
+
return text(await res.text())
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
case 'restart_watcher': {
|
|
302
|
+
const res = await fetch(`${daemonUrl}/watchers/${args?.watcherId}/restart`, { method: 'POST' })
|
|
303
|
+
if (!res.ok) return error('Watcher not found')
|
|
304
|
+
return text(await res.text())
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
default:
|
|
308
|
+
return error(`Unknown tool: ${name}`)
|
|
309
|
+
}
|
|
310
|
+
} catch (err) {
|
|
311
|
+
return error(`livetap daemon error: ${(err as Error).message}. Is the daemon running?`)
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection Manager — creates, tracks, and destroys data source connections.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type Redis from 'ioredis'
|
|
6
|
+
import type { ConnectionConfig, ConnectionRecord, ConnectionStatus, Subscriber } from './types.js'
|
|
7
|
+
import { MqttSubscriber } from './connections/mqtt.js'
|
|
8
|
+
import { WebhookIngestor } from './connections/webhook.js'
|
|
9
|
+
import { WsSubscriber } from './connections/websocket.js'
|
|
10
|
+
import { FileSubscriber } from './connections/file.js'
|
|
11
|
+
|
|
12
|
+
function generateId(): string {
|
|
13
|
+
const hex = Array.from(crypto.getRandomValues(new Uint8Array(4)))
|
|
14
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
15
|
+
.join('')
|
|
16
|
+
return `conn_${hex}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ConnectionManager {
|
|
20
|
+
private connections = new Map<string, ConnectionRecord>()
|
|
21
|
+
private redis: Redis
|
|
22
|
+
private redisUrl: string
|
|
23
|
+
|
|
24
|
+
constructor(redis: Redis, redisUrl: string) {
|
|
25
|
+
this.redis = redis
|
|
26
|
+
this.redisUrl = redisUrl
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async create(config: ConnectionConfig, name?: string): Promise<ConnectionRecord> {
|
|
30
|
+
const id = generateId()
|
|
31
|
+
const streamKey = `livetap:stream:${id}`
|
|
32
|
+
const createdAt = new Date().toISOString()
|
|
33
|
+
|
|
34
|
+
const record: ConnectionRecord = {
|
|
35
|
+
id,
|
|
36
|
+
config,
|
|
37
|
+
name,
|
|
38
|
+
streamKey,
|
|
39
|
+
createdAt,
|
|
40
|
+
subscriber: null as unknown as Subscriber,
|
|
41
|
+
msgCount: 0,
|
|
42
|
+
msgCountAtLastSample: 0,
|
|
43
|
+
msgPerSec: 0,
|
|
44
|
+
bufferedCount: 0,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const onMessage = () => { record.msgCount++ }
|
|
48
|
+
|
|
49
|
+
// Create appropriate subscriber
|
|
50
|
+
if (config.type === 'mqtt') {
|
|
51
|
+
record.subscriber = new MqttSubscriber({
|
|
52
|
+
config,
|
|
53
|
+
streamKey,
|
|
54
|
+
redis: this.redis,
|
|
55
|
+
onMessage,
|
|
56
|
+
})
|
|
57
|
+
} else if (config.type === 'webhook') {
|
|
58
|
+
record.subscriber = new WebhookIngestor({
|
|
59
|
+
streamKey,
|
|
60
|
+
redis: this.redis,
|
|
61
|
+
onMessage,
|
|
62
|
+
})
|
|
63
|
+
} else if (config.type === 'websocket') {
|
|
64
|
+
record.subscriber = new WsSubscriber({
|
|
65
|
+
config,
|
|
66
|
+
streamKey,
|
|
67
|
+
redis: this.redis,
|
|
68
|
+
onMessage,
|
|
69
|
+
})
|
|
70
|
+
} else if (config.type === 'file') {
|
|
71
|
+
record.subscriber = new FileSubscriber({
|
|
72
|
+
config,
|
|
73
|
+
streamKey,
|
|
74
|
+
redis: this.redis,
|
|
75
|
+
onMessage,
|
|
76
|
+
})
|
|
77
|
+
} else {
|
|
78
|
+
throw new Error(`Unsupported connection type: ${(config as any).type}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await record.subscriber.start()
|
|
82
|
+
|
|
83
|
+
// Throughput tracking (every 5s)
|
|
84
|
+
record.throughputInterval = setInterval(() => {
|
|
85
|
+
const delta = record.msgCount - record.msgCountAtLastSample
|
|
86
|
+
record.msgPerSec = Math.round((delta / 5) * 10) / 10
|
|
87
|
+
record.msgCountAtLastSample = record.msgCount
|
|
88
|
+
}, 5000)
|
|
89
|
+
|
|
90
|
+
// Buffered count tracking (every 5s)
|
|
91
|
+
record.bufferedInterval = setInterval(async () => {
|
|
92
|
+
try {
|
|
93
|
+
record.bufferedCount = await this.redis.xlen(streamKey)
|
|
94
|
+
} catch { /* ignore */ }
|
|
95
|
+
}, 5000)
|
|
96
|
+
|
|
97
|
+
this.connections.set(id, record)
|
|
98
|
+
return record
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
list(): ConnectionStatus[] {
|
|
102
|
+
return Array.from(this.connections.values()).map((r) => this.toStatus(r))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get(id: string): ConnectionRecord | undefined {
|
|
106
|
+
return this.connections.get(id)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getStatus(id: string): ConnectionStatus | undefined {
|
|
110
|
+
const r = this.connections.get(id)
|
|
111
|
+
return r ? this.toStatus(r) : undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async destroy(id: string): Promise<boolean> {
|
|
115
|
+
const record = this.connections.get(id)
|
|
116
|
+
if (!record) return false
|
|
117
|
+
|
|
118
|
+
if (record.throughputInterval) clearInterval(record.throughputInterval)
|
|
119
|
+
if (record.bufferedInterval) clearInterval(record.bufferedInterval)
|
|
120
|
+
|
|
121
|
+
await record.subscriber.stop()
|
|
122
|
+
await this.redis.del(record.streamKey)
|
|
123
|
+
this.connections.delete(id)
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async destroyAll(): Promise<void> {
|
|
128
|
+
for (const id of this.connections.keys()) {
|
|
129
|
+
await this.destroy(id)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get the WebhookIngestor for a connection (for routing ingest POSTs).
|
|
135
|
+
*/
|
|
136
|
+
getWebhookIngestor(id: string): WebhookIngestor | null {
|
|
137
|
+
const record = this.connections.get(id)
|
|
138
|
+
if (!record || record.config.type !== 'webhook') return null
|
|
139
|
+
return record.subscriber as WebhookIngestor
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private toStatus(r: ConnectionRecord): ConnectionStatus {
|
|
143
|
+
const sub = r.subscriber.getStatus()
|
|
144
|
+
const base: ConnectionStatus = {
|
|
145
|
+
connectionId: r.id,
|
|
146
|
+
type: r.config.type,
|
|
147
|
+
name: r.name,
|
|
148
|
+
summary: this.buildSummary(r),
|
|
149
|
+
streamKey: r.streamKey,
|
|
150
|
+
createdAt: r.createdAt,
|
|
151
|
+
msgPerSec: r.msgPerSec,
|
|
152
|
+
bufferedCount: r.bufferedCount,
|
|
153
|
+
runtimeState: sub.runtimeState,
|
|
154
|
+
error: sub.error,
|
|
155
|
+
}
|
|
156
|
+
return base
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private buildSummary(r: ConnectionRecord): string {
|
|
160
|
+
switch (r.config.type) {
|
|
161
|
+
case 'mqtt':
|
|
162
|
+
return `mqtt://${r.config.broker}:${r.config.port}/${r.config.topics.join(',')}`
|
|
163
|
+
case 'webhook':
|
|
164
|
+
return `webhook ingest`
|
|
165
|
+
case 'websocket':
|
|
166
|
+
return r.config.url
|
|
167
|
+
case 'file':
|
|
168
|
+
return `file://${r.config.path}`
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
4
|
+
* Auto-detects JSON vs plain text per line.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { watch, type FSWatcher } from 'fs'
|
|
8
|
+
import { open, stat, type FileHandle } from 'fs/promises'
|
|
9
|
+
import type Redis from 'ioredis'
|
|
10
|
+
import type { FileConnectionConfig, Subscriber, ConnectionStatus } from '../types.js'
|
|
11
|
+
|
|
12
|
+
export interface FileSubscriberOpts {
|
|
13
|
+
config: FileConnectionConfig
|
|
14
|
+
streamKey: string
|
|
15
|
+
redis: Redis
|
|
16
|
+
retentionMs?: number
|
|
17
|
+
onMessage?: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class FileSubscriber implements Subscriber {
|
|
21
|
+
private config: FileConnectionConfig
|
|
22
|
+
private streamKey: string
|
|
23
|
+
private redis: Redis
|
|
24
|
+
private retentionMs: number
|
|
25
|
+
private onMessage: (() => void) | undefined
|
|
26
|
+
private state: ConnectionStatus['runtimeState'] = 'disconnected'
|
|
27
|
+
private error?: string
|
|
28
|
+
private fileHandle: FileHandle | null = null
|
|
29
|
+
private watcher: FSWatcher | null = null
|
|
30
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null
|
|
31
|
+
private offset = 0
|
|
32
|
+
private buffer = ''
|
|
33
|
+
private stopped = false
|
|
34
|
+
|
|
35
|
+
constructor(opts: FileSubscriberOpts) {
|
|
36
|
+
this.config = opts.config
|
|
37
|
+
this.streamKey = opts.streamKey
|
|
38
|
+
this.redis = opts.redis
|
|
39
|
+
this.retentionMs = opts.retentionMs ?? 5 * 60 * 1000
|
|
40
|
+
this.onMessage = opts.onMessage
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async start(): Promise<void> {
|
|
44
|
+
this.stopped = false
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
// Open file and seek to end (tail -f: new lines only)
|
|
48
|
+
const stats = await stat(this.config.path)
|
|
49
|
+
this.offset = stats.size
|
|
50
|
+
this.fileHandle = await open(this.config.path, 'r')
|
|
51
|
+
this.state = 'connected'
|
|
52
|
+
this.error = undefined
|
|
53
|
+
|
|
54
|
+
// Watch for changes with fs.watch + fallback poll
|
|
55
|
+
try {
|
|
56
|
+
this.watcher = watch(this.config.path, () => this.readNewLines())
|
|
57
|
+
} catch {
|
|
58
|
+
// fs.watch not available on all platforms/filesystems
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Poll every 1s as fallback (fs.watch can miss events)
|
|
62
|
+
this.pollTimer = setInterval(() => this.readNewLines(), 1000)
|
|
63
|
+
|
|
64
|
+
} catch (err) {
|
|
65
|
+
this.state = 'error'
|
|
66
|
+
this.error = (err as Error).message
|
|
67
|
+
throw err
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async readNewLines() {
|
|
72
|
+
if (this.stopped || !this.fileHandle) return
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const stats = await stat(this.config.path)
|
|
76
|
+
if (stats.size <= this.offset) return // no new data
|
|
77
|
+
|
|
78
|
+
const readSize = stats.size - this.offset
|
|
79
|
+
const buf = Buffer.alloc(readSize)
|
|
80
|
+
const { bytesRead } = await this.fileHandle.read(buf, 0, readSize, this.offset)
|
|
81
|
+
this.offset += bytesRead
|
|
82
|
+
|
|
83
|
+
this.buffer += buf.toString('utf-8', 0, bytesRead)
|
|
84
|
+
|
|
85
|
+
// Split into complete lines
|
|
86
|
+
const lines = this.buffer.split('\n')
|
|
87
|
+
this.buffer = lines.pop() ?? '' // keep incomplete last line in buffer
|
|
88
|
+
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
if (!line.trim()) continue // skip empty lines
|
|
91
|
+
|
|
92
|
+
// Auto-detect: try JSON, fall back to text
|
|
93
|
+
let fields: Record<string, string>
|
|
94
|
+
try {
|
|
95
|
+
JSON.parse(line)
|
|
96
|
+
fields = { payload: line, format: 'json' }
|
|
97
|
+
} catch {
|
|
98
|
+
fields = { payload: line, format: 'text' }
|
|
99
|
+
}
|
|
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())
|
|
104
|
+
this.onMessage?.()
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// File may have been deleted/rotated
|
|
108
|
+
this.error = (err as Error).message
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async stop(): Promise<void> {
|
|
113
|
+
this.stopped = true
|
|
114
|
+
if (this.watcher) { this.watcher.close(); this.watcher = null }
|
|
115
|
+
if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null }
|
|
116
|
+
if (this.fileHandle) { await this.fileHandle.close(); this.fileHandle = null }
|
|
117
|
+
this.state = 'disconnected'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getStatus() {
|
|
121
|
+
return { runtimeState: this.state, error: this.error }
|
|
122
|
+
}
|
|
123
|
+
}
|