diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..75edc97f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,45 @@ +# Dependencies +node_modules + +# Build output +dist + +# Git +.git +.gitignore + +# IDE +.idea +.vscode +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs + +# Environment files +.env +.env.* + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml + +# Documentation +README.md +docs + +# Tests +*.test.ts +*.spec.ts +__tests__ +coverage + +# Context directory +.context diff --git a/package.json b/package.json index 38421fc1..54c37146 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "devDependencies": { "@types/node": "^25.0.10", "@types/uuid": "^11.0.0", - "socket.io-client": "^4.8.3", "tsx": "^4.21.0", "typescript": "^5.9.3" }, @@ -35,6 +34,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", "uuid": "^13.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ec8248a..ced15c9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: socket.io: specifier: ^4.8.3 version: 4.8.3 + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 uuid: specifier: ^13.0.0 version: 13.0.0 @@ -54,9 +57,6 @@ importers: '@types/uuid': specifier: ^11.0.0 version: 11.0.0 - socket.io-client: - specifier: ^4.8.3 - version: 4.8.3 tsx: specifier: ^4.21.0 version: 4.21.0 diff --git a/src/gateway/Dockerfile b/src/gateway/Dockerfile new file mode 100644 index 00000000..5e078e57 --- /dev/null +++ b/src/gateway/Dockerfile @@ -0,0 +1,47 @@ +# Build stage +FROM node:22-alpine AS builder + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@10.16.1 --activate + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml ./ + +# Install all dependencies (including devDependencies for build) +RUN pnpm install --frozen-lockfile + +# Copy source code +COPY tsconfig.json ./ +COPY src ./src + +# Build TypeScript +RUN pnpm build + +# Production stage +FROM node:22-alpine AS production + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@10.16.1 --activate + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml ./ + +# Install production dependencies only +RUN pnpm install --frozen-lockfile --prod + +# Copy built files from builder stage +COPY --from=builder /app/dist ./dist + +# Set environment variables +ENV NODE_ENV=production +ENV PORT=3000 + +# Expose the port +EXPOSE 3000 + +# Run the gateway +CMD ["node", "dist/gateway/main.js"] diff --git a/src/gateway/scripts/build-and-push.sh b/src/gateway/scripts/build-and-push.sh new file mode 100755 index 00000000..19731f42 --- /dev/null +++ b/src/gateway/scripts/build-and-push.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GATEWAY_DIR="$(dirname "$SCRIPT_DIR")" +PROJECT_ROOT="$(cd "$GATEWAY_DIR/../.." && pwd)" + +REPO="085931705009.dkr.ecr.us-west-2.amazonaws.com/super-multica/gateway" +BRANCH="$(git symbolic-ref --short -q HEAD | tr '/' '-')" +IMAGE_TAG="$(date +%F_%H-%M-%S)-${BRANCH}-$(git rev-parse --short HEAD)" +IMAGE="$REPO:$IMAGE_TAG" + +# Determine if sudo is needed for docker commands +if [[ "$(uname -s)" == "Linux" ]]; then + DOCKER_CMD="sudo docker" +else + DOCKER_CMD="docker" +fi + +echo "Building image: $IMAGE" +echo "Using Dockerfile: $GATEWAY_DIR/Dockerfile" +echo "Build context: $PROJECT_ROOT" + +# Login to ECR +aws ecr get-login-password --region us-west-2 | $DOCKER_CMD login --username AWS --password-stdin 085931705009.dkr.ecr.us-west-2.amazonaws.com + +# Build from project root with gateway Dockerfile +$DOCKER_CMD build -f "$GATEWAY_DIR/Dockerfile" -t "$IMAGE" "$PROJECT_ROOT" +$DOCKER_CMD push "$IMAGE" + +echo "" +echo "Successfully pushed: $IMAGE" diff --git a/src/shared/gateway-sdk/USAGE.md b/src/shared/gateway-sdk/USAGE.md new file mode 100644 index 00000000..3ca4c807 --- /dev/null +++ b/src/shared/gateway-sdk/USAGE.md @@ -0,0 +1,594 @@ +# Gateway SDK Usage Guide + +This document describes how to use the Gateway SDK for both Client and Agent implementations. + +## Installation + +The SDK is located at `src/shared/gateway-sdk`. Import from the index file: + +```typescript +import { + GatewayClient, + type RoutedMessage, + type ConnectionState, + // Actions + HelloAction, + RequestAction, + ResponseAction, + StreamAction, + // Types + type HelloPayload, + type RequestPayload, + type ResponsePayload, + type StreamPayload, +} from "../shared/gateway-sdk/index.js"; +``` + +## Core Concepts + +### Device Types + +- `client`: End-user applications (web, mobile, desktop) +- `agent`: Backend processing units that handle requests from clients + +### Connection States + +```typescript +type ConnectionState = "disconnected" | "connecting" | "connected" | "registered"; +``` + +- `disconnected`: Not connected to gateway +- `connecting`: Connection in progress +- `connected`: WebSocket connected, not yet registered +- `registered`: Fully operational, can send/receive messages + +### Message Structure + +All messages follow the `RoutedMessage` interface: + +```typescript +interface RoutedMessage { + id: string; // Unique message ID (UUID v7, contains timestamp) + uid: string | null; // User ID (null if not authenticated) + from: string; // Sender's deviceId + to: string; // Recipient's deviceId + action: string; // Action type (e.g., "hello", "request", "stream") + payload: T; // Message payload +} +``` + +> Note: The `id` field uses UUID v7 which embeds a millisecond timestamp. To extract it: +> ```typescript +> function getTimestampFromId(id: string): Date { +> const hex = id.replace(/-/g, '').slice(0, 12); +> return new Date(parseInt(hex, 16)); +> } +> ``` + +## Client Implementation + +### Basic Setup + +```typescript +import { GatewayClient } from "../shared/gateway-sdk/index.js"; + +const client = new GatewayClient({ + url: "http://localhost:3000", // Gateway server URL + deviceId: "client-001", // Unique device identifier + deviceType: "client", // Device type + metadata: { name: "My App" }, // Optional metadata + autoReconnect: true, // Auto reconnect on disconnect (default: true) + reconnectDelay: 1000, // Reconnect delay in ms (default: 1000) +}); +``` + +### Connecting and Event Handling + +```typescript +client + .onStateChange((state) => { + console.log("Connection state:", state); + }) + .onConnect((socketId) => { + console.log("Connected with socket ID:", socketId); + }) + .onRegistered((deviceId) => { + console.log("Registered as:", deviceId); + // Now safe to send messages + }) + .onMessage((message) => { + console.log("Received:", message); + // Handle incoming messages from agents + }) + .onSendError((error) => { + console.error("Send failed:", error); + // error.code: "DEVICE_NOT_FOUND" | "NOT_REGISTERED" | "INVALID_MESSAGE" + }) + .onDisconnect((reason) => { + console.log("Disconnected:", reason); + }) + .onError((error) => { + console.error("Connection error:", error); + }) + .connect(); +``` + +### Sending Messages to an Agent + +```typescript +import { HelloAction, type HelloPayload } from "../shared/gateway-sdk/index.js"; + +// Send a hello message to agent-001 +client.send("agent-001", HelloAction, { + greeting: "Hello from client!", +}); + +// With custom message ID +const messageId = client.send( + "agent-001", + HelloAction, + { greeting: "Hello!" }, + "custom-message-id-123" +); +``` + +### RPC Pattern (Request/Response) + +```typescript +import { + RequestAction, + ResponseAction, + type RequestPayload, + type ResponsePayload, + isResponseSuccess, + isResponseError, +} from "../shared/gateway-sdk/index.js"; + +// Track pending requests +const pendingRequests = new Map void>(); + +// Send RPC request +function callAgent(agentId: string, method: string, params?: unknown): Promise { + return new Promise((resolve, reject) => { + const messageId = client.send(agentId, RequestAction, { + method, + params, + }); + + pendingRequests.set(messageId, (response) => { + if (isResponseSuccess(response)) { + resolve(response.payload); + } else { + reject(new Error(response.error.message)); + } + }); + + // Timeout after 30 seconds + setTimeout(() => { + if (pendingRequests.has(messageId)) { + pendingRequests.delete(messageId); + reject(new Error("Request timeout")); + } + }, 30000); + }); +} + +// Handle responses +client.onMessage((message) => { + if (message.action === ResponseAction) { + const payload = message.payload as ResponsePayload; + const callback = pendingRequests.get(payload.requestId); + if (callback) { + pendingRequests.delete(payload.requestId); + callback(payload); + } + } +}); + +// Usage +const result = await callAgent<{ data: string }>("agent-001", "getData", { id: 123 }); +``` + +### Receiving Streams + +```typescript +import { StreamAction, type StreamPayload } from "../shared/gateway-sdk/index.js"; + +// Track active streams +const activeStreams = new Map void>(); + +client.onMessage((message) => { + if (message.action === StreamAction) { + const payload = message.payload as StreamPayload; + const handler = activeStreams.get(payload.streamId); + if (handler) { + handler(payload.data); + } + } +}); + +// Subscribe to a stream +function subscribeToStream(streamId: string, onData: (data: unknown) => void) { + activeStreams.set(streamId, onData); + return () => activeStreams.delete(streamId); // Unsubscribe function +} +``` + +### Disconnecting + +```typescript +client.disconnect(); +``` + +### Checking Connection Status + +```typescript +client.isConnected; // true if connected or registered +client.isRegistered; // true if registered (can send messages) +client.state; // Current ConnectionState +client.deviceId; // Device ID +client.socketId; // Socket ID (available after connect) +``` + +## Agent Implementation + +### Basic Setup + +```typescript +import { GatewayClient } from "../shared/gateway-sdk/index.js"; + +const agent = new GatewayClient({ + url: "http://localhost:3000", + deviceId: "agent-001", + deviceType: "agent", + metadata: { + name: "Processing Agent", + capabilities: ["chat", "image-generation"], + }, +}); +``` + +### Handling Requests + +```typescript +import { + HelloAction, + HelloResponseAction, + RequestAction, + ResponseAction, + type HelloPayload, + type HelloResponsePayload, + type RequestPayload, + type ResponseSuccessPayload, + type ResponseErrorPayload, +} from "../shared/gateway-sdk/index.js"; + +agent + .onRegistered((deviceId) => { + console.log("Agent registered:", deviceId); + }) + .onMessage(async (message) => { + // Handle hello action + if (message.action === HelloAction) { + const payload = message.payload as HelloPayload; + agent.send(message.from, HelloResponseAction, { + reply: `Hello! You said: "${payload.greeting}"`, + }); + return; + } + + // Handle RPC requests + if (message.action === RequestAction) { + const request = message.payload as RequestPayload; + + try { + const result = await processRequest(request.method, request.params); + agent.send(message.from, ResponseAction, { + requestId: message.id, + ok: true, + payload: result, + }); + } catch (error) { + agent.send(message.from, ResponseAction, { + requestId: message.id, + ok: false, + error: { + code: "PROCESSING_ERROR", + message: error instanceof Error ? error.message : "Unknown error", + retryable: false, + }, + }); + } + } + }) + .connect(); + +async function processRequest(method: string, params: unknown): Promise { + switch (method) { + case "getData": + return { data: "some data" }; + case "processImage": + // Process image... + return { url: "https://..." }; + default: + throw new Error(`Unknown method: ${method}`); + } +} +``` + +### Sending Streams + +```typescript +import { StreamAction, type StreamPayload } from "../shared/gateway-sdk/index.js"; +import { v7 as uuidv7 } from "uuid"; + +async function sendStream(clientId: string, generateChunks: AsyncIterable) { + const streamId = uuidv7(); + + for await (const chunk of generateChunks) { + agent.send>(clientId, StreamAction, { + streamId, + data: chunk, + }); + } + + // Send end-of-stream marker + agent.send>(clientId, StreamAction, { + streamId, + data: null, + }); +} + +// Usage with an async generator +async function* generateResponse(): AsyncIterable { + yield "Hello"; + yield " "; + yield "World"; + yield "!"; +} + +sendStream("client-001", generateResponse()); +``` + +### Multiple Agent Instances + +For scaling, run multiple agent instances with unique IDs: + +```typescript +const agentId = `agent-${process.env.INSTANCE_ID || uuidv7()}`; + +const agent = new GatewayClient({ + url: process.env.GATEWAY_URL || "http://localhost:3000", + deviceId: agentId, + deviceType: "agent", + metadata: { + instanceId: process.env.INSTANCE_ID, + region: process.env.REGION, + }, +}); +``` + +## Predefined Actions + +### Hello Action + +Simple greeting for testing connectivity. + +```typescript +// Client sends +client.send("agent-001", HelloAction, { + greeting: "Hello!", +}); + +// Agent responds +agent.send(message.from, HelloResponseAction, { + reply: "Hi there!", +}); +``` + +### Request/Response Action (RPC) + +For request-response patterns. + +```typescript +// Request +interface RequestPayload { + method: string; + params?: T; +} + +// Success Response +interface ResponseSuccessPayload { + requestId: string; + ok: true; + payload: T; +} + +// Error Response +interface ResponseErrorPayload { + requestId: string; + ok: false; + error: { + code: string; + message: string; + retryable?: boolean; + }; +} +``` + +### Stream Action + +For streaming data (e.g., LLM token streaming). + +```typescript +interface StreamPayload { + streamId: string; // Correlates all messages in a stream + data: T; // Chunk data, null indicates end-of-stream +} +``` + +## Error Handling + +### Send Errors + +```typescript +client.onSendError((error) => { + switch (error.code) { + case "DEVICE_NOT_FOUND": + console.error(`Device ${error.messageId} not found`); + break; + case "NOT_REGISTERED": + console.error("You are not registered"); + break; + case "INVALID_MESSAGE": + console.error("Invalid message format"); + break; + } +}); +``` + +### Connection Errors + +```typescript +client.onError((error) => { + console.error("Connection error:", error.message); + // SDK will auto-reconnect if autoReconnect is true +}); +``` + +## Type Safety + +Use generics for type-safe payloads: + +```typescript +// Define your payload types +interface MyRequestPayload { + query: string; + limit: number; +} + +interface MyResponsePayload { + results: string[]; + total: number; +} + +// Send with type safety +client.send("agent-001", "search", { + query: "hello", + limit: 10, +}); + +// Receive with type assertion +agent.onMessage((message) => { + if (message.action === "search") { + const payload = message.payload as MyRequestPayload; + // payload.query and payload.limit are typed + } +}); +``` + +## Complete Example: Chat Application + +### Client Side + +```typescript +import { + GatewayClient, + RequestAction, + ResponseAction, + StreamAction, + type RequestPayload, + type ResponsePayload, + type StreamPayload, + isResponseSuccess, +} from "../shared/gateway-sdk/index.js"; + +const client = new GatewayClient({ + url: "http://localhost:3000", + deviceId: `user-${Date.now()}`, + deviceType: "client", +}); + +// Collect stream chunks +const streamBuffers = new Map(); + +client + .onMessage((message) => { + if (message.action === StreamAction) { + const { streamId, data } = message.payload as StreamPayload; + + if (data === null) { + // Stream ended + const chunks = streamBuffers.get(streamId) || []; + console.log("Complete response:", chunks.join("")); + streamBuffers.delete(streamId); + } else { + // Accumulate chunk + const chunks = streamBuffers.get(streamId) || []; + chunks.push(data); + streamBuffers.set(streamId, chunks); + process.stdout.write(data); // Print chunk immediately + } + } + }) + .onRegistered(() => { + // Send a chat message + client.send("chat-agent", RequestAction, { + method: "chat", + params: { message: "Tell me a joke" }, + }); + }) + .connect(); +``` + +### Agent Side + +```typescript +import { + GatewayClient, + RequestAction, + StreamAction, + type RequestPayload, + type StreamPayload, +} from "../shared/gateway-sdk/index.js"; +import { v7 as uuidv7 } from "uuid"; + +const agent = new GatewayClient({ + url: "http://localhost:3000", + deviceId: "chat-agent", + deviceType: "agent", +}); + +agent + .onMessage(async (message) => { + if (message.action === RequestAction) { + const { method, params } = message.payload as RequestPayload<{ message: string }>; + + if (method === "chat") { + const streamId = uuidv7(); + + // Simulate streaming response + const response = "Why did the programmer quit? Because he didn't get arrays!"; + + for (const char of response) { + agent.send>(message.from, StreamAction, { + streamId, + data: char, + }); + await sleep(50); // Simulate delay + } + + // End stream + agent.send>(message.from, StreamAction, { + streamId, + data: null, + }); + } + } + }) + .connect(); + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +``` diff --git a/src/shared/gateway-sdk/client.ts b/src/shared/gateway-sdk/client.ts index a9abae38..8c7ae46d 100644 --- a/src/shared/gateway-sdk/client.ts +++ b/src/shared/gateway-sdk/client.ts @@ -121,7 +121,6 @@ export class GatewayClient { to, action, payload, - timestamp: new Date().toISOString(), }; this.socket.emit(GatewayEvents.SEND, message); diff --git a/src/shared/gateway-sdk/types.ts b/src/shared/gateway-sdk/types.ts index 526f5843..92f20434 100644 --- a/src/shared/gateway-sdk/types.ts +++ b/src/shared/gateway-sdk/types.ts @@ -42,7 +42,7 @@ export interface RegisteredResponse { /** 路由消息 */ export interface RoutedMessage { - /** 消息唯一ID */ + /** 消息唯一ID (UUID v7,包含时间戳) */ id: string; /** 用户ID(登录后填充) */ uid: string | null; @@ -54,8 +54,6 @@ export interface RoutedMessage { action: string; /** 消息内容 */ payload: T; - /** 时间戳 */ - timestamp: string; } /** 发送失败响应 */