diff --git a/README.md b/README.md index 3d77a9b..fd0660a 100644 --- a/README.md +++ b/README.md @@ -22,23 +22,35 @@ import { parse, combineRanges } from "range-parser"; ### `parse(size, header)` -Parse the given `header` string where `size` is the size of the selected representation that is to be partitioned into subranges. A type and an array of subranges will be returned or negative numbers indicating an error parsing. +Parse the given `header` string where `size` is the size of the selected representation that is to be partitioned into subranges. Always returns an object with `code`, `type`, and `ranges` properties. -- `-2` signals a malformed header string -- `-1` signals an unsatisfiable range +- `code: 0` means all ranges were parsed successfully. +- `code: 1` means the header was malformed. +- `code: 2` means one or more ranges were unsatisfiable. +- `code: 3` means both malformed and unsatisfiable ranges were encountered (`1 | 2`). + +`ranges` contains all valid and satisfiable ranges. Each range has inclusive `start` and `end` positions. + +```js +parse(200, "bytes=0-99,x-,500-"); +// => { +// code: 3, +// type: "bytes", +// ranges: [{ start: 0, end: 99 }] +// } +``` **Note:** `size` must be a safe integer or a `TypeError` is thrown. The maximum supported file size is `Number.MAX_SAFE_INTEGER` (9,007,199,254,740,991 bytes), about 9 PB. ```js // parse header from request var header = req.headers.range; -var subranges = typeof header === "string" ? parse(size, header) : -2; +var subranges = parse(size, typeof header === "string" ? header : ""); -// the type of the subranges -if (typeof subranges === "object" && subranges.type === "bytes") { - // the ranges - subranges.ranges.forEach(function (r) { - // do something with r.start and r.end +// Process headers whose ranges are all valid and satisfiable. +if (subranges.type === "bytes" && subranges.ranges.length) { + subranges.ranges.forEach((r) => { + // Do something with r.start and r.end }); } ``` @@ -50,7 +62,7 @@ Returned ranges include the `index` used to preserve their original order. ```js const subranges = parse(100, "bytes=50-55,0-10,5-10,56-60"); -if (typeof subranges === "object") { +if (subranges.ranges.length) { combineRanges(subranges.ranges); // => [ // { start: 50, end: 60, index: 0 }, diff --git a/src/index.spec.ts b/src/index.spec.ts index e962ffe..58dc181 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,66 +1,159 @@ import { describe, it, expect } from "vitest"; import { parse, combineRanges } from "./index"; -describe("parse(len, str)", () => { - it("should return -2 for an empty str", () => { - expect(parse(200, "")).toBe(-2); +describe("parse(size, str)", () => { + it("should return code 1 for an empty str", () => { + expect(parse(200, "")).toEqual({ code: 1, type: "", ranges: [] }); }); - it("should return -2 for range missing dash", () => { - expect(parse(200, "bytes=100200")).toBe(-2); - expect(parse(200, "bytes=,100200")).toBe(-2); + it("should return code 1 for range missing dash", () => { + expect(parse(200, "bytes=100200")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes=,100200")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); }); - it("should return -2 for empty range value", () => { - expect(parse(200, "bytes=")).toBe(-2); - expect(parse(200, "bytes=,")).toBe(-2); - expect(parse(200, "bytes= , , ")).toBe(-2); + it("should return code 1 for empty range value", () => { + expect(parse(200, "bytes=")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes=,")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes= , , ")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); }); - it("should return -2 when all multiple ranges have invalid format", () => { - expect(parse(200, "bytes=y-v,x-")).toBe(-2); - expect(parse(200, "bytes=abc-def,ghi-jkl")).toBe(-2); - expect(parse(200, "bytes=x-,y-,z-")).toBe(-2); + it("should return code 1 when all multiple ranges have invalid format", () => { + expect(parse(200, "bytes=y-v,x-")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes=abc-def,ghi-jkl")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes=x-,y-,z-")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); }); - it("should return -2 for invalid str", () => { - expect(parse(200, "malformed")).toBe(-2); + it("should return code 1 for a header missing equals", () => { + expect(parse(200, "malformed")).toEqual({ code: 1, type: "", ranges: [] }); }); - it("should return -2 for missing bytes", () => { - expect(parse(200, "bytes=")).toBe(-2); - expect(parse(200, "bytes= ")).toBe(-2); + it("should return code 1 for missing bytes", () => { + expect(parse(200, "bytes=")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes= ")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); }); - it("should return -2 for invalid start byte position", () => { - expect(parse(200, "bytes=x-100")).toBe(-2); + it("should return code 1 for invalid start byte position", () => { + expect(parse(200, "bytes=x-100")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); }); - it("should return -2 for invalid end byte position", () => { - expect(parse(200, "bytes=100-x")).toBe(-2); + it("should return code 1 for invalid end byte position", () => { + expect(parse(200, "bytes=100-x")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); }); - it("should return -2 for invalid range format", () => { - expect(parse(200, "bytes=--100")).toBe(-2); - expect(parse(200, "bytes=100--200")).toBe(-2); - expect(parse(200, "bytes=-")).toBe(-2); - expect(parse(200, "bytes= - ")).toBe(-2); + it("should return code 1 for invalid range format", () => { + expect(parse(200, "bytes=--100")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes=100--200")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); + }); + + it("should return code 1 for bare dash ranges", () => { + expect(parse(200, "bytes=-")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes= - ")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); }); - it("should return -2 with multiple dashes in range", () => { - expect(parse(200, "bytes=100-200-300")).toBe(-2); + it("should retain valid ranges when a bare dash is present", () => { + expect(parse(200, "bytes=-,0-10")).toEqual({ + code: 1, + type: "bytes", + ranges: [{ start: 0, end: 10 }], + }); + }); + + it("should return code 1 with multiple dashes in range", () => { + expect(parse(200, "bytes=100-200-300")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); }); - it("should return -2 for negative start byte position", () => { - expect(parse(200, "bytes=-100-150")).toBe(-2); + it("should return code 1 for negative start byte position", () => { + expect(parse(200, "bytes=-100-150")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); }); - it("should return -2 for invalid number format", () => { - expect(parse(200, "bytes=01a-150")).toBe(-2); - expect(parse(200, "bytes=100-15b0")).toBe(-2); + it("should return code 1 for invalid number format", () => { + expect(parse(200, "bytes=01a-150")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes=100-15b0")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); }); it.each([ + -1, + Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER + 1, Number.MIN_SAFE_INTEGER - 1, 1e20, @@ -76,6 +169,7 @@ describe("parse(len, str)", () => { it("should accept the maximum safe integer size", () => { expect(parse(Number.MAX_SAFE_INTEGER, "bytes=9007199254740990-")).toEqual({ + code: 0, type: "bytes", ranges: [{ start: 9007199254740990, end: 9007199254740990 }], }); @@ -83,42 +177,128 @@ describe("parse(len, str)", () => { it("should cap overflowing end positions at size", () => { expect(parse(200, `bytes=0-${"9".repeat(400)}`)).toEqual({ + code: 0, type: "bytes", ranges: [{ start: 0, end: 199 }], }); }); it("should reject invalid digits after positions exceed safe integers", () => { - expect(parse(200, "bytes=0-90071992547409930x")).toBe(-2); + expect(parse(200, "bytes=0-90071992547409930x")).toEqual({ + code: 1, + type: "bytes", + ranges: [], + }); }); - it("should return -1 for unsatisfiable range", () => { - expect(parse(200, "bytes=500-600")).toBe(-1); + it("should return code 2 for unsatisfiable range", () => { + expect(parse(200, "bytes=500-600")).toEqual({ + code: 2, + type: "bytes", + ranges: [], + }); }); - it("should return -1 for unsatisfiable range with multiple ranges", () => { - expect(parse(200, "bytes=500-600,601-700")).toBe(-1); + it("should return code 2 for unsatisfiable range with multiple ranges", () => { + expect(parse(200, "bytes=500-600,601-700")).toEqual({ + code: 2, + type: "bytes", + ranges: [], + }); }); - it("should return -1 if all specified ranges are unsatisfiable", () => { - expect(parse(200, "bytes=500-20")).toBe(-1); - expect(parse(200, "bytes=500-999")).toBe(-1); - expect(parse(200, "bytes=500-999,1000-1499")).toBe(-1); + it("should return code 2 if all specified ranges are unsatisfiable", () => { + expect(parse(200, "bytes=500-999")).toEqual({ + code: 2, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes=500-999,1000-1499")).toEqual({ + code: 2, + type: "bytes", + ranges: [], + }); }); - it("should prefer -1 when all ranges are invalid and unsatisfiable ranges", () => { - expect(parse(200, "bytes=abc-def,500-999")).toBe(-1); - expect(parse(200, "bytes=500-999,xyz-uvw")).toBe(-1); - expect(parse(200, "bytes=abc-def,500-999,xyz-uvw")).toBe(-1); + it.each([ + "bytes=50-20", + "bytes=500-20", + "bytes=600-500", + "bytes=9007199254740993-9007199254740992", + `bytes=${"9".repeat(400)}-${"8".repeat(400)}`, + ])("should return code 2 for reversed bounds in %s", (header) => { + expect(parse(200, header)).toEqual({ code: 2, type: "bytes", ranges: [] }); + }); + + it("should preserve valid ranges when explicit bounds are reversed", () => { + expect(parse(200, "bytes=50-20,0-10")).toEqual({ + code: 2, + type: "bytes", + ranges: [{ start: 0, end: 10 }], + }); + }); + + it("should report reversed bounds together with unsatisfiable ranges", () => { + expect(parse(200, "bytes=50-20,500-600")).toEqual({ + code: 2, + type: "bytes", + ranges: [], + }); + }); + + it.each([ + "bytes=9007199254740992-9007199254740993", + `bytes=${"8".repeat(400)}-${"9".repeat(400)}`, + ])("should keep large ordered bounds unsatisfiable in %s", (header) => { + expect(parse(200, header)).toEqual({ code: 2, type: "bytes", ranges: [] }); }); - it("should be unsatisfiable asking for -0", () => { - expect(parse(1000, "bytes=-0")).toBe(-1); + it("should return code 3 when ranges are malformed and unsatisfiable", () => { + expect(parse(200, "bytes=abc-def,500-999")).toEqual({ + code: 3, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes=500-999,xyz-uvw")).toEqual({ + code: 3, + type: "bytes", + ranges: [], + }); + expect(parse(200, "bytes=abc-def,500-999,xyz-uvw")).toEqual({ + code: 3, + type: "bytes", + ranges: [], + }); }); + it.each(["bytes=-1", "bytes=-500", "bytes=,-1,", "bytes=-1,-2"])( + "should accept nonzero suffixes for an empty representation in %s", + (header) => { + expect(parse(0, header)).toEqual({ code: 0, type: "bytes", ranges: [] }); + }, + ); + + it.each([ + ["bytes=-0", 2], + ["bytes=0-", 2], + ["bytes=0-0", 2], + ["bytes=-x", 1], + ["bytes=-1,x-", 1], + ["bytes=-1,0-", 2], + ["bytes=-1,-0", 2], + ["bytes=-1,x-,0-", 3], + ["bytes=, \t,", 1], + ])( + "should preserve errors for an empty representation in %s", + (header, code) => { + expect(parse(0, header)).toEqual({ code, type: "bytes", ranges: [] }); + }, + ); + it("should parse str", () => { var range = parse(1000, "bytes=0-499"); expect(range).toEqual({ + code: 0, type: "bytes", ranges: [{ start: 0, end: 499 }], }); @@ -127,6 +307,7 @@ describe("parse(len, str)", () => { it("should cap end at size", () => { var range = parse(200, "bytes=0-499"); expect(range).toEqual({ + code: 0, type: "bytes", ranges: [{ start: 0, end: 199 }], }); @@ -135,6 +316,7 @@ describe("parse(len, str)", () => { it("should parse str", () => { var range = parse(1000, "bytes=40-80"); expect(range).toEqual({ + code: 0, type: "bytes", ranges: [{ start: 40, end: 80 }], }); @@ -143,6 +325,7 @@ describe("parse(len, str)", () => { it("should parse str asking for last n bytes", () => { var range = parse(1000, "bytes=-400"); expect(range).toEqual({ + code: 0, type: "bytes", ranges: [{ start: 600, end: 999 }], }); @@ -151,6 +334,7 @@ describe("parse(len, str)", () => { it("should parse str with only start", () => { var range = parse(1000, "bytes=400-"); expect(range).toEqual({ + code: 0, type: "bytes", ranges: [{ start: 400, end: 999 }], }); @@ -159,6 +343,7 @@ describe("parse(len, str)", () => { it('should parse "bytes=0-"', () => { var range = parse(1000, "bytes=0-"); expect(range).toEqual({ + code: 0, type: "bytes", ranges: [{ start: 0, end: 999 }], }); @@ -167,6 +352,7 @@ describe("parse(len, str)", () => { it("should parse str with no bytes", () => { var range = parse(1000, "bytes=0-0"); expect(range).toEqual({ + code: 0, type: "bytes", ranges: [{ start: 0, end: 0 }], }); @@ -175,6 +361,7 @@ describe("parse(len, str)", () => { it("should parse str asking for last byte", () => { var range = parse(1000, "bytes=-1"); expect(range).toEqual({ + code: 0, type: "bytes", ranges: [{ start: 999, end: 999 }], }); @@ -183,6 +370,7 @@ describe("parse(len, str)", () => { it("should parse str with multiple ranges", () => { var range = parse(1000, "bytes=40-80,81-90,-1"); expect(range).toEqual({ + code: 0, type: "bytes", ranges: [ { start: 40, end: 80 }, @@ -192,9 +380,10 @@ describe("parse(len, str)", () => { }); }); - it("should parse str with some invalid ranges", () => { + it("should retain valid ranges with code 2 for unsatisfiable ranges", () => { var range = parse(200, "bytes=0-499,1000-,500-999"); expect(range).toEqual({ + code: 2, type: "bytes", ranges: [{ start: 0, end: 199 }], }); @@ -203,6 +392,7 @@ describe("parse(len, str)", () => { it("should parse str with whitespace", () => { var range = parse(1000, "bytes= 40-80 , 81-90 , -1 "); expect(range).toEqual({ + code: 0, type: "bytes", ranges: [ { start: 40, end: 80 }, @@ -212,9 +402,22 @@ describe("parse(len, str)", () => { }); }); + it("should parse tabs around range elements", () => { + expect(parse(1000, "bytes=\t40-80\t,\t81-\t,\t-1\t")).toEqual({ + code: 0, + type: "bytes", + ranges: [ + { start: 40, end: 80 }, + { start: 81, end: 999 }, + { start: 999, end: 999 }, + ], + }); + }); + it("should parse non-byte range", () => { var range = parse(1000, "items=0-5"); expect(range).toEqual({ + code: 0, type: "items", ranges: [{ start: 0, end: 5 }], }); @@ -222,27 +425,34 @@ describe("parse(len, str)", () => { it("should use the whole representation when the suffix-length is larger", () => { expect(parse(100, "bytes=-101")).toEqual({ + code: 0, type: "bytes", ranges: [{ start: 0, end: 99 }], }); }); - it("should ignore invalid format range when valid range exists", () => { + it("should report code 1 for invalid format range when valid range exists", () => { var range = parse(1000, "bytes=100-200,x-"); expect(range).toEqual({ + code: 1, type: "bytes", ranges: [{ start: 100, end: 200 }], }); }); - it("should ignore invalid format ranges when some are valid", () => { + it("should report code 1 for invalid format ranges when some are valid", () => { var range = parse(1000, "bytes=x-,0-100,y-"); - expect(range).toEqual({ type: "bytes", ranges: [{ start: 0, end: 100 }] }); + expect(range).toEqual({ + code: 1, + type: "bytes", + ranges: [{ start: 0, end: 100 }], + }); }); - it("should ignore invalid format ranges at different positions", () => { + it("should report code 1 for invalid format ranges at different positions", () => { var range = parse(1000, "bytes=0-50,abc-def,100-150"); expect(range).toEqual({ + code: 1, type: "bytes", ranges: [ { start: 0, end: 50 }, @@ -251,14 +461,63 @@ describe("parse(len, str)", () => { }); }); - it("should ignore whitespace-only invalid ranges when valid present", () => { - var range = parse(1000, "bytes= , 0-10"); + it.each([ + "bytes=,0-10", + "bytes= , 0-10", + "bytes=0-10,", + "bytes=0-10,,", + "bytes=, ,\t,0-10, \t,", + ])("should ignore empty list elements in %s", (header) => { + var range = parse(1000, header); + + expect(range).toEqual({ + code: 0, + type: "bytes", + ranges: [{ start: 0, end: 10 }], + }); + }); + + it("should ignore empty elements between ranges", () => { + expect(parse(200, "bytes=0-10,, \t,20-30")).toEqual({ + code: 0, + type: "bytes", + ranges: [ + { start: 0, end: 10 }, + { start: 20, end: 30 }, + ], + }); + }); + + it.each([ + ["bytes=,500-600,", 2], + ["bytes=,x-,", 1], + ["bytes=,x-,,500-600,", 3], + ["bytes=, \t,", 1], + ])( + "should preserve errors while ignoring empty elements in %s", + (header, code) => { + expect(parse(200, header)).toEqual({ code, type: "bytes", ranges: [] }); + }, + ); - expect(range).toEqual({ type: "bytes", ranges: [{ start: 0, end: 10 }] }); + it.each([ + "bytes=0-10,x-,1000-", + "bytes=1000-,0-10,x-", + "bytes=x-,1000-,0-10", + ])("should retain valid ranges with both status bits for %s", (header) => { + expect(parse(200, header)).toEqual({ + code: 3, + type: "bytes", + ranges: [{ start: 0, end: 10 }], + }); }); }); describe("combineRanges(ranges)", () => { + it("should return an empty array for no ranges", () => { + expect(combineRanges([])).toEqual([]); + }); + it("should return new ranges without modifying the input", () => { const ranges = [ { start: 10, end: 20 }, diff --git a/src/index.ts b/src/index.ts index 8640bea..23d7735 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,9 +14,10 @@ export interface Range { } /** - * Result object contains type and array of Range objects. + * Result object contains a status code, type, and array of Range objects. */ export interface Result { + code: number; type: string; ranges: Range[]; } @@ -24,47 +25,69 @@ export interface Result { /** * Parse "Range" header `str` relative to the given file `size`. */ -export function parse(size: number, str: string): Result | -1 | -2 { - if (!Number.isSafeInteger(size)) { +export function parse(size: number, str: string): Result { + if (!Number.isSafeInteger(size) || size < 0) { throw new TypeError(`Invalid size: ${size}`); } + const ranges: Range[] = []; const eqIndex = str.indexOf("="); - if (eqIndex === -1) return -2; + if (eqIndex === -1) return { code: 1, type: "", ranges }; - const ranges: Range[] = []; const type = str.slice(0, eqIndex); const len = str.length; let index = eqIndex + 1; - let valid = false; + let code: Result["code"] = 0; + let hasRange = false; do { const commaIndex = str.indexOf(",", index); const endIndex = commaIndex !== -1 ? commaIndex : len; - const dashIndex = str.indexOf("-", index); - if (dashIndex === -1 || dashIndex > endIndex) { - index = endIndex + 1; + // Ignore empty list elements, including optional whitespace. + let startStrIndex = index; + let endStrIndex = endIndex; + while ( + str.charCodeAt(startStrIndex) === 32 || + str.charCodeAt(startStrIndex) === 9 + ) + startStrIndex++; + + while ( + str.charCodeAt(endStrIndex - 1) === 32 || + str.charCodeAt(endStrIndex - 1) === 9 + ) + endStrIndex--; + + index = endIndex + 1; + + if (startStrIndex >= endStrIndex) { continue; } - // handle ows around numbers - let startStrIndex = index; - let endStrIndex = endIndex; - while (str.charCodeAt(startStrIndex) === 32) startStrIndex++; - while (str.charCodeAt(endStrIndex - 1) === 32) endStrIndex--; + hasRange = true; + + const dashIndex = str.indexOf("-", startStrIndex); + if (dashIndex === -1 || dashIndex > endStrIndex) { + code |= 1; // Invalid range. + continue; + } let start = NaN; let end = NaN; if (startStrIndex === dashIndex) { if (endStrIndex === dashIndex + 1) { - index = endIndex + 1; + code |= 1; // Invalid range. continue; // Just "-" is not valid. } // suffix-byte-range-spec, e.g. "-500" - start = Math.max(size - parsePos(str, dashIndex + 1, endStrIndex), 0); + const suffixLength = parsePos(str, dashIndex + 1, endStrIndex); + // A nonzero suffix is satisfiable for an empty file. + if (size === 0 && suffixLength > 0) continue; + + start = Math.max(size - suffixLength, 0); end = size - 1; } else { start = parsePos(str, startStrIndex, dashIndex); @@ -77,15 +100,13 @@ export function parse(size: number, str: string): Result | -1 | -2 { } } - index = endIndex + 1; - if (isNaN(start) || isNaN(end)) { + code |= 1; // Unable to parse. continue; } - // Invalid or unsatisfiable. if (start > end) { - valid = true; + code |= 2; // Bad range request. continue; } @@ -95,12 +116,10 @@ export function parse(size: number, str: string): Result | -1 | -2 { }); } while (index < len); - if (ranges.length < 1) { - // unsatisfiable - return valid ? -1 : -2; - } + // A range-set must contain at least one nonempty element. + if (!hasRange) return { code: 1, type, ranges }; - return { type, ranges }; + return { code, type, ranges }; } /** @@ -125,6 +144,8 @@ export interface RangeWithIndex extends Range { * Combine overlapping & adjacent ranges. */ export function combineRanges(ranges: Range[]): RangeWithIndex[] { + if (ranges.length === 0) return []; + const ordered = ranges .map((range, index) => ({ start: range.start, end: range.end, index })) .sort((a, b) => a.start - b.start);