HTTP Range Header Parser (TypeScript)

Parse single and multi-range byte requests, merge overlaps, and distinguish invalid syntax from unsatisfiable ranges for 206 or 416 responses.

TypescriptData Fetching

Code

snippet
export interface ByteRange {
  readonly start: number;
  readonly end: number;
}

export type RangeParseResult =
  | { kind: "none" }
  | { kind: "invalid"; reason: string }
  | { kind: "unsatisfiable"; reason: string }
  | { kind: "partial"; ranges: ByteRange[] };

function parseInteger(value: string): number | null {
  if (!/^\d+$/.test(value)) return null;
  const parsed = Number(value);
  return Number.isSafeInteger(parsed) ? parsed : null;
}

export function parseRangeHeader(
  header: string | null | undefined,
  size: number,
): RangeParseResult {
  if (!Number.isSafeInteger(size) || size < 0) {
    throw new RangeError("size must be a non-negative safe integer");
  }
  if (header === null || header === undefined) {
    return { kind: "none" };
  }
  if (typeof header !== "string") {
    throw new TypeError("Range header must be a string");
  }

  const value = header.trim();
  if (value.length === 0) {
    return { kind: "invalid", reason: "empty Range header" };
  }
  const match = /^bytes=(.+)$/i.exec(value);
  if (match === null) {
    return {
      kind: "invalid",
      reason: "only bytes ranges are supported",
    };
  }

  const specs = match[1].split(",").map((item) => item.trim());
  if (specs.length === 0 || specs.length > 16) {
    return {
      kind: "invalid",
      reason: "expected between 1 and 16 ranges",
    };
  }

  const ranges: ByteRange[] = [];
  let hadUnsatisfiable = false;

  for (const spec of specs) {
    if (spec.length === 0) {
      return { kind: "invalid", reason: "empty range specification" };
    }

    if (spec.startsWith("-")) {
      const suffix = parseInteger(spec.slice(1));
      if (suffix === null || suffix === 0) {
        return {
          kind: "invalid",
          reason: `invalid suffix range "${spec}"`,
        };
      }
      if (size === 0) {
        hadUnsatisfiable = true;
        continue;
      }
      const length = Math.min(suffix, size);
      ranges.push({ start: size - length, end: size - 1 });
      continue;
    }

    const separator = spec.indexOf("-");
    if (separator < 1) {
      return { kind: "invalid", reason: `invalid range "${spec}"` };
    }
    const start = parseInteger(spec.slice(0, separator));
    const endText = spec.slice(separator + 1);
    if (start === null) {
      return { kind: "invalid", reason: `invalid range "${spec}"` };
    }
    if (start >= size) {
      hadUnsatisfiable = true;
      continue;
    }
    if (endText.length === 0) {
      ranges.push({ start, end: size - 1 });
      continue;
    }

    const end = parseInteger(endText);
    if (end === null || end < start) {
      return { kind: "invalid", reason: `invalid range "${spec}"` };
    }
    ranges.push({ start, end: Math.min(end, size - 1) });
  }

  if (ranges.length === 0) {
    return {
      kind: "unsatisfiable",
      reason: hadUnsatisfiable
        ? "no range overlaps the resource"
        : "no ranges requested",
    };
  }

  ranges.sort((left, right) => left.start - right.start || left.end - right.end);
  const merged: ByteRange[] = [];
  for (const range of ranges) {
    const last = merged[merged.length - 1];
    if (last !== undefined && range.start <= last.end + 1) {
      merged[merged.length - 1] = {
        start: last.start,
        end: Math.max(last.end, range.end),
      };
    } else {
      merged.push(range);
    }
  }
  return { kind: "partial", ranges: merged };
}

const cases: Array<[string | null, number]> = [
  ["bytes=0-499", 1000],
  ["bytes=500-", 1000],
  ["bytes=-200", 1000],
  ["bytes=0-0,900-999", 1000],
  ["bytes=2000-3000", 1000],
  ["items=0-10", 1000],
];
for (const [header, size] of cases) {
  console.log(header, parseRangeHeader(header, size));
}