nikcli-remote 1.0.9 → 1.0.11

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 CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * @nikcli/remote - Native Remote Server
3
- * WebSocket-based remote terminal with mobile-friendly web client
2
+ * @nikcli/remote - Direct Terminal Streaming Server
3
+ * Proxies NikCLI's stdin/stdout to WebSocket clients for true real-time terminal access
4
4
  */
5
5
 
6
6
  import { EventEmitter } from 'node:events'
7
7
  import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
8
- import { WebSocketServer, WebSocket } from 'ws'
8
+ import { WebSocketServer, WebSocket, type RawData } from 'ws'
9
9
  import crypto from 'node:crypto'
10
10
  import os from 'node:os'
11
11
  import type {
@@ -16,12 +16,8 @@ import type {
16
16
  RemoteNotification,
17
17
  ClientConnection,
18
18
  ClientMessage,
19
- TunnelProvider,
20
19
  } from './types'
21
20
  import { DEFAULT_CONFIG, MessageTypes } from './types'
22
- import { TerminalManager } from './terminal'
23
- import { TunnelManager } from './tunnel'
24
- import { getWebClient } from './web-client'
25
21
 
26
22
  export interface RemoteServerEvents {
27
23
  started: (session: RemoteSession) => void
@@ -31,9 +27,9 @@ export interface RemoteServerEvents {
31
27
  'client:error': (clientId: string, error: Error) => void
32
28
  'tunnel:connected': (url: string) => void
33
29
  'tunnel:error': (error: Error) => void
34
- command: (cmd: { clientId: string; command: string; args?: string[] }) => void
35
30
  message: (client: ClientConnection, message: ClientMessage) => void
36
31
  error: (error: Error) => void
32
+ 'terminal:output': (data: string) => void
37
33
  }
38
34
 
39
35
  export class RemoteServer extends EventEmitter {
@@ -42,13 +38,15 @@ export class RemoteServer extends EventEmitter {
42
38
  private wss: WebSocketServer | null = null
43
39
  private clients: Map<string, ClientConnection & { ws: WebSocket }> = new Map()
44
40
  private session: RemoteSession | null = null
45
- private terminal: TerminalManager | null = null
46
- private tunnel: TunnelManager | null = null
47
41
  private heartbeatTimer: NodeJS.Timeout | null = null
48
42
  private sessionTimeoutTimer: NodeJS.Timeout | null = null
49
43
  private isRunning = false
50
44
  private sessionSecret: string
51
45
 
46
+ // stdin/stdout proxy state
47
+ private originalStdoutWrite: ((data: string | Uint8Array, ...args: unknown[]) => boolean) | null = null
48
+ private originalStdinOn: ((event: string, listener: (...args: unknown[]) => void) => void) | null = null
49
+
52
50
  constructor(config: Partial<ServerConfig> = {}) {
53
51
  super()
54
52
  this.config = { ...DEFAULT_CONFIG, ...config }
@@ -56,19 +54,26 @@ export class RemoteServer extends EventEmitter {
56
54
  }
57
55
 
58
56
  /**
59
- * Start the remote server
57
+ * Generate a random secret
58
+ */
59
+ private generateSecret(): string {
60
+ return crypto.randomBytes(16).toString('hex')
61
+ }
62
+
63
+ /**
64
+ * Start the remote server - creates WebSocket server that proxies stdin/stdout
60
65
  */
61
- async start(options: { name?: string } = {}): Promise<RemoteSession> {
66
+ async start(options: { name?: string; processForStreaming?: { stdout: NodeJS.WriteStream; stdin: NodeJS.ReadStream } } = {}): Promise<RemoteSession> {
62
67
  if (this.isRunning) {
63
68
  throw new Error('Server already running')
64
69
  }
65
70
 
66
71
  const sessionId = this.generateSessionId()
67
72
 
68
- // Create HTTP server
73
+ // Create HTTP server for web client and health checks
69
74
  this.httpServer = createServer((req, res) => this.handleHttpRequest(req, res))
70
75
 
71
- // Create WebSocket server
76
+ // Create WebSocket server for terminal streaming
72
77
  this.wss = new WebSocketServer({ server: this.httpServer })
73
78
  this.setupWebSocketHandlers()
74
79
 
@@ -85,57 +90,23 @@ export class RemoteServer extends EventEmitter {
85
90
  const localIp = this.getLocalIP()
86
91
  const localUrl = `http://${localIp}:${port}`
87
92
 
88
- // Create initial session
93
+ // Create session
89
94
  this.session = {
90
95
  id: sessionId,
91
96
  name: options.name || `nikcli-${sessionId}`,
92
97
  qrCode: '',
93
- qrUrl: localUrl,
98
+ qrUrl: `${localUrl}?s=${sessionId}&t=${this.sessionSecret}`,
94
99
  localUrl,
95
- status: 'starting',
100
+ status: 'waiting',
96
101
  connectedDevices: [],
97
102
  startedAt: new Date(),
98
103
  lastActivity: new Date(),
99
104
  port,
100
105
  }
101
106
 
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
- // Broadcast to all connected clients
131
- this.broadcast({ type: MessageTypes.TERMINAL_OUTPUT, payload: { data } })
132
- // Also emit for NikCLI to capture
133
- this.emit('terminal:output', data)
134
- })
135
-
136
- this.terminal.on('exit', (code: number) => {
137
- this.broadcast({ type: MessageTypes.TERMINAL_EXIT, payload: { code } })
138
- })
107
+ // Setup stdin/stdout proxy if process provided
108
+ if (options.processForStreaming) {
109
+ this.setupStdioProxy(options.processForStreaming.stdout, options.processForStreaming.stdin)
139
110
  }
140
111
 
141
112
  // Start heartbeat
@@ -153,6 +124,63 @@ export class RemoteServer extends EventEmitter {
153
124
  return this.session
154
125
  }
155
126
 
127
+ /**
128
+ * Setup stdin/stdout proxy to forward to WebSocket clients
129
+ */
130
+ private setupStdioProxy(stdout: NodeJS.WriteStream, stdin: NodeJS.ReadStream): void {
131
+ // Store original write method
132
+ const originalWrite = stdout.write.bind(stdout)
133
+
134
+ // Override stdout.write to broadcast to all clients
135
+ stdout.write = ((
136
+ data: string | Uint8Array,
137
+ encoding?: BufferEncoding | undefined,
138
+ cb?: (err?: Error | null) => void
139
+ ): boolean => {
140
+ // Call original write
141
+ const result = originalWrite(data, encoding, cb)
142
+
143
+ // Broadcast to all authenticated clients (cleaned for mobile)
144
+ const text = data instanceof Buffer ? data.toString() : data
145
+ const cleaned = this.cleanOutputForMobile(text as any)
146
+ this.broadcastToAll({ type: MessageTypes.TERMINAL_OUTPUT, payload: { data: cleaned } })
147
+
148
+ return result
149
+ }) as typeof stdout.write
150
+
151
+ // Listen for client messages and forward to stdin
152
+ this.wss?.on('connection', (ws) => {
153
+ ws.on('message', (rawData: RawData) => {
154
+ try {
155
+ const msg = JSON.parse(rawData.toString())
156
+ if (msg.type === MessageTypes.TERMINAL_INPUT && msg.data) {
157
+ // Forward client input to stdin
158
+ const inputData = Buffer.from(msg.data as string)
159
+ stdin.emit('data', inputData)
160
+ }
161
+ } catch {
162
+ // Invalid message, ignore
163
+ }
164
+ })
165
+ })
166
+ }
167
+
168
+ /**
169
+ * Clean output for mobile display - remove ANSI codes and TUI artifacts
170
+ */
171
+ private cleanOutputForMobile(text: string): string {
172
+ // Remove ANSI escape sequences (colors, cursor movements, clear screen)
173
+ let cleaned = text
174
+ // Remove escape sequences (colors, cursor, clear)
175
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
176
+ // Remove carriage returns that cause overwrites
177
+ .replace(/\r/g, '')
178
+ // Remove special control chars that cause issues
179
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
180
+
181
+ return cleaned
182
+ }
183
+
156
184
  /**
157
185
  * Stop the server
158
186
  */
@@ -161,6 +189,11 @@ export class RemoteServer extends EventEmitter {
161
189
 
162
190
  this.isRunning = false
163
191
 
192
+ // Restore original stdout.write
193
+ if (this.originalStdoutWrite && this.session) {
194
+ // We can't restore the global process.stdout, but we stop broadcasting
195
+ }
196
+
164
197
  // Clear timers
165
198
  if (this.heartbeatTimer) {
166
199
  clearInterval(this.heartbeatTimer)
@@ -172,7 +205,7 @@ export class RemoteServer extends EventEmitter {
172
205
  }
173
206
 
174
207
  // Notify clients
175
- this.broadcast({ type: MessageTypes.SESSION_END, payload: {} })
208
+ this.broadcastToAll({ type: MessageTypes.SESSION_END, payload: {} })
176
209
 
177
210
  // Close all connections
178
211
  for (const client of this.clients.values()) {
@@ -180,18 +213,6 @@ export class RemoteServer extends EventEmitter {
180
213
  }
181
214
  this.clients.clear()
182
215
 
183
- // Stop terminal
184
- if (this.terminal) {
185
- this.terminal.destroy()
186
- this.terminal = null
187
- }
188
-
189
- // Stop tunnel
190
- if (this.tunnel) {
191
- await this.tunnel.close()
192
- this.tunnel = null
193
- }
194
-
195
216
  // Close WebSocket server
196
217
  if (this.wss) {
197
218
  this.wss.close()
@@ -216,7 +237,7 @@ export class RemoteServer extends EventEmitter {
216
237
  /**
217
238
  * Broadcast message to all authenticated clients
218
239
  */
219
- broadcast(message: BroadcastMessage): void {
240
+ private broadcastToAll(message: BroadcastMessage): void {
220
241
  const data = JSON.stringify({
221
242
  type: message.type,
222
243
  payload: message.payload,
@@ -230,11 +251,18 @@ export class RemoteServer extends EventEmitter {
230
251
  }
231
252
  }
232
253
 
254
+ /**
255
+ * Public broadcast method for compatibility
256
+ */
257
+ broadcast(message: BroadcastMessage): void {
258
+ this.broadcastToAll(message)
259
+ }
260
+
233
261
  /**
234
262
  * Send notification to clients
235
263
  */
236
264
  notify(notification: RemoteNotification): void {
237
- this.broadcast({
265
+ this.broadcastToAll({
238
266
  type: MessageTypes.NOTIFICATION,
239
267
  payload: notification,
240
268
  })
@@ -266,17 +294,25 @@ export class RemoteServer extends EventEmitter {
266
294
  }
267
295
 
268
296
  /**
269
- * Write to terminal
297
+ * Write data to all connected clients (for manual output streaming)
298
+ */
299
+ writeToClients(data: string): void {
300
+ this.broadcastToAll({ type: MessageTypes.TERMINAL_OUTPUT, payload: { data } })
301
+ }
302
+
303
+ /**
304
+ * Alias for writeToClients - for compatibility
270
305
  */
271
306
  writeToTerminal(data: string): void {
272
- this.terminal?.write(data)
307
+ this.writeToClients(data)
273
308
  }
274
309
 
275
310
  /**
276
- * Resize terminal
311
+ * Resize terminal (for compatibility - not used in direct streaming mode)
277
312
  */
278
313
  resizeTerminal(cols: number, rows: number): void {
279
- this.terminal?.resize(cols, rows)
314
+ // In direct streaming mode, terminal size is handled by the client
315
+ // This is a no-op for compatibility
280
316
  }
281
317
 
282
318
  /**
@@ -366,37 +402,18 @@ export class RemoteServer extends EventEmitter {
366
402
  break
367
403
 
368
404
  case MessageTypes.TERMINAL_INPUT:
369
- if (client.authenticated && message.data) {
370
- this.terminal?.write(message.data as string)
371
- }
405
+ // Input is handled in setupStdioProxy via ws message handler
372
406
  break
373
407
 
374
408
  case MessageTypes.TERMINAL_RESIZE:
375
- if (client.authenticated && message.cols && message.rows) {
376
- this.terminal?.resize(message.cols as number, message.rows as number)
377
- }
378
- break
379
-
380
- case MessageTypes.TERMINAL_CLEAR:
381
- if (client.authenticated) {
382
- this.terminal?.clear()
383
- }
409
+ // Resize can be forwarded if needed
410
+ this.emit('message', client, message)
384
411
  break
385
412
 
386
413
  case MessageTypes.PING:
387
414
  client.ws.send(JSON.stringify({ type: MessageTypes.PONG, timestamp: Date.now() }))
388
415
  break
389
416
 
390
- case MessageTypes.COMMAND:
391
- if (client.authenticated) {
392
- this.emit('command', {
393
- clientId: client.id,
394
- command: message.command as string,
395
- args: message.args as string[] | undefined,
396
- })
397
- }
398
- break
399
-
400
417
  default:
401
418
  this.emit('message', client, message)
402
419
  }
@@ -419,17 +436,11 @@ export class RemoteServer extends EventEmitter {
419
436
  type: MessageTypes.AUTH_SUCCESS,
420
437
  payload: {
421
438
  sessionId: this.session?.id,
422
- terminalEnabled: this.config.enableTerminal,
423
439
  },
424
440
  timestamp: Date.now(),
425
441
  })
426
442
  )
427
443
 
428
- // Start terminal if needed
429
- if (this.config.enableTerminal && !this.terminal?.isRunning()) {
430
- this.terminal?.start()
431
- }
432
-
433
444
  this.emit('client:connected', client.device)
434
445
  } else {
435
446
  client.ws.send(JSON.stringify({ type: MessageTypes.AUTH_FAILED, timestamp: Date.now() }))
@@ -457,6 +468,7 @@ export class RemoteServer extends EventEmitter {
457
468
 
458
469
  // Serve web client
459
470
  if (path === '/' || path === '/index.html') {
471
+ const { getWebClient } = require('./web-client')
460
472
  res.writeHead(200, {
461
473
  'Content-Type': 'text/html; charset=utf-8',
462
474
  'Content-Security-Policy': "default-src 'self' 'unsafe-inline' 'unsafe-eval' https: data: ws: wss:",
@@ -558,11 +570,4 @@ export class RemoteServer extends EventEmitter {
558
570
  private generateClientId(): string {
559
571
  return 'c_' + crypto.randomBytes(4).toString('hex')
560
572
  }
561
-
562
- /**
563
- * Generate secret
564
- */
565
- private generateSecret(): string {
566
- return crypto.randomBytes(16).toString('hex')
567
- }
568
573
  }
package/src/tunnel.ts CHANGED
@@ -6,6 +6,30 @@
6
6
  import { spawn, type ChildProcess } from 'node:child_process'
7
7
  import type { TunnelProvider } from './types'
8
8
 
9
+ export type { TunnelProvider }
10
+
11
+ export interface TunnelResult {
12
+ url: string
13
+ provider: TunnelProvider
14
+ close: () => Promise<void>
15
+ }
16
+
17
+ /**
18
+ * Create a tunnel for the given port using the specified provider
19
+ */
20
+ export async function createTunnel(
21
+ port: number,
22
+ provider: TunnelProvider = 'cloudflared'
23
+ ): Promise<TunnelResult> {
24
+ const manager = new TunnelManager(provider)
25
+ const url = await manager.create(port)
26
+ return {
27
+ url,
28
+ provider,
29
+ close: () => manager.close(),
30
+ }
31
+ }
32
+
9
33
  export class TunnelManager {
10
34
  private provider: TunnelProvider
11
35
  private process: ChildProcess | null = null