nikcli-remote 1.0.9 → 1.0.10

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,46 @@ 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
144
+ const text = data instanceof Buffer ? data.toString() : data
145
+ this.broadcastToAll({ type: MessageTypes.TERMINAL_OUTPUT, payload: { data: text } })
146
+
147
+ return result
148
+ }) as typeof stdout.write
149
+
150
+ // Listen for client messages and forward to stdin
151
+ this.wss?.on('connection', (ws) => {
152
+ ws.on('message', (rawData: RawData) => {
153
+ try {
154
+ const msg = JSON.parse(rawData.toString())
155
+ if (msg.type === MessageTypes.TERMINAL_INPUT && msg.data) {
156
+ // Forward client input to stdin
157
+ const inputData = Buffer.from(msg.data as string)
158
+ stdin.emit('data', inputData)
159
+ }
160
+ } catch {
161
+ // Invalid message, ignore
162
+ }
163
+ })
164
+ })
165
+ }
166
+
156
167
  /**
157
168
  * Stop the server
158
169
  */
@@ -161,6 +172,11 @@ export class RemoteServer extends EventEmitter {
161
172
 
162
173
  this.isRunning = false
163
174
 
175
+ // Restore original stdout.write
176
+ if (this.originalStdoutWrite && this.session) {
177
+ // We can't restore the global process.stdout, but we stop broadcasting
178
+ }
179
+
164
180
  // Clear timers
165
181
  if (this.heartbeatTimer) {
166
182
  clearInterval(this.heartbeatTimer)
@@ -172,7 +188,7 @@ export class RemoteServer extends EventEmitter {
172
188
  }
173
189
 
174
190
  // Notify clients
175
- this.broadcast({ type: MessageTypes.SESSION_END, payload: {} })
191
+ this.broadcastToAll({ type: MessageTypes.SESSION_END, payload: {} })
176
192
 
177
193
  // Close all connections
178
194
  for (const client of this.clients.values()) {
@@ -180,18 +196,6 @@ export class RemoteServer extends EventEmitter {
180
196
  }
181
197
  this.clients.clear()
182
198
 
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
199
  // Close WebSocket server
196
200
  if (this.wss) {
197
201
  this.wss.close()
@@ -216,7 +220,7 @@ export class RemoteServer extends EventEmitter {
216
220
  /**
217
221
  * Broadcast message to all authenticated clients
218
222
  */
219
- broadcast(message: BroadcastMessage): void {
223
+ private broadcastToAll(message: BroadcastMessage): void {
220
224
  const data = JSON.stringify({
221
225
  type: message.type,
222
226
  payload: message.payload,
@@ -234,7 +238,7 @@ export class RemoteServer extends EventEmitter {
234
238
  * Send notification to clients
235
239
  */
236
240
  notify(notification: RemoteNotification): void {
237
- this.broadcast({
241
+ this.broadcastToAll({
238
242
  type: MessageTypes.NOTIFICATION,
239
243
  payload: notification,
240
244
  })
@@ -266,17 +270,17 @@ export class RemoteServer extends EventEmitter {
266
270
  }
267
271
 
268
272
  /**
269
- * Write to terminal
273
+ * Write data to all connected clients (for manual output streaming)
270
274
  */
271
- writeToTerminal(data: string): void {
272
- this.terminal?.write(data)
275
+ writeToClients(data: string): void {
276
+ this.broadcastToAll({ type: MessageTypes.TERMINAL_OUTPUT, payload: { data } })
273
277
  }
274
278
 
275
279
  /**
276
- * Resize terminal
280
+ * Alias for writeToClients - for compatibility
277
281
  */
278
- resizeTerminal(cols: number, rows: number): void {
279
- this.terminal?.resize(cols, rows)
282
+ writeToTerminal(data: string): void {
283
+ this.writeToClients(data)
280
284
  }
281
285
 
282
286
  /**
@@ -366,37 +370,18 @@ export class RemoteServer extends EventEmitter {
366
370
  break
367
371
 
368
372
  case MessageTypes.TERMINAL_INPUT:
369
- if (client.authenticated && message.data) {
370
- this.terminal?.write(message.data as string)
371
- }
373
+ // Input is handled in setupStdioProxy via ws message handler
372
374
  break
373
375
 
374
376
  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
- }
377
+ // Resize can be forwarded if needed
378
+ this.emit('message', client, message)
384
379
  break
385
380
 
386
381
  case MessageTypes.PING:
387
382
  client.ws.send(JSON.stringify({ type: MessageTypes.PONG, timestamp: Date.now() }))
388
383
  break
389
384
 
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
385
  default:
401
386
  this.emit('message', client, message)
402
387
  }
@@ -419,17 +404,11 @@ export class RemoteServer extends EventEmitter {
419
404
  type: MessageTypes.AUTH_SUCCESS,
420
405
  payload: {
421
406
  sessionId: this.session?.id,
422
- terminalEnabled: this.config.enableTerminal,
423
407
  },
424
408
  timestamp: Date.now(),
425
409
  })
426
410
  )
427
411
 
428
- // Start terminal if needed
429
- if (this.config.enableTerminal && !this.terminal?.isRunning()) {
430
- this.terminal?.start()
431
- }
432
-
433
412
  this.emit('client:connected', client.device)
434
413
  } else {
435
414
  client.ws.send(JSON.stringify({ type: MessageTypes.AUTH_FAILED, timestamp: Date.now() }))
@@ -457,6 +436,7 @@ export class RemoteServer extends EventEmitter {
457
436
 
458
437
  // Serve web client
459
438
  if (path === '/' || path === '/index.html') {
439
+ const { getWebClient } = require('./web-client')
460
440
  res.writeHead(200, {
461
441
  'Content-Type': 'text/html; charset=utf-8',
462
442
  'Content-Security-Policy': "default-src 'self' 'unsafe-inline' 'unsafe-eval' https: data: ws: wss:",
@@ -558,11 +538,4 @@ export class RemoteServer extends EventEmitter {
558
538
  private generateClientId(): string {
559
539
  return 'c_' + crypto.randomBytes(4).toString('hex')
560
540
  }
561
-
562
- /**
563
- * Generate secret
564
- */
565
- private generateSecret(): string {
566
- return crypto.randomBytes(16).toString('hex')
567
- }
568
541
  }
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