Monitoring the Progress of an HTTP Request in NestJS via WebSockets.
Vyacheslav Chub
Posted on February 22, 2023
A couple of days ago, I faced an issue with Monitoring the Progress of an HTTP Request. According to old developers' tradition, firstly, I asked Google for it. I anticipated getting a bunch of different answers and choosing an appropriate one. This time my instincts failed me. Even though I got a bunch of similar solutions, I didn't find the appropriate example. It is worth clarifying that I'm working on a NestJS-based project. Let me explain why I decided to create my solution from scratch and why most of the existing solutions on the topic need to be revised in my case.
First, I want to share the article that describes a bunch of the results above as well as possible. Let me provide essential thoughts on the article.
- The article describes the request that provides content downloading. In this case, we are talking about the precious content size.
- The
Content-Length
HTTP header is essential to the correct HTTP response. - After the server application sets
Content-Length,
chunked data writing process should be run. - First, the client application gets
Content-Length.
- After that, it gets every data chunk and calculates the progress as the following.
progress = 100 * (chunkSize / contentLength)
The approach above is beneficial if we are talking about content downloading. Despite it doesn't work in my case due to the following reasons.
- My task is about something other than content downloading. Moreover, we need to have a functionality that allows us to calculate the progress according to calculations, not only according to data transfer.
- Despite the application not knowing the content size, it has a total number of iterations.
- Chunk-based approach doesn't work in this case. The final result preparation will take a long time, and the output data should be written to the response simultaneously. That's why we need to inform the client before sending a response.
In other words, the requirements for the new approach are the following.
- The response writing goes simultaneously without any data chunking.
- The progress should be provided before that.
I don't want to waste your time and give a couple of conceptual points of my approach regarding accounting the requirements above.
- Provide the progress via WebSockets because of persistent connection and high performance.
- Connect WebSockets with a current session to pass all needed data from the HTTP request processing process.
All thoughts and code below will be strongly connected to these points. But before, let me share the final solution: https://github.com/buchslava/nest-request-progress
Data Providing Example
I provided a simplified version of the data processing because I want to focus on this task. We have 150 iterations in the example below. The result is an array of 150 random numbers, each calculates in 100 - 1000 milliseconds. I found this example as a minimally viable model of the objective process.
import { Injectable } from '@nestjs/common';
const getRandomArbitrary = (min: number, max: number): number =>
Math.random() * (max - min) + min;
const delay = (time: number) =>
new Promise((resolve) => setTimeout(resolve, time));
@Injectable()
export class AppService {
getIterationCount(): number {
return 150;
}
async getData(token: string): Promise<string[]> {
return new Promise(async (resolve, reject) => {
try {
const result = [];
for (let i = 0; i < this.getIterationCount(); i++) {
result.push(getRandomArbitrary(1, 9999));
await delay(getRandomArbitrary(100, 1000));
}
resolve(result);
} catch (e) {
reject(e);
}
});
}
}
Progress Manager
The future steps are regarding the ProgressManager
implementation.
The ProgressManager
should be a separate NestJS service able to do the following.
- Start the "Progress" session (Not the HTTP session) with the unique token taken from the client application.
- Stop the "Progress" session
- Increase the value of the progress.
Please look at the following commented code.
import { Injectable } from '@nestjs/common';
import { Server } from 'socket.io';
export interface ProgressSession {
token: string;
total: number;
counter: number;
timerId: any;
}
@Injectable()
export class ProgressManager {
// The Socket Server injection will be described later
public server: Server;
// This map contains all Progress session data
private storage: Map<string, ProgressSession> = new Map();
// Start the session with the token and the total number of iterations
startSession(token: string, total: number, delay = 2000) {
// Get current session from the storage
const currentSession = this.storage.get(token);
// Do nothing if it's already exist
if (currentSession) {
return;
}
// Send the progress every "delay" milliseconds
const timerId = setInterval(async () => {
const currentSession: ProgressSession = this.storage.get(token);
// Protect the functionality: if the current session is missing then do nothing
if (!currentSession) {
return;
}
// Calculate the progress
let progress = Math.ceil(
(currentSession.counter / currentSession.total) * 100
);
// Protect the progress value, it should be less or equal 100
if (progress > 100) {
progress = 100;
}
// Send the progress. Pay attention that the event name should contain the "token"
// Client will use this token also
this.server.emit(`progress-${token}`, progress);
}, delay);
// Initial Progress Session settings. Token is a key.
this.storage.set(token, {
token,
total,
counter: 0,
timerId,
});
}
// This method increases the progress
step(token: string, value = 1) {
// Get the current session
const currentSession: ProgressSession = this.storage.get(token);
// Do nothing if it doesn't exist
if (!currentSession) {
return;
}
// Increase the counter
const counter = currentSession.counter + value;
// Update the storage
this.storage.set(token, {
...currentSession,
counter,
});
}
// Stop the session by the token
stopSession(token: string) {
// Get the current session
const currentSession: ProgressSession = this.storage.get(token);
// Do nothing if it doesn't exist
if (currentSession) {
// Stop the current timer
clearInterval(currentSession.timerId);
// Remove information regarding the current session from the storage
this.storage.delete(token);
}
}
}
You can find the code above here.
WebSockets Server
Another important is the integration of NestJS with WebSockets and connecting the Progress Manager with it. The following code is responsible for that.
import {
WebSocketGateway,
WebSocketServer,
OnGatewayInit,
} from '@nestjs/websockets';
import { Server } from 'socket.io';
import { ProgressManager } from './progress-manager';
@WebSocketGateway({ cors: true })
export class AppGateway implements OnGatewayInit {
constructor(private progressManager: ProgressManager) {}
@WebSocketServer() server: Server;
afterInit() {
// After the WebSockets Gateway has to init, then pass it to the ProgressManager
this.progressManager.server = this.server;
}
}
And, of course, according to NestJS requirements, we need to tell the related module about that.
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AppGateway } from './app.gateway';
import { ProgressManager } from './progress-manager';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService, AppGateway, ProgressManager],
})
export class AppModule {}
Data Processing
It's time to focus on the endpoint's controller. It looks pretty simple.
import { Controller, Get, Query } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getData(@Query() query: { token: string }) {
return this.appService.getData(query.token);
}
}
And the last thing about the server is regarding the Data Providing Example modification. The following example is close to the first example in this article. The main aim is to add "Progress functionality" here. Please, read the comment in the code. It's important.
import { Injectable } from '@nestjs/common';
import { ProgressManager } from './progress-manager';
const getRandomArbitrary = (min: number, max: number): number =>
Math.random() * (max - min) + min;
const delay = (time: number) =>
new Promise((resolve) => setTimeout(resolve, time));
@Injectable()
export class AppService {
// Use progressManager
constructor(private readonly progressManager: ProgressManager) {}
// 150 iterations should be processed
getIterationCount(): number {
return 150;
}
async getData(token: string): Promise<string[]> {
return new Promise(async (resolve, reject) => {
// We need to start the Progress Session before data preparation
this.progressManager.startSession(token, this.getIterationCount());
try {
// Initialize the array of results
const result = [];
for (let i = 0; i < this.getIterationCount(); i++) {
// Calculate the result
result.push(getRandomArbitrary(1, 9999));
// Increase the Progress counter
this.progressManager.step(token);
// Random delay
await delay(getRandomArbitrary(100, 1000));
}
// Return the result
resolve(result);
} catch (e) {
reject(e);
} finally {
// We need to stop the ProgressManager in any case.
// Otherwise, we have a redundant timeout.
this.progressManager.stopSession(token);
}
});
}
}
The backend part of my example is ready. You can find the full backend solution here.
The Client
The client part of my example is placed here. Both parts are placed in one monorepo. Thanks Nx for that. Lets look at it. Please, read the comments in the code below.
import * as io from 'socket.io-client';
import { v4 } from 'uuid';
import axios from 'axios';
// Generate a unique ID (token)
const token = v4();
console.info(new Date().toISOString(), `start the request`);
// Call the endpoint described above
axios
.get(`http://localhost:3333/api?token=${token}`)
.then((resp) => {
// Print the total length of requested data (an array of random numbers)
console.info(new Date().toISOString(), `got ${resp.data.length} records`);
process.exit(0);
})
.catch((e) => {
console.info(e);
process.exit(0);
});
// We need to connect to the related Socket Server
const ioClient = io.connect('ws://localhost:3333');
// And wait for `progress-${token}` event
ioClient.on(`progress-${token}`, (progress) =>
console.info(new Date().toISOString(), `processed ${progress}%`)
);
The Final Steps
It's time to try the solution.
git clone git@github.com:buchslava/nest-request-progress.git
cd nest-request-progress
npm i
npx nx run server:serve
Open another terminal and run:
npx nx run client:serve
Voilà
Posted on February 22, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.