@jnode/server
Version:
Simple web server package for Node.js.
263 lines (229 loc) • 9.1 kB
JavaScript
/*
@jnode/server/handlers.js
v2
Simple web server package for Node.js.
by JustApple
*/
// dependencies
const stream = require('stream');
const fs = require('fs');
const mime = require('./mime.json');
const path = require('path');
// data handler: string, buffer, or stream
class DataHandler {
constructor(data, options = {}) {
this.data = data;
this.options = options;
// prebuild headers
this._statusCode = this.options.statusCode ?? 200;
if (typeof this.data === 'string') { // string
this.data = Buffer.from(this.data, 'utf8');
this._headers = {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': this.data.length,
...this.options.headers
};
} else if (this.data instanceof Uint8Array) { // buffer
this._headers = {
'Content-Type': 'application/octet-stream',
'Content-Length': this.data.length,
...this.options.headers
};
} else if (stream.isReadable(this.data)) {
this._headers = {
'Content-Type': 'application/octet-stream',
...this.options.headers
};
} else {
throw new Error('Unsupported data type.');
}
}
async handle(ctx, env) {
if (this.data instanceof Uint8Array) { // buffer
ctx.res.writeHead(this._statusCode, this._headers);
ctx.res.end(this.data);
} else if (stream.isReadable(this.data)) { // stream
ctx.res.writeHead(this._statusCode, this._headers);
try {
await stream.promises.pipeline(this.data, ctx.res);
} catch (e) {
if (e.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
throw e;
}
}
}
}
// file handler: local file
class FileHandler {
constructor(file, options = {}) {
this.file = path.resolve(file);
this.options = options;
// range may be disabled by `options.disableRange` or when `statusCode` is set to non-200 value
this.options.disableRange = this.options.disableRange || (this.options.statusCode && this.options.statusCode !== 200);
}
async handle(ctx, env) {
let stats;
try {
stats = await fs.promises.stat(this.file);
} catch { throw 404; }
// folders are not allowed (if you need, try to make your own handler :D)
if (!stats.isFile()) throw 404;
// handle caching
if (this.options.cache) {
// set etag
if (!this._etag) this._etag = this.options.headers?.['ETag'] ?? `"${stats.size}-${stats.mtime.getTime()}"`;
// by etag
if (ctx.req.headers['if-none-match'] === this._etag) {
ctx.res.writeHead(304, {
'Last-Modified': stats.mtime.toUTCString(),
'ETag': this._etag,
...this.options.headers
});
ctx.res.end();
return;
}
// by mtime
const since = new Date(ctx.req.headers['if-modified-since'] ?? 0).getTime();
if (!isNaN(since) && stats.mtime.getTime() <= since) {
ctx.res.writeHead(304, {
'Last-Modified': stats.mtime.toUTCString(),
'ETag': this._etag,
...this.options.headers
});
ctx.res.end();
return;
}
}
// range
let start = 0;
let end = stats.size - 1;
// parse range header
let range = /^bytes=(\d*)-(\d*)$/.exec(ctx.req.headers.range || '');
if (this.options.disableRange || !(ctx.method === 'GET' || ctx.method === 'HEAD')) range = null;
if (range) {
// cache check
if (this.options.cache && ctx.req.headers['if-range']) {
if (ctx.req.headers['if-range'].startsWith('"') && ctx.req.headers['if-range'].endsWith('"')) { // etag
if (ctx.req.headers['if-range'] !== this._etag) range = null;
} else { // mtime
const since = new Date(ctx.req.headers['if-range']).getTime();
if (isNaN(since) || stats.mtime.getTime() > since) range = null;
}
}
if (range[1] === '' && range[2] === '') throw 416; // invalid range
if (range[1] === '' && range[2] !== '') {
start = Math.max(stats.size - parseInt(range[2], 10), 0); // last n bytes
} else {
if (range[1] !== '') start = parseInt(range[1], 10);
if (range[2] !== '') end = Math.min(parseInt(range[2], 10), stats.size - 1);
}
if (start > end) throw 416;
}
// headers
ctx.res.writeHead(this.options.statusCode ?? (range ? 206 : 200), {
'Content-Type': mime[path.extname(this.file)] || 'application/octet-stream',
'Content-Length': range ? (end - start) + 1 : stats.size,
'Last-Modified': stats.mtime.toUTCString(),
...(this._etag && { 'ETag': this._etag }),
...(this.options.cache && { 'Cache-Control': 'max-age=' + (this.options.cache === true ? 'no-cache' : this.options.cache) }), // cache
...(!this.options.disableRange && { 'Accept-Ranges': 'bytes' }),
...(range && { 'Content-Range': `bytes ${start}-${end}/${stats.size}` }),
...this.options.headers
});
// pipe stream or send with only headers
if (ctx.method === 'HEAD' && !this.options.disableHead) {
ctx.res.end();
} else {
// send with stream
try {
await stream.promises.pipeline(
fs.createReadStream(this.file, {
start, end,
highWaterMark: this.options.highWaterMark || 64 * 1024
}),
ctx.res
);
} catch (e) {
if (e.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
throw e;
}
}
}
}
// folder handler: local folder, continues from env.path
class FolderHandler {
constructor(folder, options = {}) {
this.folder = path.resolve(folder);
this.options = options;
}
handle(ctx, env) {
const file = path.resolve(this.folder, ...env.path.slice(env.pathPointer));
// safety check
if (file.includes(path.sep + '.') && !this.options.allowHiddenFile) throw 404;
const rel = path.relative(this.folder, file);
if (rel.startsWith('..') || path.isAbsolute(rel)) throw 404;
// use a FileHandler
return (new FileHandler(file, this.options)).handle(ctx, env);
}
}
// JSON handler: JSON object
class JSONHandler {
constructor(obj, options = {}) {
this.data = Buffer.from(JSON.stringify(obj), 'utf8');
this.options = options;
// prebuild headers
this._statusCode = this.options.statusCode ?? 200;
this._headers = {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(this.data, 'utf8'),
...this.options.headers
};
}
handle(ctx, env) {
ctx.res.writeHead(this._statusCode, this._headers);
ctx.res.end(this.data);
}
}
// redirect handler: 307 redirect
class RedirectHandler {
constructor(location, options = {}) {
this.location = location;
this.options = options;
// prebuild headers
this._statusCode = this.options.statusCode ?? 307;
}
handle(ctx, env) {
ctx.res.writeHead(this._statusCode, {
'Location': this.options.base ?
this.options.base +
(this.options.base.endsWith('/') ? '' : '/') +
env.path.slice(env.pathPointer).map(encodeURIComponent).join('/') :
this.location,
...this.options.headers
});
ctx.res.end();
}
}
// function handler: custom function
class FunctionHandler {
constructor(func, ext) {
this.func = func;
this.ext = ext;
}
handle(ctx, env) {
return this.func(ctx, env, this.ext);
}
}
// export
module.exports = {
DataHandler, TextHandler: DataHandler, FileHandler, FolderHandler, JSONHandler, RedirectHandler, FunctionHandler,
handlerConstructors: {
Data: (data, options) => new DataHandler(data, options),
Text: (data, options) => new DataHandler(data, options),
File: (file, options) => new FileHandler(file, options),
Folder: (folder, options) => new FolderHandler(folder, options),
JSON: (obj, options) => new JSONHandler(obj, options),
Redirect: (location, options) => new RedirectHandler(location, options),
Function: (func, ext) => new FunctionHandler(func, ext)
}
};