UNPKG

@monitoro/herd

Version:

Automate your browser, build AI web tools and MCP servers with Monitoro Herd

315 lines (314 loc) 10.7 kB
import { EventEmitter } from 'events'; import EventSource from 'eventsource'; import fetch from 'node-fetch'; import { connect, JSONCodec, credsAuthenticator } from 'nats'; import { Device } from './Device.js'; import { TrailEngine } from './TrailEngine.js'; // A simple wrapper for trails that matches the expected API pattern class Trail { constructor(client, trailIdentifier, version) { this.client = client; this.trailIdentifier = trailIdentifier; this.version = version; } /** * Run a trail action * @param actionOrParams Either the action name to run, or the parameters for the default action * @param params Optional parameters if an action name is provided */ async run(actionOrParams, params) { let actionName; let actionParams = {}; if (typeof actionOrParams === 'string') { actionName = actionOrParams; actionParams = params || {}; } else if (actionOrParams && typeof actionOrParams === 'object') { actionParams = actionOrParams; } return this.client.runTrail(this.trailIdentifier, { actionName, params: actionParams, version: this.version }); } } export class HerdClient extends EventEmitter { constructor(options) { super(); this.baseUrl = "https://herd.garden"; this.natsUrl = null; this.deviceMap = new Map(); this.natsConnection = null; this.natsServiceUserJwt = null; this.natsAccountId = null; this.jc = JSONCodec(); this._initialized = false; this._trailEngine = null; if (options.baseUrl) { this.baseUrl = options.baseUrl.replace(/\/$/, ''); } if ('token' in options && options.token) { this.token = options.token; } else if ('apiKey' in options && options.apiKey) { this.token = options.apiKey; } else { throw new Error('Token or API key is required. Get yours at https://herd.garden'); } } // Alias for initialize to match the guide example async init() { return this.initialize(); } /** * Make an HTTP request to the Herd API */ async request(path, options = {}) { const url = `${this.baseUrl}${path}`; const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.token}`, ...(options.headers || {}) }; const response = await fetch(url, { ...options, headers }); return response; } /** * Get the base URL for the Herd API */ getBaseUrl() { return this.baseUrl; } /** * Get current user information */ async me() { const response = await this.request('/api/auth/me'); if (!response.ok) { throw new Error(`Failed to fetch user info: ${response.statusText}`); } return response.json(); } /** * Get cache encryption key */ async getCacheKey() { const response = await this.request('/api/auth/cache-key'); if (!response.ok) { throw new Error(`Failed to fetch cache key: ${response.statusText}`); } const data = await response.json(); return data.key; } /** * Initialize the client by fetching the NATS service user JWT and connecting to NATS */ async initialize() { // Get user info and NATS credentials const meResponse = await this.me(); this.natsUrl = meResponse.natsUrl || null; const serviceUserResponse = await this.request('/api/auth/service-user-jwt'); const { natsServiceUserJwt, natsAccountId } = await serviceUserResponse.json(); this.natsServiceUserJwt = natsServiceUserJwt; this.natsAccountId = natsAccountId; // Connect to NATS await this.connectToNats(); this._initialized = true; } async connectToNats() { if (!this.natsServiceUserJwt || !this.natsAccountId) { throw new Error('NATS credentials not available. Call initialize() first.'); } try { this.natsConnection = await connect({ servers: this.natsUrl, name: this.natsAccountId, reconnect: true, maxReconnectAttempts: 10, reconnectTimeWait: 1000, timeout: 20000, authenticator: credsAuthenticator(new TextEncoder().encode(this.natsServiceUserJwt)) }); } catch (error) { console.error('[HerdClient] Failed to connect:', error); throw error; } } /** * Send a command to a device via NATS */ async sendNatsCommand(deviceId, command, params) { if (!this.natsConnection) { throw new Error('Not connected to NATS. Call initialize() first.'); } const subject = `${deviceId}.${command}`; try { const response = await this.natsConnection.request(subject, this.jc.encode(params), { timeout: 100000 } // 10 second timeout ); return this.jc.decode(response.data); } catch (error) { // console.error(`[HerdClient] Failed to send command ${command} to device ${deviceId}:`, error); throw error; } } /** * List all available devices */ async listDevices() { const response = await this.request('/api/devices'); const devices = await response.json(); return devices.map(info => { let device = this.deviceMap.get(info.deviceId); if (!device) { device = new Device(this, info); this.deviceMap.set(info.deviceId, device); } else { device.updateInfo(info); } return device; }); } /** * Get a specific device by ID */ async getDevice(deviceId) { const devices = await this.listDevices(); const device = devices.find(d => d.id === deviceId); if (!device) { throw new Error(`Device ${deviceId} not found`); } return device; } /** * Register a new device */ async registerDevice(options) { const response = await this.request('/api/devices/register', { method: 'POST', body: JSON.stringify(options), }); return response.json(); } /** * Send an RPC command to a device */ async sendCommand(deviceId, command, params) { // Initialize if not already initialized if (!this.natsConnection) { await this.initialize(); // Assuming there's an initialize method } // Use NATS if connected, otherwise fall back to HTTP if (this.natsConnection) { return this.sendNatsCommand(deviceId, command, params); } throw new Error('Not connected to NATS. Call initialize() first.'); } /** * Subscribe to all events from a device */ subscribeToDeviceEvents(deviceId, callback) { const eventSource = new EventSource(`${this.baseUrl}/api/devices/${deviceId}/events`, { headers: { 'Authorization': `Bearer ${this.token}` } }); eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); callback(data); } catch (error) { // console.error('Error parsing event data:', error); } }; eventSource.onerror = (error) => { // console.error('EventSource error:', error); eventSource.close(); }; return () => eventSource.close(); } /** * Subscribe to a specific event from a device */ subscribeToDeviceEvent(deviceId, eventName, callback) { const eventSource = new EventSource(`${this.baseUrl}/api/devices/${deviceId}/events/${eventName}`, { headers: { 'Authorization': `Bearer ${this.token}` } }); eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); callback(data); } catch (error) { // console.error('Error parsing event data:', error); } }; eventSource.onerror = (error) => { // console.error('EventSource error:', error); eventSource.close(); }; return () => eventSource.close(); } /** * Close the client and clean up resources */ async close() { if (this.natsConnection) { await this.natsConnection.close(); this.natsConnection = null; } } /** * Check if the client has been initialized */ isInitialized() { return this._initialized; } /** * Get the trail engine instance, creating it if it doesn't exist * @param options Options for the trail engine */ trails(options = {}) { if (!this._trailEngine) { this._trailEngine = new TrailEngine(this, options); } return this._trailEngine; } /** * Get a trail instance for running actions * @param trailIdentifier The trail name or path * @param version Optional version for remote trails */ trail(trailIdentifier, version) { return new Trail(this, trailIdentifier, version); } /** * Run a trail by identifier (internal implementation used by Trail class) * @param trailIdentifier Local path or organization/trail identifier * @param options Run options including version if specified */ async runTrail(trailIdentifier, options = {}) { const { version, ...runOptions } = options; // Make sure client is initialized if (!this.isInitialized()) { await this.initialize(); } // Use the trail engine to load and run the trail const trailEngine = this.trails(); // First load the trail (this handles remote vs local, building, etc.) const { actions, resources } = await trailEngine.loadTrail(trailIdentifier); // Then delegate to the trail engine to run it return trailEngine.runTrail(trailIdentifier, { ...runOptions, // Pass the already loaded actions and resources to avoid loading twice actions, resources }); } }