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,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
+ }