@monitoro/herd
Version:
Automate your browser, build AI web tools and MCP servers with Monitoro Herd
315 lines (314 loc) • 10.7 kB
JavaScript
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
});
}
}