@remix-run/headers
Version:
A toolkit for working with HTTP headers in JavaScript
181 lines (161 loc) • 5.53 kB
text/typescript
import { type HeaderValue } from './header-value.ts'
/**
* Initializer for a `Range` header value.
*/
export interface RangeInit {
/**
* The unit of the range, typically "bytes"
*/
unit?: string
/**
* The ranges requested. Each range has optional start and end values.
* - {start: 0, end: 99} = bytes 0-99
* - {start: 100} = bytes 100- (from 100 to end)
* - {end: 500} = bytes -500 (last 500 bytes)
*/
ranges?: Array<{ start?: number; end?: number }>
}
/**
* The value of a `Range` HTTP header.
*
* [MDN `Range` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range)
*
* [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.range)
*/
export class Range implements HeaderValue, RangeInit {
unit: string = ''
ranges: Array<{ start?: number; end?: number }> = []
/**
* @param init A string or object to initialize the header
*/
constructor(init?: string | RangeInit) {
if (init) {
if (typeof init === 'string') {
// Parse: "bytes=200-1000" or "bytes=200-" or "bytes=-500" or "bytes=0-99,200-299"
let match = init.match(/^(\w+)=(.+)$/)
if (match) {
this.unit = match[1]
let rangeParts = match[2].split(',')
// Track if any range part is invalid to mark the entire header as malformed
let hasInvalidPart = false
for (let part of rangeParts) {
let rangeMatch = part.trim().match(/^(\d*)-(\d*)$/)
if (!rangeMatch) {
// Invalid syntax for this range part
hasInvalidPart = true
continue
}
let [, startStr, endStr] = rangeMatch
// At least one bound must be specified
if (!startStr && !endStr) {
hasInvalidPart = true
continue
}
let start = startStr ? parseInt(startStr, 10) : undefined
let end = endStr ? parseInt(endStr, 10) : undefined
// If both bounds are specified, start must be <= end
if (start !== undefined && end !== undefined && start > end) {
hasInvalidPart = true
continue
}
this.ranges.push({ start, end })
}
// If any part was invalid, mark as malformed by clearing ranges
if (hasInvalidPart) {
this.ranges = []
}
}
} else {
if (init.unit !== undefined) this.unit = init.unit
if (init.ranges !== undefined) this.ranges = init.ranges
}
}
}
/**
* Checks if this range can be satisfied for a resource of the given size.
*
* @param resourceSize The size of the resource in bytes
* @return `false` if the range is malformed or all ranges are beyond the resource size
*/
canSatisfy(resourceSize: number): boolean {
// No unit or no ranges means header was malformed or empty
if (!this.unit || this.ranges.length === 0) return false
// Validate all ranges first
for (let range of this.ranges) {
// At least one bound must be specified
if (range.start === undefined && range.end === undefined) {
return false
}
// If both are specified, start must be <= end
if (range.start !== undefined && range.end !== undefined && range.start > range.end) {
return false
}
}
// Check if at least one range is within the resource
for (let range of this.ranges) {
if (range.start === undefined) {
// Suffix range (e.g., "-500") is always satisfiable
return true
}
if (range.start < resourceSize) {
// At least one range starts within the resource
return true
}
}
return false
}
/**
* Normalizes the ranges for a resource of the given size.
* Returns an array of ranges with resolved start and end values.
* Returns an empty array if the range cannot be satisfied.
*
* @param resourceSize The size of the resource in bytes
* @return An array of ranges with resolved start and end values
*/
normalize(resourceSize: number): Array<{ start: number; end: number }> {
if (!this.canSatisfy(resourceSize)) {
return []
}
return this.ranges.map((range) => {
if (range.start !== undefined && range.end !== undefined) {
// Both bounds specified (e.g., "0-99")
return {
start: range.start,
end: Math.min(range.end, resourceSize - 1),
}
} else if (range.start !== undefined) {
// Only start specified (e.g., "100-")
return {
start: range.start,
end: resourceSize - 1,
}
} else {
// Only end specified (e.g., "-500" means last 500 bytes)
let suffix = range.end!
return {
start: Math.max(0, resourceSize - suffix),
end: resourceSize - 1,
}
}
})
}
/**
* Returns the string representation of the header value.
*
* @return The header value as a string
*/
toString(): string {
if (!this.unit || this.ranges.length === 0) return ''
let rangeParts = this.ranges.map((range) => {
if (range.start !== undefined && range.end !== undefined) {
return `${range.start}-${range.end}`
} else if (range.start !== undefined) {
return `${range.start}-`
} else if (range.end !== undefined) {
return `-${range.end}`
}
return ''
})
return `${this.unit}=${rangeParts.join(',')}`
}
}