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/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
+ }
@@ -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
+ }