The chat application is the very common example that used to show simple real-time communication between client and server. This tutorial describes how it can be easily done with Odi, TypeScript server-side framework for Node.js.
We are going to develop the application that not only establishes the real-time communication channel but also renders frontend to the client, including required assets.
Let’s set up the Odi project. First of all, we should initialize package.json
and tsconfig.json
files. We can do it with two simple commands.
npm init -ytsc --init
And install Odi.
npm install odi
Also, we need to modify tsconfig.json
file, as there are few options that must be edited. Odi actively uses decorators and metadata, so we need to enable these features.
"experimentalDecorators": true,"emitDecoratorMetadata": true
Another thing is target
option. By default, it set toes5
but there are several things that are not supported in is this specification. As we are progressive, let’s set it to the latest version
"target": "ES2018"
We are going to have different folders for views, assets and server source code.
JSX templates are tsx files that must be compiled. Add views folder to rootDirs in tsconfig.json
file and setup outDir.
"outDir": "./build","rootDirs": ["./src", "./views"]
Odi is based on the Dependency Injection pattern, so every application component will be automatically imported, instantiated and injected.
Only folder with source files must be specified, then Odi can scan it for importing application components (Controllers, Services, Repositories and etc).
Create index.ts
file in src
folder. It will be the server entry point file.
import { Core } from "odi";import { join } from "path";
new Core({sources: __dirname,server: {port: 8080,socket: true,static: {root: join(__dirname, '../../assets'),prefix: '/assets'}}}).listen(() => console.log("Server successfully started!"));
We just need to instantiate Core
class. Core
constructor accepts a single argument, settings object. There are a lot of possible options, but for now, we need only several of them.
First of all, we need to specify sources
property. It’s required setting for Odi application. As index.ts
file in src
folder, which we choose for server-side code, we can use __dirname
to set current directory.
port
property is also required. It binds the server on the specified port.
Now about the following part:
socket: true,static: {root: join(__dirname, '../../assets'),prefix: '/assets'}
We must enable sockets and set options for serving static files All files from the assets folder are available by URL with /assets
prefix.
Odi framework automatically includes only several packages that are required. All other dependencies for different features are optional, so they need to be installed only if you use a certain feature.
For example, if you are going to build a simple REST server, you don’t need GraphQL, WebSockets, SSR and other packages.
We want to have WebSockets and Templating (JSX) in our chat application. So, let’s install missing packages:
npm install socket.io react react-dom
That’s all, Odi will automatically import it. As you can see, socket.io is used under the hood for real-time functionality. Also React packages is required for templates processing.
Now we can start writing our code :)
We are going to create a web server, that renders HTML to the client, using templates, serves files for the client (JS, CSS) and set up a real-time communication channel using WebSockets for chat. Let’s add history to our chat. So, the last 10 messages will be saved in our system.
Message
will be pretty simple, only username
and text
fields. We can do it with a simple interface, as we are not going to use a database.
export interface Message {username: string;text: string;}
And history service
@Service()export default class HistoryService {private store: Message[] = [];
getMessages() {
return this.store;
}
addMessage(message: Message) {
if(this.store.length > 10)
this.store.shift();
this.store.push(message);
}
}
Our store is a simple array of messages. And few methods for store management. If we get more than 10 messages, we simply remove the first message from the array.
As you can see, Service
decorator was used for HistoryService
class to set is as a service component. Service is singleton in Dependency Injection Container. Now it can be injected into others application components.
Put all this code in history.ts
file in src/services
folder.
Create chat.socket.ts
file in the src/sockets
directory with the following code.
import { Socket, OnEvent, ISocket, Autowired } from "odi";import HistoryService, { Message } from "../services/history";
@Socket('chat')export default class ChatSocket extends ISocket {
@Autowired()
history: HistoryService;
@OnEvent('massage:send')
onmessage(message: Message) {
this.history.addMessage(message);
this.emit('message:new', message);
}
}
We defined /chat
namespace with handler for message:send
event. If message:send
event is fired, all clients that connected to this namespace will be notified with message:new
event and message data.
As you can notice Socket
decorator defines namespaces. Leading slash is not required. To set up method as the handler for certain event, use OnEvent
decorator, that accepts event name as the argument.
Also, we injected HistoryService
using Autowired
decorator. history
field of ChatSocket
class will be initialized by Odi, so you don’t need to do anything additional.
The only thing, you can see such error from TypeScript
[ts] Property 'history' has no initializer and is not definitely assigned in the constructor.
Odi automatically initializes injected fields, so just disable this check in tsconfig.json
"strictPropertyInitialization": false
There a lot of templating processors — EJS, Jade, Pug. But there are a lot of limitations and inconveniences with those technologies. In most cases, to have IntelliSense and code highlight for templates, you need to install an extension for IDE/Editor.
In Odi, JSX powered by React is used for templating. You can simply create components with JSX. But remember, it’s only for templates, any logic, listeners or client-side code will be ignored during rendering. (Currently, we are working on full SSR. Hope it will be released soon)
We need to tell TypeScript compiler, that we are going to use React JSX.In tsconfig.json
..."jsx": "react"
Let’s create our layout component layout.view.tsx
that will be a wrapper for all pages. As was mentioned above, all templates will be in views folder.
import React, { SFC } from 'react';
export const Html: SFC = ({ children }) => (<html lang="en"><head><meta charSet="UTF-8" /><meta name="viewport" /><meta httpEquiv="X-UA-Compatible" content="ie=edge"/><link href="/assets/index.css" type="text/css" ... /><title> Simple chat </title></head><body>{children}</body>
<script src="path/to/socket.io" />
<script src="/assets/index.js" />
</html>
)
For socket.io-client library we can use CDN. So simply replace path/to/socket.io
in the script tag with the following link https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.2.0/socket.io.js
Client js file was specified in the second script tag. We will create it a little bit later in assets folder.
Actually, we need 3 components for our chat:
I think we can put all these components in 1 file, chat.view.tsx
import React from 'react';import { Message } from './services/history.service';
export const ChatMessage = ({ username, text }: Message) => (<div><b>{username}: </b><span>{text}</span></div>)
We can use Message
interface as props type for ChatMessage
component.
Let’s add chat controls. id
attribute was used for convenience, as we are going to use js on the client side without any libs or frameworks.
export const ChatControlls = () => (<div className="message-box"><input placeholder="User" id="user-input" /><input placeholder="Message" id="message-input" /><button> Send </button></div>)
And the last thing, chat wrapper.
interface ChatProps {messages: Message[];}
export const Chat = ({ messages }: ChatProps) => (<div className="chat"><div className="container">{messages.map(msg,i) => <ChatMessage key={i} {...msg}/> )}</div><ChatControlls /></div>)
This component accepts an array of messages (our history) in props to render it on page load.
Now we can put everything together and define our page component page.view.tsx
import React from 'react';import { Chat } from './chat.view';import { Html } from './layout.view';import { Message } from './services/history.service';
interface ChatPageProps {history: Message[];}
export const ChatPage = ({ history }: ChatPageProps) => (<Html><Chat messages={history} /></Html>)
That’s all about templating for our chat application. I have several lines of CSS that I will include it in the source code, that you can find at the end of the article.
We can move to controllers.
Controllers serve as a simple yet powerful routing mechanism. Controller methods are mapped to web server paths. The value returned by the method is sent as the response.
In order to create a Controller, you must use the @Controller
decorator and inherit the IController
class. The decorator sets the component type, so the DI (dependency injection) container can detect what the class will be used for.
For our chat, we need only one controller to render a template to the client. As we are going to use JSX inside the controller file, it must have tsx
file extension. So, let’s create render.controller.tsx
in src/controllers
folder.
import React from 'react';import { Controller, IController, Get, Autowired } from "odi";import { ChatPage } from '../../views/page.view';import HistoryService from '../services/history.service';
@Controller()export default class RenderController extends IController {
@Autowired()
history: HistoryService;
@Get index() {
return <ChatPage history={this.history.getMessages()}/>;
}
}
As you can see, we injected our HistoryService
into history
property. Also, the handler for /
path with Get
method was defined. We can simply return our JSX component as a result, Odi automatically detects that it’s a template and renders it as simple HTML for the client (web browser).
Now, we can start our application and see what we got. Let’s specify start
script in package.json
file:
"scripts": {"start": "tsc && node build/src/index.js"}
Running npm start
command compile our source code and run server entry file.
Let’s open the browser and check localhost:8080
As you can see, we have just empty chat without any functionality, as we did not specify the client index.js
into assets folder.
First of all, let’s get references for chat container and controls.
const button = document.querySelector('button');
const messageInput = document.querySelector('#message-input');const usernameInput = document.querySelector('#user-input');const container = document.querySelector('.container');
When a new message comes, we need to append it as a child in container
element. We need the function for creating elements that represent messages.
function createMessage({ username, text }) {const element = document.createElement('div');
element.innerHTML = \`
<b>${username}: </b>
<span>${text}</span>
\`;
return element;
}
Then, let’s connect to our chat
namespace and add the event handler for message:new
event. When this event is fired, the message element will be appended to the container.
const socket = io('/chat');socket.on('message:new', message => {const messageElement = createMessage(message);container.appendChild(messageElement);});
And the last step, onclinck
handler for our button.
button.onclick = () => {socket.emit('massage:send', {text: messageInput.value,username: usernameInput.value});
messageInput.value = "";
}
We are collecting data from inputs and sending it as message:send
event. Also, the message input text will be cleared after every send.
Now we can refresh the page, and see what we have got.
After refreshing the page, we will have history our messaging.
You can check the source code and interact with the application right here:
Thanks for the reading! If you like Odi, please support us with a simple start on GitHub Odi
Also, if you are looking for more information, you can check previous articles and docs:
If you have any ideas or questions, feel free to leave them! Thanks a lot! :)