nikcli-remote 1.0.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/dist/chunk-MCKGQKYU.js +15 -0
- package/dist/chunk-TJTKYXIZ.js +2782 -0
- package/dist/index.cjs +6804 -0
- package/dist/index.d.cts +372 -0
- package/dist/index.d.ts +372 -0
- package/dist/index.js +155 -0
- package/dist/localtunnel-XT32JGNN.js +3805 -0
- package/dist/server-XW6FLG7E.js +7 -0
- package/package.json +58 -0
- package/src/index.ts +82 -0
- package/src/qrcode.ts +164 -0
- package/src/server.ts +562 -0
- package/src/terminal.ts +163 -0
- package/src/tunnel.ts +258 -0
- package/src/types.ts +169 -0
- package/src/web-client.ts +657 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nikcli/remote - Native Remote Server
|
|
3
|
+
* WebSocket-based remote terminal with mobile-friendly web client
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EventEmitter } from 'node:events'
|
|
7
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
8
|
+
import { WebSocketServer, WebSocket } from 'ws'
|
|
9
|
+
import crypto from 'node:crypto'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import type {
|
|
12
|
+
ServerConfig,
|
|
13
|
+
RemoteSession,
|
|
14
|
+
DeviceInfo,
|
|
15
|
+
BroadcastMessage,
|
|
16
|
+
RemoteNotification,
|
|
17
|
+
ClientConnection,
|
|
18
|
+
ClientMessage,
|
|
19
|
+
TunnelProvider,
|
|
20
|
+
} from './types'
|
|
21
|
+
import { DEFAULT_CONFIG, MessageTypes } from './types'
|
|
22
|
+
import { TerminalManager } from './terminal'
|
|
23
|
+
import { TunnelManager } from './tunnel'
|
|
24
|
+
import { getWebClient } from './web-client'
|
|
25
|
+
|
|
26
|
+
export interface RemoteServerEvents {
|
|
27
|
+
started: (session: RemoteSession) => void
|
|
28
|
+
stopped: () => void
|
|
29
|
+
'client:connected': (device: DeviceInfo) => void
|
|
30
|
+
'client:disconnected': (device: DeviceInfo) => void
|
|
31
|
+
'client:error': (clientId: string, error: Error) => void
|
|
32
|
+
'tunnel:connected': (url: string) => void
|
|
33
|
+
'tunnel:error': (error: Error) => void
|
|
34
|
+
command: (cmd: { clientId: string; command: string; args?: string[] }) => void
|
|
35
|
+
message: (client: ClientConnection, message: ClientMessage) => void
|
|
36
|
+
error: (error: Error) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class RemoteServer extends EventEmitter {
|
|
40
|
+
private config: ServerConfig
|
|
41
|
+
private httpServer: Server | null = null
|
|
42
|
+
private wss: WebSocketServer | null = null
|
|
43
|
+
private clients: Map<string, ClientConnection & { ws: WebSocket }> = new Map()
|
|
44
|
+
private session: RemoteSession | null = null
|
|
45
|
+
private terminal: TerminalManager | null = null
|
|
46
|
+
private tunnel: TunnelManager | null = null
|
|
47
|
+
private heartbeatTimer: NodeJS.Timeout | null = null
|
|
48
|
+
private sessionTimeoutTimer: NodeJS.Timeout | null = null
|
|
49
|
+
private isRunning = false
|
|
50
|
+
private sessionSecret: string
|
|
51
|
+
|
|
52
|
+
constructor(config: Partial<ServerConfig> = {}) {
|
|
53
|
+
super()
|
|
54
|
+
this.config = { ...DEFAULT_CONFIG, ...config }
|
|
55
|
+
this.sessionSecret = config.sessionSecret || this.generateSecret()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Start the remote server
|
|
60
|
+
*/
|
|
61
|
+
async start(options: { name?: string } = {}): Promise<RemoteSession> {
|
|
62
|
+
if (this.isRunning) {
|
|
63
|
+
throw new Error('Server already running')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sessionId = this.generateSessionId()
|
|
67
|
+
|
|
68
|
+
// Create HTTP server
|
|
69
|
+
this.httpServer = createServer((req, res) => this.handleHttpRequest(req, res))
|
|
70
|
+
|
|
71
|
+
// Create WebSocket server
|
|
72
|
+
this.wss = new WebSocketServer({ server: this.httpServer })
|
|
73
|
+
this.setupWebSocketHandlers()
|
|
74
|
+
|
|
75
|
+
// Start listening
|
|
76
|
+
const port = await new Promise<number>((resolve, reject) => {
|
|
77
|
+
this.httpServer!.listen(this.config.port, this.config.host, () => {
|
|
78
|
+
const addr = this.httpServer!.address()
|
|
79
|
+
resolve(typeof addr === 'object' ? addr?.port || 0 : 0)
|
|
80
|
+
})
|
|
81
|
+
this.httpServer!.on('error', reject)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Get local URL
|
|
85
|
+
const localIp = this.getLocalIP()
|
|
86
|
+
const localUrl = `http://${localIp}:${port}`
|
|
87
|
+
|
|
88
|
+
// Create initial session
|
|
89
|
+
this.session = {
|
|
90
|
+
id: sessionId,
|
|
91
|
+
name: options.name || `nikcli-${sessionId}`,
|
|
92
|
+
qrCode: '',
|
|
93
|
+
qrUrl: localUrl,
|
|
94
|
+
localUrl,
|
|
95
|
+
status: 'starting',
|
|
96
|
+
connectedDevices: [],
|
|
97
|
+
startedAt: new Date(),
|
|
98
|
+
lastActivity: new Date(),
|
|
99
|
+
port,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Create tunnel if enabled
|
|
103
|
+
if (this.config.enableTunnel && this.config.tunnelProvider !== 'none') {
|
|
104
|
+
try {
|
|
105
|
+
this.tunnel = new TunnelManager(this.config.tunnelProvider)
|
|
106
|
+
const tunnelUrl = await this.tunnel.create(port)
|
|
107
|
+
this.session.tunnelUrl = tunnelUrl
|
|
108
|
+
this.session.qrUrl = `${tunnelUrl}?s=${sessionId}&t=${this.sessionSecret}`
|
|
109
|
+
this.emit('tunnel:connected', tunnelUrl)
|
|
110
|
+
} catch (error: any) {
|
|
111
|
+
this.emit('tunnel:error', error)
|
|
112
|
+
// Continue with local URL
|
|
113
|
+
this.session.qrUrl = `${localUrl}?s=${sessionId}&t=${this.sessionSecret}`
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
this.session.qrUrl = `${localUrl}?s=${sessionId}&t=${this.sessionSecret}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Initialize terminal if enabled
|
|
120
|
+
if (this.config.enableTerminal) {
|
|
121
|
+
this.terminal = new TerminalManager({
|
|
122
|
+
shell: this.config.shell,
|
|
123
|
+
cols: this.config.cols,
|
|
124
|
+
rows: this.config.rows,
|
|
125
|
+
cwd: this.config.cwd,
|
|
126
|
+
env: this.config.env,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
this.terminal.on('data', (data: string) => {
|
|
130
|
+
this.broadcast({ type: MessageTypes.TERMINAL_OUTPUT, payload: { data } })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
this.terminal.on('exit', (code: number) => {
|
|
134
|
+
this.broadcast({ type: MessageTypes.TERMINAL_EXIT, payload: { code } })
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Start heartbeat
|
|
139
|
+
this.startHeartbeat()
|
|
140
|
+
|
|
141
|
+
// Start session timeout if configured
|
|
142
|
+
if (this.config.sessionTimeout > 0) {
|
|
143
|
+
this.startSessionTimeout()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.session.status = 'waiting'
|
|
147
|
+
this.isRunning = true
|
|
148
|
+
this.emit('started', this.session)
|
|
149
|
+
|
|
150
|
+
return this.session
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Stop the server
|
|
155
|
+
*/
|
|
156
|
+
async stop(): Promise<void> {
|
|
157
|
+
if (!this.isRunning) return
|
|
158
|
+
|
|
159
|
+
this.isRunning = false
|
|
160
|
+
|
|
161
|
+
// Clear timers
|
|
162
|
+
if (this.heartbeatTimer) {
|
|
163
|
+
clearInterval(this.heartbeatTimer)
|
|
164
|
+
this.heartbeatTimer = null
|
|
165
|
+
}
|
|
166
|
+
if (this.sessionTimeoutTimer) {
|
|
167
|
+
clearTimeout(this.sessionTimeoutTimer)
|
|
168
|
+
this.sessionTimeoutTimer = null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Notify clients
|
|
172
|
+
this.broadcast({ type: MessageTypes.SESSION_END, payload: {} })
|
|
173
|
+
|
|
174
|
+
// Close all connections
|
|
175
|
+
for (const client of this.clients.values()) {
|
|
176
|
+
client.ws.close(1000, 'Server shutting down')
|
|
177
|
+
}
|
|
178
|
+
this.clients.clear()
|
|
179
|
+
|
|
180
|
+
// Stop terminal
|
|
181
|
+
if (this.terminal) {
|
|
182
|
+
this.terminal.destroy()
|
|
183
|
+
this.terminal = null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Stop tunnel
|
|
187
|
+
if (this.tunnel) {
|
|
188
|
+
await this.tunnel.close()
|
|
189
|
+
this.tunnel = null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Close WebSocket server
|
|
193
|
+
if (this.wss) {
|
|
194
|
+
this.wss.close()
|
|
195
|
+
this.wss = null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Close HTTP server
|
|
199
|
+
if (this.httpServer) {
|
|
200
|
+
await new Promise<void>((resolve) => {
|
|
201
|
+
this.httpServer!.close(() => resolve())
|
|
202
|
+
})
|
|
203
|
+
this.httpServer = null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (this.session) {
|
|
207
|
+
this.session.status = 'stopped'
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.emit('stopped')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Broadcast message to all authenticated clients
|
|
215
|
+
*/
|
|
216
|
+
broadcast(message: BroadcastMessage): void {
|
|
217
|
+
const data = JSON.stringify({
|
|
218
|
+
type: message.type,
|
|
219
|
+
payload: message.payload,
|
|
220
|
+
timestamp: message.timestamp || Date.now(),
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
for (const client of this.clients.values()) {
|
|
224
|
+
if (client.authenticated && client.ws.readyState === WebSocket.OPEN) {
|
|
225
|
+
client.ws.send(data)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Send notification to clients
|
|
232
|
+
*/
|
|
233
|
+
notify(notification: RemoteNotification): void {
|
|
234
|
+
this.broadcast({
|
|
235
|
+
type: MessageTypes.NOTIFICATION,
|
|
236
|
+
payload: notification,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get current session
|
|
242
|
+
*/
|
|
243
|
+
getSession(): RemoteSession | null {
|
|
244
|
+
return this.session
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Check if server is running
|
|
249
|
+
*/
|
|
250
|
+
isActive(): boolean {
|
|
251
|
+
return this.isRunning && this.session?.status !== 'stopped'
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get connected client count
|
|
256
|
+
*/
|
|
257
|
+
getConnectedCount(): number {
|
|
258
|
+
let count = 0
|
|
259
|
+
for (const client of this.clients.values()) {
|
|
260
|
+
if (client.authenticated) count++
|
|
261
|
+
}
|
|
262
|
+
return count
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Write to terminal
|
|
267
|
+
*/
|
|
268
|
+
writeToTerminal(data: string): void {
|
|
269
|
+
this.terminal?.write(data)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Resize terminal
|
|
274
|
+
*/
|
|
275
|
+
resizeTerminal(cols: number, rows: number): void {
|
|
276
|
+
this.terminal?.resize(cols, rows)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Setup WebSocket handlers
|
|
281
|
+
*/
|
|
282
|
+
private setupWebSocketHandlers(): void {
|
|
283
|
+
this.wss!.on('connection', (ws, req) => {
|
|
284
|
+
const clientId = this.generateClientId()
|
|
285
|
+
|
|
286
|
+
const client: ClientConnection & { ws: WebSocket } = {
|
|
287
|
+
id: clientId,
|
|
288
|
+
ws,
|
|
289
|
+
authenticated: false,
|
|
290
|
+
device: {
|
|
291
|
+
id: clientId,
|
|
292
|
+
userAgent: req.headers['user-agent'],
|
|
293
|
+
ip: req.socket.remoteAddress,
|
|
294
|
+
connectedAt: new Date(),
|
|
295
|
+
lastActivity: new Date(),
|
|
296
|
+
},
|
|
297
|
+
lastPing: Date.now(),
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Check max connections
|
|
301
|
+
if (this.clients.size >= this.config.maxConnections) {
|
|
302
|
+
ws.close(1013, 'Max connections reached')
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
this.clients.set(clientId, client)
|
|
307
|
+
|
|
308
|
+
// Request auth
|
|
309
|
+
ws.send(JSON.stringify({ type: MessageTypes.AUTH_REQUIRED, timestamp: Date.now() }))
|
|
310
|
+
|
|
311
|
+
ws.on('message', (data) => {
|
|
312
|
+
try {
|
|
313
|
+
const message: ClientMessage = JSON.parse(data.toString())
|
|
314
|
+
this.handleClientMessage(client, message)
|
|
315
|
+
} catch {
|
|
316
|
+
// Invalid JSON, ignore
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
ws.on('close', () => {
|
|
321
|
+
this.clients.delete(clientId)
|
|
322
|
+
if (this.session && client.authenticated) {
|
|
323
|
+
this.session.connectedDevices = this.session.connectedDevices.filter(
|
|
324
|
+
(d) => d.id !== clientId
|
|
325
|
+
)
|
|
326
|
+
if (this.session.connectedDevices.length === 0) {
|
|
327
|
+
this.session.status = 'waiting'
|
|
328
|
+
}
|
|
329
|
+
this.emit('client:disconnected', client.device)
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
ws.on('error', (error) => {
|
|
334
|
+
this.emit('client:error', clientId, error)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
ws.on('pong', () => {
|
|
338
|
+
client.lastPing = Date.now()
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Handle client message
|
|
345
|
+
*/
|
|
346
|
+
private handleClientMessage(
|
|
347
|
+
client: ClientConnection & { ws: WebSocket },
|
|
348
|
+
message: ClientMessage
|
|
349
|
+
): void {
|
|
350
|
+
client.device.lastActivity = new Date()
|
|
351
|
+
if (this.session) {
|
|
352
|
+
this.session.lastActivity = new Date()
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Reset session timeout
|
|
356
|
+
if (this.config.sessionTimeout > 0) {
|
|
357
|
+
this.resetSessionTimeout()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
switch (message.type) {
|
|
361
|
+
case MessageTypes.AUTH:
|
|
362
|
+
this.handleAuth(client, message.token as string)
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
case MessageTypes.TERMINAL_INPUT:
|
|
366
|
+
if (client.authenticated && message.data) {
|
|
367
|
+
this.terminal?.write(message.data as string)
|
|
368
|
+
}
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
case MessageTypes.TERMINAL_RESIZE:
|
|
372
|
+
if (client.authenticated && message.cols && message.rows) {
|
|
373
|
+
this.terminal?.resize(message.cols as number, message.rows as number)
|
|
374
|
+
}
|
|
375
|
+
break
|
|
376
|
+
|
|
377
|
+
case MessageTypes.TERMINAL_CLEAR:
|
|
378
|
+
if (client.authenticated) {
|
|
379
|
+
this.terminal?.clear()
|
|
380
|
+
}
|
|
381
|
+
break
|
|
382
|
+
|
|
383
|
+
case MessageTypes.PING:
|
|
384
|
+
client.ws.send(JSON.stringify({ type: MessageTypes.PONG, timestamp: Date.now() }))
|
|
385
|
+
break
|
|
386
|
+
|
|
387
|
+
case MessageTypes.COMMAND:
|
|
388
|
+
if (client.authenticated) {
|
|
389
|
+
this.emit('command', {
|
|
390
|
+
clientId: client.id,
|
|
391
|
+
command: message.command as string,
|
|
392
|
+
args: message.args as string[] | undefined,
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
break
|
|
396
|
+
|
|
397
|
+
default:
|
|
398
|
+
this.emit('message', client, message)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Handle authentication
|
|
404
|
+
*/
|
|
405
|
+
private handleAuth(client: ClientConnection & { ws: WebSocket }, token: string): void {
|
|
406
|
+
if (token === this.sessionSecret) {
|
|
407
|
+
client.authenticated = true
|
|
408
|
+
|
|
409
|
+
if (this.session) {
|
|
410
|
+
this.session.connectedDevices.push(client.device)
|
|
411
|
+
this.session.status = 'connected'
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
client.ws.send(
|
|
415
|
+
JSON.stringify({
|
|
416
|
+
type: MessageTypes.AUTH_SUCCESS,
|
|
417
|
+
payload: {
|
|
418
|
+
sessionId: this.session?.id,
|
|
419
|
+
terminalEnabled: this.config.enableTerminal,
|
|
420
|
+
},
|
|
421
|
+
timestamp: Date.now(),
|
|
422
|
+
})
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
// Start terminal if needed
|
|
426
|
+
if (this.config.enableTerminal && !this.terminal?.isRunning()) {
|
|
427
|
+
this.terminal?.start()
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
this.emit('client:connected', client.device)
|
|
431
|
+
} else {
|
|
432
|
+
client.ws.send(JSON.stringify({ type: MessageTypes.AUTH_FAILED, timestamp: Date.now() }))
|
|
433
|
+
setTimeout(() => client.ws.close(1008, 'Authentication failed'), 100)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Handle HTTP request
|
|
439
|
+
*/
|
|
440
|
+
private handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
441
|
+
const url = new URL(req.url || '/', `http://${req.headers.host}`)
|
|
442
|
+
const path = url.pathname
|
|
443
|
+
|
|
444
|
+
// CORS headers
|
|
445
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
446
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
|
447
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
448
|
+
|
|
449
|
+
if (req.method === 'OPTIONS') {
|
|
450
|
+
res.writeHead(204)
|
|
451
|
+
res.end()
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Serve web client
|
|
456
|
+
if (path === '/' || path === '/index.html') {
|
|
457
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
458
|
+
res.end(getWebClient())
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Health check
|
|
463
|
+
if (path === '/health') {
|
|
464
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
465
|
+
res.end(JSON.stringify({ status: 'ok', session: this.session?.id }))
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Session info
|
|
470
|
+
if (path === '/api/session') {
|
|
471
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
472
|
+
res.end(
|
|
473
|
+
JSON.stringify({
|
|
474
|
+
id: this.session?.id,
|
|
475
|
+
name: this.session?.name,
|
|
476
|
+
status: this.session?.status,
|
|
477
|
+
connectedDevices: this.session?.connectedDevices.length,
|
|
478
|
+
})
|
|
479
|
+
)
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
484
|
+
res.end('Not Found')
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Start heartbeat
|
|
489
|
+
*/
|
|
490
|
+
private startHeartbeat(): void {
|
|
491
|
+
this.heartbeatTimer = setInterval(() => {
|
|
492
|
+
const now = Date.now()
|
|
493
|
+
for (const [id, client] of this.clients) {
|
|
494
|
+
if (now - client.lastPing > this.config.heartbeatInterval * 2) {
|
|
495
|
+
client.ws.terminate()
|
|
496
|
+
this.clients.delete(id)
|
|
497
|
+
} else if (client.ws.readyState === WebSocket.OPEN) {
|
|
498
|
+
client.ws.ping()
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}, this.config.heartbeatInterval)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Start session timeout
|
|
506
|
+
*/
|
|
507
|
+
private startSessionTimeout(): void {
|
|
508
|
+
this.sessionTimeoutTimer = setTimeout(() => {
|
|
509
|
+
if (this.session?.connectedDevices.length === 0) {
|
|
510
|
+
this.stop()
|
|
511
|
+
}
|
|
512
|
+
}, this.config.sessionTimeout)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Reset session timeout
|
|
517
|
+
*/
|
|
518
|
+
private resetSessionTimeout(): void {
|
|
519
|
+
if (this.sessionTimeoutTimer) {
|
|
520
|
+
clearTimeout(this.sessionTimeoutTimer)
|
|
521
|
+
}
|
|
522
|
+
if (this.config.sessionTimeout > 0) {
|
|
523
|
+
this.startSessionTimeout()
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Get local IP
|
|
529
|
+
*/
|
|
530
|
+
private getLocalIP(): string {
|
|
531
|
+
const interfaces = os.networkInterfaces()
|
|
532
|
+
for (const name of Object.keys(interfaces)) {
|
|
533
|
+
for (const iface of interfaces[name] || []) {
|
|
534
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
535
|
+
return iface.address
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return '127.0.0.1'
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Generate session ID
|
|
544
|
+
*/
|
|
545
|
+
private generateSessionId(): string {
|
|
546
|
+
return crypto.randomBytes(4).toString('hex')
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Generate client ID
|
|
551
|
+
*/
|
|
552
|
+
private generateClientId(): string {
|
|
553
|
+
return 'c_' + crypto.randomBytes(4).toString('hex')
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Generate secret
|
|
558
|
+
*/
|
|
559
|
+
private generateSecret(): string {
|
|
560
|
+
return crypto.randomBytes(16).toString('hex')
|
|
561
|
+
}
|
|
562
|
+
}
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nikcli/remote - Terminal Manager
|
|
3
|
+
* PTY-based terminal with fallback to basic spawn
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EventEmitter } from 'node:events'
|
|
7
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
8
|
+
|
|
9
|
+
// Try to import node-pty
|
|
10
|
+
let pty: typeof import('node-pty') | null = null
|
|
11
|
+
try {
|
|
12
|
+
pty = require('node-pty')
|
|
13
|
+
} catch {
|
|
14
|
+
// node-pty not available
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TerminalConfig {
|
|
18
|
+
shell: string
|
|
19
|
+
cols: number
|
|
20
|
+
rows: number
|
|
21
|
+
cwd?: string
|
|
22
|
+
env?: Record<string, string>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class TerminalManager extends EventEmitter {
|
|
26
|
+
private config: TerminalConfig
|
|
27
|
+
private ptyProcess: any = null
|
|
28
|
+
private process: ChildProcess | null = null
|
|
29
|
+
private running = false
|
|
30
|
+
|
|
31
|
+
constructor(config: TerminalConfig) {
|
|
32
|
+
super()
|
|
33
|
+
this.config = config
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start the terminal
|
|
38
|
+
*/
|
|
39
|
+
start(): void {
|
|
40
|
+
if (this.running) return
|
|
41
|
+
|
|
42
|
+
const { shell, cols, rows, cwd, env } = this.config
|
|
43
|
+
const termEnv = {
|
|
44
|
+
...process.env,
|
|
45
|
+
...env,
|
|
46
|
+
TERM: 'xterm-256color',
|
|
47
|
+
COLORTERM: 'truecolor',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (pty) {
|
|
51
|
+
// Use node-pty for full terminal emulation
|
|
52
|
+
try {
|
|
53
|
+
this.ptyProcess = pty.spawn(shell, [], {
|
|
54
|
+
name: 'xterm-256color',
|
|
55
|
+
cols,
|
|
56
|
+
rows,
|
|
57
|
+
cwd: cwd || process.cwd(),
|
|
58
|
+
env: termEnv,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
this.ptyProcess.onData((data: string) => {
|
|
62
|
+
this.emit('data', data)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
this.ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
|
66
|
+
this.running = false
|
|
67
|
+
this.emit('exit', exitCode)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
this.running = true
|
|
71
|
+
return
|
|
72
|
+
} catch (error: any) {
|
|
73
|
+
// Fall through to basic spawn
|
|
74
|
+
console.warn('node-pty failed, using fallback:', error.message)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fallback to basic spawn
|
|
79
|
+
this.process = spawn(shell, [], {
|
|
80
|
+
cwd: cwd || process.cwd(),
|
|
81
|
+
env: termEnv,
|
|
82
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
83
|
+
shell: true,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
this.process.stdout?.on('data', (data: Buffer) => {
|
|
87
|
+
this.emit('data', data.toString())
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
this.process.stderr?.on('data', (data: Buffer) => {
|
|
91
|
+
this.emit('data', data.toString())
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
this.process.on('exit', (code) => {
|
|
95
|
+
this.running = false
|
|
96
|
+
this.emit('exit', code || 0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
this.process.on('error', (error) => {
|
|
100
|
+
this.emit('error', error)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
this.running = true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Write data to terminal
|
|
108
|
+
*/
|
|
109
|
+
write(data: string): void {
|
|
110
|
+
if (this.ptyProcess) {
|
|
111
|
+
this.ptyProcess.write(data)
|
|
112
|
+
} else if (this.process?.stdin) {
|
|
113
|
+
this.process.stdin.write(data)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resize terminal
|
|
119
|
+
*/
|
|
120
|
+
resize(cols: number, rows: number): void {
|
|
121
|
+
if (this.ptyProcess) {
|
|
122
|
+
this.ptyProcess.resize(cols, rows)
|
|
123
|
+
}
|
|
124
|
+
// Basic spawn doesn't support resize
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Clear terminal
|
|
129
|
+
*/
|
|
130
|
+
clear(): void {
|
|
131
|
+
// Send clear screen escape sequence
|
|
132
|
+
this.emit('data', '\x1b[2J\x1b[H')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Kill terminal process
|
|
137
|
+
*/
|
|
138
|
+
destroy(): void {
|
|
139
|
+
this.running = false
|
|
140
|
+
if (this.ptyProcess) {
|
|
141
|
+
this.ptyProcess.kill()
|
|
142
|
+
this.ptyProcess = null
|
|
143
|
+
}
|
|
144
|
+
if (this.process) {
|
|
145
|
+
this.process.kill()
|
|
146
|
+
this.process = null
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check if terminal is running
|
|
152
|
+
*/
|
|
153
|
+
isRunning(): boolean {
|
|
154
|
+
return this.running
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if using PTY
|
|
159
|
+
*/
|
|
160
|
+
hasPty(): boolean {
|
|
161
|
+
return this.ptyProcess !== null
|
|
162
|
+
}
|
|
163
|
+
}
|