Video streaming service in Deno

Mayank C
Tech Tonic

--

Streaming is the continuous transmission of audio or video files from a server to a client. In simpler terms, streaming happens when people watch TV, YouTube videos, Instagram videos, etc. With streaming, the media file being played is transmitted a few seconds at a time over the Internet.

Streaming is real-time, and it’s more efficient than downloading media files. If a video file is downloaded, a copy of the entire file is saved onto a device’s hard drive, and the video cannot play until the entire file finishes downloading. If it’s streamed instead, the browser plays the video without actually copying and saving it. The video loads in chunks instead of the entire file loading at once, and the information that the browser loads is not saved locally.

In this article, we’ll write a small video streaming service in Deno. Let’s get started.

Range & Partial content

To enable streaming of data from server to client, we need to learn some fundamentals like range request, range response, partial content, etc. Let’s have a look at them.

Range

Range is the most important principle behind streaming. Here is an excerpt from MDN docs:

An HTTP range request asks the server to send only a portion of an HTTP message back to a client. Range requests are useful for clients like media players that support random access, data tools that know they need only part of a large file, and download managers that let the user pause and resume the download.

To fully implement part download, three range related HTTP headers comes into play:

Range

The Range is an HTTP request header that indicates the part of a document that the server should return. The format of header that we’ll be using for video streaming application is:

Range: <unit>=<range-start>-

The ‘unit’ field is usually bytes, and the ‘range-start’ field usually contains a number that indicates the start index of the data. This enables the server to understand from what index it should send data.

Some relevant examples of range header are:

Range: bytes=0-
Range: bytes=5000-

Accept-ranges

This Accept-ranges is an HTTP response header to indicate that the server supports ranges. The format of header is:

Accept-Ranges: <range-unit>
Accept-Ranges: none

The ‘range-unit’ field contains bytes, as that’s the only standard value defined by RFC 7233.

A relevant example of accept-ranges header is:

Accept-Ranges: bytes

Content-range

The content-range is an HTTP response header that indicates where in a full body message a partial message belongs. The general format of header is:

Content-Range: <unit> <range-start>-<range-end>/<size>

The ‘unit’ field is bytes. The ‘range-start’, ‘range-end’ and ‘size’ fields indicate the place of the chunk in the overall file streaming.

A relevant example of content-range header is:

Content-Range: bytes 0-10000/50000

Partial content

The HTTP 206 Partial Content success status response code indicates that the request has succeeded, and the body contains the requested ranges of data, as described in the Range header of the request.

The Content-Type of the whole response is set to the type of the document, and a Content-Range is provided.

HTTP/1.1 206 Partial Content
Content-Range: bytes 21010-47021/47022
Content-Length: 26012
Content-Type: video/mp4
... 26012 bytes of partial image data ...

That’s all about the basics. Let’s move on to writing the server.

Video streaming server

The video streaming server is simple, but contains a number of parts. Let’s build the parts one-by-one, starting with the main request handler.

Imports

We’ll be using two imports for video streaming application:

  • serve API from Deno’s standard library
  • colors API from Deno’s standard library
import { serve } from "https://deno.land/std/http/mod.ts";
import * as colors from "https://deno.land/std/fmt/colors.ts";

Both of the imports are optional, as the video streaming service can be fully built using native code.

Constants

We need a number of settings/constants to control the streaming behavior:

const port = 9000,
videoPath = "./testdata/",
numBlocksPerRequest = 30,
blockSize = 16_384,
videoBlockSize = blockSize * numBlocksPerRequest;

In a single HTTP response, the video streaming service will send ~480K (16384*30 bytes) of data. In Deno, the low-level read API caps a single call of read at 16K. We need to repeat it till we reach desired chunk size of 480K. More details are covered in the getStream function.

Main request handler

The main request handler is a small router function that supports two endpoints:

  • /: Returns the basic HTML
  • /getVideo: Returns a part of the video

The usual error codes are 400, 404, 405, 500, etc. Before sending the response, relevant data from request and response objects is logged on the console.

Here is the code of the main request handler:

async function handleRequest(req: Request) {
let resp: Response | undefined;
const u = new URL(req.url);
const videoName = u.searchParams.get("videoName") || "video.mp4";
if (req.method !== "GET") {
resp = new Response(null, { status: 405 });
}
switch (u.pathname) {
case "/": {
resp = baseHtml(videoName);
break;
}
case "/getVideo": {
if (!videoName) {
resp = new Response(null, { status: 400 });
} else {
resp = await getVideo(req.headers, videoName);
}
}
}
if (!resp) {
resp = new Response(null, { status: 404 });
}
logHttpTxn(req, resp);
return resp;
}

baseHtml

The baseHtml function simply returns a very small HTML file that contains an HTML5 video tag with src as /getVideo?videoName=<videoName>.

