@fly/sprites
Version:
JavaScript/TypeScript SDK for Sprites - remote command execution
197 lines • 5.64 kB
JavaScript
/**
* WebSocket communication layer for command execution
*/
import { EventEmitter } from 'node:events';
import { Writable } from 'node:stream';
import { StreamID } from './types.js';
/**
* WebSocket command execution handler
*/
export class WSCommand extends EventEmitter {
url;
headers;
ws = null;
exitCode = -1;
tty;
started = false;
done = false;
stdout;
stderr;
constructor(url, headers, tty = false) {
super();
this.url = url;
this.headers = headers;
this.tty = tty;
this.stdout = new Writable({
write: () => { }, // No-op, actual writing happens in message handler
});
this.stderr = new Writable({
write: () => { }, // No-op, actual writing happens in message handler
});
}
/**
* Start the WebSocket connection
*/
async start() {
if (this.started) {
throw new Error('WSCommand already started');
}
this.started = true;
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(this.url, {
headers: this.headers,
});
this.ws.binaryType = 'arraybuffer';
this.ws.addEventListener('open', () => {
resolve();
});
this.ws.addEventListener('error', () => {
const error = new Error('WebSocket error');
this.emit('error', error);
if (!this.started) {
reject(error);
}
});
this.ws.addEventListener('message', (event) => {
this.handleMessage(event);
});
this.ws.addEventListener('close', (event) => {
this.handleClose(event);
});
}
catch (error) {
reject(error);
}
});
}
/**
* Handle incoming WebSocket messages
*/
handleMessage(event) {
if (this.tty) {
// TTY mode
if (typeof event.data === 'string') {
// Text message - control or notification
try {
const msg = JSON.parse(event.data);
this.emit('message', msg);
}
catch {
// Not JSON, treat as raw text
this.emit('message', event.data);
}
}
else {
// Binary - raw terminal data
const buffer = Buffer.from(event.data);
this.emit('stdout', buffer);
}
}
else {
// Non-TTY mode - stream-based protocol
const data = Buffer.from(event.data);
if (data.length === 0)
return;
const streamId = data[0];
const payload = data.subarray(1);
switch (streamId) {
case StreamID.Stdout:
this.emit('stdout', payload);
break;
case StreamID.Stderr:
this.emit('stderr', payload);
break;
case StreamID.Exit:
this.exitCode = payload.length > 0 ? payload[0] : 0;
this.close();
break;
}
}
}
/**
* Handle WebSocket close
*/
handleClose(event) {
if (!this.done) {
this.done = true;
// If we're in TTY mode and haven't received an exit code, determine it from close event
if (this.tty && this.exitCode === -1) {
this.exitCode = event.code === 1000 ? 0 : 1;
}
this.emit('exit', this.exitCode);
}
}
/**
* Write data to stdin
*/
writeStdin(data) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket not open');
}
if (this.tty) {
this.ws.send(data);
}
else {
const message = Buffer.allocUnsafe(data.length + 1);
message[0] = StreamID.Stdin;
data.copy(message, 1);
this.ws.send(message);
}
}
/**
* Send stdin EOF
*/
sendStdinEOF() {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
return;
if (!this.tty) {
const message = Buffer.from([StreamID.StdinEOF]);
this.ws.send(message);
}
}
/**
* Send resize control message (TTY only)
*/
resize(cols, rows) {
if (!this.tty || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
const msg = { type: 'resize', cols, rows };
this.ws.send(JSON.stringify(msg));
}
/**
* Get the exit code
*/
getExitCode() {
return this.exitCode;
}
/**
* Check if the command is done
*/
isDone() {
return this.done;
}
/**
* Close the WebSocket connection
*/
close() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, '');
}
}
/**
* Wait for the command to complete
*/
async wait() {
if (this.done) {
return this.exitCode;
}
return new Promise((resolve) => {
this.once('exit', (code) => {
resolve(code);
});
});
}
}
//# sourceMappingURL=websocket.js.map