litejs
Version:
Full-stack web framework in a tiny package
710 lines (634 loc) • 19.3 kB
JavaScript
var fs = require("fs")
, content = require("./content")
, event = require("./event")
, path = require("./path")
, util = require("./util")
, defaultOpts = {
maxBodySize: 1e6,
maxNameSize: 100,
maxFields: 1000,
maxFieldSize: 1000,
maxFiles: 1000,
maxFileSize: Infinity,
maxURILength: 2000,
log: console,
exitTime: 5000,
accept: {
"application/json;filename=;select=;space=": function(data, negod) {
return JSON.stringify(
data,
negod.select ? negod.select.split(",") : null,
+negod.space || negod.space
// Line and Paragraph separator needing to be escaped in JavaScript but not in JSON,
// escape those so the JSON can be evaluated or directly utilized within JSONP.
).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")
},
// RFC 4180 optional parameters: charset, header
"text/csv;br=\"\r\n\";delimiter=\",\";fields=;filename=;header=;NULL=;select=": require("./csv.js").encode,
"application/sql;fields=;filename=;NULL=NULL;select=;table=table": function(data, negod) {
negod.re = /\D/
negod.br = "),\n("
negod.prefix = "INSERT INTO " +
negod.table + (negod.fields ? " (" + negod.fields + ")" : "") + " VALUES ("
negod.postfix = ");"
return csv.encode(data, negod)
}
},
bodyRe: /^(?:PATCH|POST|PUT)$/i,
charset: "UTF-8",
compress: false,
encoding: {
"deflate;q=0.1": "createDeflate",
"gzip;q=0.2": "createGzip",
"br": "createBrotliCompress"
},
error: {
"URIError": { code: 400 }
},
method: {
"DELETE": "del",
"GET": "get",
"PATCH": "patch",
"POST": "post",
"PUT": "put"
},
mime: {
"asf": "video/x-ms-asf",
"asx": "video/x-ms-asx",
"avi": "video/x-msvideo",
"css": "text/css",
"csv": "text/csv",
"cur": "image/vnd.microsoft.icon",
"doc": "application/msword",
"drw": "application/drafting",
"dvi": "application/x-dvi",
"dwg": "application/acad",
"dxf": "application/dxf",
"gif": "image/gif",
"gz": "application/x-gzip",
"htm": "text/html",
"html": "text/html",
"ico": "image/x-icon",
"jar": "application/java-archive",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"js": "text/javascript",
"json": "application/json",
"m3u": "audio/x-mpegurl",
"manifest": "text/cache-manifest",
"midi": "audio/midi",
"mjs": "text/javascript",
"mp3": "audio/mpeg",
"mp4": "video/mp4",
"mpeg": "video/mpeg",
"mpg": "video/mpeg",
"mpga": "audio/mpeg",
"pdf": "application/pdf",
"pgp": "application/pgp",
"png": "image/png",
"ppz": "application/vnd.ms-powerpoint",
"ps": "application/postscript",
"qt": "video/quicktime",
"ra": "audio/x-realaudio",
"rar": "application/x-rar-compressed",
"rm": "audio/x-pn-realaudio",
"rtf": "text/rtf",
"rtx": "text/richtext",
"sgml": "text/sgml",
"sh": "application/x-sh",
"snd": "audio/basic",
"sql": "application/sql",
"svg": "image/svg+xml",
"tex": "application/x-tex",
"tgz": "application/x-tar-gz",
"tiff": "image/tiff",
"tsv": "text/tab-separated-values",
"txt": "text/plain",
"wav": "audio/x-wav",
"wma": "audio/x-ms-wma",
"wmv": "video/x-ms-wmv",
"xls": "application/vnd.ms-excel",
"xlw": "application/vnd.ms-excel",
"xml": "text/xml",
"zip": "application/zip"
},
rangeSize: 500 * 1024,
status: {
200: "OK",
201: "Created",
202: "Accepted",
203: "Non-Authoritative Information",
204: "No Content",
205: "Reset Content",
206: "Partial Content",
207: "Multi-Status",
208: "Already Reported",
226: "IM Used",
300: "Multiple Choices",
301: "Moved Permanently",
302: "Found",
303: "See Other",
304: "Not Modified",
305: "Use Proxy",
307: "Temporary Redirect",
308: "Permanent Redirect",
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
406: "Not Acceptable",
407: "Proxy Authentication Required",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
411: "Length Required",
412: "Precondition Failed",
413: "Payload Too Large",
414: "URI Too Long",
415: "Unsupported Media Type",
416: "Range Not Satisfiable",
417: "Expectation Failed",
421: "Misdirected Request",
422: "Unprocessable Entity",
423: "Locked",
424: "Failed Dependency",
425: "Too Early",
426: "Upgrade Required",
428: "Precondition Required",
429: "Too Many Requests",
451: "Unavailable For Legal Reasons",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
505: "HTTP Version Not Supported",
506: "Variant Also Negotiates",
507: "Insufficient Storage",
508: "Loop Detected",
509: "Bandwidth Limit Exceeded",
510: "Not Extended",
511: "Network Authentication Required"
},
tmp: (
process.env.TMPDIR ||
process.env.TEMP ||
process.env.TMP ||
(
process.platform === "win32" ?
/* istanbul ignore next */
(process.env.SystemRoot || process.env.windir) + "\\temp" :
"/tmp"
)
).replace(/([^:])[\/\\]+$/, "$1") + "/up-" + process.pid + "-",
http: {
port: 8080
}
}
, hasOwn = defaultOpts.hasOwnProperty
, cookieRe = /[^!#-~]|[%,;\\]/g
, rangeRe = /^bytes=(\d*)-(\d*)^/
, tmpDate = new Date()
module.exports = createApp
createApp.setCookie = setCookie
createApp.getCookie = getCookie
function createApp(opts_) {
var key
, uses = []
, opts = util.deepAssign(app.opts = {defaults: defaultOpts}, defaultOpts, opts_)
event.asEmitter(app)
if (!opts._accept) opts._accept = require("./accept").accept(opts.accept)
if (!opts._encoding) opts._encoding = require("./accept").accept(opts.encoding)
Object.keys(opts.method).forEach(function(method) {
app[opts.method[method]] = function() {
var arr = uses.slice.call(arguments)
if (typeof arr[0] === "function") {
arr.unshift(null)
}
arr.unshift(method)
return use.apply(app, arr)
}
})
app.listen = listen
app.readBody = readBody
app.static = createStatic
app.use = use
return app
function app(req, res, _next) {
var oldPath, oldUrl
, tryCatch = true
, usePos = 0
, forwarded = req.headers[opts.ipHeader || "x-forwarded-for"]
if (!res.send) {
req.date = new Date()
req.ip = forwarded ? forwarded.split(/[\s,]+/)[0] : req.connection && req.connection.remoteAddress
req.opts = res.opts = opts
req.res = res
res.req = req
res.send = send
res.sendStatus = sendStatus
// IE8-10 accept 2083 chars in URL
// Sitemaps protocol has a limit of 2048 characters in a URL
// Google SERP tool wouldn't cope with URLs longer than 1855 chars
if (req.url.length > opts.maxURILength) {
return sendError(res, opts, "URI Too Long")
}
req.content = content
req.cookie = getCookie
req.originalUrl = req.url
res.cookie = setCookie
res.link = setLink
res.sendFile = sendFile
}
next()
function next(err) {
if (err) {
return sendError(res, opts, err)
}
var method = uses[usePos]
, path = uses[usePos + 1]
, pos = usePos += 3
if (method && method !== req.method || path && path !== req.url.slice(0, path.length)) {
next()
} else if (uses[pos - 1] === void 0) {
if (typeof _next === "function") {
_next()
} else {
res.sendStatus(404)
}
} else {
method = uses[pos - 1]
if (path) {
oldPath = req.baseUrl
oldUrl = req.url
req.baseUrl = path
req.url = req.url === path ? "/" : req.url.slice(path.slice(-1) === "/" ? path.length - 1 : path.length)
}
if (tryCatch === true) {
tryCatch = false
try {
method.call(app, req, res, path ? nextPath : next, opts)
} catch(e) {
return sendError(res, opts, e)
}
} else {
method.call(app, req, res, path ? nextPath : next, opts)
}
if (pos === usePos) {
tryCatch = true
}
}
}
function nextPath(e) {
req.baseUrl = oldPath
req.url = oldUrl
next(e)
}
}
function use(method, path) {
var fn
, arr = Array.from(arguments)
, len = arr.length
, i = 2
if (typeof method === "function") {
method = path = null
i = 0
} else if (typeof path === "function") {
path = method
method = null
i = 1
}
for (; i < len; ) {
if (typeof arr[i] !== "function") throw Error("Not a function")
uses.push(method, path, arr[i++])
}
return app
}
}
function createStatic(root_, opts_) {
var root = path.resolve(root_)
, opts = util.deepAssign({
root: root,
index: "index.html",
maxAge: "1 year",
cache: {
"cache.manifest": 0,
"worker.js": 0
}
}, opts_)
resolveFile("cache", util.num)
resolveFile("headers")
return function(req, res, next) {
var file
if (req.method !== "GET" && req.method !== "HEAD") {
if (opts.otherMethod) return opts.otherMethod(req, res, next, opts)
res.setHeader("Allow", "GET, HEAD")
return res.sendStatus(405) // Method not allowed
}
if (req.url === "/") {
if (!opts.index) return res.sendStatus(404)
if (typeof opts.index === "function") return opts.index(req, res, next, opts)
file = path.resolve(root, opts.index)
} else try {
file = path.resolve(root, "." + decodeURIComponent(req.url.split("?")[0].replace(/\+/g, " ")))
} catch (e) {
return res.sendStatus(400)
}
if (file.slice(0, root.length) !== root) {
return res.sendStatus(404)
}
res.sendFile(file, opts, function(err) {
next()
})
}
function resolveFile(name, util) {
if (!opts[name]) return
var file
, map = opts[name]
opts[name] = {}
for (file in map) if (hasOwn.call(map, file)) {
opts[name][file === "*" ? file : path.resolve(root, file)] = util ? util(map[file]) : map[file]
}
}
}
function readBody(req, res, next, opts) {
if (req.body === void 0 && opts.bodyRe.test(req.method)) {
req.content(next)
} else {
next()
}
}
function send(body, opts_) {
var tmp
, res = this
, reqHead = res.req.headers
, resHead = {}
, negod = res.opts._accept(reqHead.accept || reqHead["content-type"] || "*/*")
, opts = Object.assign({statusCode: res.statusCode}, res.opts, negod, opts_)
, outStream = opts.stream || res
if (!negod.match) {
return res.sendStatus(406) // Not Acceptable
}
tmp = util.num(opts.cache && opts.filename && opts.cache[opts.filename], opts.maxAge)
if (typeof tmp === "number") {
// max-age=N is relative to the time of the request
resHead["Cache-Control"] = tmp > 0 ? "public, max-age=" + (0|(tmp/1000)) : "no-cache, max-age=0"
}
if (opts.mtime && opts.mtime > Date.parse(reqHead["if-modified-since"])) {
return res.sendStatus(304)
}
if (typeof body !== "string") {
negod.select = opts && opts.select || res.req.url.split("$select")[1] || ""
body = negod.o(body, negod)
opts.mimeType = negod.rule
}
resHead["Content-Type"] = opts.mimeType + (
opts.charset && opts.mimeType.slice(0, 5) === "text/" ? "; charset=" + opts.charset : ""
)
if (opts.size >= 0) {
resHead["Content-Length"] = opts.size
if (opts.size > opts.rangeSize) {
resHead["Accept-Ranges"] = "bytes"
resHead["Content-Length"] = opts.size
if (tmp = reqHead.range && !reqHead["if-range"] && rangeRe.exec(reqHead.range)) {
opts.start = range[1] ? +range[1] : range[2] ? opts.size - range[2] - 1 : 0
opts.end = range[1] && range[2] ? +range[2] : opts.size - 1
if (opts.start > opts.end || opts.end >= opts.size) {
opts.start = 0
opts.end = opts.size - 1
} else {
opts.statusCode = 206
resHead["Content-Length"] = opts.end - opts.start
resHead["Content-Range"] = "bytes " + opts.start + "-" + opts.end + "/" + opts.size
}
}
}
}
if (opts.filename) {
resHead["Content-Disposition"] = "attachment; filename=" + (
typeof opts.filename === "function" ? opts.filename() : opts.filename
)
}
negod = opts.compress && opts._encoding(reqHead["accept-encoding"])
if (negod.match) {
// Server may choose not to compress the body, if:
// - data is already compressed (some image format)
// - server is overloaded and cannot afford the computational overhead.
// Microsoft recommends not to compress if a server uses more than 80% of its computational power.
delete resHead["Content-Length"]
resHead["Content-Encoding"] = negod.match
resHead.Vary = "Accept-Encoding"
outStream = typeof negod.o === "string" ? require("zlib")[negod.o]() : negod.o()
outStream.pipe(res)
}
if (opts.headers) Object.assign(resHead, opts.headers["*"], opts.headers[opts.sendfile || res.req.url])
res.writeHead(opts.statusCode || 200, resHead)
if (res.req.method == "HEAD") {
return res.end()
}
if (opts.sendfile) {
fs.createReadStream(opts.sendfile, {start: opts.start, end: opts.end}).pipe(outStream)
} else {
outStream.end(body)
}
}
function sendFile(file, opts_, next_) {
var res = this
, opts = typeof opts_ === "function" ? (next_ = opts_) && {} : opts_ || {}
, next = typeof next_ === "function" ? next_ : function(code) {
res.sendStatus(code)
}
fs.stat(file, function(err, stat) {
if (err) return next(404)
if (stat.isDirectory()) return next(403)
opts.mtime = stat.mtime
opts.size = stat.size
opts.filename = opts.download === true ? file.split("/").pop() : opts.download
opts.mimeType = res.opts.mime[ file.split(".").pop() ] || "application/octet-stream"
opts.sendfile = file
res.send(file, opts)
})
}
function sendStatus(code, message) {
var res = this
res.statusCode = code
if (code > 199 && code != 204 && code != 304) {
res.setHeader("Content-Type", "text/plain")
message = (message || res.opts.status[code] || code) + "\n"
res.setHeader("Content-Length", message.length)
if ("HEAD" != res.req.method) {
res.write(message)
}
}
res.end()
}
function sendError(res, opts, e) {
var message = typeof e === "string" ? e : e.message
, map = opts.error && (opts.error[message] || opts.error[e.name]) || {}
, error = {
id: Math.random().toString(36).slice(2,10),
time: res.req.date,
code: map.code || e.code || 500,
message: map.message || message
}
res.statusCode = error.code
res.statusMessage = opts.status[error.code] || message
res.send(error)
opts.log.error(
(e.stack || (e.name || "Error") + ": " + error.message).replace(":", ":" + error.id)
)
}
function setLink(url, rel) {
var res = this
, existing = res.getHeader("link") || []
if (!Array.isArray(existing)) {
existing = [ existing ]
}
existing.push("<" + encodeURI(url) + ">; rel=\"" + rel + "\"")
res.setHeader("Link", existing)
}
function listen() {
var exiting
, server = this
, opts = server.opts
process.on("uncaughtException", function(e) {
if (opts.log) opts.log.error(
"\nUNCAUGHT EXCEPTION!\n" +
(e.stack || (e.name || "Error") + ": " + (e.message || e))
)
else throw e
;(opts.exit || exit).call(server, 1)
})
process.on("SIGINT", function() {
if (exiting) {
opts.log.info("\nKilling from SIGINT (got Ctrl-C twice)")
return process.exit()
}
exiting = true
opts.log.info("\nGracefully shutting down from SIGINT (Ctrl-C)")
;(opts.exit || exit).call(server, 0)
})
process.on("SIGTERM", function() {
opts.log.info("Gracefully shutting down from SIGTERM (kill)")
;(opts.exit || exit).call(server, 0)
})
process.on("SIGHUP", function() {
opts.log.info("Reloading configuration from SIGHUP")
server.listen(true)
server.emit("reload")
})
server.listen = opts.listen || function() {
;["http", "https", "http2"].forEach(createNet)
}
server.listen()
return server
function createNet(proto) {
var map = opts[proto]
, net = server["_" + proto] && !server["_" + proto].close()
if (!map || !map.port) return
net = server["_" + proto] = (
proto == "http" ?
require(proto).createServer(map.redirect ? forceHttps : server) :
require(proto).createSecureServer(map, map.redirect ? forceHttps : server)
)
.listen(map.port, map.host || "0.0.0.0", function() {
var addr = this.address()
opts.log.info("Listening %s at %s:%s", proto, addr.address, addr.port)
this.on("close", function() {
opts.log.info("Stop listening %s at %s:%s", proto, addr.address, addr.port)
})
if (map.noDelay) this.on("connection", setNoDelay)
})
if (map.sessionReuse) {
var sessionStore = {}
, timeout = map.sessionTimeout || 300
net
.on("newSession", function(id, data, cb) {
sessionStore[id] = data
cb()
})
.on("resumeSession", function(id, cb) {
cb(null, sessionStore[id] || null)
})
}
}
function setNoDelay(socket) {
if (socket.setNoDelay) socket.setNoDelay(true)
}
function forceHttps(req, res) {
// Safari 5 and IE9 drop the original URI's fragment if a HTTP/3xx redirect occurs.
// If the Location header on the response specifies a fragment, it is used.
// IE10+, Chrome 11+, Firefox 4+, and Opera will all "reattach" the original URI's fragment after following a 3xx redirection.
var port = opts.https && opts.https.port || 8443
, host = (req.headers.host || "localhost").split(":")[0]
, url = "https://" + (port == 443 ? host : host + ":" + port) + req.url
res.writeHead(301, {"Content-Type": "text/html", "Location": url})
res.end("Redirecting to <a href=\"" + url + "\">" + url + "</a>")
}
function exit(code) {
var softKill = util.wait(function() {
opts.log.info("Everything closed cleanly")
process.exit(code)
}, 1)
server.emit("beforeExit", softKill)
try {
if (server._http) server._http.close(softKill.wait()).unref()
if (server._https) server._https.close(softKill.wait()).unref()
if (server._http2) server._http2.close(softKill.wait()).unref()
} catch(e) {}
setTimeout(function() {
opts.log.warn("Kill (timeout)")
process.exit(code)
}, opts.exitTime).unref()
softKill()
}
}
function setCookie(opts, value) {
var res = this
, existing = res.getHeader("set-cookie")
, cookie = (typeof opts === "string" ? (opts = { name: opts }) : opts).name +
("=" + value).replace(cookieRe, encodeURIComponent) +
(opts.maxAge ? "; Expires=" + (tmpDate.setTime(opts.maxAge < 1 ? 0 : Date.now() + util.num(opts.maxAge)), tmpDate).toUTCString() : "") +
(opts.path ? "; Path=" + opts.path : "") +
(opts.domain ? "; Domain=" + opts.domain : "") +
(opts.secure ? "; Secure" : "") +
(opts.httpOnly ? "; HttpOnly" : "") +
(opts.sameSite ? "; SameSite=" + opts.sameSite : "")
if (Array.isArray(existing)) {
existing.push(cookie)
} else {
res.setHeader("Set-Cookie", existing ? [existing, cookie] : cookie)
}
}
function getCookie(opts) {
var req = this
, name = (typeof opts === "string" ? (opts = { name: opts }) : opts).name
, junks = ("; " + req.headers.cookie).split("; " + name + "=")
if (junks.length > 2) {
;(opts.path || "").split("/").map(function(val, key, arr) {
var map = {
name: name,
maxAge: -1,
path: arr.slice(0, key + 1).join("/")
}
, domain = opts.domain
req.res.cookie(map, "")
if (domain) {
map.domain = domain
req.res.cookie(map, "")
if (domain !== (domain = domain.replace(/^[^.]+(?=\.(?=.+\.))/, "*"))) {
map.domain = domain
req.res.cookie(map, "")
}
}
})
req.opts.log.warn("Cookie fixation detected: %s", req.headers.cookie)
} else try {
return decodeURIComponent((junks[1] || "").split(";")[0])
} catch(e) {
req.opts.log.warn("Invalid cookie '%s' in: %s", name, req.headers.cookie)
}
return ""
}