diff --git a/package.json b/package.json index 54c37146..80754e36 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "tsx src/index.ts", "dev:gateway": "tsx --watch src/gateway/main.ts", + "dev:console": "tsx --watch src/console/main.ts", "build": "tsc", "start": "node dist/index.js", "typecheck": "tsc --noEmit" @@ -26,6 +27,7 @@ "@nestjs/core": "^11.1.12", "@nestjs/platform-express": "^11.1.12", "@nestjs/platform-socket.io": "^11.1.12", + "@nestjs/serve-static": "^5.0.4", "@nestjs/websockets": "^11.1.12", "nestjs-pino": "^4.5.0", "pino": "^10.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ced15c9e..48ba54f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@nestjs/platform-socket.io': specifier: ^11.1.12 version: 11.1.12(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.12)(rxjs@7.8.2) + '@nestjs/serve-static': + specifier: ^5.0.4 + version: 5.0.4(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(express@5.2.1) '@nestjs/websockets': specifier: ^11.1.12 version: 11.1.12(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -273,6 +276,22 @@ packages: '@nestjs/websockets': ^11.0.0 rxjs: ^7.1.0 + '@nestjs/serve-static@5.0.4': + resolution: {integrity: sha512-3kO1M9D3vsPyWPFardxIjUYeuolS58PnhCoBTkS7t3BrdZFZCKHnBZ15js+UOzOR2Q6HmD7ssGjLd0DVYVdvOw==} + peerDependencies: + '@fastify/static': ^8.0.4 + '@nestjs/common': ^11.0.2 + '@nestjs/core': ^11.0.2 + express: ^5.0.1 + fastify: ^5.2.1 + peerDependenciesMeta: + '@fastify/static': + optional: true + express: + optional: true + fastify: + optional: true + '@nestjs/websockets@11.1.12': resolution: {integrity: sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==} peerDependencies: @@ -1009,6 +1028,14 @@ snapshots: - supports-color - utf-8-validate + '@nestjs/serve-static@5.0.4(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(express@5.2.1)': + dependencies: + '@nestjs/common': 11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) + path-to-regexp: 8.3.0 + optionalDependencies: + express: 5.2.1 + '@nestjs/websockets@11.1.12(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2) diff --git a/src/console/app.controller.ts b/src/console/app.controller.ts new file mode 100644 index 00000000..8bef2135 --- /dev/null +++ b/src/console/app.controller.ts @@ -0,0 +1,45 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + Inject, +} from "@nestjs/common"; +import { Hub } from "../hub/hub.js"; + +@Controller("api") +export class AppController { + constructor(@Inject("HUB") private readonly hub: Hub) {} + + @Get("hub") + getHub() { + return { + deviceId: this.hub.deviceId, + url: this.hub.url, + connectionState: this.hub.connectionState, + agentCount: this.hub.listAgents().length, + }; + } + + @Get("agents") + listAgents() { + return this.hub.listAgents().map((id) => { + const agent = this.hub.getAgent(id); + return { id, closed: agent?.closed ?? true }; + }); + } + + @Post("agents") + createAgent(@Body() body?: { id?: string }) { + const agent = this.hub.createAgent(body?.id); + return { id: agent.id }; + } + + @Delete("agents/:id") + deleteAgent(@Param("id") id: string) { + const ok = this.hub.closeAgent(id); + return { ok }; + } +} diff --git a/src/console/app.module.ts b/src/console/app.module.ts new file mode 100644 index 00000000..12db26f3 --- /dev/null +++ b/src/console/app.module.ts @@ -0,0 +1,48 @@ +import { Module } from "@nestjs/common"; +import { ServeStaticModule } from "@nestjs/serve-static"; +import { LoggerModule } from "nestjs-pino"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { AppController } from "./app.controller.js"; +import { Hub } from "../hub/hub.js"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const isDev = process.env["NODE_ENV"] !== "production"; + +@Module({ + imports: [ + ServeStaticModule.forRoot({ + rootPath: join(__dirname, "public"), + serveRoot: "/", + exclude: ["/api/(.*)"], + }), + LoggerModule.forRoot({ + pinoHttp: isDev + ? { + transport: { + target: "pino-pretty", + options: { + colorize: true, + singleLine: true, + }, + }, + level: process.env["LOG_LEVEL"] ?? "debug", + } + : { + level: process.env["LOG_LEVEL"] ?? "info", + }, + }), + ], + controllers: [AppController], + providers: [ + { + provide: "HUB", + useFactory: () => { + const gatewayUrl = + process.env["GATEWAY_URL"] ?? "http://localhost:3000"; + return new Hub(gatewayUrl); + }, + }, + ], +}) +export class AppModule {} diff --git a/src/console/main.ts b/src/console/main.ts new file mode 100644 index 00000000..8d803c8f --- /dev/null +++ b/src/console/main.ts @@ -0,0 +1,20 @@ +import "reflect-metadata"; +import { NestFactory } from "@nestjs/core"; +import { Logger } from "nestjs-pino"; +import { AppModule } from "./app.module.js"; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule, { bufferLogs: true }); + app.useLogger(app.get(Logger)); + + const port = process.env["PORT"] ?? 4000; + await app.listen(port); + + const logger = app.get(Logger); + logger.log(`Console is running on http://localhost:${port}`); +} + +bootstrap().catch((err) => { + console.error("Failed to start console:", err); + process.exit(1); +}); diff --git a/src/console/public/client.html b/src/console/public/client.html new file mode 100644 index 00000000..297becb9 --- /dev/null +++ b/src/console/public/client.html @@ -0,0 +1,211 @@ + + + + + + Demo Client + + + +

