diff --git a/README.md b/README.md index fd0660a..7068617 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ npm install range-parser ## API ```js -import { parse, combineRanges } from "range-parser"; +import { parse, format, combineRanges } from "range-parser"; ``` ### `parse(size, header)` @@ -55,6 +55,29 @@ if (subranges.type === "bytes" && subranges.ranges.length) { } ``` +### `format(obj)` + +Format an object containing a range unit `type` and an array of `ranges` with inclusive `start` and `end` positions as a `Range` header. + +```js +format({ + type: "bytes", + ranges: [ + { start: 0, end: 99 }, + { start: 200, end: 299 }, + ], +}); +// => "bytes=0-99,200-299" + +format({ type: "items", ranges: [{ start: 0, end: 5 }] }); +// => "items=0-5" + +format(parse(1000, "bytes=900-,-10")); +// => "bytes=900-999,990-999" +``` + +Throws a `TypeError` if the object contains an invalid type or range. + ### `combineRanges(ranges)` Overlapping and adjacent ranges will be combined and returned. diff --git a/src/index.spec.ts b/src/index.spec.ts index 7c329bd..a2b134e 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,5 +1,99 @@ import { describe, it, expect } from "vitest"; -import { parse, combineRanges } from "./index.js"; +import { parse, format, combineRanges } from "./index.js"; + +describe("format({ type, ranges })", () => { + it("should format a single byte range", () => { + expect(format({ type: "bytes", ranges: [{ start: 0, end: 0 }] })).toBe( + "bytes=0-0", + ); + }); + + it("should preserve input order without modifying or combining ranges", () => { + const ranges = Object.freeze([ + Object.freeze({ start: 20, end: 30 }), + Object.freeze({ start: 0, end: 20 }), + Object.freeze({ start: 20, end: 30 }), + ]); + expect(format({ type: "bytes", ranges })).toBe("bytes=20-30,0-20,20-30"); + }); + + it("should format a custom range unit", () => { + expect(format({ type: "items", ranges: [{ start: 0, end: 5 }] })).toBe( + "items=0-5", + ); + }); + + it("should accept all HTTP token characters in the range unit", () => { + const type = + "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + expect(format({ type: type, ranges: [{ start: 0, end: 0 }] })).toBe( + `${type}=0-0`, + ); + }); + + it("should round trip normalized parsed ranges", () => { + const result = parse(1000, "bytes=40-80,900-,-10"); + expect(format(result)).toBe("bytes=40-80,900-999,990-999"); + expect(parse(1000, format(result))).toEqual(result); + }); + + it("should format retained ranges regardless of the parse status code", () => { + expect(format(parse(100, "bytes=0-9,x-,200-"))).toBe("bytes=0-9"); + }); + + it("should format safe integer boundaries without exponential notation", () => { + expect( + format({ + type: "bytes", + ranges: [{ start: 0, end: Number.MAX_SAFE_INTEGER }], + }), + ).toBe("bytes=0-9007199254740991"); + }); + + it("should reject an empty range list", () => { + expect(() => format({ type: "bytes", ranges: [] })).toThrow(TypeError); + }); + + it("should reject sparse range lists", () => { + expect(() => format({ type: "bytes", ranges: new Array(1) })).toThrow( + TypeError, + ); + }); + + it.each([-1, 0.5, NaN, Infinity, -Infinity, Number.MAX_SAFE_INTEGER + 1])( + "should reject an invalid position of %s", + (position) => { + expect(() => + format({ type: "bytes", ranges: [{ start: position, end: 10 }] }), + ).toThrow(TypeError); + expect(() => + format({ type: "bytes", ranges: [{ start: 0, end: position }] }), + ).toThrow(TypeError); + }, + ); + + it("should reject reversed bounds", () => { + expect(() => + format({ type: "bytes", ranges: [{ start: 10, end: 9 }] }), + ).toThrow(TypeError); + }); + + it.each([ + "", + "bytes ", + " bytes", + "a=b", + "a,b", + "a/b", + "bytes\r\nX: y", + "é", + "bytes\n", + ])("should reject an invalid range unit of %j", (type) => { + expect(() => + format({ type: type, ranges: [{ start: 0, end: 0 }] }), + ).toThrow(TypeError); + }); +}); describe("parse(size, str)", () => { it("should return code 1 for an empty str", () => { @@ -518,12 +612,12 @@ describe("combineRanges(ranges)", () => { expect(combineRanges([])).toEqual([]); }); - it("should return new ranges without modifying the input", () => { - const ranges = [ - { start: 10, end: 20 }, - { start: 0, end: 9 }, - { start: 30, end: 40 }, - ]; + it("should return new ranges without modifying input", () => { + const ranges = Object.freeze([ + Object.freeze({ start: 10, end: 20 }), + Object.freeze({ start: 0, end: 9 }), + Object.freeze({ start: 30, end: 40 }), + ]); const original = ranges.map((range) => ({ ...range })); const combined = combineRanges(ranges); @@ -532,7 +626,6 @@ describe("combineRanges(ranges)", () => { { start: 30, end: 40, index: 2 }, ]); expect(ranges).toEqual(original); - expect(combined[1]).not.toBe(ranges[2]); }); it("should combine overlapping ranges", () => { diff --git a/src/index.ts b/src/index.ts index 23d7735..f26bbfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,39 @@ export interface Range { export interface Result { code: number; type: string; - ranges: Range[]; + ranges: readonly Range[]; +} + +/** + * Format normalized ranges as a "Range" header value. + */ +export function format(obj: Partial): string { + const { type, ranges } = obj; + + if (!type || /[^!#$%&'*+\-.^_`|~0-9A-Za-z]/.test(type)) { + throw new TypeError("Invalid range type"); + } + + if (!ranges || ranges.length === 0) { + throw new TypeError("At least one range is required"); + } + + const parts: string[] = []; + for (const range of ranges) { + if ( + !range || + !Number.isSafeInteger(range.start) || + !Number.isSafeInteger(range.end) || + range.start < 0 || + range.end < range.start + ) { + throw new TypeError("Invalid range"); + } + + parts.push(`${range.start}-${range.end}`); + } + + return `${type}=${parts.join(",")}`; } /** @@ -143,7 +175,9 @@ export interface RangeWithIndex extends Range { /** * Combine overlapping & adjacent ranges. */ -export function combineRanges(ranges: Range[]): RangeWithIndex[] { +export function combineRanges( + ranges: readonly Range[], +): readonly RangeWithIndex[] { if (ranges.length === 0) return []; const ordered = ranges