http Range

Range

Range 范围请求,允许服务器只发送 HTTP 消息的一部分到客户端。Range 可以一次性请求多个部分,请求范围用一个逗号分隔开,服务器会以 multipart/byteranges 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206 Partial Content 状态码。在请求的范围越界的情况下,服务器会返回 416 Range Not Satisfiable (请求的范围无法满足) 状态码,表示客户端错误。服务器允许忽略 Range,从而返回整个文件,状态码用 200 。


相关状态码: 200, 206, 416.
相关首部: Accept-Ranges, Range, Content-Range, If-Range, Transfer-Encoding.

  • index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>http range</title>
    <style>
      body {
        margin: 0;
        padding: 20px;
        box-sizing: border-box;
      }
    </style>
  </head>
  <body>
    <h1>HTTP Range</h1>
    <hr />
    <h3 class="result"></h3>

    <script>
      fetchData()
      async function fetchData() {
        const url = 'http://localhost:3000/range';
        const res = await fetch(url, {
          headers: {
            Range: `bytes=0-2048`,
          },
        }).then((resp) => resp.blob());
        console.log("res => ", res)
        // open(URL.createObjectURL(res), '_blank');
        const img = new Image()
        img.onload = () => {
          document.querySelector('.result').appendChild(img)
        }
        img.src =  URL.createObjectURL(res)

        // document.querySelector('.result').textContent = res;
      }
    </script>
  </body>
</html>
  • server.mjs
import { createServer } from 'node:http';
import { readFile, stat, createReadStream } from 'node:fs';
import { parse } from 'node:url';
import { promisify } from 'node:util';

const filePath = './favicon.ico';
const contentType = 'image/vnd.microsoft.icon';

createServer(async (req, res) => {
  const { pathname } = parse(req.url);

  res.setHeader('Access-Control-Allow-Origin', '*');

  console.log('pathname => ', req.method, pathname);

  if (pathname === '/') {
    const html = await promisify(readFile)('./range.html');
    res.setHeader('Content-Type', 'text/html;charset=utf-8');
    res.end(html);
  } else if (pathname === '/range') {
    if (req.method.toUpperCase() === 'OPTIONS') {
      res.setHeader(
        'Access-Control-Allow-Headers',
        'Accept-Ranges, Range, Content-Type, Content-Length'
      );
      res.statusCode = 200;
      return res.end();
    }

    const { size: fileSize } = await promisify(stat)(filePath);

    const headerRange = req.headers['range'];
    console.log('headerRange => ', headerRange);

    res.setHeader('Accept-Ranges', 'bytes');
    res.setHeader('Content-Type', contentType);

    // 分片传输
    if (headerRange) {
      // 只获取一个range
      const reg = /^bytes=\d+$/;
      if (reg.test(headerRange)) {
        const index = +headerRange.split('=')[1];
        if (index >= fileSize) return handleBoudary(res);
        return createReadStream(filePath, { start: index, end: index }).pipe(res);
      }

      const ranges = headerRange
        .replace('bytes=', '')
        .split(',')
        .map((item) => item.trim().split('-'));

      console.log('ranges => ', ranges);

      // 处理越界情况
      const boundaryStatus = ranges.some(([start, end]) => {
        return start >= fileSize || end >= fileSize;
      });
      if (boundaryStatus) return handleBoudary(res);

      // 处理多个range
      if (ranges.length > 1) {
        const range_divider = String(Math.random()).slice(2);
        res.statusCode = 206;
        res.setHeader('Content-Type', `multipart/byteranges; boundary=${range_divider}`);

        const chunksPromise = ranges.map(([start, end]) => {
          // 从结尾开始获取
          if (start === '' && end !== '') {
            start = fileSize - parseInt(end);
            end = fileSize - 1;
          } else {
            start = parseInt(start);
            end = end ? parseInt(end) : fileSize - 1;
          }
          return getChunk(start, end, filePath);
        });

        const chunks = await Promise.all(chunksPromise);

        let list = [];
        for (const { start, end, chunk } of chunks) {
          const str = [
            `\r\n--${range_divider}`,
            `Content-Type: ${contentType}`,
            `Content-Range: bytes ${start}-${end}/${fileSize}`,
            '',
            chunk,
          ].join('\r\n');
          list.push(str);
        }

        const result = list.join('') + `\r\n--${range_divider}--`;

        return res.end(result);
      }

      // 处理单个range (部分内容)
      let [start, end] = ranges[0];
      // 从结尾开始获取
      if (start === '' && end !== '') {
        start = fileSize - parseInt(end);
        end = fileSize - 1;
      } else {
        start = parseInt(start);
        end = end ? parseInt(end) : fileSize - 1;
      }

      res.writeHead(206, {
        'Content-Length': end - start + 1,
        'Content-Range': `bytes ${start}-${end}/${fileSize}`,
      });
      const readStream = createReadStream(filePath, { start, end });
      return readStream.pipe(res);
    }

    // 完整的内容
    res.statusCode = 200;
    res.setHeader('Content-Length', fileSize);
    createReadStream(filePath).pipe(res);
  } else {
    res.statusCode = 404;
    res.end('Not found');
  }
}).listen(3000, () => {
  console.log('server runnning in 3000');
});

function handleBoudary(res) {
  res.statusCode = 416
  res.end('请求范围不符合要求')
}

function getChunk(start, end, filePath) {
  return new Promise((resolve) => {
    const readStream = createReadStream(filePath, { start, end });
    let chunk = '';
    readStream.on('data', (data) => {
      chunk += data;
    });
    readStream.on('end', () => {
      resolve({ start, end, chunk });
      // console.log('end');
    });
  });
}
posted @ 2024-04-13 23:46  _clai  阅读(210)  评论(0编辑  收藏  举报