|
| 1 | +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. |
| 2 | + |
| 3 | +First, I want to share the https://dev.to/tqbit/how-to-monitor-the-progress-of-a-javascript-fetch-request-and-cancel-it-on-demand-107f[article that describes a bunch of the results above as well as possible, window=_blank]. Let me provide essential thoughts on the article. |
| 4 | +
|
| 5 | +1. The article describes the request that provides content downloading. In this case, we are talking about the precious content size. + |
| 6 | +2. The #Content-Length# HTTP header is essential to the correct HTTP response. + |
| 7 | +3. After the server application sets #Content-Length#, chunked data writing process should be run. + |
| 8 | +4. First, the client application gets #Content-Length#. + |
| 9 | +5. After that, it gets every data chunk and calculates the progress as the following. |
| 10 | +
|
| 11 | +[,js] |
| 12 | +---- |
| 13 | +progress = 100 * (chunkSize / contentLength) |
| 14 | +---- |
| 15 | +
|
| 16 | +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. |
| 17 | +
|
| 18 | +1. 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. + |
| 19 | +2. Despite the application not knowing the content size, it has a total number of iterations. + |
| 20 | +3. 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. |
| 21 | +
|
| 22 | +In other words, the requirements for the new approach are the following. |
| 23 | +
|
| 24 | +1. The response writing goes simultaneously without any data chunking. + |
| 25 | +2. The progress should be provided before that. + |
| 26 | +
|
| 27 | +I don't want to waste your time and give a couple of conceptual points of my approach regarding accounting the requirements above. |
| 28 | +
|
| 29 | +* Provide the progress via WebSockets because of persistent connection and high performance. |
| 30 | +* Connect WebSockets with a current session to pass all needed data from the HTTP request processing process. |
| 31 | +
|
| 32 | +All thoughts and code below will be strongly connected to these points. But before, let me share the https://github.com/buchslava/nest-request-progress[final solution, window=_blank]. |
| 33 | +
|
| 34 | +== Data Providing Example |
| 35 | +
|
| 36 | +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. |
| 37 | +
|
| 38 | +[, js] |
| 39 | +---- |
| 40 | +import { Injectable } from '@nestjs/common'; |
| 41 | +
|
| 42 | +const getRandomArbitrary = (min: number, max: number): number => |
| 43 | + Math.random() * (max - min) + min; |
| 44 | +const delay = (time: number) => |
| 45 | + new Promise((resolve) => setTimeout(resolve, time)); |
| 46 | +
|
| 47 | +@Injectable() |
| 48 | +export class AppService { |
| 49 | +
|
| 50 | + getIterationCount(): number { |
| 51 | + return 150; |
| 52 | + } |
| 53 | +
|
| 54 | + async getData(token: string): Promise<string[]> { |
| 55 | + return new Promise(async (resolve, reject) => { |
| 56 | + try { |
| 57 | + const result = []; |
| 58 | +
|
| 59 | + for (let i = 0; i < this.getIterationCount(); i++) { |
| 60 | + result.push(getRandomArbitrary(1, 9999)); |
| 61 | + await delay(getRandomArbitrary(100, 1000)); |
| 62 | + } |
| 63 | +
|
| 64 | + resolve(result); |
| 65 | + } catch (e) { |
| 66 | + reject(e); |
| 67 | + } |
| 68 | + }); |
| 69 | + } |
| 70 | +} |
| 71 | +---- |
| 72 | +
|
| 73 | +== Progress Manager |
| 74 | +The future steps are regarding the #ProgressManager# implementation. |
| 75 | +
|
| 76 | +The #ProgressManager# should be a separate NestJS service able to do the following. |
| 77 | +
|
| 78 | +1. Start the "Progress" session (Not the HTTP session) with the unique token taken from the client application. + |
| 79 | +2. Stop the "Progress" session + |
| 80 | +3. Increase the value of the progress. + |
| 81 | +
|
| 82 | +Please look at the following commented code. |
| 83 | +[, js] |
| 84 | +---- |
| 85 | +import { Injectable } from '@nestjs/common'; |
| 86 | +import { Server } from 'socket.io'; |
| 87 | +
|
| 88 | +export interface ProgressSession { |
| 89 | + token: string; |
| 90 | + total: number; |
| 91 | + counter: number; |
| 92 | + timerId: any; |
| 93 | +} |
| 94 | +
|
| 95 | +@Injectable() |
| 96 | +export class ProgressManager { |
| 97 | + // The Socket Server injection will be described later |
| 98 | + public server: Server; |
| 99 | + // This map contains all Progress session data |
| 100 | + private storage: Map<string, ProgressSession> = new Map(); |
| 101 | +
|
| 102 | + // Start the session with the token and the total number of iterations |
| 103 | + startSession(token: string, total: number, delay = 2000) { |
| 104 | + // Get current session from the storage |
| 105 | + const currentSession = this.storage.get(token); |
| 106 | + // Do nothing if it's already exist |
| 107 | + if (currentSession) { |
| 108 | + return; |
| 109 | + } |
| 110 | + // Send the progress every "delay" milliseconds |
| 111 | + const timerId = setInterval(async () => { |
| 112 | + const currentSession: ProgressSession = this.storage.get(token); |
| 113 | + // Protect the functionality: if the current session is missing then do nothing |
| 114 | + if (!currentSession) { |
| 115 | + return; |
| 116 | + } |
| 117 | + // Calculate the progress |
| 118 | + let progress = Math.ceil( |
| 119 | + (currentSession.counter / currentSession.total) * 100 |
| 120 | + ); |
| 121 | + // Protect the progress value, it should be less or equal 100 |
| 122 | + if (progress > 100) { |
| 123 | + progress = 100; |
| 124 | + } |
| 125 | + // Send the progress. Pay attention that the event name should contain the "token" |
| 126 | + // Client will use this token also |
| 127 | + this.server.emit(`progress-${token}`, progress); |
| 128 | + }, delay); |
| 129 | + // Initial Progress Session settings. Token is a key. |
| 130 | + this.storage.set(token, { |
| 131 | + token, |
| 132 | + total, |
| 133 | + counter: 0, |
| 134 | + timerId, |
| 135 | + }); |
| 136 | + } |
| 137 | +
|
| 138 | + // This method increases the progress |
| 139 | + step(token: string, value = 1) { |
| 140 | + // Get the current session |
| 141 | + const currentSession: ProgressSession = this.storage.get(token); |
| 142 | + // Do nothing if it doesn't exist |
| 143 | + if (!currentSession) { |
| 144 | + return; |
| 145 | + } |
| 146 | + // Increase the counter |
| 147 | + const counter = currentSession.counter + value; |
| 148 | + // Update the storage |
| 149 | + this.storage.set(token, { |
| 150 | + ...currentSession, |
| 151 | + counter, |
| 152 | + }); |
| 153 | + } |
| 154 | +
|
| 155 | + // Stop the session by the token |
| 156 | + stopSession(token: string) { |
| 157 | + // Get the current session |
| 158 | + const currentSession: ProgressSession = this.storage.get(token); |
| 159 | + // Do nothing if it doesn't exist |
| 160 | + if (currentSession) { |
| 161 | + // Stop the current timer |
| 162 | + clearInterval(currentSession.timerId); |
| 163 | + // Remove information regarding the current session from the storage |
| 164 | + this.storage.delete(token); |
| 165 | + } |
| 166 | + } |
| 167 | +} |
| 168 | +---- |
| 169 | +You can find the code above https://github.com/buchslava/nest-request-progress/blob/main/packages/server/src/app/progress-manager.ts[here, window=_blank]. |
| 170 | +
|
| 171 | +== WebSockets Server |
| 172 | +
|
| 173 | +Another important is the integration of NestJS with WebSockets and connecting the Progress Manager with it. The following code is responsible for that. |
| 174 | +
|
| 175 | +[, js] |
| 176 | +---- |
| 177 | +import { |
| 178 | + WebSocketGateway, |
| 179 | + WebSocketServer, |
| 180 | + OnGatewayInit, |
| 181 | +} from '@nestjs/websockets'; |
| 182 | +import { Server } from 'socket.io'; |
| 183 | +import { ProgressManager } from './progress-manager'; |
| 184 | +
|
| 185 | +@WebSocketGateway({ cors: true }) |
| 186 | +export class AppGateway implements OnGatewayInit { |
| 187 | + constructor(private progressManager: ProgressManager) {} |
| 188 | +
|
| 189 | + @WebSocketServer() server: Server; |
| 190 | +
|
| 191 | + afterInit() { |
| 192 | + // After the WebSockets Gateway has to init, then pass it to the ProgressManager |
| 193 | + this.progressManager.server = this.server; |
| 194 | + } |
| 195 | +} |
| 196 | +---- |
| 197 | +https://github.com/buchslava/nest-request-progress/blob/main/packages/server/src/app/app.gateway.ts[The source >>, window=_blank] + |
| 198 | +And, of course, according to NestJS requirements, we need to tell the related module about that. |
| 199 | +[, js] |
| 200 | +---- |
| 201 | +import { Module } from '@nestjs/common'; |
| 202 | +
|
| 203 | +import { AppController } from './app.controller'; |
| 204 | +import { AppService } from './app.service'; |
| 205 | +import { AppGateway } from './app.gateway'; |
| 206 | +import { ProgressManager } from './progress-manager'; |
| 207 | +
|
| 208 | +@Module({ |
| 209 | + imports: [], |
| 210 | + controllers: [AppController], |
| 211 | + providers: [AppService, AppGateway, ProgressManager], |
| 212 | +}) |
| 213 | +export class AppModule {} |
| 214 | +---- |
| 215 | +https://github.com/buchslava/nest-request-progress/blob/main/packages/server/src/app/app.module.ts[The source >>, window=_blank] |
| 216 | +
|
| 217 | +== Data Processing |
| 218 | +
|
| 219 | +It's time to focus on the endpoint's controller. It looks pretty simple. |
| 220 | +
|
| 221 | +[, js] |
| 222 | +---- |
| 223 | +import { Controller, Get, Query } from '@nestjs/common'; |
| 224 | +import { AppService } from './app.service'; |
| 225 | +
|
| 226 | +@Controller() |
| 227 | +export class AppController { |
| 228 | + constructor(private readonly appService: AppService) {} |
| 229 | +
|
| 230 | + @Get() |
| 231 | + getData(@Query() query: { token: string }) { |
| 232 | + return this.appService.getData(query.token); |
| 233 | + } |
| 234 | +} |
| 235 | +---- |
| 236 | +https://github.com/buchslava/nest-request-progress/blob/main/packages/server/src/app/app.controller.ts[The source >>, window=_blank] |
| 237 | +
|
| 238 | +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. |
| 239 | +
|
| 240 | +[, js] |
| 241 | +---- |
| 242 | +import { Injectable } from '@nestjs/common'; |
| 243 | +import { ProgressManager } from './progress-manager'; |
| 244 | +
|
| 245 | +const getRandomArbitrary = (min: number, max: number): number => |
| 246 | + Math.random() * (max - min) + min; |
| 247 | +const delay = (time: number) => |
| 248 | + new Promise((resolve) => setTimeout(resolve, time)); |
| 249 | +
|
| 250 | +@Injectable() |
| 251 | +export class AppService { |
| 252 | + // Use progressManager |
| 253 | + constructor(private readonly progressManager: ProgressManager) {} |
| 254 | +
|
| 255 | + // 150 iterations should be processed |
| 256 | + getIterationCount(): number { |
| 257 | + return 150; |
| 258 | + } |
| 259 | +
|
| 260 | + async getData(token: string): Promise<string[]> { |
| 261 | + return new Promise(async (resolve, reject) => { |
| 262 | + // We need to start the Progress Session before data preparation |
| 263 | + this.progressManager.startSession(token, this.getIterationCount()); |
| 264 | + try { |
| 265 | + // Initialize the array of results |
| 266 | + const result = []; |
| 267 | +
|
| 268 | + for (let i = 0; i < this.getIterationCount(); i++) { |
| 269 | + // Calculate the result |
| 270 | + result.push(getRandomArbitrary(1, 9999)); |
| 271 | + // Increase the Progress counter |
| 272 | + this.progressManager.step(token); |
| 273 | + // Random delay |
| 274 | + await delay(getRandomArbitrary(100, 1000)); |
| 275 | + } |
| 276 | +
|
| 277 | + // Return the result |
| 278 | + resolve(result); |
| 279 | + } catch (e) { |
| 280 | + reject(e); |
| 281 | + } finally { |
| 282 | + // We need to stop the ProgressManager in any case. |
| 283 | + // Otherwise, we have a redundant timeout. |
| 284 | + this.progressManager.stopSession(token); |
| 285 | + } |
| 286 | + }); |
| 287 | + } |
| 288 | +} |
| 289 | +---- |
| 290 | +https://github.com/buchslava/nest-request-progress/blob/main/packages/server/src/app/app.service.ts[The source >>, window=_blank] |
| 291 | +
|
| 292 | +The backend part of my example is ready. You can find the full backend solution https://github.com/buchslava/nest-request-progress/tree/main/packages/server[here, window=_blank]. |
| 293 | +
|
| 294 | +== The Client |
| 295 | +
|
| 296 | +The client part of my example is placed https://github.com/buchslava/nest-request-progress/tree/main/packages/client[here, window=_blank]. Both parts are placed in one monorepo. Thanks https://nx.dev/[Nx, window=_blank] for that. Lets look at it. Please, read the comments in the code below. |
| 297 | +
|
| 298 | +[, js] |
| 299 | +---- |
| 300 | +import * as io from 'socket.io-client'; |
| 301 | +import { v4 } from 'uuid'; |
| 302 | +import axios from 'axios'; |
| 303 | +
|
| 304 | +// Generate a unique ID (token) |
| 305 | +const token = v4(); |
| 306 | +
|
| 307 | +console.info(new Date().toISOString(), `start the request`); |
| 308 | +
|
| 309 | +// Call the endpoint described above |
| 310 | +axios |
| 311 | + .get(`http://localhost:3333/api?token=${token}`) |
| 312 | + .then((resp) => { |
| 313 | + // Print the total length of requested data (an array of random numbers) |
| 314 | + console.info(new Date().toISOString(), `got ${resp.data.length} records`); |
| 315 | + process.exit(0); |
| 316 | + }) |
| 317 | + .catch((e) => { |
| 318 | + console.info(e); |
| 319 | + process.exit(0); |
| 320 | + }); |
| 321 | +// We need to connect to the related Socket Server |
| 322 | +const ioClient = io.connect('ws://localhost:3333'); |
| 323 | +// And wait for `progress-${token}` event |
| 324 | +ioClient.on(`progress-${token}`, (progress) => |
| 325 | + console.info(new Date().toISOString(), `processed ${progress}%`) |
| 326 | +); |
| 327 | +---- |
| 328 | +
|
| 329 | +== The Final Steps |
| 330 | +It's time to try the solution. |
| 331 | +[, bash] |
| 332 | +---- |
| 333 | +git clone git@github.com:buchslava/nest-request-progress.git |
| 334 | +cd nest-request-progress |
| 335 | +npm i |
| 336 | +npx nx run server:serve |
| 337 | +---- |
| 338 | +Open another terminal and run: |
| 339 | +[, bash] |
| 340 | +---- |
| 341 | +npx nx run client:serve |
| 342 | +---- |
| 343 | +
|
| 344 | +== Voilà |
| 345 | +[.img] |
| 346 | +image::img1.png[] |
0 commit comments