221 lines
4.7 KiB
TypeScript
221 lines
4.7 KiB
TypeScript
import { Readable, ReadableOptions } from "node:stream";
|
|
import { Buffer, constants as BufferConstants } from "node:buffer";
|
|
import * as fs from "node:fs";
|
|
|
|
interface ByteRange {
|
|
start: bigint;
|
|
end: bigint;
|
|
size: bigint;
|
|
}
|
|
|
|
const BOUNDARY_CHARS =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
const BYTERANGE_SPEC_REGEX = /^bytes=(.+)$/;
|
|
const BYTERANGE_REGEX = /(\d*)-(\d*)/;
|
|
|
|
const BIGINT_0 = BigInt(0);
|
|
const BIGINT_1 = BigInt(1);
|
|
const BOUNDARY_SIZE = 40;
|
|
|
|
function extractRanges(
|
|
fileSize: bigint,
|
|
maxByteRanges: number,
|
|
rangeHeaderValue: string,
|
|
): ByteRange[] {
|
|
const ranges: ByteRange[] = [];
|
|
|
|
if (!rangeHeaderValue) return ranges;
|
|
|
|
const rangeSpecMatch = rangeHeaderValue.match(BYTERANGE_SPEC_REGEX);
|
|
if (!rangeSpecMatch) return [];
|
|
|
|
const rangeSpecs = rangeSpecMatch[1].split(",");
|
|
for (let i = 0; i < rangeSpecs.length; i = i + 1) {
|
|
const byteRange = rangeSpecs[i].match(BYTERANGE_REGEX);
|
|
if (!byteRange) return [];
|
|
|
|
let start: bigint;
|
|
let end: bigint;
|
|
let size: bigint;
|
|
|
|
if (byteRange[1]) {
|
|
start = BigInt(byteRange[1]);
|
|
}
|
|
|
|
if (byteRange[2]) {
|
|
end = BigInt(byteRange[2]);
|
|
}
|
|
|
|
if (start === undefined && end === undefined) {
|
|
/* some invalid range like bytes=- */
|
|
return [];
|
|
}
|
|
|
|
if (start === undefined) {
|
|
/* end-of-file range like -500 */
|
|
start = fileSize - end;
|
|
end = fileSize - BIGINT_1;
|
|
if (start < BIGINT_0) return []; /* range larger than file, return */
|
|
}
|
|
|
|
if (end === undefined) {
|
|
/* range like 0- */
|
|
end = fileSize - BIGINT_1;
|
|
}
|
|
|
|
if (start > end || end >= fileSize) {
|
|
/* return empty range to issue regular 200 */
|
|
return [];
|
|
}
|
|
size = end - start + BIGINT_1;
|
|
|
|
if (1 > maxByteRanges - ranges.length) return [];
|
|
|
|
ranges.push({
|
|
start: start,
|
|
end: end,
|
|
size: size,
|
|
});
|
|
}
|
|
|
|
return ranges;
|
|
}
|
|
|
|
function createBoundary(len: number): string {
|
|
let chars = [];
|
|
for (let i = 0; i < len; i = i + 1) {
|
|
chars[i] = BOUNDARY_CHARS.charAt(
|
|
Math.floor(Math.random() * BOUNDARY_CHARS.length),
|
|
);
|
|
}
|
|
return chars.join("");
|
|
}
|
|
|
|
class ByteRangeReadable extends Readable {
|
|
size: bigint; /* the total size in bytes */
|
|
boundary: string; /* boundary marker to use in multipart headers */
|
|
|
|
private fd: number;
|
|
private ranges: ByteRange[];
|
|
private index: number; /* index within ranges */
|
|
private position: bigint;
|
|
private end: bigint;
|
|
private contentType: string;
|
|
private fileSize: bigint;
|
|
private headers: Buffer[];
|
|
private trailer: Buffer;
|
|
|
|
static parseByteRanges(
|
|
fileSize: bigint,
|
|
maxByteRanges: number,
|
|
rangeHeaderValue?: string,
|
|
): ByteRange[] {
|
|
return extractRanges(fileSize, maxByteRanges, rangeHeaderValue);
|
|
}
|
|
|
|
private createPartHeader(range: ByteRange): Buffer {
|
|
return Buffer.from(
|
|
[
|
|
"",
|
|
`--${this.boundary}`,
|
|
`Content-Type: ${this.contentType}`,
|
|
`Content-Range: bytes ${range.start}-${range.end}/${this.fileSize}`,
|
|
"",
|
|
"",
|
|
].join("\r\n"),
|
|
);
|
|
}
|
|
|
|
constructor(
|
|
fd: number,
|
|
fileSize: bigint,
|
|
ranges: ByteRange[],
|
|
contentType: string,
|
|
opts?: ReadableOptions,
|
|
) {
|
|
super(opts);
|
|
|
|
if (ranges.length === 0) {
|
|
throw Error("this requires at least 1 byte range");
|
|
}
|
|
|
|
this.fd = fd;
|
|
this.ranges = ranges;
|
|
this.fileSize = fileSize;
|
|
this.contentType = contentType;
|
|
|
|
this.position = BIGINT_1;
|
|
this.end = BIGINT_0;
|
|
this.index = -1;
|
|
this.headers = [];
|
|
|
|
this.size = BIGINT_0;
|
|
|
|
if (this.ranges.length === 1) {
|
|
this.size = this.ranges[0].size;
|
|
} else {
|
|
this.boundary = createBoundary(BOUNDARY_SIZE);
|
|
this.ranges.forEach((r) => {
|
|
const header = this.createPartHeader(r);
|
|
this.headers.push(header);
|
|
|
|
this.size += BigInt(header.length) + r.size;
|
|
});
|
|
this.trailer = Buffer.from(`\r\n--${this.boundary}--\r\n`);
|
|
this.size += BigInt(this.trailer.length);
|
|
}
|
|
}
|
|
|
|
_read(n) {
|
|
if (this.index == this.ranges.length) {
|
|
this.push(null);
|
|
return;
|
|
}
|
|
|
|
if (this.position > this.end) {
|
|
/* move ahead to the next index */
|
|
this.index++;
|
|
|
|
if (this.index === this.ranges.length) {
|
|
if (this.trailer) {
|
|
this.push(this.trailer);
|
|
return;
|
|
}
|
|
this.push(null);
|
|
return;
|
|
}
|
|
|
|
this.position = this.ranges[this.index].start;
|
|
this.end = this.ranges[this.index].end;
|
|
|
|
if (this.ranges.length > 1) {
|
|
this.push(this.headers[this.index]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const max = this.end - this.position + BIGINT_1;
|
|
|
|
if (n > max) n = Number(max);
|
|
const buf = Buffer.alloc(n);
|
|
|
|
fs.read(this.fd, buf, 0, n, this.position, (err, bytesRead) => {
|
|
if (err) {
|
|
this.destroy(err);
|
|
return;
|
|
}
|
|
if (bytesRead == 0) {
|
|
/* something seems to have gone wrong? */
|
|
this.push(null);
|
|
return;
|
|
}
|
|
|
|
if (bytesRead > n) bytesRead = n;
|
|
|
|
this.position += BigInt(bytesRead);
|
|
this.push(buf.slice(0, bytesRead));
|
|
});
|
|
}
|
|
}
|
|
|
|
export { ByteRange, ByteRangeReadable };
|