DEV Community

Cover image for Build a Type-Safe Live Chat with Inversify and SSE
notaphplover
notaphplover

Posted on

Build a Type-Safe Live Chat with Inversify and SSE

Real-time applications are everywhere. From live support chats to stock tickers, users expect instant updates. While WebSockets are often the go-to solution, Server-Sent Events (SSE) provide a simpler, lighter alternative for unidirectional (server-to-client) data flow.

In this tutorial, we'll build a multi-channel live chat API using InversifyJS for dependency injection and Hono as our high-performance web framework.

💡 Get the Code: The complete source code for this example is available in the InversifyJS Framework Examples repository.


Why this stack?

  • InversifyJS: Provides powerful Dependency Injection (DI) to keep your services and controllers decoupled and testable.
  • Hono: A small, fast, and web-standards based web framework that runs anywhere (Node.js, Bun, Deno, Cloudflare Workers).
  • SSE: Native browser support, automatic reconnection, and no need for heavy WebSocket protocols for simple broadcast scenarios.

1. Project Setup

First, let's install the necessary dependencies. We need inversify for DI, hono for the server, and the Inversify adapters.

npm install hono inversify reflect-metadata @inversifyjs/http-hono @inversifyjs/http-sse @hono/node-server
Enter fullscreen mode Exit fullscreen mode

Note: Make sure you have experimentalDecorators and emitDecoratorMetadata enabled in your tsconfig.json.

2. Define the Typed Message

We start by defining what our chat message looks like. Using TypeScript interfaces ensures type safety throughout our app.

// src/types/ChatMessage.ts
export interface ChatMessage {
  content: string;
  sender: string;
  timestamp?: string;
}
Enter fullscreen mode Exit fullscreen mode

3. The Channel Service

The heart of our real-time logic. This service manages active SSE connections (streams) for each chat channel.

// src/services/ChannelService.ts
import { injectable } from 'inversify';
import { SseStream } from '@inversifyjs/http-sse';
import { ChatMessage } from '../types/ChatMessage.js';

@injectable()
export class ChannelService {
  // Store active streams per channel
  private readonly channels: Map<string, Set<SseStream>> = new Map();

  public subscribe(channelId: string): SseStream {
    if (!this.channels.has(channelId)) {
      this.channels.set(channelId, new Set());
    }

    const streams = this.channels.get(channelId);
    if (!streams) {
      throw new Error(`Channel ${channelId} not found`);
    }

    const stream = new SseStream();
    streams.add(stream);

    // Cleanup on disconnect
    stream.on('close', () => {
      streams.delete(stream);
      if (streams.size === 0) {
        this.channels.delete(channelId);
      }
    });

    return stream;
  }

  public async publish(channelId: string, message: ChatMessage): Promise<void> {
    const streams = this.channels.get(channelId);
    if (!streams) return;

    // Broadcast message to all connected clients in the channel
    const promises = Array.from(streams).map(async (stream) =>
      stream.writeMessageEvent({
        data: JSON.stringify(message),
        type: 'message',
      }),
    );

    await Promise.all(promises);
  }
}
Enter fullscreen mode Exit fullscreen mode

4. The Controller

We use Inversify's @Controller to define our API endpoints.

  • GET /channels/:channelId/messages: Subscribes a client to the channel using @SsePublisher.
  • POST /channels/:channelId/messages: Publishes a new message.
// src/controllers/ChannelController.ts
import { Controller, Get, Post, Body, Params } from '@inversifyjs/http-core';
import {
  SsePublisher,
  SsePublisherOptions,
  SseStream,
} from '@inversifyjs/http-sse';
import { inject } from 'inversify';
import { ChannelService } from '../services/ChannelService.js';
import { ChatMessage } from '../types/ChatMessage.js';

@Controller('/channels')
export class ChannelController {
  constructor(
    @inject(ChannelService) private readonly channelService: ChannelService,
  ) {}

  @Get('/:channelId/messages')
  public subscribe(
    @Params('channelId') channelId: string,
    @SsePublisher() publisher: (options: SsePublisherOptions) => unknown,
  ): unknown {
    const stream: SseStream = this.channelService.subscribe(channelId);
    // Return the stream wrapped in the publisher
    return publisher({ events: stream });
  }

  @Post('/:channelId/messages')
  public async publish(
    @Params('channelId') channelId: string,
    @Body() body: ChatMessage,
  ): Promise<void> {
    await this.channelService.publish(channelId, body);
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Wiring It All Together

Finally, we set up the Inversify container and the Hono adapter.

// src/index.ts
import 'reflect-metadata';
import { Container } from 'inversify';
import { InversifyHonoHttpAdapter } from '@inversifyjs/http-hono';
import { serve } from '@hono/node-server';
import { ChannelService } from './services/ChannelService.js';
import { ChannelController } from './controllers/ChannelController.js';

async function main() {
  const container = new Container();

  // Bind dependencies
  container.bind(ChannelService).toSelf().inSingletonScope();
  container.bind(ChannelController).toSelf();

  // Create the Hono App with Inversify
  const adapter = new InversifyHonoHttpAdapter(container);
  const app = await adapter.build();

  console.log('Server started on http://localhost:3000');

  serve({
    fetch: app.fetch,
    port: 3000,
  });
}

await main();
Enter fullscreen mode Exit fullscreen mode

Conclusion

With just a few files, we've created a scalable, type-safe architecture for real-time communication. The combination of Hono's speed and InversifyJS's structure makes it easy to build and maintain complex applications.

Check out the full InversifyJS framework Documentation for more examples!

Top comments (0)