cri.dev

Implementing HTTP range requests in Node.js

Published on
Tagged with nodejs37 http4

Ever paused a download, to later resume it? Or played back a video from a specific point, without downloading the whole video?

You already used HTTP range requests without even realizing it.

Recently had the chance to use them in a personal project, my-yt, and I learned a ton, so here we go.

In a nutshell

In simple terms: HTTP range requests allow clients to ask for only a portion of the resource, given a start and end byte marker.

How? Using the HTTP Range header.

E.g. Range: bytes=0-2048 means “give me bytes from 0 to 2048”. You can also request multiple ranges and a resource given only a starting range.

The server then send back the requested resource portion. It does that using the Content-Range HTTP header to inform the client.

There is also the Accept-Ranges used by the server to tell the client that range requests are supported.

And have you ever heard about the HTTP status code 206? It’s the one used when a server sends back a portion of the resource, like in this case.

(BTW, there is waaaaay more stuff to get into when talking about this, so here I am only scratching the surface)


Show me some code

A basic example (to see the full code, check out my-yt)

The client (e.g. a video player in your HTML) requests the first X bytes of a video file.

The server then send back the requested resource portion. As mentioned above, some HTTP headers actions comes into play here.

E.g.

The HTTP function handler responsible for replying with the correct chunk of the video:

function watchVideoHandler (req, res) {
  const location = `./your/video.mp4`
  const contentType = 'video/mp4'
  if (!fs.existsSync(location)) {
    res.writeHead(404, { 'Content-Type': 'text/plain' })
    res.end('Video not found')
    return
  }
  res.setHeader('content-type', contentType)

Parse the requested HTTP range

  const options = {}

  let start
  let end

  const range = req.headers.range
  if (range) {
    const bytesPrefix = 'bytes='
    if (range.startsWith(bytesPrefix)) {
      const bytesRange = range.substring(bytesPrefix.length)
      const parts = bytesRange.split('-')
      if (parts.length === 2) {
        const rangeStart = parts[0] && parts[0].trim()
        if (rangeStart && rangeStart.length > 0) {
          options.start = start = parseInt(rangeStart)
        }
        const rangeEnd = parts[1] && parts[1].trim()
        if (rangeEnd && rangeEnd.length > 0) {
          options.end = end = parseInt(rangeEnd)
        }
      }
    }
  }

Determine the file size and return it if its a HEAD request

  const stat = fs.statSync(location)

  const contentLength = stat.size

  if (req.method === 'HEAD') {
    res.statusCode = 200
    res.setHeader('accept-ranges', 'bytes')
    res.setHeader('content-length', contentLength)
    return res.end()
  }

Calculate the bytes that are being sent back to the client

  let retrievedLength = contentLength
  if (start !== undefined && end !== undefined) {
    retrievedLength = end - start + 1
  } else if (start !== undefined) {
    retrievedLength = contentLength - start
  }

  res.statusCode = (start !== undefined || end !== undefined) ? 206 : 200

  res.setHeader('content-length', retrievedLength)

  if (range !== undefined) {
    res.setHeader('accept-ranges', 'bytes')
    res.setHeader('content-range', `bytes ${start || 0}-${end || (contentLength - 1)}/${contentLength}`)
  }

Pipe the file stream to the response

  const fileStream = fs.createReadStream(location, options)
  fileStream.on('error', error => {
    console.error(`Error reading file ${location}.`, error)

    res.writeHead(500)
    res.end()
  })

  fileStream.pipe(res)
}

Here, have a slice of pizza 🍕