UNPKG

@heroku/mcp-server

Version:
141 lines (140 loc) 4.57 kB
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); } } }