# Container Development Guide Learn how to build robust containers for Huly Virtual Network. ## Table of Contents - [Introduction](#introduction) - [Container Basics](#container-basics) - [Container Lifecycle](#container-lifecycle) - [Implementing Request Handlers](#implementing-request-handlers) - [Event Broadcasting](#event-broadcasting) - [State Management](#state-management) - [Error Handling](#error-handling) - [Testing Containers](#testing-containers) - [Best Practices](#best-practices) - [Common Patterns](#common-patterns) ## Introduction Containers are the core building blocks of Huly Network applications. They encapsulate business logic, manage state, and handle client requests. This guide will teach you how to build production-ready containers. ## Container Basics ### The Container Interface Every container must implement the `Container` interface: ```typescript import type { Container, ContainerUuid, ClientUuid } from '@hcengineering/network-core' interface Container { // Handle requests from clients request(operation: string, data?: any, clientId?: ClientUuid): Promise // Health check ping(): Promise // Cleanup resources terminate(): Promise // Client connection management connect(clientId: ClientUuid, broadcast: (data: any) => Promise): void disconnect(clientId: ClientUuid): void // Optional: called when container is removed from network onTerminated?(): void } ``` ### Minimal Container Here's the simplest possible container: ```typescript export class MinimalContainer implements Container { constructor(readonly uuid: ContainerUuid) {} async request(operation: string, data?: any): Promise { return { message: 'Hello, World!' } } async ping(): Promise {} async terminate(): Promise {} connect(clientId: ClientUuid, broadcast: (data: any) => Promise): void {} disconnect(clientId: ClientUuid): void {} } ``` ### Container with Operations Most containers handle multiple operations: ```typescript export class CalculatorContainer implements Container { constructor(readonly uuid: ContainerUuid) {} async request(operation: string, data?: any): Promise { switch (operation) { case 'add': return { result: data.a + data.b } case 'subtract': return { result: data.a - data.b } case 'multiply': return { result: data.a * data.b } case 'divide': if (data.b === 0) { throw new Error('Division by zero') } return { result: data.a / data.b } default: throw new Error(`Unknown operation: ${operation}`) } } async ping(): Promise {} async terminate(): Promise {} connect(clientId: ClientUuid, broadcast: (data: any) => Promise): void {} disconnect(clientId: ClientUuid): void {} } ``` ## Container Lifecycle ### Lifecycle Phases ``` 1. Creation → Container factory called 2. Registration → Added to network registry 3. Active → Processing requests 4. Referenced → Clients hold references 5. Idle → No references, countdown started 6. Terminating → terminate() called 7. Removed → Removed from registry ``` ### Handling Creation Containers are created by factory functions: ```typescript import type { GetOptions, ContainerUuid } from '@hcengineering/network-core' import { createNetworkClient } from '@hcengineering/network-client' const client = createNetworkClient('localhost:3737') await client.waitConnection(5000) await client.serveAgent('localhost:3738', { 'my-service': async (options: GetOptions) => { // Extract creation parameters const uuid = options.uuid ?? generateUuid() const userId = options.extra?.userId const tier = options.labels?.[0] || 'free' // Create container with parameters const container = new MyServiceContainer(uuid, userId, tier) // Initialize if needed await container.initialize() // Return container with endpoint return { uuid, container, endpoint: `myservice://host/${uuid}` as any } } }) ``` ### Handling Termination Always clean up resources in `terminate()`: ```typescript export class DatabaseContainer implements Container { private connection?: DatabaseConnection private cache = new Map() async terminate(): Promise { console.log(`Terminating container ${this.uuid}`) // 1. Notify connected clients await this.notifyShutdown() // 2. Close external connections if (this.connection) { await this.connection.close() this.connection = undefined } // 3. Clear caches this.cache.clear() // 4. Cancel any pending operations this.cancelPendingOperations() console.log(`Container ${this.uuid} terminated`) } // Optional: called after removal from network onTerminated(): void { console.log(`Container ${this.uuid} removed from network`) } } ``` ## Implementing Request Handlers ### Request Handler Pattern Use a switch statement or command pattern: ```typescript export class UserServiceContainer implements Container { private users = new Map() async request(operation: string, data?: any, clientId?: ClientUuid): Promise { console.log(`Operation: ${operation}`, data) try { switch (operation) { case 'createUser': return await this.createUser(data) case 'getUser': return await this.getUser(data.userId) case 'updateUser': return await this.updateUser(data.userId, data.updates) case 'deleteUser': return await this.deleteUser(data.userId) case 'listUsers': return await this.listUsers(data.filter) default: return { success: false, error: `Unknown operation: ${operation}`, supportedOperations: ['createUser', 'getUser', 'updateUser', 'deleteUser', 'listUsers'] } } } catch (error: any) { console.error(`Error in ${operation}:`, error) return { success: false, error: error.message } } } private async createUser(data: any): Promise { const user: User = { id: generateId(), name: data.name, email: data.email, createdAt: Date.now() } this.users.set(user.id, user) await this.broadcast({ type: 'userCreated', user }) return { success: true, user } } private async getUser(userId: string): Promise { const user = this.users.get(userId) if (!user) { return { success: false, error: 'User not found' } } return { success: true, user } } // ... other methods } ``` ### Async Operations Handle long-running operations properly: ```typescript export class ProcessingContainer implements Container { private activeJobs = new Map() async request(operation: string, data?: any): Promise { switch (operation) { case 'startJob': { const jobId = generateId() const controller = new AbortController() this.activeJobs.set(jobId, controller) // Start async processing this.processJob(jobId, data, controller.signal).catch((err) => { console.error(`Job ${jobId} failed:`, err) }) return { success: true, jobId } } case 'cancelJob': { const controller = this.activeJobs.get(data.jobId) if (controller) { controller.abort() this.activeJobs.delete(data.jobId) return { success: true } } return { success: false, error: 'Job not found' } } case 'getJobStatus': { const active = this.activeJobs.has(data.jobId) return { success: true, active } } } } private async processJob(jobId: string, data: any, signal: AbortSignal): Promise { try { for (let i = 0; i < 100; i++) { if (signal.aborted) { await this.broadcast({ type: 'jobCancelled', jobId }) return } // Do work await this.processChunk(data, i) // Report progress await this.broadcast({ type: 'jobProgress', jobId, progress: i + 1 }) } await this.broadcast({ type: 'jobCompleted', jobId }) } finally { this.activeJobs.delete(jobId) } } async terminate(): Promise { // Cancel all active jobs for (const [jobId, controller] of this.activeJobs) { controller.abort() } this.activeJobs.clear() } } ``` ## Event Broadcasting ### Broadcasting to Connected Clients ```typescript export class ChatRoomContainer implements Container { private clients = new Map Promise>() private messages: Message[] = [] connect(clientId: ClientUuid, broadcast: (data: any) => Promise): void { console.log(`Client ${clientId} connected`) this.clients.set(clientId, broadcast) // Send welcome message broadcast({ type: 'welcome', message: `Welcome! ${this.clients.size} users online`, history: this.messages.slice(-10) // Last 10 messages }).catch((err) => console.error('Failed to send welcome:', err)) } disconnect(clientId: ClientUuid): void { console.log(`Client ${clientId} disconnected`) this.clients.delete(clientId) // Notify others this.broadcast({ type: 'userLeft', clientId, usersOnline: this.clients.size }).catch((err) => console.error('Failed to broadcast:', err)) } async request(operation: string, data?: any, clientId?: ClientUuid): Promise { switch (operation) { case 'sendMessage': { const message: Message = { id: generateId(), clientId: clientId!, text: data.text, timestamp: Date.now() } this.messages.push(message) // Broadcast to all connected clients await this.broadcast({ type: 'newMessage', message }) return { success: true, messageId: message.id } } } } private async broadcast(event: any): Promise { const promises = Array.from(this.clients.values()).map((fn) => fn(event).catch((err) => console.error('Broadcast error:', err)) ) await Promise.all(promises) } async terminate(): Promise { await this.broadcast({ type: 'roomClosed', message: 'Chat room is closing' }) this.clients.clear() this.messages = [] } } ``` ### Selective Broadcasting Send events to specific clients: ```typescript export class NotificationContainer implements Container { private subscribers = new Map< ClientUuid, { broadcast: (data: any) => Promise filter: NotificationFilter } >() connect(clientId: ClientUuid, broadcast: (data: any) => Promise): void { // Store with default filter this.subscribers.set(clientId, { broadcast, filter: { all: true } }) } async request(operation: string, data?: any, clientId?: ClientUuid): Promise { switch (operation) { case 'subscribe': { const sub = this.subscribers.get(clientId!) if (sub) { sub.filter = data.filter } return { success: true } } case 'sendNotification': { await this.sendNotification(data.notification) return { success: true } } } } private async sendNotification(notification: Notification): Promise { const promises: Promise[] = [] for (const [clientId, { broadcast, filter }] of this.subscribers) { if (this.matchesFilter(notification, filter)) { promises.push( broadcast({ type: 'notification', notification }).catch((err) => console.error(`Failed to notify ${clientId}:`, err) ) ) } } await Promise.all(promises) } private matchesFilter(notification: Notification, filter: NotificationFilter): boolean { if (filter.all) return true if (filter.types && !filter.types.includes(notification.type)) return false if (filter.priority && notification.priority < filter.priority) return false return true } } ``` ## State Management ### In-Memory State ```typescript export class SessionContainer implements Container { private sessionData = new Map() private lastActivity = Date.now() private readonly TIMEOUT = 30 * 60 * 1000 // 30 minutes async request(operation: string, data?: any): Promise { this.lastActivity = Date.now() switch (operation) { case 'set': this.sessionData.set(data.key, data.value) return { success: true } case 'get': return { success: true, value: this.sessionData.get(data.key) } case 'getAll': return { success: true, data: Object.fromEntries(this.sessionData) } case 'isActive': const inactive = Date.now() - this.lastActivity return { success: true, active: inactive < this.TIMEOUT } } } } ``` ### Persistent State ```typescript export class PersistentContainer implements Container { private cache = new Map() private db: Database constructor(readonly uuid: ContainerUuid, private readonly dbPath: string) {} async initialize(): Promise { this.db = await openDatabase(this.dbPath) // Load initial data into cache const data = await this.db.loadAll() for (const [key, value] of data) { this.cache.set(key, value) } } async request(operation: string, data?: any): Promise { switch (operation) { case 'set': { // Update cache this.cache.set(data.key, data.value) // Persist to database (async) this.db.save(data.key, data.value).catch((err) => console.error('Failed to persist:', err)) return { success: true } } case 'get': { // Try cache first let value = this.cache.get(data.key) // Fall back to database if (value === undefined) { value = await this.db.load(data.key) if (value !== undefined) { this.cache.set(data.key, value) } } return { success: true, value } } } } async terminate(): Promise { // Flush any pending writes await this.db.flush() await this.db.close() this.cache.clear() } } ``` ## Error Handling ### Graceful Error Handling ```typescript export class RobustContainer implements Container { async request(operation: string, data?: any): Promise { try { // Validate input this.validateRequest(operation, data) // Process request const result = await this.processRequest(operation, data) return { success: true, result } } catch (error: any) { console.error(`Error in ${operation}:`, error) // Categorize errors if (error instanceof ValidationError) { return { success: false, error: 'validation', message: error.message, fields: error.fields } } if (error instanceof NotFoundError) { return { success: false, error: 'not_found', message: error.message } } if (error instanceof PermissionError) { return { success: false, error: 'permission_denied', message: error.message } } // Generic error return { success: false, error: 'internal_error', message: process.env.NODE_ENV === 'development' ? error.message : 'An error occurred' } } } private validateRequest(operation: string, data?: any): void { if (!operation) { throw new ValidationError('Operation is required') } // Operation-specific validation switch (operation) { case 'createUser': if (!data?.email) { throw new ValidationError('Email is required', ['email']) } if (!this.isValidEmail(data.email)) { throw new ValidationError('Invalid email', ['email']) } break } } } ``` ## Testing Containers ### Unit Tests ```typescript import { describe, it, expect } from '@jest/globals' describe('CalculatorContainer', () => { let container: CalculatorContainer beforeEach(() => { container = new CalculatorContainer('test-uuid' as ContainerUuid) }) afterEach(async () => { await container.terminate() }) it('should add numbers', async () => { const result = await container.request('add', { a: 2, b: 3 }) expect(result).toEqual({ result: 5 }) }) it('should handle division by zero', async () => { await expect(container.request('divide', { a: 10, b: 0 })).rejects.toThrow('Division by zero') }) it('should reject unknown operations', async () => { await expect(container.request('unknown', {})).rejects.toThrow('Unknown operation') }) }) ``` ### Integration Tests ```typescript describe('Container Integration', () => { let tickManager: TickManager let network: Network let client: NetworkClient beforeAll(async () => { // Setup infrastructure tickManager = new TickManagerImpl(1) tickManager.start() network = new NetworkImpl(tickManager) // Connect client and serve agent using serveAgent client = createNetworkClient('localhost:3737') await client.waitConnection() await client.serveAgent('localhost:3738', { calculator: async (options) => ({ uuid: options.uuid ?? ('calc-1' as ContainerUuid), container: new CalculatorContainer('calc-1' as ContainerUuid), endpoint: 'test://calc-1' as any }) }) }) afterAll(async () => { await client.close() tickManager.stop() }) it('should perform calculations via network', async () => { const ref = await client.get('calculator' as any, {}) const result = await ref.request('multiply', { a: 6, b: 7 }) expect(result).toEqual({ result: 42 }) await ref.close() }) }) ``` ## Best Practices ### 1. Validate Inputs Always validate incoming data: ```typescript private validateCreateUser(data: any): void { if (!data?.name || typeof data.name !== 'string') { throw new ValidationError('Name must be a non-empty string') } if (!data?.email || !this.isValidEmail(data.email)) { throw new ValidationError('Valid email is required') } } ``` ### 2. Use Type Safety Define proper types: ```typescript interface CreateUserRequest { name: string email: string role?: UserRole } interface UpdateUserRequest { userId: string updates: Partial } async request(operation: string, data?: any): Promise { switch (operation) { case 'createUser': return await this.createUser(data as CreateUserRequest) case 'updateUser': return await this.updateUser(data as UpdateUserRequest) } } ``` ### 3. Log Operations Add structured logging: ```typescript async request(operation: string, data?: any, clientId?: ClientUuid): Promise { const startTime = Date.now() console.log('Request', { container: this.uuid, operation, clientId, timestamp: new Date().toISOString() }) try { const result = await this.handleRequest(operation, data, clientId) console.log('Success', { container: this.uuid, operation, duration: Date.now() - startTime }) return result } catch (error: any) { console.error('Error', { container: this.uuid, operation, error: error.message, duration: Date.now() - startTime }) throw error } } ``` ### 4. Handle Cleanup Properly Always clean up in terminate(): ```typescript async terminate(): Promise { try { // 1. Stop accepting new requests this.isTerminating = true // 2. Wait for pending operations await this.waitForPendingOperations() // 3. Notify clients await this.broadcast({ type: 'containerClosing' }) // 4. Close connections await this.closeConnections() // 5. Clear state this.clearState() } catch (error) { console.error('Error during termination:', error) } } ``` ### 5. Document Operations Document your container's API: ```typescript /** * User Management Container * * Operations: * - createUser(data: CreateUserRequest): Promise * - getUser(data: { userId: string }): Promise * - updateUser(data: UpdateUserRequest): Promise * - deleteUser(data: { userId: string }): Promise * - listUsers(data: ListUsersRequest): Promise * * Events: * - userCreated: { user: User } * - userUpdated: { userId: string, changes: Partial } * - userDeleted: { userId: string } */ export class UserManagementContainer implements Container { // ... } ``` ## Common Patterns ### Singleton Container For containers that should have only one instance: ```typescript // Use stateless container with HA agent.addStatelessContainer( 'singleton-service' as ContainerUuid, 'singleton' as ContainerKind, 'singleton://agent/service' as ContainerEndpointRef, new SingletonContainer('singleton-service' as ContainerUuid) ) ``` ### Container with Dependencies Inject dependencies: ```typescript export class ServiceContainer implements Container { constructor( readonly uuid: ContainerUuid, private readonly database: Database, private readonly cache: CacheService, private readonly eventBus: EventBus ) {} // Factory function static async create(uuid: ContainerUuid): Promise { const db = await Database.connect() const cache = new CacheService() const eventBus = new EventBus() return new ServiceContainer(uuid, db, cache, eventBus) } } ``` ### Container Pool For resource-intensive containers: ```typescript // Agent maintains a pool const containerPool = new ContainerPool(5) // Max 5 instances const client = createNetworkClient('localhost:3737') await client.waitConnection(5000) await client.serveAgent('localhost:3738', { worker: async (options) => { const container = await containerPool.acquire() return { uuid: container.uuid, container, endpoint: `worker://agent/${container.uuid}` as any } } }) ``` ## Next Steps - [Agent Development Guide](AGENT_DEVELOPMENT.md) - Deploy your containers - [Error Handling Best Practices](ERROR_HANDLING.md) - Advanced error handling - [Testing Guide](TESTING.md) - Comprehensive testing strategies - [Examples](../examples/) - Real-world container examples --- Need help? Check the [Troubleshooting Guide](TROUBLESHOOTING.md) or open an issue on GitHub.