Demo Client DEMO

+

Test client for sending messages to agents via Gateway

+ +
+
Device ID:
+
+ Status: Disconnected +
+ +
+ + + +
+ + +
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + + + diff --git a/src/console/public/index.html b/src/console/public/index.html new file mode 100644 index 00000000..720439db --- /dev/null +++ b/src/console/public/index.html @@ -0,0 +1,83 @@ + + + + + + Hub Console + + + +

Hub Console

+ +
+

Hub Status

+
Loading...
+
+ +
+

Agents

+
+ +
+ +
+ + + + diff --git a/src/gateway/events.gateway.ts b/src/gateway/events.gateway.ts index e1c8fe13..3c7ab4ca 100644 --- a/src/gateway/events.gateway.ts +++ b/src/gateway/events.gateway.ts @@ -15,13 +15,12 @@ import type { Server, Socket } from "socket.io"; import { InjectPinoLogger, PinoLogger } from "nestjs-pino"; import { GatewayEvents, - type RegisterPayload, - type RegisteredResponse, type RoutedMessage, type SendErrorResponse, type PingPayload, type PongResponse, type DeviceInfo, + type DeviceType, } from "../shared/gateway-sdk/index.js"; @Injectable() @@ -55,12 +54,56 @@ export class EventsGateway } handleConnection(client: Socket): void { - this.logger.info({ socketId: client.id }, "Socket connected"); + const query = client.handshake.query; + const deviceId = query["deviceId"] as string | undefined; + const deviceType = query["deviceType"] as DeviceType | undefined; + + this.logger.debug( + { socketId: client.id, deviceId, deviceType }, + "Incoming connection" + ); + + if (!deviceId || !deviceType) { + this.logger.warn( + { socketId: client.id }, + "Missing deviceId or deviceType in query, disconnecting" + ); + client.disconnect(true); + return; + } + + // 检查 deviceId 是否已被其他 socket 使用 + const existingSocketId = this.deviceToSocket.get(deviceId); + if (existingSocketId && existingSocketId !== client.id) { + this.logger.warn( + { deviceId, existingSocketId }, + "Device already registered by another socket, disconnecting" + ); + client.emit(GatewayEvents.REGISTERED, { + success: false, + deviceId, + error: "Device ID already in use", + }); + client.disconnect(true); + return; + } + + // 注册设备 + const deviceInfo: DeviceInfo = { deviceId, deviceType }; + this.deviceToSocket.set(deviceId, client.id); + this.socketToDevice.set(client.id, deviceInfo); + + this.logger.info({ deviceId, deviceType }, "Device connected and registered"); + client.emit(GatewayEvents.REGISTERED, { success: true, deviceId }); } handleDisconnect(client: Socket): void { const deviceInfo = this.socketToDevice.get(client.id); if (deviceInfo) { + this.logger.debug( + { socketId: client.id, deviceId: deviceInfo.deviceId, deviceType: deviceInfo.deviceType }, + "Device disconnecting" + ); this.logger.info( { deviceId: deviceInfo.deviceId, deviceType: deviceInfo.deviceType }, "Device disconnected" @@ -72,45 +115,16 @@ export class EventsGateway } } - @SubscribeMessage(GatewayEvents.REGISTER) - handleRegister( - @MessageBody() data: RegisterPayload, - @ConnectedSocket() client: Socket - ): void { - const { deviceId, deviceType, metadata } = data; - - // 检查 deviceId 是否已被其他 socket 使用 - const existingSocketId = this.deviceToSocket.get(deviceId); - if (existingSocketId && existingSocketId !== client.id) { - this.logger.warn( - { deviceId, existingSocketId }, - "Device already registered by another socket" - ); - const response: RegisteredResponse = { - success: false, - deviceId, - error: "Device ID already in use", - }; - client.emit(GatewayEvents.REGISTERED, response); - return; - } - - // 注册设备 - const deviceInfo: DeviceInfo = { deviceId, deviceType, metadata }; - this.deviceToSocket.set(deviceId, client.id); - this.socketToDevice.set(client.id, deviceInfo); - - this.logger.info({ deviceId, deviceType }, "Device registered"); - - const response: RegisteredResponse = { success: true, deviceId }; - client.emit(GatewayEvents.REGISTERED, response); - } - @SubscribeMessage(GatewayEvents.SEND) handleSend( @MessageBody() message: RoutedMessage, @ConnectedSocket() client: Socket ): void { + this.logger.debug( + { socketId: client.id, message }, + "Received send event" + ); + const senderDevice = this.socketToDevice.get(client.id); // 检查发送者是否已注册 diff --git a/src/gateway/test-client.ts b/src/gateway/test-client.ts index a5bc56ea..ac4ed253 100644 --- a/src/gateway/test-client.ts +++ b/src/gateway/test-client.ts @@ -11,7 +11,6 @@ const client = new GatewayClient({ url: "http://localhost:3000", deviceId: "client-001", deviceType: "client", - metadata: { name: "Test Client" }, }); // 模拟一个 Agent @@ -19,7 +18,6 @@ const agent = new GatewayClient({ url: "http://localhost:3000", deviceId: "agent-001", deviceType: "agent", - metadata: { name: "Test Agent" }, }); // Agent 监听消息 diff --git a/src/hub/agent.ts b/src/hub/agent.ts new file mode 100644 index 00000000..25da2d85 --- /dev/null +++ b/src/hub/agent.ts @@ -0,0 +1,44 @@ +import { v7 as uuidv7 } from "uuid"; +import { Channel } from "./channel.js"; +import type { Message } from "./types.js"; + +/** + * Mock Agent — 本地回环实现,用于测试。 + * write() 将消息放入 channel,read() 从 channel 读取。 + */ +export class Agent { + readonly id: string; + private readonly channel = new Channel(); + private _closed = false; + + constructor(id?: string) { + this.id = id ?? uuidv7(); + } + + get closed(): boolean { + return this._closed; + } + + /** 写入消息到 agent(非阻塞) */ + write(content: string): void { + if (this._closed) { + throw new Error("Agent is closed"); + } + this.channel.send({ + id: uuidv7(), + content: `[mock-agent:${this.id}] echo: ${content}`, + }); + } + + /** 持续读取消息流 */ + read(): AsyncIterable { + return this.channel; + } + + /** 关闭 agent,停止所有读取 */ + close(): void { + if (this._closed) return; + this._closed = true; + this.channel.close(); + } +} diff --git a/src/hub/channel.ts b/src/hub/channel.ts new file mode 100644 index 00000000..266b2ba4 --- /dev/null +++ b/src/hub/channel.ts @@ -0,0 +1,64 @@ +/** + * Go channel 风格的异步可迭代队列。 + * 支持多 writer、单 reader,close 后结束迭代。 + */ +export class Channel implements AsyncIterable { + private buffer: T[] = []; + private closed = false; + + private readers: Array<{ + resolve: (result: IteratorResult) => void; + }> = []; + + get isClosed(): boolean { + return this.closed; + } + + get size(): number { + return this.buffer.length; + } + + /** 发送值到 channel。channel 已关闭时返回 false。 */ + send(value: T): boolean { + if (this.closed) return false; + + const reader = this.readers.shift(); + if (reader) { + reader.resolve({ value, done: false }); + return true; + } + + this.buffer.push(value); + return true; + } + + /** 关闭 channel,唤醒所有等待中的 reader。 */ + close(): void { + if (this.closed) return; + this.closed = true; + + for (const reader of this.readers) { + reader.resolve({ value: undefined as T, done: true }); + } + this.readers = []; + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: (): Promise> => { + if (this.buffer.length > 0) { + const value = this.buffer.shift()!; + return Promise.resolve({ value, done: false }); + } + + if (this.closed) { + return Promise.resolve({ value: undefined as T, done: true }); + } + + return new Promise>((resolve) => { + this.readers.push({ resolve }); + }); + }, + }; + } +} diff --git a/src/hub/device.ts b/src/hub/device.ts new file mode 100644 index 00000000..1b375b1b --- /dev/null +++ b/src/hub/device.ts @@ -0,0 +1,23 @@ +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { v7 as uuidv7 } from "uuid"; + +const MULTICA_DIR = join(homedir(), ".multica"); +const DEVICE_ID_FILE = join(MULTICA_DIR, "device-id"); + +/** + * 获取当前设备的 ID。 + * 首次调用时生成 UUIDv7 并持久化到 ~/.multica/device-id, + * 后续调用直接读取。 + */ +export function getDeviceId(): string { + try { + return readFileSync(DEVICE_ID_FILE, "utf-8").trim(); + } catch { + const id = uuidv7(); + mkdirSync(MULTICA_DIR, { recursive: true }); + writeFileSync(DEVICE_ID_FILE, id, "utf-8"); + return id; + } +} diff --git a/src/hub/hub.ts b/src/hub/hub.ts new file mode 100644 index 00000000..f34c5935 --- /dev/null +++ b/src/hub/hub.ts @@ -0,0 +1,131 @@ +import type { HubOptions } from "./types.js"; +import type { ConnectionState } from "../shared/gateway-sdk/types.js"; +import { Agent } from "./agent.js"; +import { getDeviceId } from "./device.js"; +import { GatewayClient } from "../shared/gateway-sdk/client.js"; + +export class Hub { + private readonly agents = new Map(); + private readonly agentSenders = new Map(); + private readonly client: GatewayClient; + readonly url: string; + readonly path: string; + readonly deviceId: string; + + /** 当前 Gateway 连接状态 */ + get connectionState(): ConnectionState { + return this.client.state; + } + + constructor(url: string, path?: string) { + this.url = url; + this.path = path ?? "/ws"; + this.deviceId = getDeviceId(); + + this.client = new GatewayClient({ + url: this.url, + path: this.path, + deviceId: this.deviceId, + deviceType: "client", + autoReconnect: true, + reconnectDelay: 1000, + }); + + this.client.onStateChange((state) => { + console.log(`[Hub] Connection state: ${state}`); + }); + + this.client.onRegistered((deviceId) => { + console.log(`[Hub] Registered as: ${deviceId}`); + }); + + this.client.onError((err) => { + console.error(`[Hub] Connection error:`, err.message); + }); + + this.client.onMessage((msg) => { + console.log(`[Hub] Received message: id=${msg.id} from=${msg.from} to=${msg.to} action=${msg.action} payload=${JSON.stringify(msg.payload)}`); + const payload = msg.payload as { agentId?: string; content?: string } | undefined; + const agentId = payload?.agentId; + const content = payload?.content; + if (!agentId || !content) { + console.warn(`[Hub] Invalid payload, missing agentId or content`); + return; + } + const agent = this.agents.get(agentId); + if (agent && !agent.closed) { + this.agentSenders.set(agentId, msg.from); + agent.write(content); + } else { + console.warn(`[Hub] Agent not found or closed: ${agentId}`); + } + }); + + this.client.onSendError((err) => { + console.error(`[Hub] Send error: messageId=${err.messageId} code=${err.code} error=${err.error}`); + }); + + this.client.connect(); + } + + /** 创建新 Agent,或用已有 ID 重建 */ + createAgent(id?: string): Agent { + if (id) { + const existing = this.agents.get(id); + if (existing && !existing.closed) { + return existing; + } + } + + const agent = new Agent(id); + this.agents.set(agent.id, agent); + + // 内部消费 agent 产出的消息 + void this.consumeAgent(agent); + + console.log(`Agent created: ${agent.id}`); + return agent; + } + + /** 内部读取 agent 的输出并通过 Gateway 发送 */ + private async consumeAgent(agent: Agent): Promise { + for await (const msg of agent.read()) { + console.log(`[${agent.id}] ${msg.content}`); + const targetDeviceId = this.agentSenders.get(agent.id); + if (targetDeviceId) { + this.client.send(targetDeviceId, "message", { + agentId: agent.id, + content: msg.content, + }); + } + } + } + + getAgent(id: string): Agent | undefined { + return this.agents.get(id); + } + + listAgents(): string[] { + return Array.from(this.agents.entries()) + .filter(([, a]) => !a.closed) + .map(([id]) => id); + } + + closeAgent(id: string): boolean { + const agent = this.agents.get(id); + if (!agent) return false; + agent.close(); + this.agents.delete(id); + this.agentSenders.delete(id); + return true; + } + + shutdown(): void { + for (const [id, agent] of this.agents) { + agent.close(); + this.agents.delete(id); + } + this.client.disconnect(); + console.log("Hub shut down"); + } +} diff --git a/src/hub/index.ts b/src/hub/index.ts new file mode 100644 index 00000000..54adb722 --- /dev/null +++ b/src/hub/index.ts @@ -0,0 +1,5 @@ +export { Channel } from "./channel.js"; +export { Agent } from "./agent.js"; +export { Hub } from "./hub.js"; +export { getDeviceId } from "./device.js"; +export type { Message, HubOptions } from "./types.js"; diff --git a/src/hub/types.ts b/src/hub/types.ts new file mode 100644 index 00000000..780f660b --- /dev/null +++ b/src/hub/types.ts @@ -0,0 +1,11 @@ +export interface Message { + readonly id: string; + readonly content: string; +} + +export interface HubOptions { + /** 远端 Gateway WebSocket 地址,如 "http://localhost:3000" */ + url: string; + /** WebSocket 路径,默认 "/ws" */ + path?: string | undefined; +} diff --git a/src/shared/gateway-sdk/client.ts b/src/shared/gateway-sdk/client.ts index 8c7ae46d..4a126a2d 100644 --- a/src/shared/gateway-sdk/client.ts +++ b/src/shared/gateway-sdk/client.ts @@ -17,7 +17,6 @@ interface ResolvedOptions { path: string; deviceId: string; deviceType: DeviceType; - metadata: Record | undefined; autoReconnect: boolean; reconnectDelay: number; } @@ -38,7 +37,6 @@ export class GatewayClient { path: options.path ?? "/ws", deviceId: options.deviceId, deviceType: options.deviceType, - metadata: options.metadata, autoReconnect: options.autoReconnect ?? true, reconnectDelay: options.reconnectDelay ?? 1000, }; @@ -74,7 +72,7 @@ export class GatewayClient { return this._state === "registered"; } - /** 连接到服务器 */ + /** 连接到服务器,deviceId 和 deviceType 通过 query 传递 */ connect(): this { if (this.socket) { return this; @@ -82,8 +80,14 @@ export class GatewayClient { this.setState("connecting"); + const query: Record = { + deviceId: this.options.deviceId, + deviceType: this.options.deviceType, + }; + this.socket = io(this.options.url, { path: this.options.path, + query, reconnection: this.options.autoReconnect, reconnectionDelay: this.options.reconnectDelay, }); @@ -204,24 +208,13 @@ export class GatewayClient { return uuidv7(); } - private register(): void { - if (!this.socket) return; - - this.socket.emit(GatewayEvents.REGISTER, { - deviceId: this.options.deviceId, - deviceType: this.options.deviceType, - metadata: this.options.metadata, - }); - } - private setupListeners(): void { if (!this.socket) return; this.socket.on("connect", () => { this.setState("connected"); this.callbacks.onConnect?.(this.socket!.id!); - // 连接后自动注册 - this.register(); + // 服务端在连接时从 query 自动注册,等待 registered 事件即可 }); this.socket.on("disconnect", (reason: string) => { diff --git a/src/shared/gateway-sdk/index.ts b/src/shared/gateway-sdk/index.ts index e169800b..e1b696f2 100644 --- a/src/shared/gateway-sdk/index.ts +++ b/src/shared/gateway-sdk/index.ts @@ -3,7 +3,6 @@ export { GatewayEvents, type DeviceType, type DeviceInfo, - type RegisterPayload, type RegisteredResponse, type RoutedMessage, type SendErrorResponse, diff --git a/src/shared/gateway-sdk/types.ts b/src/shared/gateway-sdk/types.ts index 92f20434..352e4060 100644 --- a/src/shared/gateway-sdk/types.ts +++ b/src/shared/gateway-sdk/types.ts @@ -3,7 +3,6 @@ export const GatewayEvents = { // 系统事件 PING: "ping", PONG: "pong", - REGISTER: "register", REGISTERED: "registered", // 消息路由 @@ -21,14 +20,6 @@ export type DeviceType = "client" | "agent"; export interface DeviceInfo { deviceId: string; deviceType: DeviceType; - metadata?: Record | undefined; -} - -/** 注册请求 */ -export interface RegisterPayload { - deviceId: string; - deviceType: DeviceType; - metadata?: Record; } /** 注册响应 */ @@ -88,8 +79,6 @@ export interface GatewayClientOptions { deviceId: string; /** 设备类型 */ deviceType: DeviceType; - /** 设备元数据 */ - metadata?: Record | undefined; /** 自动重连,默认 true */ autoReconnect?: boolean | undefined; /** 重连延迟(毫秒),默认 1000 */ @@ -115,16 +104,3 @@ export interface GatewayClientCallbacks { onStateChange?: (state: ConnectionState) => void; } -// ============ 兼容旧API(可删除) ============ - -/** @deprecated 使用 RoutedMessage */ -export interface SendMessagePayload { - text: string; -} - -/** @deprecated 使用 RoutedMessage */ -export interface BroadcastMessage { - from: string; - text: string; - timestamp: string; -}