@ceicc/range
Advanced tools
Comparing version 2.2.1 to 3.0.0-beta.1
362
index.js
const | ||
fs = require("fs"), | ||
{ pipeline } = require("stream"), | ||
{ contentType } = require("mime-types"); | ||
{ promisify } = require("util"), | ||
{ contentType } = require("mime-types"), | ||
optionsChecker = require("@ceicc/options-checker"), | ||
{ URL } = require("url"), | ||
{ IncomingMessage, ServerResponse } = require("http"), | ||
// Im not going to use `stream/promises` liberary because it was added in | ||
// version 15.0, Not all hosting providers support that version (including mine) | ||
pipelinePromised = promisify(pipeline), | ||
nextTick = () => new Promise(r => process.nextTick(r)) | ||
module.exports = range | ||
/** | ||
* @typedef {object} options | ||
* @property {object} [headers] the request headers object `req.headers` | ||
* | ||
* if `range` and/or `conditionalRequest` are true, | ||
* then the headers object is required. | ||
* | ||
* you can pass the whole headers object, or only the conditional and range headers | ||
* | ||
* @property {boolean} [conditional] whether to respect conditional requests or not - default false | ||
* | ||
* if true, the headers object is required | ||
* @property {boolean} [range] accept range request - default false | ||
* | ||
* if true, the headers object is required | ||
* @property {number} [maxAge] max age of caching in seconds - default 0 | ||
* @property {boolean} [etag] add Etag header - default true | ||
* @property {boolean} [lastModified] add last-modified header - default true | ||
* | ||
* @property {number} [maxAge] caching period in seconds - default `10800` (3 hours) | ||
* | ||
* @property {boolean} [etag] add Etag header - default `true` | ||
* | ||
* @property {boolean} [lastModified] add `last-modified` header - default `true` | ||
* | ||
* @property {boolean} [conditional] whether to respect conditional requests or not - default `true` | ||
* | ||
* @property {boolean} [range] accept range requests - default `true` | ||
* | ||
* @property {string|boolean} [notFound] a handler for non existing files | ||
* | ||
* `notFound: false` a rejection will be thrown (default). | ||
* | ||
* `notFound: true` empty body with response code '404' will be sent. | ||
* | ||
* `notFound: <string>` send a file with response code '404', the given string is the path to file. | ||
* if the path doesn't led to a file, a rejection will be thrown | ||
* @property {boolean|Array<string>} [implicitIndex=false] Check for index files if the request path is a directory. default: `false` | ||
* | ||
* Pass an array of extensions to check against. e.g. _`["html", "css"]`_ | ||
* | ||
* Or simply pass `true` to check for html extension only | ||
* | ||
* `notFound: false` `next` will be called. | ||
* | ||
* `notFound: true` empty body with response code '404' will be sent (default). | ||
* | ||
* `notFound: <string>` send a file with response code '404', the given string is the path to file. | ||
* | ||
* if the path doesn't led to a file, `next` will be called. | ||
* | ||
* ***Note:*** The path is relative to the `baseDir` path. | ||
* | ||
* @property {boolean|Array<string>} [implicitIndex] Check for index files if the request path is a directory - default: `true` | ||
* | ||
* Pass an array of extensions to check against. e.g. _`["html", "css"]`_ | ||
* | ||
* Or simply pass `true` to check for html extension only | ||
* | ||
* @property {string} [baseDir] the base dirctory - default: `'.'` | ||
* | ||
* @property {boolean} [hushErrors] Whether to ignore errors and reply with status code `500`, or pass the error to `next` function - default: `false` | ||
*/ | ||
@@ -42,129 +58,185 @@ | ||
/** | ||
* | ||
* @param {string} path path to file, `req.url` | ||
* @param {object} res http.ServerResponse object `res` | ||
* @param {options} options optional | ||
* @returns {Promise<number>} A Promise with the response status code | ||
* @param {options} [options] configuration object | ||
* @returns {Function} Middleware function | ||
*/ | ||
const range = async (path, res, options) => new Promise(async (resolve, rejects) => { | ||
if (typeof path !== "string") return rejects(new Error("path argument is required!")); | ||
if (typeof res !== "object") return rejects(new Error("res object is required!")); | ||
if ((options?.conditional || options?.range) && !options?.headers) return rejects(new Error("headers object is required!")); | ||
function range(options = {}) { | ||
const stat = await fs.promises.stat(path).catch(err => { | ||
if (err.code === "ENOENT") { | ||
optionsChecker(options, { | ||
baseDir: { default: '.', type: "string" }, | ||
hushErrors: { default: false, type: "boolean" }, | ||
conditional: { default: true, type: "boolean" }, | ||
range: { default: true, type: "boolean" }, | ||
maxAge: { default: 10800, type: "number" }, | ||
etag: { default: true, type: "boolean" }, | ||
lastModified: { default: true, type: "boolean" }, | ||
notFound: { default: true, type: "boolean|string" }, | ||
implicitIndex: { default: true, type: "boolean|array" }, | ||
}) | ||
if (options?.notFound === true) { | ||
res.statusCode = 404; | ||
res.end(); | ||
resolve(404); | ||
return false; | ||
} | ||
if (typeof options?.notFound === "string") { | ||
range(options.notFound, res, { | ||
...options, | ||
notFound: false | ||
}).then(resolve).catch(rejects); | ||
return false; | ||
} | ||
return async function rangeMiddleware(req, res, next = console.error) { | ||
const e = new Error("File Not Found"); | ||
e.code = 404; | ||
e.path = path; | ||
rejects(e); | ||
return false; | ||
if (!(req instanceof IncomingMessage)) { | ||
await nextTick() // To make it truly asynchronous | ||
next(TypeError("Request object is not an instance of IncomingMessage")) | ||
} | ||
if (!(res instanceof ServerResponse)) { | ||
await nextTick() // To make it truly asynchronous | ||
next(TypeError("Response object is not an instance of ServerResponse")) | ||
} | ||
rejects(err); | ||
return false; | ||
}); | ||
if (!stat) return; | ||
if (stat.isDirectory()) { | ||
req.pathname = new URL(`https://example.com${req.url}`).pathname | ||
if (!options?.implicitIndex) | ||
return resolve(forgetAboutIt(res, 404)) | ||
try { | ||
const extensions = new Set() | ||
// Using `var` to get function scope | ||
var stat = await fs.promises.stat(options.baseDir + req.pathname) | ||
if (Array.isArray(options.implicitIndex)) | ||
options.implicitIndex.forEach(v => extensions.add(v)) | ||
else if (options.implicitIndex === true) | ||
extensions.add("html") | ||
} catch (error) { | ||
let resolved = false | ||
if (error.code === "ENOENT") { | ||
const directory = await fs.promises.readdir(path) | ||
if (options.notFound === true) | ||
return forgetAboutIt(res, 404) | ||
for (const extension of extensions) { | ||
if (!directory.includes(`index.${extension}`)) | ||
continue | ||
if (typeof options.notFound === "string") { | ||
await range(`${path}/index.${extension}`, res, { ...options }).then(resolve).catch(rejects) | ||
resolved = true | ||
break | ||
req.url = `/${options.notFound}` | ||
options.notFound = false | ||
return rangeMiddleware(req, res, next) | ||
} | ||
return options.hushErrors ? forgetAboutIt(res, 404) : next(error) | ||
} | ||
return options.hushErrors ? forgetAboutIt(res, 500) : next(error) | ||
} | ||
return resolved ? null : resolve(forgetAboutIt(res, 404)) | ||
} | ||
const | ||
headers = options?.headers, | ||
accRange = options?.range === true, | ||
conditional = options?.conditional === true, | ||
maxAge = typeof options?.maxAge === 'number' ? options?.maxAge : 0, | ||
etag = options?.etag === false ? false : getEtag(stat.mtime, stat.size), | ||
lastMod = options?.lastModified === false ? false : new Date(stat.mtime).toUTCString(); | ||
if (stat.isDirectory()) { | ||
etag && res.setHeader("etag", etag); | ||
lastMod && res.setHeader("last-modified", lastMod); | ||
maxAge && res.setHeader("cache-control", `max-age=${maxAge}`); | ||
accRange && res.setHeader("accept-ranges", "bytes"); // Hint to the browser range is supported | ||
if (!options.implicitIndex) | ||
return forgetAboutIt(res, 404) | ||
res.setHeader("content-type", contentType(path.split(".").pop())); | ||
res.setHeader("content-length", stat.size); | ||
const extensions = new Set() | ||
// check conditional request, calclute a diff up to 2 sec because browsers sends seconds and javascript uses milliseconds | ||
if (conditional && (headers["if-none-match"] === etag || (Date.parse(headers["if-modified-since"]) - stat.mtime.getTime()) >= -2000)) | ||
return resolve(forgetAboutIt(res, 304)); | ||
if (Array.isArray(options.implicitIndex)) | ||
options.implicitIndex.forEach(v => extensions.add(v)) | ||
if (accRange && headers["range"]) { | ||
if (headers["if-range"] && headers["if-range"] !== etag) { | ||
res.statusCode = 200; | ||
streamIt(path, res, resolve, rejects); | ||
return; | ||
else if (options.implicitIndex === true) | ||
extensions.add("html") | ||
const directory = await fs.promises.readdir(options.baseDir + req.pathname) | ||
for (const extension of extensions) { | ||
if (!directory.includes(`index.${extension}`)) | ||
continue | ||
req.url = `${req.pathname}/index.${extension}` | ||
return rangeMiddleware(req, res, next) | ||
} | ||
return forgetAboutIt(res, 404) | ||
} | ||
if (headers["if-match"] && headers["if-match"] !== etag || (Date.parse(headers["if-unmodified-since"]) - stat.mtime.getTime()) < -2000) | ||
return resolve(forgetAboutIt(res, 412)); | ||
return rangeReq(path, res, resolve, rejects, headers["range"], stat.size); | ||
} | ||
const etag = options.etag && getEtag(stat.mtime, stat.size) | ||
res.statusCode = 200; | ||
streamIt(path, res, resolve, rejects); | ||
return; | ||
etag && res.setHeader("etag", etag) | ||
options.lastModified && res.setHeader("last-modified", stat.mtime.toUTCString()) | ||
options.maxAge && res.setHeader("cache-control", `max-age=${options.maxAge}`) | ||
options.range && res.setHeader("accept-ranges", "bytes") // Hint to the browser range is supported | ||
}); | ||
res.setHeader("content-type", contentType(req.pathname.split(".").pop())) | ||
res.setHeader("content-length", stat.size) | ||
module.exports = range; | ||
// check conditional request, calclute a diff up to 2 sec because browsers sends seconds and javascript uses milliseconds | ||
if ( options.conditional && ( | ||
req.headers["if-none-match"] === etag || | ||
// No need to check if the header exist because `Date.parse` will return `NaN` to falsy inputs, | ||
// any arithmetic to `NaN` will result in `NaN`, | ||
// and any compartion to `NaN` will result in `false` | ||
( Date.parse(req.headers["if-modified-since"]) - stat.mtime.getTime() ) >= -2000 ) | ||
) | ||
return forgetAboutIt(res, 304) | ||
function streamIt(path, res, resolve, rejects, opts) { | ||
pipeline( | ||
if (options.range && req.headers["range"]) { | ||
if (req.headers["if-range"] && req.headers["if-range"] !== etag) { | ||
res.statusCode = 200 | ||
try { | ||
await streamIt(options.baseDir + req.pathname, res) | ||
} catch (error) { | ||
options.hushErrors ? hush(res) : next(error) | ||
} | ||
return | ||
} | ||
if ( | ||
req.headers["if-match"] && req.headers["if-match"] !== etag || | ||
(Date.parse(req.headers["if-unmodified-since"]) - stat.mtime.getTime()) < -2000 | ||
) | ||
return forgetAboutIt(res, 412) | ||
try { | ||
await rangeRequest(options.baseDir + req.pathname, res, req.headers["range"], stat.size) | ||
} catch (error) { | ||
options.hushErrors ? hush(res) : next(error) | ||
} | ||
return | ||
} | ||
res.statusCode = 200 | ||
try { | ||
await streamIt(options.baseDir + req.pathname, res) | ||
} catch (error) { | ||
options.hushErrors ? hush(res) : next(error) | ||
} | ||
} | ||
} | ||
function hush(res) { | ||
if (!res.headersSent) { | ||
res.statusCode = 500 | ||
res.end() | ||
} | ||
} | ||
async function streamIt(path, res, opts) { | ||
return pipelinePromised( | ||
fs.createReadStream(path, opts ? { start: opts.start, end: opts.end} : null), | ||
res, | ||
err => { | ||
if (err && err.code !== "ERR_STREAM_PREMATURE_CLOSE") // Stream closed (normal) | ||
return rejects(err); | ||
resolve(res.statusCode); | ||
} | ||
); | ||
).catch(async (err) => { | ||
if (!err || err.code === "ERR_STREAM_PREMATURE_CLOSE") // Stream closed (normal) | ||
return | ||
else | ||
throw err | ||
}) | ||
} | ||
@@ -182,16 +254,16 @@ | ||
res.end(); | ||
return status; | ||
} | ||
function rangeReq(path, res, resolve, rejects, Range, size) { | ||
function rangeRequest(path, res, range, size) { | ||
res.removeHeader("content-length"); | ||
Range = Range.match(/bytes=([0-9]+)?-([0-9]+)?/i); | ||
res.removeHeader("content-length") | ||
if (!Range) { // Incorrect pattren | ||
res.setHeader("content-range", `bytes */${size}`); | ||
return resolve(forgetAboutIt(res, 416)); | ||
range = range.match(/bytes=([0-9]+)?-([0-9]+)?/i) | ||
if (!range) { // Incorrect pattren | ||
res.setHeader("content-range", `bytes */${size}`) | ||
return forgetAboutIt(res, 416) | ||
} | ||
let [start, end] = [Range[1], Range[2]]; | ||
let [start, end] = [range[1], range[2]] | ||
@@ -201,28 +273,28 @@ switch (true) { | ||
case !!start && !end: // Range: <unit>=<range-start>- | ||
start = Number(start); | ||
end = size - 1; | ||
break; | ||
start = Number(start) | ||
end = size - 1 | ||
break | ||
case !start && !!end: // Range: <unit>=-<suffix-length> | ||
start = size - end; | ||
end = size - 1; | ||
break; | ||
start = size - end | ||
end = size - 1 | ||
break | ||
case !start && !end: // Range: <unit>=- | ||
start = 0; | ||
end = size - 1; | ||
break; | ||
start = 0 | ||
end = size - 1 | ||
break | ||
default: // Range: <unit>=<range-start>-<range-end> | ||
[start, end] = [Number(start), Number(end)]; | ||
[start, end] = [Number(start), Number(end)] | ||
} | ||
if (start < 0 || start > end || end >= size) { // Range out of order or bigger than file size | ||
res.setHeader("content-range", `bytes */${size}`); | ||
return resolve(forgetAboutIt(res, 416)); | ||
res.setHeader("content-range", `bytes */${size}`) | ||
return forgetAboutIt(res, 416) | ||
} | ||
res.statusCode = 206; // partial content | ||
res.setHeader("content-range", `bytes ${start}-${end}/${size}`); | ||
streamIt(path, res, resolve, rejects, { start, end }); | ||
res.statusCode = 206 // partial content | ||
res.setHeader("content-range", `bytes ${start}-${end}/${size}`) | ||
return streamIt(path, res, { start, end }) | ||
} |
{ | ||
"name": "@ceicc/range", | ||
"version": "2.2.1", | ||
"version": "3.0.0-beta.1", | ||
"description": "http range request handler", | ||
@@ -9,2 +9,3 @@ "main": "index.js", | ||
"app-dev": "nodemon test/app.js", | ||
"dev-v3": "node test/v3.js", | ||
"postversion": "echo 'Pushing to Github ----------' && git push && git push --tags && echo 'Pushing to NPM -----------' && npm publish" | ||
@@ -33,4 +34,8 @@ }, | ||
"dependencies": { | ||
"@ceicc/options-checker": "^1.0.2", | ||
"mime-types": "^2.1.32" | ||
}, | ||
"devDependencies": { | ||
"express": "^4.17.2" | ||
} | ||
} |
181
README.md
# range | ||
static files request handler | ||
A static files middleware | ||
# Installation | ||
## Installation | ||
``` | ||
npm i @ceicc/range | ||
npm i @ceicc/range@3.0.0-beta.1 | ||
``` | ||
# Usage | ||
start by importing `http` and `range` | ||
## Usage | ||
add `range` to an existence express app | ||
```js | ||
const | ||
http = require("http"), | ||
range = require("@ceicc/range"); | ||
import range from "@ceicc/range" | ||
// CommonJS | ||
// const range = require("@ceicc/range") | ||
app.get('/public/*', range()) | ||
app.listen(3000) | ||
``` | ||
This will server every request starts with `/public/` with `range`. | ||
Then make a simple server that responds with a file based on the requested path | ||
```js | ||
http.createServer((req, res) => { | ||
The base directory will be `.` or the current working directory, unless specified in the `options` object. | ||
range(__dirname + req.url, res).catch(console.error) | ||
## Options Object | ||
}).listen(2000); | ||
``` | ||
# Parameters | ||
1. file path. | ||
2. the response object. | ||
3. optional object | ||
#### `maxAge` | ||
# Options Object | ||
1. `maxAge` max age of caching in seconds - default 0 | ||
2. `etag` add Etag header - default true | ||
3. `lastModified` add last-modified header - default true | ||
4. `conditional` whether to respect conditional requests or not - default false | ||
if true, the headers object is required. | ||
5. `range` accept range request - default false | ||
if true, the headers object is required. | ||
6. `headers` the request headers object `req.headers` | ||
if `range` and/or `conditionalRequest` are true, then the headers object is required. | ||
you can pass the whole headers object, or only the conditional and range headers. | ||
- default: `10800` | ||
- type: `number` | ||
7. `notFound` handler for non existing files | ||
`notFound: false` - a rejection will be thrown (default). | ||
`notFound: true` - empty body with response code '404' will be sent. | ||
`notFound: <string>` - send a file with response code '404', the given string is the path to file. | ||
if the path doesn't led to a file, a rejection will be thrown. | ||
8. `implicitIndex` Check for index files if the request path is a directory. default: `false` | ||
Pass an array of extensions to check against. e.g. _`["html", "css"]`_ | ||
Or simply pass `true` to check for html extension only. | ||
caching period in seconds. | ||
# Resolves | ||
the response status code | ||
# Rejects | ||
'File Not Found' error. | ||
Any other unexpected error | ||
# Real World Example | ||
```js | ||
const | ||
express = require("express"), | ||
range = require("@ceicc/range"), | ||
app = express(); | ||
#### `etag` | ||
app.get('/', (req, res, next) => range('./public/index.html', res).catch(next)); | ||
- default: `true` | ||
- type: `boolean` | ||
app.get('/public/*', (req, res, next) => { | ||
range('.' + req.path, res, { | ||
headers: req.headers, | ||
range: true, | ||
conditional: true, | ||
maxAge: 2592000, // 30 Days | ||
notFound: './test/public/404.html', | ||
}).catch(next); | ||
}); | ||
add Etag header. | ||
app.use((err, req, res, next) => { | ||
console.dir(err); | ||
if (!res.headersSent) | ||
res.sendStatus(500); | ||
}); | ||
#### `lastModified` | ||
app.listen(2000); | ||
``` | ||
- default: `true` | ||
- type: `boolean` | ||
add last-modified header. | ||
#### `conditional` | ||
- default: `true` | ||
- type: `boolean` | ||
whether to respect conditional requests or not. | ||
#### `range` | ||
- default: `true` | ||
- type: `boolean` | ||
accept range request. | ||
#### `notFound` | ||
- default: `true` | ||
- type: `boolean|string` | ||
a handler for non existing files | ||
`notFound: false` `next` will be called. | ||
`notFound: true` empty body with status code '404' will be sent. | ||
`notFound: <string>` send a file with status code '404', the given string is the path to file. | ||
if the path doesn't led to a file, `next` will be called. | ||
***Note:*** The path is relative to the `baseDir` path. | ||
#### `implicitIndex` | ||
- default: `true` | ||
- type: `boolean|Array<string>` | ||
Check for index files if the request path is a directory. | ||
Pass an array of extensions to check against. e.g. _`["html", "css"]`_ | ||
Or simply pass `true` to check for html extension only. | ||
#### `baseDir` | ||
- default: `'.'` | ||
- type: `string` | ||
the base dirctory. | ||
#### `hushErrors` | ||
- default: `false` | ||
- type: `boolean` | ||
Whether to ignore errors and reply with status code `500`, or pass the error to `next` function. | ||
## Real World Example | ||
```js | ||
import express from "express" | ||
import range from "@ceicc/range" | ||
const app = express() | ||
app.get('*', range({ baseDir: './public/' })) | ||
app.use((error, req, res, next) => { | ||
console.error(error) | ||
res.sendStatus(500) | ||
}) | ||
app.listen(80, () => console.log("server listening on localhost")) | ||
``` |
Sorry, the diff of this file is not supported yet
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
12641
211
123
2
1
1
2
+ Added@ceicc/options-checker@1.0.2(transitive)