diff --git a/examples/01-basic-container-request-response.ts b/examples/01-basic-container-request-response.ts index 21ea86490f..a06d2abed7 100644 --- a/examples/01-basic-container-request-response.ts +++ b/examples/01-basic-container-request-response.ts @@ -12,7 +12,7 @@ * // npx ts-node examples/01-basic-container-request-response.ts */ -import { AgentImpl, TickManagerImpl, NetworkImpl } from '../packages/core/src' +import { AgentImpl, TickManagerImpl, NetworkImpl, createProxyHandler } from '../packages/core/src' import { NetworkServer } from '../packages/server/src' import { createNetworkClient, NetworkAgentServer } from '../packages/client/src' import type { @@ -23,6 +23,15 @@ import type { GetOptions } from '../packages/core/src' +// Define the service interface for type-safe proxy usage +interface DataProcessorService { + store: (key: string, value: any) => Promise<{ success: boolean; key: string }> + retrieve: (key: string) => Promise<{ success: boolean; value: any; found: boolean }> + delete: (key: string) => Promise<{ success: boolean; deleted: boolean }> + list: () => Promise<{ success: boolean; keys: string[] }> + stats: () => Promise<{ success: boolean; totalKeys: number; uuid: ContainerUuid }> +} + class DataProcessorContainer implements Container { private data: Map = new Map() @@ -30,37 +39,39 @@ class DataProcessorContainer implements Container { console.log(`[DataProcessor] Container ${uuid} created`) } - async request(operation: string, data?: any, clientId?: ClientUuid): Promise { - console.log(`[DataProcessor] Operation: ${operation}`, data) - - switch (operation) { - case 'store': - this.data.set(data.key, data.value) - return { success: true, key: data.key } + // Implement the service interface + private serviceImpl: DataProcessorService = { + store: async (key: string, value: any) => { + this.data.set(key, value) + return { success: true, key } + }, - case 'retrieve': - const value = this.data.get(data.key) - return { success: true, value, found: value !== undefined } + retrieve: async (key: string) => { + const value = this.data.get(key) + return { success: true, value, found: value !== undefined } + }, - case 'delete': - const existed = this.data.delete(data.key) - return { success: true, deleted: existed } + delete: async (key: string) => { + const deleted = this.data.delete(key) + return { success: true, deleted } + }, - case 'list': - return { success: true, keys: Array.from(this.data.keys()) } + list: async () => { + return { success: true, keys: Array.from(this.data.keys()) } + }, - case 'stats': - return { - success: true, - totalKeys: this.data.size, - uuid: this.uuid - } - - default: - return { success: false, error: 'Unknown operation' } + stats: async () => { + return { + success: true, + totalKeys: this.data.size, + uuid: this.uuid + } } } + // Use proxy handler to route requests to service implementation + request = createProxyHandler(this.serviceImpl as any, 'DataProcessorService') + async ping(): Promise { // Health check - container is alive } @@ -121,43 +132,47 @@ async function main(): Promise { const containerRef = await client.get('data-processor' as ContainerKind, {}) console.log(`✓ Got container: ${containerRef.uuid}\n`) - // 6. Perform various operations + // 6. Create typed proxy using cast method + const processor = containerRef.cast('DataProcessorService') + console.log('✓ Created typed proxy\n') + + // 7. Perform various operations using typed methods console.log('--- Storing data ---') - await containerRef.request('store', { key: 'user:123', value: { name: 'Alice', age: 30 } }) - await containerRef.request('store', { key: 'user:456', value: { name: 'Bob', age: 25 } }) - await containerRef.request('store', { key: 'config:app', value: { theme: 'dark' } }) + await processor.store('user:123', { name: 'Alice', age: 30 }) + await processor.store('user:456', { name: 'Bob', age: 25 }) + await processor.store('config:app', { theme: 'dark' }) console.log('✓ Data stored\n') console.log('--- Retrieving data ---') - const result1 = await containerRef.request('retrieve', { key: 'user:123' }) + const result1 = await processor.retrieve('user:123') console.log('Retrieved:', result1) - const result2 = await containerRef.request('retrieve', { key: 'user:456' }) + const result2 = await processor.retrieve('user:456') console.log('Retrieved:', result2) - const result3 = await containerRef.request('retrieve', { key: 'nonexistent' }) + const result3 = await processor.retrieve('nonexistent') console.log('Retrieved (not found):', result3) console.log() console.log('--- Listing all keys ---') - const listResult = await containerRef.request('list') + const listResult = await processor.list() console.log('All keys:', listResult) console.log() console.log('--- Getting stats ---') - const stats = await containerRef.request('stats') + const stats = await processor.stats() console.log('Stats:', stats) console.log() console.log('--- Deleting data ---') - const deleteResult = await containerRef.request('delete', { key: 'user:456' }) + const deleteResult = await processor.delete('user:456') console.log('Delete result:', deleteResult) - const statsAfterDelete = await containerRef.request('stats') + const statsAfterDelete = await processor.stats() console.log('Stats after delete:', statsAfterDelete) console.log() - // 7. Cleanup + // 8. Cleanup console.log('--- Cleanup ---') await containerRef.close() await agentServer.close() diff --git a/examples/02-event-broadcasting.ts b/examples/02-event-broadcasting.ts index 8a3a96edf7..855e16c083 100644 --- a/examples/02-event-broadcasting.ts +++ b/examples/02-event-broadcasting.ts @@ -24,7 +24,7 @@ * // npx ts-node examples/02-event-broadcasting.ts */ -import { AgentImpl, TickManagerImpl, NetworkImpl } from '../packages/core/src' +import { AgentImpl, TickManagerImpl, NetworkImpl, createProxyHandler } from '../packages/core/src' import { NetworkServer } from '../packages/server/src' import { createNetworkClient, NetworkAgentServer } from '../packages/client/src' import type { @@ -41,6 +41,32 @@ interface ChatMessage { timestamp: number } +// Define the service interface for type-safe proxy usage +interface ChatRoomService { + sendMessage: (username: string, text: string) => Promise<{ + success: boolean; + messageId: number; + timestamp: number + }> + getHistory: () => Promise<{ + success: boolean; + messages: ChatMessage[]; + totalMessages: number + }> + getUserCount: () => Promise<{ + success: boolean; + count: number; + connectedClients: ClientUuid[] + }> + getRoomInfo: () => Promise<{ + success: boolean; + roomName: string; + uuid: ContainerUuid; + messageCount: number; + clientCount: number; + }> +} + class ChatRoomContainer implements Container { private clients = new Map Promise>() private messages: ChatMessage[] = [] @@ -52,60 +78,62 @@ class ChatRoomContainer implements Container { console.log(`[ChatRoom] Room '${roomName}' created (${uuid})`) } - async request(operation: string, data?: any, clientId?: ClientUuid): Promise { - switch (operation) { - case 'sendMessage': { - const message: ChatMessage = { - username: data.username, - text: data.text, - timestamp: Date.now() - } - this.messages.push(message) - - console.log(`[ChatRoom] ${message.username}: ${message.text}`) - - // Broadcast to all connected clients - await this.broadcastToAll({ - type: 'newMessage', - message, - messageCount: this.messages.length - }) - - return { - success: true, - messageId: this.messages.length - 1, - timestamp: message.timestamp - } + // Implement the service interface + private serviceImpl: ChatRoomService = { + sendMessage: async (username: string, text: string) => { + const message: ChatMessage = { + username, + text, + timestamp: Date.now() } + this.messages.push(message) + + console.log(`[ChatRoom] ${message.username}: ${message.text}`) + + // Broadcast to all connected clients + await this.broadcastToAll({ + type: 'newMessage', + message, + messageCount: this.messages.length + }) + + return { + success: true, + messageId: this.messages.length - 1, + timestamp: message.timestamp + } + }, - case 'getHistory': - return { - success: true, - messages: this.messages, - totalMessages: this.messages.length - } + getHistory: async () => { + return { + success: true, + messages: this.messages, + totalMessages: this.messages.length + } + }, - case 'getUserCount': - return { - success: true, - count: this.clients.size, - connectedClients: Array.from(this.clients.keys()) - } + getUserCount: async () => { + return { + success: true, + count: this.clients.size, + connectedClients: Array.from(this.clients.keys()) + } + }, - case 'getRoomInfo': - return { - success: true, - roomName: this.roomName, - uuid: this.uuid, - messageCount: this.messages.size, - clientCount: this.clients.size - } - - default: - return { success: false, error: 'Unknown operation' } + getRoomInfo: async () => { + return { + success: true, + roomName: this.roomName, + uuid: this.uuid, + messageCount: this.messages.length, + clientCount: this.clients.size + } } } + // Use proxy handler to route requests to service implementation + request = createProxyHandler(this.serviceImpl as any, 'ChatRoomService') + async ping(): Promise {} async terminate(): Promise { @@ -169,7 +197,7 @@ async function main(): Promise { // 2. Create agent const agent = new AgentImpl('chat-agent' as any, { - 'chat-room': async (options: GetOptions) => { + ['chat-room' as ContainerKind]: async (options: GetOptions) => { const roomName = options.labels?.[0] || 'general' const uuid = options.uuid ?? `room-${roomName}-${Date.now()}` as ContainerUuid const container = new ChatRoomContainer(uuid, roomName) @@ -237,30 +265,36 @@ async function main(): Promise { await new Promise(resolve => setTimeout(resolve, 500)) console.log('✓ Event listeners ready\n') - // 7. Check room status + // 7. Create typed proxies using cast method + const chat1 = conn1.cast('ChatRoomService') + const chat2 = conn2.cast('ChatRoomService') + const chat3 = conn3.cast('ChatRoomService') + console.log('✓ Created typed proxies\n') + + // 8. Check room status console.log('--- Room info ---') - const roomInfo = await conn1.request('getUserCount') + const roomInfo = await chat1.getUserCount() console.log('Users in room:', roomInfo) console.log() - // 8. Send messages (will be broadcast to all clients) + // 9. Send messages using typed methods (will be broadcast to all clients) console.log('--- Broadcasting messages ---') - await conn1.request('sendMessage', { username: 'Alice', text: 'Hello everyone!' }) + await chat1.sendMessage('Alice', 'Hello everyone!') await new Promise(resolve => setTimeout(resolve, 200)) - await conn2.request('sendMessage', { username: 'Bob', text: 'Hi Alice!' }) + await chat2.sendMessage('Bob', 'Hi Alice!') await new Promise(resolve => setTimeout(resolve, 200)) - await conn3.request('sendMessage', { username: 'Charlie', text: 'Hey folks! 👋' }) + await chat3.sendMessage('Charlie', 'Hey folks! 👋') await new Promise(resolve => setTimeout(resolve, 200)) - await conn1.request('sendMessage', { username: 'Alice', text: 'Nice to meet you all!' }) + await chat1.sendMessage('Alice', 'Nice to meet you all!') await new Promise(resolve => setTimeout(resolve, 200)) console.log() - // 9. Retrieve chat history + // 10. Retrieve chat history console.log('--- Chat history ---') - const history = await conn2.request('getHistory') + const history = await chat2.getHistory() console.log(`Total messages: ${history.totalMessages}`) history.messages.forEach((msg: ChatMessage, idx: number) => { const time = new Date(msg.timestamp).toISOString() @@ -268,19 +302,19 @@ async function main(): Promise { }) console.log() - // 10. Simulate one client leaving + // 11. Simulate one client leaving console.log('--- Client3 disconnecting ---') await conn3.close() await chatRef3.close() await new Promise(resolve => setTimeout(resolve, 500)) console.log() - // 11. Check updated user count - const updatedInfo = await conn1.request('getUserCount') + // 12. Check updated user count + const updatedInfo = await chat1.getUserCount() console.log('Users remaining in room:', updatedInfo.count) console.log() - // 12. Cleanup + // 13. Cleanup console.log('--- Cleanup ---') await conn1.close() await conn2.close() diff --git a/examples/README.md b/examples/README.md index 80bd831f3e..48afd7f393 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,6 +2,35 @@ This directory contains comprehensive examples demonstrating various aspects of the Huly Virtual Network. +## 🆕 Updated to Use Typed Proxies + +All examples have been updated to demonstrate the new **typed proxy pattern** using the `cast()` method. This provides: + +- ✅ **Full type safety** - TypeScript checks method signatures +- ✅ **IDE autocomplete** - Get suggestions for available methods +- ✅ **Better documentation** - Interfaces serve as contracts +- ✅ **Refactoring safety** - Compile-time errors catch issues + +See [PROXY_MIGRATION.md](./PROXY_MIGRATION.md) for detailed migration guide. + +### Example Usage + +```typescript +// Define your service interface +interface MyService { + getData: (id: string) => Promise<{ data: any }> + updateData: (id: string, data: any) => Promise +} + +// On client side - use cast() for typed proxy +const containerRef = await client.get('my-service', {}) +const service = containerRef.cast('MyService') + +// Call methods with full type safety +const result = await service.getData('123') +await service.updateData('123', { updated: true }) +``` + ## Important Note: Auto-Disposal All examples in this directory use the `await using` keyword for automatic client disposal. This ensures proper cleanup when the example completes or if an error occurs. For production services that need to run indefinitely, you should use manual lifecycle management instead. See [docs/AUTO_DISPOSAL_GUIDE.md](../docs/AUTO_DISPOSAL_GUIDE.md) for detailed guidance. @@ -35,13 +64,29 @@ Before running any example, make sure you have: **File**: `01-basic-container-request-response.ts` -Learn the fundamentals of creating containers that handle various operations. This example shows: +Learn the fundamentals of creating containers with typed proxies. This example shows: -- Container implementation basics -- Request/response pattern +- Defining service interfaces for type safety +- Container implementation with `createProxyHandler` +- Using `cast()` method for typed proxies +- Request/response pattern with full TypeScript support - State management within containers - Proper lifecycle management +**Key concepts**: + +```typescript +// Define interface +interface DataProcessorService { + store: (key: string, value: any) => Promise<{ success: boolean }> + retrieve: (key: string) => Promise<{ success: boolean; value: any }> +} + +// Use typed proxy +const processor = containerRef.cast('DataProcessorService') +await processor.store('key', value) +``` + **Use case**: Simple data storage service, key-value stores, stateful services --- @@ -50,13 +95,29 @@ Learn the fundamentals of creating containers that handle various operations. Th **File**: `02-event-broadcasting.ts` -Demonstrates real-time event broadcasting to multiple connected clients. Features: +Demonstrates real-time event broadcasting to multiple connected clients with typed proxies. Features: +- Defining chat service interface with typed methods - Multiple clients connecting to the same container - Broadcasting events to all connected clients -- Chat room implementation +- Using typed proxies for chat operations - Connection lifecycle management +**Key concepts**: + +```typescript +// Define interface +interface ChatRoomService { + sendMessage: (username: string, text: string) => Promise<{ success: boolean }> + getHistory: () => Promise<{ messages: ChatMessage[] }> +} + +// Use typed proxy +const chat = connection.cast('ChatRoomService') +await chat.sendMessage('Alice', 'Hello!') +const history = await chat.getHistory() +``` + **Use case**: Chat systems, real-time notifications, collaborative editing, live dashboards --- diff --git a/packages/client/src/agent.ts b/packages/client/src/agent.ts index 02b27fd616..31a4905180 100644 --- a/packages/client/src/agent.ts +++ b/packages/client/src/agent.ts @@ -12,7 +12,8 @@ import { type ContainerUuid, type NetworkAgent, type NetworkEvent, - type TickManager + type TickManager, + createProxy } from '@hcengineering/network-core' import { opNames } from './types' @@ -144,7 +145,10 @@ export class RoutedNetworkAgentConnectionImpl } }, request: async (operation, data) => - await this.client.request(opNames.sendContainer, [containerUuid, operation, data]) + await this.client.request(opNames.sendContainer, [containerUuid, operation, data]), + cast(interfaceName?: string): T { + return createProxy(connection, interfaceName) + } } this.containers.set(containerUuid, connection) return connection @@ -195,6 +199,10 @@ export class NetworkDirectConnectionImpl implements ContainerConnection { return await this.client.request(operation, data) } + cast(interfaceName?: string): T { + return createProxy(this, interfaceName) + } + async requestHandler (method: string, params: any, send: BackRPCResponseSend): Promise { // No callback is required } @@ -272,6 +280,10 @@ export class ContainerConnectionImpl implements ContainerConnection { return await this.connection.request(operation, data) } + cast(interfaceName?: string): T { + return createProxy(this, interfaceName) + } + async close (): Promise { if (this.connection instanceof Promise) { this.connection = await this.connection diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 59bdbd2a5a..0d1904ff1a 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -19,7 +19,8 @@ import { type NetworkAgent, type NetworkClient, type TickManager, - NetworkEventKind + NetworkEventKind, + createProxy } from '@hcengineering/network-core' import { v4 as uuidv4 } from 'uuid' import { ContainerConnectionImpl, NetworkDirectConnectionImpl, RoutedNetworkAgentConnectionImpl } from './agent' @@ -54,6 +55,10 @@ class ContainerReferenceImpl implements ContainerReference { return await this.client.request(this.uuid, operation, data) } + cast(interfaceName?: string): T { + return createProxy(this, interfaceName) + } + async connect (): Promise { let conn = this.client.containerConnections.get(this.uuid) if (conn !== undefined) { diff --git a/packages/core/src/__test__/proxy.test.ts b/packages/core/src/__test__/proxy.test.ts new file mode 100644 index 0000000000..06a9a56ce3 --- /dev/null +++ b/packages/core/src/__test__/proxy.test.ts @@ -0,0 +1,182 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { createProxy, handleProxyCall, createProxyHandler } from '../proxy' +import type { RequestHandler } from '../api/client' + +describe('Core Proxy Tests', () => { + // Define a test service interface + interface CalculatorService { + add: (a: number, b: number) => Promise + subtract: (a: number, b: number) => Promise + multiply: (a: number, b: number) => Promise + divide: (a: number, b: number) => Promise + getName: () => Promise + } + + // Implementation of the service + const calculatorImpl: CalculatorService = { + add: async (a: number, b: number) => a + b, + subtract: async (a: number, b: number) => a - b, + multiply: async (a: number, b: number) => a * b, + divide: async (a: number, b: number) => { + if (b === 0) { + throw new Error('Division by zero') + } + return a / b + }, + getName: async () => 'Calculator' + } + + // Mock RequestHandler for testing + class MockRequestHandler implements RequestHandler { + constructor ( + private readonly impl: any, + private readonly interfaceName?: string + ) {} + + async request (method: string, params: any[]): Promise { + return await handleProxyCall(this.impl, method, params, this.interfaceName) + } + } + + it('should create proxy and call methods', async () => { + const handler = new MockRequestHandler(calculatorImpl, 'CalculatorService') + const calculator = createProxy(handler, 'CalculatorService') + + const result = await calculator.add(5, 3) + expect(result).toBe(8) + }) + + it('should handle subtract operation', async () => { + const handler = new MockRequestHandler(calculatorImpl, 'CalculatorService') + const calculator = createProxy(handler, 'CalculatorService') + + const result = await calculator.subtract(10, 4) + expect(result).toBe(6) + }) + + it('should handle multiply operation', async () => { + const handler = new MockRequestHandler(calculatorImpl, 'CalculatorService') + const calculator = createProxy(handler, 'CalculatorService') + + const result = await calculator.multiply(7, 6) + expect(result).toBe(42) + }) + + it('should handle divide operation', async () => { + const handler = new MockRequestHandler(calculatorImpl, 'CalculatorService') + const calculator = createProxy(handler, 'CalculatorService') + + const result = await calculator.divide(20, 4) + expect(result).toBe(5) + }) + + it('should handle errors in proxy calls', async () => { + const handler = new MockRequestHandler(calculatorImpl, 'CalculatorService') + const calculator = createProxy(handler, 'CalculatorService') + + await expect(calculator.divide(10, 0)).rejects.toThrow('Division by zero') + }) + + it('should handle methods without parameters', async () => { + const handler = new MockRequestHandler(calculatorImpl, 'CalculatorService') + const calculator = createProxy(handler, 'CalculatorService') + + const result = await calculator.getName() + expect(result).toBe('Calculator') + }) + + it('should work without interface name prefix', async () => { + const handler = new MockRequestHandler(calculatorImpl) + const calculator = createProxy(handler) + + const result = await calculator.add(2, 3) + expect(result).toBe(5) + }) + + it('should handle multiple concurrent calls', async () => { + const handler = new MockRequestHandler(calculatorImpl, 'CalculatorService') + const calculator = createProxy(handler, 'CalculatorService') + + const results = await Promise.all([ + calculator.add(1, 2), + calculator.subtract(10, 5), + calculator.multiply(3, 4), + calculator.divide(20, 5) + ]) + + expect(results).toEqual([3, 5, 12, 4]) + }) + + it('should throw error for non-existent methods', async () => { + const handler = new MockRequestHandler(calculatorImpl, 'CalculatorService') + + await expect(handler.request('CalculatorService.nonExistent', [])).rejects.toThrow( + "Method 'nonExistent' not found in service implementation" + ) + }) + + it('createProxyHandler should create a working handler', async () => { + const handler = createProxyHandler(calculatorImpl as any, 'CalculatorService') + + const result = await handler('CalculatorService.add', [5, 7]) + expect(result).toBe(12) + }) + + it('createProxyHandler should handle empty params', async () => { + const handler = createProxyHandler(calculatorImpl as any, 'CalculatorService') + + const result = await handler('CalculatorService.getName', undefined) + expect(result).toBe('Calculator') + }) + + it('should strip interface name prefix in handleProxyCall', async () => { + const result = await handleProxyCall(calculatorImpl as any, 'CalculatorService.add', [10, 20], 'CalculatorService') + expect(result).toBe(30) + }) + + it('should work with direct method names in handleProxyCall', async () => { + const result = await handleProxyCall(calculatorImpl as any, 'add', [15, 25]) + expect(result).toBe(40) + }) + + it('should support cast method on RequestHandler objects', async () => { + // Create a mock ContainerReference-like object + class MockContainerReference implements RequestHandler { + constructor ( + private readonly impl: any, + private readonly interfaceName?: string + ) {} + + async request (method: string, params: any[]): Promise { + return await handleProxyCall(this.impl, method, params, this.interfaceName) + } + + cast(interfaceName?: string): T { + return createProxy(this, interfaceName) + } + } + + const containerRef = new MockContainerReference(calculatorImpl, 'CalculatorService') + const calculator = containerRef.cast('CalculatorService') + + const result = await calculator.add(100, 200) + expect(result).toBe(300) + + const name = await calculator.getName() + expect(name).toBe('Calculator') + }) +}) diff --git a/packages/core/src/api/client.ts b/packages/core/src/api/client.ts index 0a2332a886..d6034904d6 100644 --- a/packages/core/src/api/client.ts +++ b/packages/core/src/api/client.ts @@ -10,6 +10,13 @@ import type { export type NetworkUpdateListener = (event: NetworkEvent) => Promise +/** + * Interface for request handler (used by proxy) + */ +export interface RequestHandler { + request: (method: string, params: any[]) => Promise +} + /** * Interface to Huly network. * @@ -57,8 +64,9 @@ export interface ConnectionManager { /** * A client reference to container, until closed, client will notify network about container is still required. + * Implements RequestHandler for proxy support. */ -export interface ContainerReference { +export interface ContainerReference extends RequestHandler { uuid: ContainerUuid endpoint: ContainerEndpointRef @@ -67,18 +75,64 @@ export interface ContainerReference { connect: () => Promise - request: (operation: string, data?: any) => Promise + /** + * Send a request to the container + * Can be called directly or via proxy with method name and params array + */ + request: (operationOrMethod: string, dataOrParams?: any) => Promise + + /** + * Create a typed proxy for this container reference + * @template T - The interface to implement + * @param interfaceName - Optional name of the interface for better error messages + * @returns A proxy object that implements the interface T + * + * @example + * ```typescript + * interface MyService { + * sayHello(name: string): Promise + * } + * + * const containerRef = await client.get('my-service', {}) + * const service = containerRef.cast('MyService') + * const greeting = await service.sayHello('Alice') + * ``` + */ + cast: (interfaceName?: string) => T // A notification will be called if container changed container endpoint reference onEndpointUpdate?: () => void } // A request/reponse interface to container. -export interface ContainerConnection { +// Implements RequestHandler for proxy support. +export interface ContainerConnection extends RequestHandler { containerId: ContainerUuid - // A simple request/response to container. - request: (operation: string, data?: any) => Promise + /** + * Send a request to the container + * Can be called directly or via proxy with method name and params array + */ + request: (operationOrMethod: string, dataOrParams?: any) => Promise + + /** + * Create a typed proxy for this container connection + * @template T - The interface to implement + * @param interfaceName - Optional name of the interface for better error messages + * @returns A proxy object that implements the interface T + * + * @example + * ```typescript + * interface MyService { + * calculate(a: number, b: number): Promise + * } + * + * const connection = await containerRef.connect() + * const service = connection.cast('MyService') + * const result = await service.calculate(10, 20) + * ``` + */ + cast: (interfaceName?: string) => T // A chunk streaming of results // stream: (data: any) => Iterable diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0dd7907855..345db27c75 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,3 +9,4 @@ export * from './endpoints' export * from './agent' export * from './containers' export * from './network' +export * from './proxy' diff --git a/packages/core/src/proxy.ts b/packages/core/src/proxy.ts new file mode 100644 index 0000000000..c6f7d35b91 --- /dev/null +++ b/packages/core/src/proxy.ts @@ -0,0 +1,151 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { RequestHandler } from './api/client' + +/** + * Interface for service implementations + */ +export type ServiceImplementation = Record Promise | any> + +/** + * Creates a proxy object that implements any TypeScript interface T. + * All method calls on the proxy are transformed into request handler calls. + * + * @template T - The interface to implement + * @param handler - The request handler to use for making requests + * @param interfaceName - Optional name of the interface for better error messages and request identification + * @returns A proxy object that implements the interface T + * + * @example + * ```typescript + * interface MyService { + * sayHello(name: string): Promise + * calculate(a: number, b: number): Promise + * } + * + * const containerRef = await client.get('my-service', {}) + * const service = createProxy(containerRef, 'MyService') + * + * // This will call containerRef.request('MyService.sayHello', ['Alice']) + * const greeting = await service.sayHello('Alice') + * ``` + */ +export function createProxy (handler: RequestHandler, interfaceName?: string): T { + return new Proxy( + {}, + { + get (_target, prop, _receiver) { + // Ignore symbol properties + if (typeof prop === 'symbol') { + return undefined + } + + // Return a function that will make the RPC call + return (...args: any[]) => { + const methodName = interfaceName !== undefined ? `${interfaceName}.${prop}` : prop + return handler.request(methodName, args) + } + } + } + ) as T +} + +/** + * Handles proxy calls by routing them to the registered service implementation. + * + * @template T - The service interface type + * @param implementation - The actual implementation of the service interface + * @param method - The method name received from the client (e.g., 'MyService.sayHello' or 'sayHello') + * @param params - Array of arguments passed from the client + * @param interfaceName - Optional interface name prefix to strip from method names + * @returns Promise that resolves to the result of the method call + * @throws Error if the method is not found in the implementation + * + * @example + * ```typescript + * interface MyService { + * sayHello(name: string): Promise + * } + * + * const myServiceImpl: MyService = { + * async sayHello(name: string): Promise { + * return `Hello, ${name}!` + * } + * } + * + * // In your container's request handler: + * async request(operation: string, data?: any) { + * return await handleProxyCall(myServiceImpl, operation, data ?? [], 'MyService') + * } + * ``` + */ +export async function handleProxyCall ( + implementation: T, + method: string, + params: any[], + interfaceName?: string +): Promise { + // Strip interface name prefix if present + let methodName = method + if (interfaceName !== undefined && method.startsWith(`${interfaceName}.`)) { + methodName = method.substring(interfaceName.length + 1) + } + + // Get the method from the implementation + const fn = implementation[methodName] + + if (fn === undefined || typeof fn !== 'function') { + throw new Error(`Method '${methodName}' not found in service implementation`) + } + + // Call the method with the provided parameters + const result = await fn.apply(implementation, params) + return result +} + +/** + * Creates a request handler wrapper for a service implementation. + * This is a convenience function that wraps handleProxyCall. + * + * @template T - The service interface type + * @param implementation - The actual implementation of the service interface + * @param interfaceName - Optional interface name prefix to strip from method names + * @returns A request handler function + * + * @example + * ```typescript + * const myServiceImpl: MyService = { + * async sayHello(name: string): Promise { + * return `Hello, ${name}!` + * } + * } + * + * // In your container factory: + * const container: Container = { + * request: createProxyHandler(myServiceImpl, 'MyService'), + * terminate: async () => {}, + * ping: async () => {} + * } + * ``` + */ +export function createProxyHandler ( + implementation: T, + interfaceName?: string +): (operation: string, data?: any) => Promise { + return async (operation: string, data?: any) => { + return await handleProxyCall(implementation, operation, data ?? [], interfaceName) + } +}