@heroku/mcp-server
Version:
Heroku Platform MCP Server
141 lines (140 loc) • 4.57 kB
JavaScript
import * as tls from 'node:tls';
/**
* Implements the Heroku Rendezvous protocol for connecting to one-off dynos
* See: https://devcenter.heroku.com/articles/one-off-dynos#rendezvous
*/
export class RendezvousConnection {
options;
connection = null;
timeout = 1000 * 60 * 60; // 1 hour timeout
output = '';
exitCode = null;
/**
* Creates a new RendezvousConnection
*
* @param options The options for the rendezvous connection
*/
constructor(options) {
this.options = options;
}
/**
* Establishes a connection to the dyno using the rendezvous protocol
* and returns the output once the dyno completes
*
* @returns A promise that resolves with the dyno output and exit code
*/
async connect() {
try {
this.updateStatus('starting');
this.connection = tls.connect(Number.parseInt(this.options.uri.port, 10), this.options.uri.hostname, {
rejectUnauthorized: this.options.rejectUnauthorized
});
await this.setupConnection();
// Return the captured output and exit code
return {
output: this.output,
exitCode: this.exitCode ?? 0
};
}
catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to establish connection');
}
}
/**
* Closes the rendezvous connection if open
*/
close() {
if (this.connection) {
this.connection.end();
this.connection = null;
}
}
/**
* Sets up the connection
*
* @returns A promise that resolves when the connection is complete
*/
setupConnection() {
if (!this.connection)
throw new Error('Connection not initialized');
return new Promise((resolve, reject) => {
this.connection.setTimeout(this.timeout);
this.connection.setEncoding('utf8');
// Setup event handlers
this.connection.on('connect', () => this.handleConnect());
this.connection.on('data', (data) => this.handleData(data));
this.connection.on('close', () => resolve());
this.connection.on('error', (err) => reject(err));
this.connection.on('timeout', () => this.handleTimeout());
// Handle process termination
process.once('SIGINT', () => {
this.close();
reject(new Error('Process terminated'));
});
});
}
/**
* Handles the initial connection setup
*/
handleConnect() {
if (!this.connection)
return;
const pathnameWithSearchParams = this.options.uri.pathname + this.options.uri.search;
this.connection.write(pathnameWithSearchParams.slice(1) + '\r\n', () => {
this.updateStatus('connecting');
});
}
/**
* Handles incoming data from the connection
* Captures both output and exit code from the dyno
*
* @param data The data received from the connection
*/
handleData(data) {
// Check for exit code in the data
const exitCodeMatch = data.match(/\[exit\s*(\d+)\]/);
if (exitCodeMatch) {
this.exitCode = parseInt(exitCodeMatch[1], 10);
// Create a new variable for the modified data
const cleanedData = data.replace(/\[exit\s*\d+\]/, '');
// Append to output buffer
this.output += cleanedData;
// Call onData callback if provided
if (this.options.onData) {
this.options.onData(cleanedData);
}
}
else {
// Append to output buffer
this.output += data;
// Call onData callback if provided
if (this.options.onData) {
this.options.onData(data);
}
}
// Update status if we detect completion
if (this.exitCode !== null) {
this.updateStatus('complete');
}
}
/**
* Handles connection timeout events
*/
handleTimeout() {
this.close();
throw new Error('Connection timed out');
}
/**
* Updates the connection status
*
* @param status The new status to set
*/
updateStatus(status) {
if (this.options.showStatus && this.options.onStatusChange) {
this.options.onStatusChange(status);
}
}
}