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/dist/chunk-5ANLFHJV.js +1145 -0
- package/dist/chunk-GI5RMYH6.js +37 -0
- package/dist/index.cjs +4683 -6082
- package/dist/index.d.cts +98 -60
- package/dist/index.d.ts +98 -60
- package/dist/index.js +228 -7
- package/dist/{localtunnel-XT32JGNN.js → localtunnel-6DCQIYU6.js} +1 -1
- package/dist/server-VOW4RWJA.js +7 -0
- package/package.json +1 -1
- package/src/index.ts +57 -12
- package/src/server.ts +113 -108
- package/src/tunnel.ts +24 -0
- package/dist/chunk-3IFHAOGG.js +0 -2747
- package/dist/chunk-MCKGQKYU.js +0 -15
- package/dist/server-MBJQBTJF.js +0 -7
package/src/server.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @nikcli/remote -
|
|
3
|
-
* WebSocket
|
|
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
|
-
*
|
|
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
|
|
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: '
|
|
100
|
+
status: 'waiting',
|
|
96
101
|
connectedDevices: [],
|
|
97
102
|
startedAt: new Date(),
|
|
98
103
|
lastActivity: new Date(),
|
|
99
104
|
port,
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
//
|
|
103
|
-
if (
|
|
104
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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
|