vitrus
Version:
TypeScript client for interfacing with the Vitrus SDK
1,120 lines (1,119 loc) • 48.9 kB
JavaScript
/**
* Vitrus SDK
*
* TypeScript client for the Vitrus DAO. Actor/Agent communication model with workflow orchestration.
*
* Communication: All agent–actor traffic (commands, responses, broadcasts, events) goes through
* the DAO WebSocket. No Zenoh in the TS SDK (avoids WASM/Node issues). Python SDK uses Zenoh
* for Python↔Python; DAO bridges WebSocket ↔ Zenoh for cross-language.
*/
// Load version dynamically - avoiding JSON import which requires TypeScript config changes
let SDK_VERSION; // Fallback version matching package.json
const DEFAULT_BASE_URL = 'wss://vitrus-dao.onrender.com';
try {
// For Node.js/Bun environments
if (typeof require !== 'undefined') {
const pkg = require('../package.json');
SDK_VERSION = pkg.version;
}
}
catch (e) {
// Fallback to hardcoded version if package.json can't be loaded
console.warn('Could not load version from package.json, using fallback version');
}
// Utility for extracting parameter types from function signatures
function getParameterTypes(func) {
// Convert function to string and analyze parameters
const funcStr = func.toString();
const argsMatch = funcStr.match(/\(([^)]*)\)/);
if (!argsMatch || !argsMatch[1].trim()) {
return [];
}
const args = argsMatch[1].split(',');
return args.map(arg => {
// Try to extract type information if available
const typeMatch = arg.trim().match(/(.*?):(.*)/);
if (typeMatch && typeMatch[2]) {
return typeMatch[2].trim();
}
return 'any';
});
}
// Actor/Player class
class Actor {
constructor(vitrus, name, metadata = {}, record) {
this.commandHandlers = new Map();
/** Supabase actor record (id, info, device_id, state, etc.) when fetched via actor(name) as agent */
this.record = null;
this.vitrus = vitrus;
this._name = name;
this.metadata = record?.info != null ? { ...record.info, ...metadata } : metadata;
this.record = record ?? null;
}
/** Actor name (always set) */
get name() { return this._name; }
/** Actor ID from Supabase (when fetched via actor(name)) */
get id() { return this.record?.id; }
/** Actor info/metadata from Supabase */
get info() { return this.record?.info ?? this.metadata; }
/** Associated device ID from Supabase */
get deviceId() { return this.record?.device_id; }
/** Actor state from Supabase (e.g. connected, disconnected) */
get state() { return this.record?.state; }
/** Registered commands from DAO/Supabase */
get registeredCommands() { return this.record?.registeredCommands; }
/**
* Register a command handler
*/
on(commandName, handler) {
this.commandHandlers.set(commandName, handler);
// Extract parameter types
const parameterTypes = getParameterTypes(handler);
// Register with Vitrus (local handler map)
this.vitrus.registerActorCommandHandler(this._name, commandName, handler, parameterTypes);
// Register command with server *only if* currently connected as this actor
if (this.vitrus.getIsAuthenticated() && this.vitrus.getActorName() === this._name) {
this.vitrus.registerCommand(this._name, commandName, parameterTypes);
}
else if (this.vitrus.getDebug()) {
console.log(`[Vitrus SDK - Actor.on] Not sending REGISTER_COMMAND for ${commandName} on ${this._name} as SDK is not authenticated as this actor.`);
}
return this;
}
/**
* Run a command on an actor
*/
async run(commandName, ...args) {
return this.vitrus.runCommand(this._name, commandName, args);
}
/**
* Broadcast an event to all agents subscribed to this actor's event (actor-side only).
* Call when connected as this actor.
*/
async broadcast(eventName, data) {
return this.vitrus.broadcastActorEvent(this._name, eventName, data);
}
/**
* Subscribe to this actor's broadcast events (agent-side). Callback receives event data.
*/
event(eventName, callback) {
this.vitrus.subscribeActorEvent(this._name, eventName, callback);
return this;
}
/**
* Alias for event(). Subscribe to this actor's broadcasts (agent-side). e.g. actor.listen('telemetry', (data) => { ... })
*/
listen(eventName, callback) {
return this.event(eventName, callback);
}
/**
* Get actor metadata
*/
getMetadata() {
return this.metadata;
}
/**
* Update actor metadata
*/
updateMetadata(newMetadata) {
this.metadata = { ...this.metadata, ...newMetadata };
// TODO: Send metadata update to server
}
/**
* Disconnect the actor if the SDK is currently connected as this actor.
*/
disconnect() {
this.vitrus.disconnectIfActor(this.name);
}
}
// Scene class
class Scene {
constructor(vitrus, sceneId) {
this.vitrus = vitrus;
this.sceneId = sceneId;
}
/**
* Set a structure to the scene
*/
set(structure) {
// Implementation would update scene structure
}
/**
* Add an object to the scene
*/
add(object) {
// Implementation would add object to scene
}
/**
* Update an object in the scene
*/
update(params) {
// Implementation would update object in scene
}
/**
* Remove an object from the scene
*/
remove(objectId) {
// Implementation would remove object from scene
}
/**
* Get the scene
*/
get() {
// Implementation would fetch scene data
return { id: this.sceneId };
}
}
/** Vitrus client. All agent/actor traffic over DAO WebSocket (no Zenoh in TS). */
class Vitrus {
constructor({ apiKey, world, baseUrl = DEFAULT_BASE_URL, debug = false }) {
this.ws = null;
this.clientId = '';
this.connected = false;
this.authenticated = false;
this.messageHandlers = new Map();
this.pendingRequests = new Map();
this.actorCommandHandlers = new Map();
this.actorCommandSignatures = new Map();
this.actorMetadata = new Map();
this.actorIds = new Map();
this.actorEventListeners = new Map(); // actorName -> eventName -> callbacks
this.connectionPromise = null;
this._connectionReject = null; // To store the reject of connectionPromise
this.apiKey = apiKey;
this.worldId = world;
this.baseUrl = baseUrl;
this.debug = debug;
if (this.debug) {
console.log(`[Vitrus v${SDK_VERSION}] Initializing with options:`, { apiKey: '***', world, baseUrl, debug });
}
// Don't connect automatically - wait for authenticate() or explicit connect()
}
/**
* Connect to the WebSocket server with authentication
* @internal This is mainly for internal use, users should use authenticate()
*/
async connect(actorName, metadata) {
if (this.connectionPromise) {
return this.connectionPromise;
}
this.actorName = actorName || this.actorName;
if (this.actorName && metadata) {
this.actorMetadata.set(this.actorName, metadata);
}
this.connectionPromise = new Promise(async (resolve, reject) => {
this._connectionReject = reject; // Store reject function for onerror/onclose
try {
await this._establishWebSocketConnection(); // This method will handle ws setup and waitForAuthentication
resolve();
}
catch (error) {
reject(error); // Errors from _establishWebSocketConnection or waitForAuthentication will be caught here
}
});
return this.connectionPromise;
}
async _establishWebSocketConnection() {
if (this.debug)
console.log(`[Vitrus] Attempting to connect to WebSocket server:`, this.baseUrl);
const url = new URL(this.baseUrl);
url.searchParams.append('apiKey', this.apiKey);
if (this.worldId) {
url.searchParams.append('worldId', this.worldId);
}
const isBrowserEnvironment = typeof window !== 'undefined' && typeof window.document !== 'undefined';
const SelectedWS = isBrowserEnvironment ? window.WebSocket : require('ws'); // Renamed to avoid conflict
if (!SelectedWS) {
throw new Error('WebSocket constructor not found. Environment detection failed or WebSocket is not available.');
}
const rawWs = new SelectedWS(url.toString());
if (isBrowserEnvironment) {
// Browser environment: Wrap the native WebSocket to provide .on() and .removeListener()
this.ws = this.createBrowserWsWrapper(rawWs);
}
else {
// Node/Bun environment: Use ws instance directly
this.ws = rawWs; // Cast to our EventEmitter interface
}
if (!this.ws) {
throw new Error('Failed to create or wrap WebSocket instance');
}
// Setup event handlers using Node/Bun .on() syntax
this.ws.on('open', () => {
this.connected = true;
if (this.debug)
console.log('[Vitrus] Connected to WebSocket server (onopen)');
// Send HANDSHAKE message
const registeredCommands = this.actorName
? this.getRegisteredCommands(this.actorName)
: undefined;
const handshakeMsg = {
type: 'HANDSHAKE',
clientType: this.actorName ? 'actor' : 'agent',
apiKey: this.apiKey,
worldId: this.worldId,
actorName: this.actorName,
actorId: this.actorName ? this.actorIds.get(this.actorName) : undefined,
registeredCommands,
metadata: this.actorName ? this.actorMetadata.get(this.actorName) : undefined
};
if (this.debug)
console.log('[Vitrus] Sending HANDSHAKE message:', handshakeMsg);
this.sendMessage(handshakeMsg).catch(sendError => {
console.error('[Vitrus] Failed to send HANDSHAKE message:', sendError);
if (this._connectionReject) {
this._connectionReject(new Error(`Failed to send HANDSHAKE message: ${sendError.message}`));
this._connectionReject = null;
}
if (this.ws) {
try {
this.ws.close();
}
catch (closeError) {
if (this.debug)
console.log('[Vitrus] Error attempting to close WebSocket after failed handshake send:', closeError);
}
}
});
});
this.ws.on('message', (data) => {
try {
// Node/ws provides data directly
const message = JSON.parse(typeof data === 'string' ? data : data.toString());
if (this.debug && message.type !== 'HANDSHAKE_RESPONSE') {
console.log('[Vitrus] Received message (generic handler):', message);
}
this.handleMessage(message);
}
catch (error) {
console.error('Error parsing WebSocket message:', error);
}
});
this.ws.on('error', (error) => {
if (!this.connected) { // Error before connection fully established
let specificError = error; // Use the actual error object from ws
if (this.worldId) {
specificError = new Error(`Connection Failed: Unable to connect to world '${this.worldId}'. This world may not exist, or the API key may be invalid. Original: ${error.message}`);
}
else {
specificError = new Error(`Connection Failed: Unable to establish initial WebSocket connection. Original: ${error.message}`);
}
console.error(specificError.message);
if (this.debug)
console.log('[Vitrus] WebSocket connection error (pre-connect):', specificError);
if (this._connectionReject) {
this._connectionReject(specificError);
this._connectionReject = null; // Prevent multiple rejections
}
}
else { // Error after connection
console.error('WebSocket error (post-connect):', error.message);
if (this.debug)
console.log('[Vitrus] WebSocket error (post-connect):', error);
}
});
this.ws.on('close', (code, reason) => {
const wasConnected = this.connected;
this.connected = false;
this.authenticated = false;
const reasonStr = reason.toString();
if (!wasConnected) { // Closed before connection was fully established and authenticated
if (this.debug)
console.log(`[Vitrus] WebSocket closed before full connection/authentication. Code: ${code}, Reason: ${reasonStr}`);
if (this._connectionReject) {
this._connectionReject(new Error(`Connection Attempt Failed: WebSocket closed before connection could be established. Code: ${code}, Reason: ${reasonStr}`));
this._connectionReject = null;
}
}
else {
if (this.debug)
console.log(`[Vitrus] Disconnected from WebSocket server (onclose after connection). Code: ${code}, Reason: ${reasonStr}`);
const closeError = new Error(`Connection Lost: The connection to the Vitrus server was lost. Code: ${code}, Reason: ${reasonStr}`);
console.error(closeError.message);
// Reject any pending requests
for (const [requestId, { reject }] of this.pendingRequests.entries()) {
reject(closeError);
this.pendingRequests.delete(requestId);
}
}
this.connectionPromise = null; // Allow new connection attempts
});
// Now, wait for the authentication process to complete.
await this.waitForAuthentication();
}
// Helper to create a wrapper for Browser WebSocket
createBrowserWsWrapper(browserWs) {
const listeners = {};
const getListeners = (event) => {
if (!listeners[event]) {
listeners[event] = [];
}
return listeners[event];
};
const wrapper = {
on: (event, listener) => {
getListeners(event).push(listener);
switch (event) {
case 'open':
browserWs.onopen = (ev) => getListeners('open').forEach(l => l(ev));
break;
case 'message':
browserWs.onmessage = (ev) => getListeners('message').forEach(l => l(ev.data));
break;
case 'error':
browserWs.onerror = (ev) => getListeners('error').forEach(l => l(ev)); // Browser error is just Event
break;
case 'close':
browserWs.onclose = (ev) => getListeners('close').forEach(l => l(ev.code, ev.reason));
break;
}
},
removeListener: (event, listener) => {
const eventListeners = getListeners(event);
const index = eventListeners.indexOf(listener);
if (index > -1) {
eventListeners.splice(index, 1);
}
// If no listeners remain for an event, clear the native handler
if (eventListeners.length === 0) {
switch (event) {
case 'open':
browserWs.onopen = null;
break;
case 'message':
browserWs.onmessage = null;
break;
case 'error':
browserWs.onerror = null;
break;
case 'close':
browserWs.onclose = null;
break;
}
}
},
send: (data) => browserWs.send(data),
close: (code, reason) => browserWs.close(code, reason),
get readyState() { return browserWs.readyState; },
};
return wrapper;
}
async waitForConnection() {
if (this.connected)
return;
if (this.debug)
console.log('[Vitrus] Waiting for connection...');
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (this.connected) {
clearInterval(checkInterval);
if (this.debug)
console.log('[Vitrus] Connection established');
resolve();
}
}, 100);
});
}
async waitForAuthentication() {
if (this.authenticated)
return;
if (this.debug)
console.log('[Vitrus] Waiting for authentication...');
return new Promise((resolve, reject) => {
const handleAuthResponse = (message) => {
if (message.type === 'HANDSHAKE_RESPONSE') {
const response = message;
if (response.success) {
this.clientId = response.clientId;
this.authenticated = true;
if (response.actorId && this.actorName) {
this.actorIds.set(this.actorName, response.actorId);
}
if (response.serverIp) {
this.serverIp = response.serverIp;
}
if (typeof response.worldExists === 'boolean') {
this.worldExists = response.worldExists;
}
// If actor info was included, restore it
if (response.actorInfo && this.actorName) {
// Store the actor metadata
this.actorMetadata.set(this.actorName, response.actorInfo.metadata);
// Re-register existing commands if available
if (response.actorInfo.registeredCommands) {
if (this.debug)
console.log('[Vitrus] Restoring registered commands:', response.actorInfo.registeredCommands);
// Create a signature map if it doesn't exist
if (!this.actorCommandSignatures.has(this.actorName)) {
this.actorCommandSignatures.set(this.actorName, new Map());
}
// Restore command signatures
const signatures = this.actorCommandSignatures.get(this.actorName);
for (const cmd of response.actorInfo.registeredCommands) {
signatures.set(cmd.name, cmd.parameterTypes);
}
}
}
if (this.debug)
console.log('[Vitrus] Authentication successful, clientId:', this.clientId);
if (this.ws) {
this.ws.removeListener('message', handleAuthResponseWrapper);
}
resolve();
}
else {
let errorMessage = response.message || 'Authentication failed';
// Check for specific error codes or messages from the DAO
if (response.error_code === 'invalid_api_key') { // Explicit check for invalid_api_key
errorMessage = "Authentication Failed: The provided API Key is invalid or expired.";
}
else if (response.error_code === 'world_not_found') { // Explicit check for world_not_found (from URL)
// Use the message directly from the server as it's already formatted
errorMessage = response.message || `Connection Failed: The world specified in the connection URL was not found.`;
}
else if (response.error_code === 'world_not_found_handshake') { // Explicit check for world from handshake msg
errorMessage = response.message || `Connection Failed: The world specified in the handshake message was not found.`;
}
else if (errorMessage.includes('Actors require a worldId')) { // Fallback message check
errorMessage = "Connection Failed: An actor connection requires a valid World ID to be specified.";
}
// Add more specific checks for other error_codes/messages as needed
if (this.debug)
console.log('[Vitrus] Authentication failed:', errorMessage);
if (this.ws) {
this.ws.removeListener('message', handleAuthResponseWrapper);
}
reject(new Error(errorMessage));
}
}
};
const handleAuthResponseWrapper = (data) => {
try {
const message = JSON.parse(typeof data === 'string' ? data : data.toString());
handleAuthResponse(message);
}
catch (error) {
// Ignore parse errors
}
};
if (this.ws) {
// Use Node/Bun style .on() and .removeListener()
this.ws.on('message', handleAuthResponseWrapper);
// Add temporary error/close handlers for auth period using .on()
const handleAuthError = (error) => {
if (!this.authenticated) {
console.error('[Vitrus] WebSocket error during authentication wait:', error.message);
reject(new Error(`WebSocket error during authentication: ${error.message}`));
// Clean up listeners after handling the error
this.ws?.removeListener('message', handleAuthResponseWrapper);
this.ws?.removeListener('error', handleAuthError);
this.ws?.removeListener('close', handleAuthClose);
}
};
const handleAuthClose = (code, reason) => {
if (!this.authenticated) {
const reasonStr = reason.toString();
console.error(`[Vitrus] WebSocket closed during authentication wait. Code: ${code}, Reason: ${reasonStr}`);
reject(new Error(`WebSocket closed during authentication. Code: ${code}, Reason: ${reasonStr}`));
// Clean up listeners after handling the close
this.ws?.removeListener('message', handleAuthResponseWrapper);
this.ws?.removeListener('error', handleAuthError);
this.ws?.removeListener('close', handleAuthClose);
}
};
this.ws.on('error', handleAuthError);
this.ws.on('close', handleAuthClose);
// The cleanup for successful auth or specific failure is handled inside handleAuthResponse
// We also need to remove error/close listeners there.
const originalResolve = resolve;
const originalReject = reject;
resolve = () => {
this.ws?.removeListener('error', handleAuthError);
this.ws?.removeListener('close', handleAuthClose);
originalResolve();
};
reject = (err) => {
this.ws?.removeListener('error', handleAuthError);
this.ws?.removeListener('close', handleAuthClose);
originalReject(err);
};
}
});
}
async sendMessage(message) {
// Ensure we're connected
if (!this.connected) {
await this.connect();
}
if (this.ws && this.ws.readyState === Vitrus.OPEN) {
if (this.debug)
console.log('[Vitrus] Sending message:', message);
this.ws.send(JSON.stringify(message));
}
else {
if (this.debug)
console.log('[Vitrus] Failed to send message - WebSocket not connected');
throw new Error('WebSocket is not connected');
}
}
handleMessage(message) {
const { type } = message;
// Handle handshake response
if (type === 'HANDSHAKE_RESPONSE') {
const response = message;
if (response.success) {
this.clientId = response.clientId;
this.authenticated = true;
if (response.actorId && this.actorName) {
this.actorIds.set(this.actorName, response.actorId);
}
if (response.serverIp) {
this.serverIp = response.serverIp;
}
if (typeof response.worldExists === 'boolean') {
this.worldExists = response.worldExists;
}
if (this.debug)
console.log('[Vitrus] Agent/actor communication: WebSocket (DAO)');
// Process actor info if available
if (response.actorInfo && this.actorName) {
this.actorMetadata.set(this.actorName, response.actorInfo.metadata);
// Process command signatures
if (response.actorInfo.registeredCommands) {
if (!this.actorCommandSignatures.has(this.actorName)) {
this.actorCommandSignatures.set(this.actorName, new Map());
}
const signatures = this.actorCommandSignatures.get(this.actorName);
for (const cmd of response.actorInfo.registeredCommands) {
signatures.set(cmd.name, cmd.parameterTypes);
}
}
}
if (this.debug)
console.log('[Vitrus] Handshake successful, clientId:', this.clientId);
}
else {
console.error('Handshake failed:', response.message);
if (this.debug)
console.log('[Vitrus] Handshake failed:', response.message);
}
return;
}
// COMMAND: actor receives from DAO over WebSocket
if (type === 'COMMAND') {
this.handleCommand(message);
return;
}
// RESPONSE: agent receives from DAO over WebSocket (result of runCommand)
if (type === 'RESPONSE') {
const resp = message;
const pending = this.pendingRequests.get(resp.requestId);
if (pending) {
this.pendingRequests.delete(resp.requestId);
if (resp.error)
pending.reject(new Error(resp.error));
else
pending.resolve(resp.result);
}
return;
}
// ACTOR_BROADCAST: agent receives from DAO over WebSocket (actor event)
if (type === 'ACTOR_BROADCAST' || type === 'ACTOR_EVENT') {
const actorName = message.actorName;
const eventName = message.eventName ?? message.broadcastName;
const data = message.args ?? {};
const callbacks = this.actorEventListeners.get(actorName)?.get(eventName) ?? [];
for (const cb of callbacks) {
try {
cb(data);
}
catch (err) {
if (this.debug)
console.log('[Vitrus] Actor event callback error:', err);
}
}
return;
}
// Handle workflow results
if (type === 'WORKFLOW_RESULT') {
const { requestId, result, error } = message;
if (this.debug)
console.log('[Vitrus] Received workflow result for requestId:', requestId, { result, error });
const pending = this.pendingRequests.get(requestId);
if (pending) {
if (error) {
pending.reject(new Error(error));
}
else {
pending.resolve(result);
}
this.pendingRequests.delete(requestId);
}
return;
}
// Handle workflow list response
if (type === 'WORKFLOW_LIST') {
const { requestId, workflows, error } = message;
if (this.debug)
console.log('[Vitrus] Received workflow list for requestId:', requestId, { workflows, error });
const pending = this.pendingRequests.get(requestId);
if (pending) {
if (error) {
pending.reject(new Error(error));
}
else {
pending.resolve(workflows || []); // Resolve with the array of WorkflowDefinition
}
this.pendingRequests.delete(requestId);
}
return;
}
// Handle get actor info response (Supabase-backed actor record)
if (type === 'GET_ACTOR_INFO_RESPONSE') {
const resp = message;
const pending = this.pendingRequests.get(resp.requestId);
if (pending) {
this.pendingRequests.delete(resp.requestId);
if (resp.error)
pending.reject(new Error(resp.error));
else
pending.resolve(resp.actor ?? null);
}
return;
}
// Handle custom messages
const handlers = this.messageHandlers.get(type) || [];
for (const handler of handlers) {
handler(message);
}
}
handleCommand(message) {
const { commandName, args, requestId, targetType, targetName, sourceChannel } = message;
const resolvedActorName = targetType === 'actor'
? targetName
: targetType === 'broadcast'
? this.actorName
: message.targetActorName;
if (!resolvedActorName) {
if (this.debug)
console.log('[Vitrus] Ignoring command without actor target:', { targetType, targetName });
return;
}
if (this.debug)
console.log('[Vitrus] Handling command:', { commandName, targetType, targetName: resolvedActorName, requestId });
const actorHandlers = this.actorCommandHandlers.get(resolvedActorName);
if (actorHandlers) {
const handler = actorHandlers.get(commandName);
if (handler) {
if (this.debug)
console.log('[Vitrus] Found handler for command:', commandName);
Promise.resolve()
.then(() => handler(...args))
.then((result) => {
if (this.debug)
console.log('[Vitrus] Command executed successfully:', { commandName, result });
this.sendResponse({
type: 'RESPONSE',
targetChannel: sourceChannel || '',
requestId,
commandId: requestId,
result,
});
})
.catch((error) => {
if (this.debug)
console.log('[Vitrus] Command execution failed:', { commandName, error: error.message });
this.sendResponse({
type: 'RESPONSE',
targetChannel: sourceChannel || '',
requestId,
commandId: requestId,
error: error.message,
});
});
}
else if (this.debug) {
console.log('[Vitrus] No handler found for command:', commandName);
}
}
else if (this.debug) {
console.log('[Vitrus] No actor found with name:', resolvedActorName);
}
}
sendResponse(response) {
if (this.debug)
console.log('[Vitrus] Sending response:', response);
this.sendMessage(response);
}
/**
* Register a command with the server
*/
async registerCommand(actorName, commandName, parameterTypes) {
if (this.debug)
console.log('[Vitrus] Registering command with server:', { actorName, commandName, parameterTypes });
const message = {
type: 'REGISTER_COMMAND',
actorName,
commandName,
parameterTypes
};
await this.sendMessage(message);
}
generateRequestId() {
const requestId = Math.random().toString(36).substring(2, 15);
if (this.debug)
console.log('[Vitrus] Generated requestId:', requestId);
return requestId;
}
/**
* Authenticate with the API
*/
async authenticate(actorName, metadata) {
if (this.debug)
console.log(`[Vitrus] Initiating connection sequence...` + (actorName ? ` (intended actor: ${actorName})` : ''));
// Require worldId if intending to be an actor
if (actorName && !this.worldId) {
throw new Error('Vitrus SDK requires a worldId to authenticate as an actor.');
}
// Store actor name and metadata for use in connection
this.actorName = actorName;
if (actorName && metadata) {
this.actorMetadata.set(actorName, metadata);
}
// Connect or reconnect
await this.connect(actorName, metadata);
return this.authenticated;
}
/**
* Register a command handler for an actor
*/
registerActorCommandHandler(actorName, commandName, handler, parameterTypes = []) {
if (this.debug)
console.log('[Vitrus] Registering command handler:', { actorName, commandName, parameterTypes });
// Store the command handler
if (!this.actorCommandHandlers.has(actorName)) {
this.actorCommandHandlers.set(actorName, new Map());
}
const actorHandlers = this.actorCommandHandlers.get(actorName);
actorHandlers.set(commandName, handler);
// Store the parameter types
if (!this.actorCommandSignatures.has(actorName)) {
this.actorCommandSignatures.set(actorName, new Map());
}
const actorSignatures = this.actorCommandSignatures.get(actorName);
actorSignatures.set(commandName, parameterTypes);
}
getRegisteredCommands(actorName) {
const signatures = this.actorCommandSignatures.get(actorName);
if (!signatures) {
return [];
}
return Array.from(signatures.entries()).map(([name, parameterTypes]) => ({ name, parameterTypes }));
}
/**
* Actor broadcasts an event to subscribed agents (actor-side). Sent to DAO over WebSocket.
*/
async broadcastActorEvent(actorName, eventName, data) {
if (!this.authenticated || this.actorName !== actorName) {
throw new Error('Must be authenticated as this actor to broadcast');
}
const payload = {
type: 'ACTOR_BROADCAST',
actorName,
eventName,
args: data ?? {},
worldId: this.worldId,
};
await this.sendMessage(payload);
if (this.debug)
console.log('[Vitrus] Broadcast via WebSocket (event=' + eventName + ')');
}
/**
* Agent subscribes to an actor's event (agent-side). Sent to DAO over WebSocket; DAO forwards ACTOR_BROADCAST to this client.
*/
subscribeActorEvent(actorName, eventName, callback) {
if (!this.actorEventListeners.has(actorName)) {
this.actorEventListeners.set(actorName, new Map());
}
const eventMap = this.actorEventListeners.get(actorName);
const list = eventMap.get(eventName) ?? [];
list.push(callback);
eventMap.set(eventName, list);
this.sendMessage({ type: 'SUBSCRIBE_ACTOR_EVENT', actorName, eventName }).catch(() => { });
}
/**
* Create or get an actor
* If options (metadata) are provided, connects and authenticates as this actor.
* If only name is provided, returns a handle for an agent to interact with.
*/
/** Fetch actor record from DAO/Supabase (id, info, device_id, state, registeredCommands). Agent-only. */
async getActorInfo(actorName) {
if (!this.worldId)
throw new Error('Vitrus SDK requires a worldId to fetch actor info.');
if (!this.authenticated)
await this.authenticate();
const requestId = this.generateRequestId();
const msg = { type: 'GET_ACTOR_INFO', worldId: this.worldId, actorName, requestId };
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (this.pendingRequests.delete(requestId))
reject(new Error('GET_ACTOR_INFO timeout'));
}, 10000);
this.pendingRequests.set(requestId, {
resolve: (r) => { clearTimeout(timeout); resolve(r); },
reject: (e) => { clearTimeout(timeout); reject(e); }
});
this.sendMessage(msg).catch((e) => { this.pendingRequests.delete(requestId); clearTimeout(timeout); reject(e); });
});
}
async actor(name, options) {
if (this.debug)
console.log('[Vitrus] Creating/getting actor handle:', name, options);
// Require worldId to create/authenticate as an actor if options are provided
if (options !== undefined && !this.worldId) {
throw new Error('Vitrus SDK requires a worldId to create/authenticate as an actor.');
}
// When options provided, set actor name before first connect so handshake is sent as actor
if (options !== undefined) {
this.actorMetadata.set(name, options);
this.actorName = name;
}
let record = null;
// Fetch actor record only in agent mode (when options undefined). In actor mode skip to start fast.
if (this.worldId && options === undefined) {
if (!this.authenticated)
await this.authenticate();
try {
record = await this.getActorInfo(name);
}
catch (e) {
if (this.debug)
console.log(`[Vitrus] Could not fetch actor info for ${name}:`, e);
}
}
const actor = new Actor(this, name, options !== undefined ? options : {}, record);
// If options are provided (even an empty object), it implies intent to *be* this actor,
// so authenticate (and wait for it) if necessary.
if (options !== undefined && (!this.authenticated || this.actorName !== name)) {
if (this.debug)
console.log(`[Vitrus] Options provided for actor ${name}, ensuring authentication as this actor...`);
try {
await this.authenticate(name, options);
if (this.debug)
console.log(`[Vitrus] Successfully authenticated as actor ${name}.`);
// After successful auth, ensure any commands queued via .on() are registered
await this.registerPendingCommands(name);
}
catch (error) {
console.error(`Failed to auto-authenticate actor ${name}:`, error);
throw error;
}
}
return actor;
}
/**
* Get a scene
*/
scene(sceneId) {
if (this.debug)
console.log('[Vitrus] Getting scene:', sceneId);
return new Scene(this, sceneId);
}
/**
* Run a command on an actor
*/
async runCommand(actorName, commandName, args) {
if (this.debug)
console.log('[Vitrus] Running command:', { actorName, commandName, args });
// Require worldId to run commands
if (!this.worldId) {
throw new Error('Vitrus SDK requires a worldId to run commands on actors.');
}
// If not authenticated yet, auto-authenticate (will default to agent if no actor context)
if (!this.authenticated) {
await this.authenticate();
}
const requestId = this.generateRequestId();
if (this.debug) {
console.log('[Vitrus] Sending command via WebSocket (actor=' + actorName + ', command=' + commandName + ')');
}
const command = {
type: 'COMMAND',
senderType: 'agent',
senderName: this.clientId || 'agent',
targetType: 'actor',
targetName: actorName,
commandName,
args,
requestId,
worldId: this.worldId
};
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (this.pendingRequests.delete(requestId)) {
reject(new Error('Command timeout'));
}
}, 30000);
this.pendingRequests.set(requestId, {
resolve: (r) => {
clearTimeout(timeout);
resolve(r);
},
reject: (e) => {
clearTimeout(timeout);
reject(e);
}
});
this.sendMessage(command).catch((error) => {
if (this.pendingRequests.delete(requestId)) {
clearTimeout(timeout);
reject(error);
}
});
});
}
/**
* Run a workflow
*/
async workflow(workflowName, args = {}) {
if (this.debug)
console.log('[Vitrus] Running workflow:', { workflowName, args });
// Preserve existing authentication state - don't force re-auth as agent
if (!this.authenticated) {
// If we have a current actor name, re-authenticate as that actor
if (this.actorName) {
await this.authenticate(this.actorName, this.actorMetadata.get(this.actorName));
}
else {
await this.authenticate(); // Authenticate as agent only if no actor context
}
}
const requestId = this.generateRequestId();
return new Promise((resolve, reject) => {
this.pendingRequests.set(requestId, { resolve, reject });
const workflow = {
type: 'WORKFLOW_INVOCATION',
workflowName,
args,
requestId,
};
this.sendMessage(workflow)
.catch((error) => {
if (this.debug)
console.log('[Vitrus] Failed to send workflow:', error);
this.pendingRequests.delete(requestId);
reject(error);
});
});
}
/**
* Upload an image
*/
async upload_image(image, filename = "image") {
if (this.debug)
console.log('[Vitrus] Uploading image:', filename);
// Implementation would handle image uploads
// For now, just return a mock URL
return `https://vitrus.io/images/${filename}`;
}
/**
* Add a record
*/
async add_record(data, name) {
if (this.debug)
console.log('[Vitrus] Adding record:', { data, name });
// Implementation would store the record
// For now, just return success
return name || this.generateRequestId();
}
/**
* List available workflows on the server, including their definitions (OpenAI Tool Schema)
*/
async list_workflows() {
if (this.debug)
console.log('[Vitrus] Requesting workflow list with definitions...');
// Preserve existing authentication state - don't force re-auth as agent
if (!this.authenticated) {
// If we have a current actor name, re-authenticate as that actor
if (this.actorName) {
await this.authenticate(this.actorName, this.actorMetadata.get(this.actorName));
}
else {
await this.authenticate(); // Authenticate as agent only if no actor context
}
}
const requestId = this.generateRequestId();
return new Promise((resolve, reject) => {
this.pendingRequests.set(requestId, { resolve, reject });
const message = {
type: 'LIST_WORKFLOWS',
requestId,
};
this.sendMessage(message)
.catch((error) => {
if (this.debug)
console.log('[Vitrus] Failed to send LIST_WORKFLOWS message:', error);
this.pendingRequests.delete(requestId);
reject(error);
});
});
}
/**
* Helper to register commands that might have been added via actor.on()
* *before* the initial authentication for that actor completed.
*/
async registerPendingCommands(actorName) {
const handlers = this.actorCommandHandlers.get(actorName);
const signatures = this.actorCommandSignatures.get(actorName);
if (!handlers || !signatures)
return;
if (this.debug)
console.log(`[Vitrus] Registering pending commands for actor ${actorName}...`);
for (const [commandName, parameterTypes] of signatures.entries()) {
if (handlers.has(commandName)) { // Ensure handler still exists
try {
await this.registerCommand(actorName, commandName, parameterTypes);
}
catch (error) {
console.error(`[Vitrus] Error registering pending command ${commandName} for actor ${actorName}:`, error);
}
}
}
}
// --- Public Getters ---
getIsAuthenticated() {
return this.authenticated;
}
getActorName() {
return this.actorName;
}
getDebug() {
return this.debug;
}
/**
* Disconnects the WebSocket if the SDK is currently authenticated as the specified actor.
* @param actorName The name of the actor to disconnect.
*/
disconnectIfActor(actorName) {
if (this.actorName === actorName && this.authenticated && this.ws && this.ws.readyState === Vitrus.OPEN) {
if (this.debug)
console.log(`[Vitrus] Actor '${actorName}' is disconnecting.`);
this.ws.close();
// The onclose handler will manage further state changes (this.connected, this.authenticated, etc.)
}
else if (this.debug) {
if (this.actorName !== actorName) {
console.log(`[Vitrus] disconnectIfActor: SDK not connected as '${actorName}' (currently: ${this.actorName || 'agent/none'}). No action taken.`);
}
else if (!this.authenticated) {
console.log(`[Vitrus] disconnectIfActor: SDK not authenticated as '${actorName}'. No action taken.`);
}
else {
console.log(`[Vitrus] disconnectIfActor: WebSocket for '${actorName}' not open or available. No action taken.`);
}
}
}
}
// WebSocket readyState constants
Vitrus.OPEN = 1;
export default Vitrus;