foundations/net/docs/CONTAINER_DEVELOPMENT.md
Learn how to build robust containers for Huly Virtual Network.
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.
Every container must implement the Container interface:
import type { Container, ContainerUuid, ClientUuid } from '@hcengineering/network-core'
interface Container {
// Handle requests from clients
request(operation: string, data?: any, clientId?: ClientUuid): Promise<any>
// Health check
ping(): Promise<void>
// Cleanup resources
terminate(): Promise<void>
// Client connection management
connect(clientId: ClientUuid, broadcast: (data: any) => Promise<void>): void
disconnect(clientId: ClientUuid): void
// Optional: called when container is removed from network
onTerminated?(): void
}
Here's the simplest possible container:
export class MinimalContainer implements Container {
constructor(readonly uuid: ContainerUuid) {}
async request(operation: string, data?: any): Promise<any> {
return { message: 'Hello, World!' }
}
async ping(): Promise<void> {}
async terminate(): Promise<void> {}
connect(clientId: ClientUuid, broadcast: (data: any) => Promise<void>): void {}
disconnect(clientId: ClientUuid): void {}
}
Most containers handle multiple operations:
export class CalculatorContainer implements Container {
constructor(readonly uuid: ContainerUuid) {}
async request(operation: string, data?: any): Promise<any> {
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<void> {}
async terminate(): Promise<void> {}
connect(clientId: ClientUuid, broadcast: (data: any) => Promise<void>): void {}
disconnect(clientId: ClientUuid): void {}
}
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
Containers are created by factory functions:
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
}
}
})
Always clean up resources in terminate():
export class DatabaseContainer implements Container {
private connection?: DatabaseConnection
private cache = new Map<string, any>()
async terminate(): Promise<void> {
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`)
}
}
Use a switch statement or command pattern:
export class UserServiceContainer implements Container {
private users = new Map<string, User>()
async request(operation: string, data?: any, clientId?: ClientUuid): Promise<any> {
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<any> {
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<any> {
const user = this.users.get(userId)
if (!user) {
return {
success: false,
error: 'User not found'
}
}
return { success: true, user }
}
// ... other methods
}
Handle long-running operations properly:
export class ProcessingContainer implements Container {
private activeJobs = new Map<string, AbortController>()
async request(operation: string, data?: any): Promise<any> {
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<void> {
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<void> {
// Cancel all active jobs
for (const [jobId, controller] of this.activeJobs) {
controller.abort()
}
this.activeJobs.clear()
}
}
export class ChatRoomContainer implements Container {
private clients = new Map<ClientUuid, (data: any) => Promise<void>>()
private messages: Message[] = []
connect(clientId: ClientUuid, broadcast: (data: any) => Promise<void>): 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<any> {
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<void> {
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<void> {
await this.broadcast({
type: 'roomClosed',
message: 'Chat room is closing'
})
this.clients.clear()
this.messages = []
}
}
Send events to specific clients:
export class NotificationContainer implements Container {
private subscribers = new Map<
ClientUuid,
{
broadcast: (data: any) => Promise<void>
filter: NotificationFilter
}
>()
connect(clientId: ClientUuid, broadcast: (data: any) => Promise<void>): void {
// Store with default filter
this.subscribers.set(clientId, {
broadcast,
filter: { all: true }
})
}
async request(operation: string, data?: any, clientId?: ClientUuid): Promise<any> {
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<void> {
const promises: Promise<void>[] = []
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
}
}
export class SessionContainer implements Container {
private sessionData = new Map<string, any>()
private lastActivity = Date.now()
private readonly TIMEOUT = 30 * 60 * 1000 // 30 minutes
async request(operation: string, data?: any): Promise<any> {
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
}
}
}
}
export class PersistentContainer implements Container {
private cache = new Map<string, any>()
private db: Database
constructor(readonly uuid: ContainerUuid, private readonly dbPath: string) {}
async initialize(): Promise<void> {
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<any> {
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<void> {
// Flush any pending writes
await this.db.flush()
await this.db.close()
this.cache.clear()
}
}
export class RobustContainer implements Container {
async request(operation: string, data?: any): Promise<any> {
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
}
}
}
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')
})
})
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()
})
})
Always validate incoming data:
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')
}
}
Define proper types:
interface CreateUserRequest {
name: string
email: string
role?: UserRole
}
interface UpdateUserRequest {
userId: string
updates: Partial<User>
}
async request(operation: string, data?: any): Promise<any> {
switch (operation) {
case 'createUser':
return await this.createUser(data as CreateUserRequest)
case 'updateUser':
return await this.updateUser(data as UpdateUserRequest)
}
}
Add structured logging:
async request(operation: string, data?: any, clientId?: ClientUuid): Promise<any> {
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
}
}
Always clean up in terminate():
async terminate(): Promise<void> {
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)
}
}
Document your container's API:
/**
* User Management Container
*
* Operations:
* - createUser(data: CreateUserRequest): Promise<CreateUserResponse>
* - getUser(data: { userId: string }): Promise<GetUserResponse>
* - updateUser(data: UpdateUserRequest): Promise<UpdateUserResponse>
* - deleteUser(data: { userId: string }): Promise<DeleteUserResponse>
* - listUsers(data: ListUsersRequest): Promise<ListUsersResponse>
*
* Events:
* - userCreated: { user: User }
* - userUpdated: { userId: string, changes: Partial<User> }
* - userDeleted: { userId: string }
*/
export class UserManagementContainer implements Container {
// ...
}
For containers that should have only one instance:
// 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)
)
Inject dependencies:
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<ServiceContainer> {
const db = await Database.connect()
const cache = new CacheService()
const eventBus = new EventBus()
return new ServiceContainer(uuid, db, cache, eventBus)
}
}
For resource-intensive containers:
// 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
}
}
})
Need help? Check the Troubleshooting Guide or open an issue on GitHub.