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.
Code
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));
}Report a problem with this exampleOpens a pre-filled issue on GitHub.