Skip to content

Commit

Permalink
Split the code, add file server example.
Browse files Browse the repository at this point in the history
  • Loading branch information
SukantGujar committed Mar 11, 2019
1 parent 45439ef commit 2d2d20f
Show file tree
Hide file tree
Showing 16 changed files with 560 additions and 135 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"license": "MIT",
"scripts": {
"build:watch": "npx tsc -w",
"build:prod": "rimraf ./dist && cross-env NODE_ENV=production tsc -b"
"build:prod": "rimraf ./dist && cross-env NODE_ENV=production tsc -p ./tsconfig.production.json",
"run:examples:file": "node ./dist/examples/express-file-server/index.js"
},
"devDependencies": {
"@types/express": "^4.16.1",
Expand All @@ -17,5 +18,8 @@
},
"peerDependencies": {
"express": "^4.16.4"
},
"dependencies": {
"express": "^4.16.4"
}
}
22 changes: 22 additions & 0 deletions src/Content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Range } from "./parseRangeHeader";
import { Stream } from "stream";
export type 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;
};
2 changes: 2 additions & 0 deletions src/ContentDoesNotExistError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export class ContentDoesNotExistError extends Error {
}
6 changes: 6 additions & 0 deletions src/ContentProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Request } from "express";
import { Content } from "./Content";
/**
* @type {function (Request): Promise<Content>}
*/
export type ContentProvider = (req: Request) => Promise<Content>;
3 changes: 3 additions & 0 deletions src/Logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface Logger {
debug(message: string, extra?: any): void;
}
5 changes: 5 additions & 0 deletions src/RangeParserError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class RangeParserError extends Error {
constructor(start: any, end: any) {
super(`Invalid start and end values: ${start}-${end}.`);
}
}
59 changes: 59 additions & 0 deletions src/createPartialStreamHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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 createPartialStreamHandler(contentProvider: ContentProvider, logger: Logger) {
return async function handler(req: Request, res: Response) {
let content;
try {
content = await contentProvider(req);
} catch (error) {
logger.debug("ContentProvider threw exception: ", error);
if (error instanceof ContentDoesNotExistError) {
return res.status(400).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(`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("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("Returning partial content for range: ", JSON.stringify(range));
res.status(206);
getStream(range).pipe(res);
};
}
7 changes: 7 additions & 0 deletions src/examples/express-file-server/files/readme.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Magna etiam tempor orci eu lobortis elementum nibh. In egestas erat imperdiet sed euismod. Amet consectetur adipiscing elit pellentesque habitant.
Vel quam elementum pulvinar etiam non quam lacus suspendisse.
Nibh sit amet commodo nulla facilisi. Vel risus commodo viverra maecenas accumsan lacus. Ornare arcu dui vivamus arcu felis bibendum ut tristique et.
Vitae semper quis lectus nulla at volutpat diam. Mauris vitae ultricies leo integer malesuada nunc. Donec massa sapien faucibus et. Senectus et netus et malesuada. Vitae tortor condimentum lacinia quis vel. Sagittis id consectetur purus ut faucibus pulvinar elementum. Nisi est sit amet facilisis magna etiam tempor orci eu.

Dictum varius duis at consectetur lorem donec massa sapien. Odio pellentesque diam volutpat commodo. Egestas dui id ornare arcu odio ut sem nulla. Consequat id porta nibh venenatis cras sed felis eget. Placerat in egestas erat imperdiet. Dui nunc mattis enim ut tellus elementum sagittis vitae. Aliquet bibendum enim facilisis gravida neque convallis a cras. Id semper risus in hendrerit gravida. Tempor orci eu lobortis elementum nibh tellus molestie. Semper auctor neque vitae tempus quam pellentesque. Vitae proin sagittis nisl rhoncus mattis rhoncus urna neque. Bibendum at varius vel pharetra vel turpis. Tellus integer feugiat scelerisque varius morbi enim nunc. Volutpat commodo sed egestas egestas fringilla. Congue eu consequat ac felis donec et odio. Venenatis cras sed felis eget velit aliquet. Urna neque viverra justo nec. Dictum non consectetur a erat nam. Lacinia quis vel eros donec.
56 changes: 56 additions & 0 deletions src/examples/express-file-server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import express, { Request } from "express";
import { promisify } from "util";
import fs from "fs";
import { Range, createPartialStreamHandler, ContentDoesNotExistError, ContentProvider } from "../../index";

const statAsync = promisify(fs.stat);
const existsAsync = promisify(fs.exists);

const fileContentProvider: ContentProvider = async (req: Request) => {
const fileName = req.params.name;
const file = `${__dirname}/files/${fileName}`;
if (!(await existsAsync(file))) {
throw new ContentDoesNotExistError(`File doesn't exists: ${file}`);
}

const stats = await statAsync(file);
const totalSize = stats.size;
const mimeType = "application/octet-stream";
const getStream = (range?: Range) => {
if (!range) {
return fs.createReadStream(file);
}
const { start, end } = range;
logger.debug(`start: ${start}, end: ${end}`);

return fs.createReadStream(file, { start, end });
};

return {
fileName,
totalSize,
mimeType,
getStream
};
};

const logger = {
debug(message: string, extra?: any) {
if (extra) {
console.log(`[debug]: ${message}`, extra);
} else {
console.log(`[debug]: ${message}`);
}
}
};

const handler = createPartialStreamHandler(fileContentProvider, logger);

const app = express();
const port = 8080;

app.get("/files/:name", handler);

app.listen(port, () => {
logger.debug("Server started!");
});
108 changes: 5 additions & 103 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,103 +1,5 @@
import { Request, Response } from "express";
import { parseRangeHeader, RangeParserError, Range } from "./parseRangeHeader";
import { Stream } from "stream";

/**
* @type {function (Request): Promise<Content>}
*/
export type ContentProvider = (req: Request) => Promise<Content>;

export class ContentDoesNotExistError extends Error {}

export interface Logger {
debug(message: string, extra?: any): void;
}

export type 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;
};

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

export function create(contentProvider: ContentProvider, logger: Logger) {
return async function handler(req: Request, res: Response) {
let content;
try {
content = await contentProvider(req);
} catch (error) {
logger.debug("ContentProvider threw exception: ", error);
if (error instanceof ContentDoesNotExistError) {
return res.status(400).send(error.message);
}

return res.sendStatus(500);
}

let { getStream, mimeType, fileName, totalSize } = content;

const rangeHeader = getRangeHeader(req);
let range;
try {
range = parseRangeHeader(rangeHeader, totalSize);
} catch (error) {
logger.debug(`parseRangeHeader error: `, error);
if (error instanceof RangeParserError) {
setContentRangeHeader(null, totalSize, res);

return res
.send(error.message)
.status(416)
.end();
}

return res.sendStatus(500);
}

let { start, end } = range;

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) {
setContentLengthHeader(totalSize, res);
return getStream().pipe(res);
}

setContentRangeHeader(range, totalSize, res);
setContentLengthHeader(start === end ? 0 : end - start + 1);
setCacheControlHeaderNoCache(res);

// Return 206 Partial Content status
res.status(206);
getStream(range).pipe(res);
};
}
export * from "./Content";
export * from "./ContentDoesNotExistError";
export * from "./ContentProvider";
export * from "./createPartialStreamHandler";
export * from "./Logger";
35 changes: 20 additions & 15 deletions src/parseRangeHeader.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { Logger } from "./Logger";
import { RangeParserError } from "./RangeParserError";

export type Range = {
start: number;
end: number;
};

const rangeRegEx = /bytes=([0-9]*)-([0-9]*)/;

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

export function parseRangeHeader(range: string, totalSize: number): Range | null {
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 === null || range.length === 0 || totalSize === 0) {
if (!range || range === null || range.length === 0 || totalSize === 0) {
return null;
}

const [startValue, endValue] = range.split(rangeRegEx);
const splitRange = range.split(rangeRegEx);
console.log("Parsed range is: ", JSON.stringify(splitRange));
const [, startValue, endValue] = splitRange;
let start = Number.parseInt(startValue);
let end = Number.parseInt(endValue);

Expand All @@ -32,26 +32,31 @@ export function parseRangeHeader(range: string, totalSize: number): Range | null

// 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;

return result;
}

// 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)) {
result.start = totalSize - end;
result.end = totalSize - 1;
logger.debug(`Start is not provided, "end" will be treated as last "end" bytes of the content.`);

return result;
result.start = Math.max(totalSize - end, 0);
result.end = totalSize - 1;
}

// 4. Handle invalid ranges.
if (start > end) {
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));
}
11 changes: 11 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Request, Response } from "express";
import { Range } from "./parseRangeHeader";
const getHeader = (name: string, req: Request) => req.headers[name];
export const getRangeHeader = getHeader.bind(null, "range");
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="${fileName}"`, res);
export const setCacheControlHeaderNoCache = setHeader.bind(null, "Cache-Control", "no-cache");
16 changes: 16 additions & 0 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"declaration": true,
"baseUrl": "src",
"paths": {
"*": ["../node_modules/*", "./types/*"]
}
}
}
Loading

0 comments on commit 2d2d20f

Please sign in to comment.