Here is the code of the baseHtml function:

function baseHtml(videoName: string) {
return new Response(
`
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Test video streaming with Deno</title>
</head>
<body>
<video id="videoPlayer" width="70%" controls muted autoplay>
<source src="/getVideo?videoName=${videoName}" type="video/mp4"/>
</video>
</body>
</html>`,
{
status: 200,
headers: {
"content-type": "text/html",
},
},
);
}

getVideo

This is the main piece of the video streaming service. The algorithm is roughly like this:

  • Determine size of video using Deno.stat
  • Check for range header and extract startIndex
  • Determine endIndex (minimum of startIndex+blockSize & video size)
  • Open the video file
  • Seek to startIndex
  • Convert portion of video (startIndex to endIndex) to stream using toStream function
  • Prepare a response object with relevant headers: content-range, accept-ranges, content-length, content-type

Here is the code of the getVideo function:

async function getVideo(headers: Headers, videoName: string) {
let videoSize = 0;
try {
videoSize = await getVideoSize(videoName);
} catch (_err) {
return new Response(null, { status: 500 });
}
if (!headers.has("range")) {
return new Response(null, { status: 400 });
}
const startIndex = headers.has("range")
? Number(headers.get("range")?.replace(/\D/g, "")?.trim())
: 0;
const endIndex = Math.min(startIndex + videoBlockSize, videoSize);
const video = await Deno.open(videoPath + videoName);
if (startIndex > 0) {
await Deno.seek(video.rid, startIndex, Deno.SeekMode.Start);
}
return new Response(getStream(video), {
status: 206,
headers: {
"Content-Range": `bytes ${startIndex}-${endIndex}/${videoSize}`,
"Accept-Ranges": "bytes",
"Content-Length": `${endIndex - startIndex}`,
"Content-Type": "video/mp4",
},
});
}

getStream

The getStream function prepares a ReadableStream object from Deno.Reader (video). It issues successive read calls till the desired chunk size has been reached.

function getStream(video: Deno.File) {
let readBlocks = numBlocksPerRequest;
return new ReadableStream({
async pull(controller) {
const chunk = new Uint8Array(blockSize);
try {
const read = await video.read(chunk);
if (read) {
controller.enqueue(chunk.subarray(0, read));
}
readBlocks--;
if (readBlocks === 0) {
video.close();
controller.close();
}
} catch (e) {
controller.error(e);
video.close();
}
},
});
}

Utils

The last two functions are utility functions: getVideoSize & logHttpTxn. The getVideoSize function uses Deno.stat to determine the size of the video. The logHttpTxn function prints a pretty log on the console.

Here is the code:

function logHttpTxn(req: Request, resp: Response) {
const g = colors.green, gr = colors.gray, c = colors.cyan;
const u = new URL(req.url);
const qs = u.searchParams.toString();
const reqRange = req.headers.get("range")?.split("=")[1] || "";
const rspCtRange = resp.headers.get("content-range")?.split(" ")[1] || "";
const rspCtl = resp.headers.get("content-length") || "0";
console.log(
`${g(req.method)} ${c(u.pathname + (qs ? "?" + qs : ""))} ${
gr(reqRange)
} - ${g(resp.status.toString())} ${c(rspCtl)} ${gr(rspCtRange)}`,
);
}
async function getVideoSize(videoName: string) {
return (await Deno.stat(videoPath + videoName)).size;
}

That’s finishes the parts of the video streaming service.

Complete code

Once all the parts are put together, the complete code of the video streaming service looks like this:

That’s just 132 lines of code. It’s time to test it out!

Testing of video streaming service

We’ll be doing tests with two video files:

> file longVideo.mp4 
longVideo.mp4: ISO Media, MP4 Base Media v1 [ISO 14496-12:2003]
> du -kh longVideo.mp4
97M longVideo.mp4
> file shortVideo.mp4
shortVideo.mp4: ISO Media, MP4 Base Media v1 [ISO 14496-12:2003]
> du -kh shortVideo.mp4
1.0M shortVideo.mp4

Firefox browser has been used for testing. When video is played, the browser buffers a certain amount of data in advance. If the video is short (like the first one), the browser loads the entire video in advance. If the video is long (like the second one), the browser loads a part of it in advance, then loads the remaining parts as the video gets played. All of this is taken care by the browser.

First, let’s do testing with the short video:

As the video is very short, the entire video gets loaded before it starts. Here is the screenshot with request timestamps in case the gif is not clear:

All the requests came at the same time i.e. the time page was opened.

Now, let’s do the testing with the long video. In this case, a part of the video gets loaded at startup. As video gets watched, more data gets loaded. Here are three animations (they are truncated to only show the loading part):

The long video indeed gets loaded in parts. Here are screenshots with request timestamps to show the loading part:

The video streaming service works as exepected!

--

--