A 206 nem félkész 200: Range kérések Node.js-ben

A videók, PDF-ek és folytatható letöltések mögött álló HTTP Range kérés nem csak egy slice: a 206, 416, suffix range és If-Range apró részletei számítanak.

Amikor egy videólejátszó a 47. percnél indul el, a szerverednek nem kell hősiesen elküldenie az előtte lévő 46 percet. A kliens Range: bytes=… fejlécet küld, te pedig 206 Partial Content választ adsz. Papíron két fejléc és egy fájlstream. A valóságban pont elég részlet van benne ahhoz, hogy egy „működik nálam” implementáció egy mobilos letöltés-folytatásnál csendben hibás legyen.

HTTP Range kérés útja a kliens, a szerver és egy nagy fájl között

A Range nem a letöltés-gyorsítás kapcsolója

A Range request azt mondja meg, hogy a kliens a reprezentáció mely bájtjaira tart igényt. Ennek klasszikus fogyasztója a <video>: előreugráskor új tartományt kérhet. Ugyanez kell letöltés folytatásához, PDF-olvasókhoz és ahhoz, hogy a kliens egy nagy objektumból csak egy kis darabot vizsgáljon meg.

A szerver Accept-Ranges: bytes fejlécben jelezheti a támogatást, de a kliens ettől még küldhet Range-et; a fejléc inkább egyértelmű képességjelzés. Érvényes egyetlen tartományra a válasz 206, és kötelező mellé a Content-Range, például bytes 1000-1999/5000. Az utolsó szám a teljes fájlméret, nem a most küldött ezer bájt. A részleteket az RFC 9110 Range fejezete rögzíti.

A három forma, amelyet a regexed valószínűleg elfelejtett

  • bytes=500-999: zárt intervallum, mindkét végével.
  • bytes=500-: az 500. bájttól a fájl végéig.
  • bytes=-500: az utolsó 500 bájt. Ez nem a nullától 500-ig tart.

Na, ezt nem tudtam sokáig: a bytes=-500 suffix range, és nem hibás hiányos intervallum. A szervernek az bytes=999999- kérésre, ha a fájl kisebb, 416 Range Not Satisfiable választ kell adnia Content-Range: bytes */<teljes méret> fejléccel. A válaszban nincs fájlrészlet. Ebből tudja a folytatást végző kliens, hová érkezett meg valójában.

Egy szándékosan szűk Node-szerver

Az alábbi példa csak egyetlen bytes tartományt szolgál ki, amit a legtöbb videós és folytató kliens kér. A többrészes kérést (bytes=0-99,200-299) tudatosan teljes 200-zal hagyja figyelmen kívül. Ilyet hibás 206-tal „támogatni” sokkal rosszabb: a helyes válasz ott multipart/byteranges törzset és boundary-kat igényel.

import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import http from 'node:http';

const filePath = new URL('./large-file.mp4', import.meta.url);

function parseSingleRange(value, size) {
  if (!value || value.includes(',')) return null;
  const match = /^bytes=(\d*)-(\d*)$/.exec(value);
  if (!match || (!match[1] && !match[2])) return null;
  const [, first, last] = match;
  let start, end;
  if (first === '') {
    const length = Number(last);
    if (!Number.isSafeInteger(length) || length <= 0) return 'unsatisfiable';
    start = Math.max(size - length, 0); end = size - 1;
  } else {
    start = Number(first);
    end = last === '' ? size - 1 : Math.min(Number(last), size - 1);
  }
  if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start > end || start >= size) return 'unsatisfiable';
  return { start, end };
}

http.createServer(async (req, res) => {
  const { size } = await stat(filePath);
  const common = { 'Accept-Ranges': 'bytes', 'Content-Type': 'video/mp4' };
  const range = parseSingleRange(req.headers.range, size);
  if (range === 'unsatisfiable')
    return res.writeHead(416, { ...common, 'Content-Range': 'bytes */' + size }).end();
  if (!range) {
    res.writeHead(200, { ...common, 'Content-Length': size });
    return createReadStream(filePath).pipe(res);
  }
  const { start, end } = range;
  res.writeHead(206, { ...common, 'Content-Length': end - start + 1,
    'Content-Range': 'bytes ' + start + '-' + end + '/' + size });
  createReadStream(filePath, { start, end }).pipe(res);
}).listen(3000);

A createReadStream() start és end opciója bájtpozíciót vár, és az end inkluzív. Emiatt a Content-Length tényleg end - start + 1. Az egyesével eltévesztett végpont különösen szép hiba: rövid kliensoldali darabokat kapsz, de a logokban minden kerek.

Ne keverd össze az objektum két verzióját

A valódi csapda nem a pozíció, hanem a változó fájl. A kliens letölthette a régi ZIP első felét, te közben újra feltöltötted ugyanarra az URL-re, majd ugyanaz a kliens a második felét kéri. A két fél összeragasztása hibás fájl, amelyet remek lesz hibajegyként kapni.

Erre való az If-Range: a kliens elküldi a korábbi erős ETaget vagy egy megfelelő dátumvalidátort. Egyezéskor jöhet a 206; eltéréskor a szerver figyelmen kívül hagyja a Range-et, és a teljes új reprezentációt küldi 200-zal. Nem 412-t. A resumable download tehát nem csak Range: stabil objektumazonosító vagy ETag is kell hozzá.

Hol legyen ez a logika?

Productionben statikus vagy objektumtárban levő videót, PDF-et és telepítőcsomagot inkább a CDN/reverse proxy rétegére bíznám. Az már kezeli a többrészes range-et, a backpressure-t, a cache-t és a kliens megszakítását; egy Node process ne váljon véletlenül médiakiszolgálóvá. Az alkalmazásból történő, jogosultság-ellenőrzött fájlletöltésnél viszont teljesen indokolt a saját handler. Ilyenkor előbb autorizálj, utána állíts elő egy változatlan, egyértelmű fájlverziót, végül szolgáld ki a range-et.

Nem érdemes Range-t adni minden dinamikus JSON válaszhoz. Nem gyorsítja fel a 30 kB-os API-választ, viszont új cache- és tömörítési kérdéseket visz a rendszerbe. Ott hasznos, ahol a teljes reprezentáció nagy, a részleges olvasás valós felhasználói művelet, és a bájtpozícióknak stabil jelentésük van.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük