Video Streaming with Nodejs and Reactjs TypeScript Chrome and Safari
We, in our company, have been using Nodejs for our backend and Reactjs for front end for almost 2 years. We have been taking advantage of using the same language — JavaScript- for both back end and front end while both frameworks are widely used all around the world.
Almost a month ago, we had the responsibility to create a new platform for users to watch videos effectively. So we have rolled our sleeves up and completed the project. However, there have been some challenges that we haven’t anticipated while planning. In this article, I will share what we have done and how we have handled these unpredictable(for us) challenges. I have divided front end and back end codes into two parts. This article will cover what we have done in the back end and the challenges we have faced.
Backend
As it is mentioned earlier, Nodejs was used for the backend part of the project. I have created a new project to avoid confusion caused by middlewares and other stuff specific to the real project. You can see the codes here.
https://github.com/tokerto7001/Streaming-App.git
As you can see in the code below, I have created a new project and in the app.ts file, I have created a new route to handle the incoming request. This route takes a videoPath from request params object and accordingly gets the video from file system.
General logic behind video streaming is that sending the whole content of the video is so resource and consuming, leading delays and is not preferable for user experience. In order to solve this, videos are sent as chunks with 206 HTTP headers. In each request, server is sending the next chunk and video streaming will continue without any delay. Explanatory comments are provided in the code block but it is better to reexplain it line by line.
import express, { Application, Request, Response } from 'express'
import cors from 'cors'
import fsPromises from 'fs/promises'
import fs from 'fs'
const app: Application = express()
app.use(cors())
app.get('/:videoPath', async (req: Request, res: Response) => {
const rangeHeader = req.headers.range
// check req header if it contains a rage attr
if (!rangeHeader) throw new Error('Requires Range header')
// get file stat with fs module to access size
const videoPath = `./videos/${req.params.videoPath}`
const fileData = await fsPromises.stat(videoPath)
const videoSize = fileData.size
// identify the size of the chunks that the server is setnding
const chunkSize = 10 ** 6
// get the starting byte from req header's range
const start = Number(rangeHeader.replace(/\D/g, ""))
// decide the end byte considering chonk size
const end = Math.min(start + chunkSize, videoSize - 1)
// calculate content length
const contentLength = end - start + 1
// create and set response headers
const headers = {
"Content-Range": `bytes ${start}-${end}/${videoSize}`,
"Accept-Ranges": "bytes",
"Content-Length": contentLength,
"Content-Type": "video/mp4",
}
// const remaining = videoSize - start
// mark the current contet as completed if this is the latest chunk
// if (remaining < chunkSize) {
// userCourseService.updateUserProgress(userId, courseId, contentId)
// }
// create a read stream and pipe it ro the res object
const videoStream = fs.createReadStream(videoPath, { start, end })
res.writeHead(206, headers)
videoStream.pipe(res)
}
)
export default app
- First we fetch the range header from the request object and if there is no range header throw an error because in order to maintain our video streaming system range is a must.
- videoPath is defined to find the necessary path of the video.
- fileData is fetched by using fsPromises.stat(). fsPromises is the module enabling us to use asynchronous operations of fs module with async/await syntax while stat method is used to return information about the given file or directory.
- videoSize is fetched from fileData.size
- chunkSize is identified as 10 ** 6 which means 1 MB per request
- start point is taken from range property of the header
- end point is calculated as starting point + chunkSize which is 1MB or whole content — 1 in order to detect which part of the video will be sent to the client. To make it clearer, if the sending chunk is not the end of the video, this chunk will be sent while if the current chunk is the last chunk, end point is videoSize — 1
- contentLength is the size of the content that we are sending to the client. It is 1MB if it is not the last part of the video or if the video size is less than 1MB.
- headers object is the most important part. In Content-Range header, which part of the video is sending is declared in terms of bytes and the whole size of the video. Accept-Ranges: bytes should be provided to maintain what we needed. Content-Length is where we declare current chunk’s size and lastly Content-Type should be video/mp4.(videos must be in the format of mp4)
In the next line, remaining chunks are calculated and if you want to keep records of a specific user’s progress in the back end, if(remaining < chunkSize) will detect that the last chunk of the video is being sent to the user. If you want to make it in front end, it will be explained in the front end part.
Lastly, reading stream is created by using fs.createReadStream method and gets the required part of the file detected via start and end properties after identifying the videoPath.
At the end of the flow, 206 response header and the headers object is added to the response object and piping is maintained via videoStream.pipe(res)
Challenge in the Back End
After finishing the Nodejs part of the project, things were going as planned and a user can watch the video without any delay or loading problem.
Google Chrome is the most used web browser all around the world and stats can be examined on https://gs.statcounter.com/. As most of the internet users, I also test the codes via Google Chrome in the production environment. However, challenge became visible when one of my colleague tried it in Safari. In Safari, no content was visible and I had no idea about the problem.
After doing some research, I have understood the problem from https://stackoverflow.com/questions/42846947/cant-play-mp4-video-in-safari-served-by-nodejs-server. The problem is the range property in the header that Safari and Google Chrome send different Content-Range headers. The solution is parsing the range header a little bit different according to the range property coming in the request object and you can also look at https://stackoverflow.com/questions/4360060/video-streaming-with-html-5-via-node-js/29126190 and https://blog.logrocket.com/build-video-streaming-server-node/.
So, let’s change our code block accordingly.
import express, { Application, Request, Response } from 'express'
import cors from 'cors'
import fsPromises from 'fs/promises'
import fs from 'fs'
const app: Application = express()
app.use(cors())
app.get('/:videoPath', async (req: Request, res: Response) => {
const rangeHeader = req.headers.range
// check req header if it contains a rage attr
if (!rangeHeader) throw new Error('Requires Range header')
// get file stat with fs module to access size
const videoPath = `./videos/${req.params.videoPath}`
const fileData = await fsPromises.stat(videoPath)
const videoSize = fileData.size
// split the range header
const splittedRange = rangeHeader.replace(/bytes=/, '').split('-')
// get the starting byte from req header's range
const start = parseInt(splittedRange[0])
// decide the end byte considering chunk size
const end = splittedRange[1] ? parseInt(splittedRange[1], 10) : videoSize - 1
// calculate content length
const contentLength = end - start + 1
// create and set response headers
const headers = {
"Content-Range": `bytes ${start}-${end}/${videoSize}`,
"Accept-Ranges": "bytes",
"Content-Length": contentLength,
"Content-Type": "video/mp4",
}
// const remaining = videoSize - start
// mark the current contet as completed if this is the latest chunk
// if (remaining < chunkSize) {
// userCourseService.updateUserProgress(userId, courseId, contentId)
// }
// create a read stream and pipe it ro the res object
const videoStream = fs.createReadStream(videoPath, { start, end })
res.writeHead(206, headers)
videoStream.pipe(res)
}
)
export default app
What we have done this time is just splitting the range header with dash(-). Starting point will be almost the same but when calculating the end point, we write a ternary operator that it will take property at the index of 1 if there is, otherwise take the videoSize — 1. By this way, you can see that your video will be displayed in Safari also without any problems.
One Last Tip
This part is an extra info for increasing the sustainability and decreasing the response time of your project. In Nodejs, we generally use middlewares for the routes in order to check or manipulate the request object before it reaches to its controller. If there is a time and resource consuming operation (database query) inside any of the middlewares — authentication of the user for example- you should use an in-memory database like Redis. Since videos are sent chunk by chunk, there will be constant requests until the video is over. This may cause performance issues.
Conclusion
As it is explained, video streaming app via Nodejs is really simple. All you have to do is to set the headers as mentioned. Sending videos chunk by chunk is a great way to avoid long loading times.
Analyzing what we have done in the front end part and the challenges we have faced during that process will be explained in the second article. I have to thank to my friend and ex-colleague Durmuş for his contributions and explaining the general structure. I hope you enjoyed the article. I have created a new repository on Github for this and you can see the codes explained here via https://github.com/tokerto7001/Streaming-App.git Comments and claps will be appreciated.
If you find it helpful, you can buy me coffee.