livetap 0.1.4 → 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.
- package/README.md +19 -18
- package/package.json +2 -4
- package/src/cli/daemon-client.ts +41 -6
- package/src/cli/setup.ts +24 -1
- package/src/cli/start.ts +7 -16
- package/src/cli/status.ts +22 -4
- package/src/cli/stop.ts +19 -36
- package/src/mcp/channel.ts +26 -8
- package/src/mcp/tools.ts +65 -2
- package/src/server/connection-manager.ts +11 -15
- package/src/server/connections/file.ts +7 -8
- package/src/server/connections/mqtt.ts +9 -10
- package/src/server/connections/webhook.ts +9 -10
- package/src/server/connections/websocket.ts +9 -10
- package/src/server/index.ts +14 -19
- package/src/server/stream-store.ts +128 -0
- package/src/server/watchers/engine.ts +7 -2
- package/src/server/watchers/manager.ts +80 -114
- package/src/shared/catalog-generators.ts +15 -7
- package/src/shared/command-catalog.ts +2 -2
- package/src/server/redis.ts +0 -62
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Watcher Manager — CRUD + evaluation loops for expression watchers.
|
|
3
|
-
* Stores definitions in
|
|
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
|
|
28
|
-
private
|
|
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(
|
|
34
|
-
this.
|
|
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>()
|
|
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
|
-
|
|
191
|
-
|
|
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
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
285
|
-
if (
|
|
286
|
-
|
|
287
|
-
|
|
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.
|
|
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
|
-
}
|
|
@@ -4,13 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
import { CLI_COMMANDS, type CatalogCommand } from './command-catalog.js'
|
|
6
6
|
import { TOOLS } from '../mcp/tools.js'
|
|
7
|
+
import { readFileSync } from 'fs'
|
|
8
|
+
import { resolve } from 'path'
|
|
9
|
+
|
|
10
|
+
const PKG = JSON.parse(readFileSync(resolve(import.meta.dir, '../../package.json'), 'utf-8'))
|
|
11
|
+
const VERSION: string = PKG.version
|
|
7
12
|
|
|
8
13
|
/**
|
|
9
14
|
* Generate human-readable --help text.
|
|
10
15
|
*/
|
|
11
16
|
export function generateHelpText(): string {
|
|
12
17
|
const lines: string[] = [
|
|
13
|
-
'
|
|
18
|
+
'LiveTap — Push live data streams into your AI coding agent',
|
|
14
19
|
'',
|
|
15
20
|
'Usage:',
|
|
16
21
|
]
|
|
@@ -50,20 +55,20 @@ export function generateHelpText(): string {
|
|
|
50
55
|
*/
|
|
51
56
|
export function generateLlmHelp(): object {
|
|
52
57
|
return {
|
|
53
|
-
name: '
|
|
54
|
-
version:
|
|
58
|
+
name: 'LiveTap',
|
|
59
|
+
version: VERSION,
|
|
55
60
|
description: 'Push live data streams into your AI coding agent',
|
|
56
61
|
setup: {
|
|
57
62
|
steps: [
|
|
58
63
|
'Step 1: npm install livetap (skip if already installed)',
|
|
59
|
-
'Step 2: npx livetap setup (creates .mcp.json
|
|
64
|
+
'Step 2: npx livetap setup (creates .mcp.json and starts the daemon — do NOT create .mcp.json manually)',
|
|
60
65
|
'Step 3: Tell user to restart Claude Code: claude --dangerously-load-development-channels server:livetap --continue',
|
|
61
66
|
'Step 4: After restart, use create_connection to connect, read_stream to sample, create_watcher to alert',
|
|
62
67
|
],
|
|
63
68
|
do_not: [
|
|
64
69
|
'Do NOT add livetap to ~/.claude/mcp.json — it goes in .mcp.json in the project root',
|
|
65
70
|
'Do NOT configure livetap as type:http — it is a stdio MCP server (command + args)',
|
|
66
|
-
'Do NOT
|
|
71
|
+
'Do NOT worry about the daemon — setup starts it, and the MCP proxy auto-starts it if needed',
|
|
67
72
|
'Do NOT guess field names — always read_stream first to see actual payload structure',
|
|
68
73
|
'Do NOT use npm init — use the existing project directory',
|
|
69
74
|
],
|
|
@@ -83,7 +88,7 @@ export function generateInstructions(): string {
|
|
|
83
88
|
const watcherTools = toolNames.filter((n) => n.includes('watcher') || n.includes('watch'))
|
|
84
89
|
|
|
85
90
|
return `
|
|
86
|
-
You have access to
|
|
91
|
+
You have access to LiveTap, a live data streaming tool. Use it to connect to data sources, sample streams, and set up expression-based watchers that alert you when conditions match.
|
|
87
92
|
|
|
88
93
|
WORKFLOW:
|
|
89
94
|
1. CONNECT: Use create_connection to tap into a data source.
|
|
@@ -107,10 +112,11 @@ WORKFLOW:
|
|
|
107
112
|
4. MANAGE:
|
|
108
113
|
- ${connectionTools.join(', ')} — manage connections
|
|
109
114
|
- ${watcherTools.join(', ')} — manage watchers
|
|
115
|
+
- status — check daemon health, uptime, active connections and watchers
|
|
110
116
|
- Watcher IDs (w_xxx) are globally unique. No connectionId needed for get/update/delete.
|
|
111
117
|
|
|
112
118
|
CHANNEL EVENTS:
|
|
113
|
-
- <channel source="
|
|
119
|
+
- <channel source="LiveTap" type="alert"> = a watcher condition matched. Read the payload and act on it.
|
|
114
120
|
The payload contains: watcherId, expression, matched_values, and the full stream entry.
|
|
115
121
|
|
|
116
122
|
When the user asks to "monitor", "watch", or "alert on" something:
|
|
@@ -133,9 +139,11 @@ DATA SHAPE BY SOURCE:
|
|
|
133
139
|
- IMPORTANT: always use read_stream first to see the actual field names. Do NOT guess — the field is "payload", not "line" or "message".
|
|
134
140
|
|
|
135
141
|
TIPS:
|
|
142
|
+
- The daemon auto-starts when needed. If a tool returns "daemon was restarted", just retry your request.
|
|
136
143
|
- Watcher IDs (w_xxx) are globally unique. You don't need the connectionId to get, update, or delete a watcher.
|
|
137
144
|
- Common MQTT brokers: broker.emqx.io (public demo), test.mosquitto.org (public test).
|
|
138
145
|
- For regex watchers, use the "matches" operator: { field: "payload", op: "matches", value: "ERROR|FATAL" }
|
|
139
146
|
- If a field path doesn't exist in the payload, the condition evaluates to false (no crash, no error).
|
|
147
|
+
- Fields with dots in the key name (like OBIS codes "2.8.0") are looked up as literal keys first, then as dot-paths.
|
|
140
148
|
`.trim()
|
|
141
149
|
}
|
|
@@ -26,13 +26,13 @@ export const CLI_COMMANDS: CatalogCommand[] = [
|
|
|
26
26
|
{
|
|
27
27
|
name: 'setup',
|
|
28
28
|
usage: 'livetap setup',
|
|
29
|
-
description: 'Configure .mcp.json
|
|
29
|
+
description: 'Configure .mcp.json, start the daemon, and print restart instructions. Run this after npm install.',
|
|
30
30
|
},
|
|
31
31
|
// --- Daemon ---
|
|
32
32
|
{
|
|
33
33
|
name: 'start',
|
|
34
34
|
usage: 'livetap start',
|
|
35
|
-
description: 'Start the livetap daemon (
|
|
35
|
+
description: 'Start the livetap daemon (HTTP API)',
|
|
36
36
|
flags: [
|
|
37
37
|
{ name: '--port', type: 'number', default: 8788, description: 'Daemon port (env: LIVETAP_PORT)' },
|
|
38
38
|
{ name: '--foreground', type: 'boolean', description: 'Run in foreground (don\'t detach)' },
|
package/src/server/redis.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
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
|
-
}
|