WebSocket is the internet protocol that allows for full-duplex communication between a server and clients. This protocol goes beyond the typical HTTP request and response paradigm. With WebSockets, the server may send data to a client without the client initiating a request, thus allowing for some very interesting applications.
In this tutorial, you will build a real-time document collaboration application (similar to Google Docs). We’ll be using the Socket.IO Node.js server framework and Angular 7 to accomplish this.
You can find the complete source code for this example project on GitHub.
To complete this tutorial, you will need:
- Node.js installed locally, which you can do by following How to Install Node.js and Create a Local Development Environment.
- A modern web browser that supports WebSocket.
This tutorial was originally written in an environment consisting of Node.js v8.11.4, npm v6.4.1, and Angular v7.0.4.
This tutorial was verified with Node v14.6.0, npm v6.14.7, Angular v10.0.5, and Socket.IO v2.3.0.
Step 1 — Setting Up the Project Directory and Creating the Socket Server
First, open your terminal and create a new project directory that will hold both our server and client code:
mkdir socket-example
Next, change into the project directory:
cd socket-example
Then, create a new directory for the server code:
mkdir socket-server
Next, change into the server directory.
cd socket-server
Then, initialize a new npm project:
npm init -y
Now, we will install our package dependencies:
npm install [email protected] [email protected] @types/[email protected] --save
These packages include Express, Socket.IO, and @types/socket.io.
Now that you have completed setting up the project, you can move on to writing code for the server.
First, create a new src directory:
mkdir src
Now, create a new file called app.js in the src directory, and open it using your favorite text editor:
nano src/app.js
Start with the require statements for Express and Socket.IO:
socket-server/src/app.js
const app = require('express')();
const http = require('http').Server(app);
const io = require('socket.io')(http);
As you can tell, we’re using Express and Socket.IO to set up our server. Socket.IO provides a layer of abstraction over
native WebSockets. It comes with some nice features, such as a fallback mechanism for older browsers that do not support
WebSockets, and the ability to create rooms. We’ll see this in action in a minute.
For the purposes of our real-time document collaboration application, we will need a way to store documents. In a production setting, you would want to use a database, but for the scope of this tutorial, we will use an in-memory store
of documents:
socket-server/src/app.js
const documents = {};
Now, let’s define what we want our socket server to actually do:
socket-server/src/app.js
io.on("connection", socket => {
// ...
});
Let’s break this down. .on('...') is an event listener. The first parameter is the name of the event, and the second one
is usually a callback executed when the event fires, with the event payload.
The first example we see is when a client connects to the socket server (connection is a reserved event type in Socket.IO).
We get a socket variable to pass to our callback to initiate communication to either that one socket or to multiple sockets (i.e., broadcasting).
safeJoin
We will set up a local function (safeJoin) that takes care of joining and leaving rooms:
socket-server/src/app.js
io.on("connection", socket => {
let previousId;
const safeJoin = currentId => {
socket.leave(previousId);
socket.join(currentId, () => console.log(`Socket ${socket.id} joined room ${currentId}`));
previousId = currentId;
};
// ...
});
In this case, when a client has joined a room, they are editing a particular document. So if multiple clients are in the same room, they are all editing the same document.
Technically, a socket can be in multiple rooms, but we don’t want to let one client edit multiple documents at the same time, so if they switch documents, we need to leave the previous room and join the new room. This little function takes care of that.
There are three event types that our socket is listening for from the client:
getDoc
addDoc
editDoc
And two event types that are emitted by our socket to the client:
document
documents
getDoc
Let’s work on the first event type - getDoc:
socket-server/src/app.js
io.on("connection", socket => {
// ...
socket.on("getDoc", docId => {
(docId);
socket.emit("document", documents[docId]);
});
// ...
});
When the client emits the getDoc event, the socket is going to take the payload (in our case, it’s just an id), join a room with that docId, and emit the stored document back to the initiating client only. That’s where socket.emit('document', ...) comes into play.
addDoc
Let’s work on the second event type - addDoc:
socket-server/src/app.js
io.on("connection", socket => {
// ...
socket.on("addDoc", doc => {
documents[doc.id] = doc;
safeJoin(doc.id);
io.emit("documents", Object.keys(documents));
socket.emit("document", doc);
});
// ...
});
With the addDoc event, the payload is a document object, which, at the moment, consists only of an id generated by the client. We tell our socket to join the room of that ID so that any future edits can be broadcast to anyone in the same room.
Next, we want everyone connected to our server to know that there is a new document to work with, so we broadcast to all clients with the io.emit('documents', ...) function.
Note the difference between socket.emit() and io.emit() - the socket version is for emitting back to only initiating the client, the io version is for emitting to everyone connected to our server.
editDoc
Let’s work on the third event type - editDoc:
socket-server/src/app.js
io.on("connection", socket => {
// ...
socket.on("editDoc", doc => {
documents[doc.id] = doc;
socket.to(doc.id).emit("document", doc);
});
// ...
});
With the editDoc event, the payload will be the whole document at its state after any keystroke. We’ll replace the existing document in the database and then broadcast the new document to only the clients that are currently viewing that document. We do this by calling socket.to(doc.id).emit(document, doc), which emits to all sockets in that particular room.
Finally, whenever a new connection is made, we broadcast to all the clients to ensure the new connection receives the latest document changes when they connect:
socket-server/src/app.js
io.on("connection", socket => {
// ...
io.emit("documents", Object.keys(documents));
console.log(`Socket ${socket.id} has connected`);
});
After the socket functions are all set up, pick a port and listen on it:
socket-server/src/app.js
http.listen(4444, () => {
console.log('Listening on port 4444');
});
Run the following command in your terminal to start the server:
node src/app.js
We now have a fully-functioning socket server for document collaboration!
Step 2 — Installing @angular/cli and Creating the Client App
Open a new terminal window and navigate to the project directory.
Run the following commands to install the Angular CLI as a devDependency:
npm install @angular/[email protected] --save-dev
Now, use the @angular/cli command to create a new Angular project, with no Angular Routing and with SCSS for styling:
ng new socket-app --routing=false --style=scss
Then, change into the server directory:
cd socket-app
Now, we will install our package dependencies:
npm install [email protected] --save
ngx-socket-io is an Angular wrapper over Socket.IO client libraries.
Then, use the @angular/cli command to generate a document model, a document-list component, a document component, and a document service:
ng generate class models/document --type=model
ng generate component components/document-list
ng generate component components/document
ng generate service services/document
Now that you have completed setting up the project, you can move on to writing code for the client.
App Module
Open app.modules.ts:
nano src/app/app.module.ts
And import FormsModule, SocketioModule, and SocketioConfig:
socket-app/src/app/app.module.ts
// ... other imports
import { FormsModule } from '@angular/forms';
import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io';
And before your @NgModule declaration, define config:
socket-app/src/app/app.module.ts
const config: SocketIoConfig = { url: 'http://localhost:4444', options: {} };
You’ll notice that this is the port number that we declared earlier in the server’s app.js.
Now, add to your imports array, so it looks like:
socket-app/src/app/app.module.ts
@NgModule({
// ...
imports: [
// ...
FormsModule,
SocketIoModule.forRoot(config)
],
// ...
})
This will fire off the connection to our socket server as soon as AppModule loads.
Document Model and Document Service
Open document.model.ts:
nano src/app/models/document.model.ts
And define id and doc:
socket-app/src/app/models/document.model.ts
export class Document
export class Document {
id: string;
doc: string;
}
Open document.service.ts
nano src/app/services/document.service.ts
And add the following in the class definition:
socket-app/src/app/services/document.service.ts
import { Injectable } from '@angular/core';
import { Socket } from 'ngx-socket-io';
import { Document } from 'src/app/models/document.model';
@Injectable({
providedIn: 'root'
})
export class DocumentService {
currentDocument = this.socket.fromEvent
documents = this.socket.fromEvent
constructor(private socket: Socket) { }
getDocument(id: string) {
this.socket.emit('getDoc', id);
}
newDocument() {
this.socket.emit('addDoc', { id: this.docId(), doc: '' });
}
editDocument(document: Document) {
this.socket.emit('editDoc', document);
}
private docId() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) { text +=possible.charAt(Math.floor(Math.random() * possible.length)); } return text; } }
The methods here represent each emit the three event types that the socket server is listening for. The properties
currentDocument and documents represent the events emitted by the socket server, which is consumed on the client as
an Observable. You may notice a call to this.docId(). This is a little private method that generates a random string
to assign as the document id. Document List Component Let’s put the list of documents in a sidenav. Right now, it’s
only showing the docId - a random string of characters. Open document-list.component.html: nano
src/app/components/document-list/document-list.component.html And replace the contents with the following:
socket-app/src/app/components/document-list/document-list.component.html
Open document-list.component.scss:
nano src/app/components/document-list/document-list.component.scss
And add some styles:
socket-app/src/app/components/document-list/document-list.component.scss
.sidenav {
background-color: #111111;
height: 100%;
left: 0;
overflow-x: hidden;
padding-top: 20px;
position: fixed;
top: 0;
width: 220px;
span {
color: #818181;
display: block;
font-family: 'Roboto', Tahoma, Geneva, Verdana, sans-serif;
font-size: 25px;
padding: 6px 8px 6px 16px;
text-decoration: none;
&.selected {
color: #e1e1e1;
}
&:hover {
color: #f1f1f1;
cursor: pointer;
}
}
}
Open document-list.component.ts:
nano src/app/components/document-list/document-list.component.ts
And add the following in the class definition:
socket-app/src/app/components/document-list/document-list.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { DocumentService } from 'src/app/services/document.service';
@Component({
selector: 'app-document-list',
templateUrl: './document-list.component.html',
styleUrls: ['./document-list.component.scss']
})
export class DocumentListComponent implements OnInit, OnDestroy {
documents: Observable
currentDoc: string;
private _docSub: Subscription;
constructor(private documentService: DocumentService) { }
ngOnInit() {
this.documents = this.documentService.documents;
this._docSub = this.documentService.currentDocument.subscribe(doc => this.currentDoc = doc.id);
}
ngOnDestroy() {
this._docSub.unsubscribe();
}
loadDoc(id: string) {
this.documentService.getDocument(id);
}
newDoc() {
this.documentService.newDocument();
}
}
Let’s start with the properties. documents will be a stream of all available documents. currentDocId is the id of the currently selected document. The document list needs to know what document we’re on, so we can highlight that doc id in the sidenav. _docSub is a reference to the Subscription that gives us the current or selected doc. We need this so we can unsubscribe in the ngOnDestroy lifecycle method.
You’ll notice the methods loadDoc() and newDoc() don’t return or assign anything. Remember, these fire off events to the socket server, which turns around and fires an event back to our Observables. The returned values for getting an existing document or adding a new document are realized from the Observable patterns above.
Document Component
This will be the document editing surface.
Open document.component.html:
nano src/app/components/document/document.component.html
And replace the contents with the following:
socket-app/src/app/components/document/document.component.html
Open document.component.scss:
nano src/app/components/document/document.component.scss
And change some styles on the default HTML textarea:
socket-app/src/app/components/document/document.component.scss
textarea {
border: none;
font-size: 18pt;
height: 100%;
padding: 20px 0 20px 15px;
position: fixed;
resize: none;
right: 0;
top: 0;
width: calc(100% - 235px);
}
Open document.component.ts:
src/app/components/document/document.component.ts
And add the following in the class definition:
socket-app/src/app/components/document/document.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { startWith } from 'rxjs/operators';
import { Document } from 'src/app/models/document.model';
import { DocumentService } from 'src/app/services/document.service';
@Component({
selector: 'app-document',
templateUrl: './document.component.html',
styleUrls: ['./document.component.scss']
})
export class DocumentComponent implements OnInit, OnDestroy {
document: Document;
private _docSub: Subscription;
constructor(private documentService: DocumentService) { }
ngOnInit() {
this._docSub = this.documentService.currentDocument.pipe(
startWith({ id: '', doc: 'Select an existing document or create a new one to get started' })
).subscribe(document => this.document = document);
}
ngOnDestroy() {
this._docSub.unsubscribe();
}
editDoc() {
this.documentService.editDocument(this.document);
}
}
Similar to the pattern we used in the DocumentListComponent above, we’re going to subscribe to the changes for our current document, and fire off an event to the socket server whenever we change the current document. This means that we will see all the changes if any other client is editing the same document we are, and vice versa. We use the RxJS startWith operator to give a little message to our users when they first open the app.
AppComponent
Open app.component.html:
nano src/app.component.html
And compose the two custom components by replacing the contents with the following:
socket-app/src/app.component.html
Step 3 — Viewing the App in Action
With our socket server still running in a terminal window, let’s open a new terminal window and start our Angular app: ng serve
Open more than one instance of http://localhost:4200 in separate browser tabs and see it in action.
Real-time Document Collaboration app with Angular and Socket.IO
Now, you can create new documents and see them update in both browser windows. You can make a change in one browser window and see the change reflected in the other browser window.