chore(express-partial-content): move source files

This commit is contained in:
Elian Doran
2025-05-03 01:21:46 +03:00
parent 24224d2c72
commit adc5e8929b
13 changed files with 7 additions and 10 deletions

View File

@@ -0,0 +1,22 @@
import { Range } from "./Range";
import { Stream } from "stream";
export interface Content {
/**
* Returns a readable stream based on the provided range (optional).
* @param {Range} range The start-end range of stream data.
* @returns {Stream} A readable stream
*/
getStream(range?: Range): Stream;
/**
* Total size of the content
*/
readonly totalSize: number;
/**
* Mime type to be sent in Content-Type header
*/
readonly mimeType: string;
/**
* File name to be sent in Content-Disposition header
*/
readonly fileName: string;
};

View File

@@ -0,0 +1,2 @@
export class ContentDoesNotExistError extends Error {
}

View File

@@ -0,0 +1,6 @@
import { Request } from "express";
import { Content } from "./Content";
/**
* @type {function (Request): Promise<Content>}
*/
export type ContentProvider = (req: Request) => Promise<Content>;

View File

@@ -0,0 +1,3 @@
export interface Logger {
debug(message: string, extra?: any): void;
}

View File

@@ -0,0 +1,4 @@
export type Range = {
start: number;
end: number;
};

View File

@@ -0,0 +1,5 @@
export class RangeParserError extends Error {
constructor(start: any, end: any) {
super(`Invalid start and end values: ${start}-${end}.`);
}
}

View File

@@ -0,0 +1,60 @@
import { Request, Response } from "express";
import { parseRangeHeader } from "./parseRangeHeader";
import { RangeParserError } from "./RangeParserError";
import { Logger } from "./Logger";
import { ContentProvider } from "./ContentProvider";
import { ContentDoesNotExistError } from "./ContentDoesNotExistError";
import {
getRangeHeader,
setContentRangeHeader,
setContentTypeHeader,
setContentDispositionHeader,
setAcceptRangesHeader,
setContentLengthHeader,
setCacheControlHeaderNoCache
} from "./utils";
export function createPartialContentHandler(contentProvider: ContentProvider, logger: Logger) {
return async function handler(req: Request, res: Response) {
let content;
try {
content = await contentProvider(req);
} catch (error) {
logger.debug("createPartialContentHandler: ContentProvider threw exception: ", error);
if (error instanceof ContentDoesNotExistError) {
return res.status(404).send(error.message);
}
return res.sendStatus(500);
}
let { getStream, mimeType, fileName, totalSize } = content;
const rangeHeader = getRangeHeader(req);
let range;
try {
range = parseRangeHeader(rangeHeader, totalSize, logger);
} catch (error) {
logger.debug(`createPartialContentHandler: parseRangeHeader error: `, error);
if (error instanceof RangeParserError) {
setContentRangeHeader(null, totalSize, res);
return res.status(416).send(`Invalid value for Range: ${rangeHeader}`);
}
return res.sendStatus(500);
}
setContentTypeHeader(mimeType, res);
setContentDispositionHeader(fileName, res);
setAcceptRangesHeader(res);
// If range is not specified, or the file is empty, return the full stream
if (range === null) {
logger.debug("createPartialContentHandler: No range found, returning full content.");
setContentLengthHeader(totalSize, res);
return getStream().pipe(res);
}
setContentRangeHeader(range, totalSize, res);
let { start, end } = range;
setContentLengthHeader(start === end ? 0 : end - start + 1, res);
setCacheControlHeaderNoCache(res);
// Return 206 Partial Content status
logger.debug("createPartialContentHandler: Returning partial content for range: ", JSON.stringify(range));
res.status(206);
return getStream(range).pipe(res);
};
}

View File

@@ -1 +1,6 @@
export * from './lib/express-partial-content.js';
export * from "./Content";
export * from "./ContentDoesNotExistError";
export * from "./ContentProvider";
export * from "./createPartialContentHandler";
export * from "./Logger";
export * from "./Range";

View File

@@ -1,3 +0,0 @@
export function expressPartialContent(): string {
return 'express-partial-content';
}

View File

@@ -0,0 +1,57 @@
import { Logger } from "./Logger";
import { RangeParserError } from "./RangeParserError";
import { Range } from "./Range";
const rangeRegEx = /bytes=([0-9]*)-([0-9]*)/;
export function parseRangeHeader(range: string, totalSize: number, logger: Logger): Range | null {
logger.debug("Un-parsed range is: ", range);
// 1. If range is not specified or the file is empty, return null.
if (!range || range === null || range.length === 0 || totalSize === 0) {
return null;
}
const splitRange = range.split(rangeRegEx);
const [, startValue, endValue] = splitRange;
let start = Number.parseInt(startValue);
let end = Number.parseInt(endValue);
// 2. Parse start and end values and ensure they are within limits.
// 2.1. start: >= 0.
// 2.2. end: >= 0, <= totalSize - 1
let result = {
start: Number.isNaN(start) ? 0 : Math.max(start, 0),
end: Number.isNaN(end) ? totalSize - 1 : Math.min(Math.max(end, 0), totalSize - 1)
};
// 3.1. If end is not provided, set end to the last byte (totalSize - 1).
if (!Number.isNaN(start) && Number.isNaN(end)) {
logger.debug("End is not provided.");
result.start = start;
result.end = totalSize - 1;
}
// 3.2. If start is not provided, set it to the offset of last "end" bytes from the end of the file.
// And set end to the last byte.
// This way we return the last "end" bytes.
if (Number.isNaN(start) && !Number.isNaN(end)) {
logger.debug(`Start is not provided, "end" will be treated as last "end" bytes of the content.`);
result.start = Math.max(totalSize - end, 0);
result.end = totalSize - 1;
}
// 4. Handle invalid ranges.
if (start < 0 || start > end || end > totalSize) {
throw new RangeParserError(start, end);
}
logRange(logger, result);
return result;
}
function logRange(logger: Logger, range: Range) {
logger.debug("Range is: ", JSON.stringify(range));
}

View File

@@ -0,0 +1,13 @@
import { Request, Response } from "express";
import { Range } from "./Range";
export const getHeader = (name: string, req: Request) => req.headers[name];
export const getRangeHeader = getHeader.bind(null, "range");
export const setHeader = (name: string, value: string, res: Response) => res.setHeader(name, value);
export const setContentTypeHeader = setHeader.bind(null, "Content-Type");
export const setContentLengthHeader = setHeader.bind(null, "Content-Length");
export const setAcceptRangesHeader = setHeader.bind(null, "Accept-Ranges", "bytes");
export const setContentRangeHeader = (range: Range | null, size: number, res: Response) =>
setHeader("Content-Range", `bytes ${range ? `${range.start}-${range.end}` : "*"}/${size}`, res);
export const setContentDispositionHeader = (fileName: string, res: Response) =>
setHeader("Content-Disposition", `attachment; filename*=utf-8''${encodeURIComponent(fileName)}`, res);
export const setCacheControlHeaderNoCache = setHeader.bind(null, "Cache-Control", "no-cache");