diff --git a/README.md b/README.md index 0098e11..147af7b 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,10 @@ server.on('socket', (socket) => { }); server.use(server.bodyParser()); +server.use(server.cookieParser()); server.use(server.jsonRPC()); server.use(server.router()); -server.use('/static', server.file(__dirname)); +server.use('/static', server.fileServer(__dirname)); server.get('/', async (req, res) => { res.html(200, 'static'); diff --git a/lib/hook.js b/lib/hook.js index 9fc77b3..bbaf85f 100644 --- a/lib/hook.js +++ b/lib/hook.js @@ -8,9 +8,13 @@ const assert = require('assert'); +/** + * Hook + */ + class Hook { /** - * Hook + * Create a hook. * @constructor * @ignore */ @@ -39,4 +43,8 @@ class Hook { } } +/* + * Expose + */ + module.exports = Hook; diff --git a/lib/middleware/file.js b/lib/middleware/file.js index 2a0fe35..e0835cf 100644 --- a/lib/middleware/file.js +++ b/lib/middleware/file.js @@ -9,7 +9,6 @@ const assert = require('assert'); const fs = require('fs'); const Path = require('path'); -const mime = require('../mime'); /** * Static file middleware. @@ -17,11 +16,11 @@ const mime = require('../mime'); * @returns {Function} */ -function file(prefix) { +function fileServer(prefix) { assert(typeof prefix === 'string'); return async (req, res) => { - if (req.method !== 'GET') + if (req.method !== 'GET' && req.method !== 'HEAD') return; const file = Path.join(prefix, req.pathname); @@ -43,33 +42,11 @@ function file(prefix) { return; } - if (!stat.isFile()) { - const err = new Error('Cannot access file.'); - err.statusCode = 403; - throw err; + try { + await res.sendFile(file); + } catch (e) { + throw wrapError(e); } - - res.setStatus(200); - res.setType(mime.file(file)); - res.setLength(stat.size); - - const stream = fs.createReadStream(file); - - stream.on('error', (err) => { - try { - stream.destroy(); - } catch (e) { - ; - } - - try { - res.destroy(); - } catch (e) { - ; - } - }); - - res.read(stream); }; } @@ -102,37 +79,46 @@ function fsReaddir(file) { } function wrapError(e) { - if (e.code === 'ENOENT') { - const err = new Error('File not found.'); - err.code = e.code; - err.statusCode = 404; - return err; - } - - if (e.code === 'EACCES' || e.code === 'EPERM') { - const err = new Error('Cannot access file.'); - err.code = e.code; - err.statusCode = 403; - return err; - } - - if (e.code === 'EMFILE') { - const err = new Error('Too many open files.'); - err.code = e.code; + if (!e.code) { + const err = new Error('Internal server error.'); err.statusCode = 500; return err; } - if (e.code) { - const err = new Error('Cannot access file.'); - err.code = e.code; - err.statusCode = 400; - return err; + switch (e.code) { + case 'ENOENT': + case 'ENAMETOOLONG': + case 'ENOTDIR': + case 'EISDIR': { + const err = new Error('File not found.'); + err.code = e.code; + err.syscall = e.syscall; + err.statusCode = 404; + return err; + } + case 'EACCES': + case 'EPERM': { + const err = new Error('Cannot access file.'); + err.code = e.code; + err.syscall = e.syscall; + err.statusCode = 403; + return err; + } + case 'EMFILE': { + const err = new Error('Too many open files.'); + err.code = e.code; + err.syscall = e.syscall; + err.statusCode = 500; + return err; + } + default: { + const err = new Error('Cannot access file.'); + err.code = e.code; + err.syscall = e.syscall; + err.statusCode = 500; + return err; + } } - - const err = new Error('Internal server error.'); - err.statusCode = 500; - return err; } function escapeHTML(html) { @@ -198,4 +184,4 @@ async function dir2html(parent, title, prefix) { * Expose */ -module.exports = file; +module.exports = fileServer; diff --git a/lib/middleware/index.js b/lib/middleware/index.js index 2d066a3..fbf1805 100644 --- a/lib/middleware/index.js +++ b/lib/middleware/index.js @@ -8,7 +8,8 @@ exports.basicAuth = require('./basicauth'); exports.bodyParser = require('./bodyparser'); +exports.cookieParser = require('./cookie'); exports.cors = require('./cors'); -exports.file = require('./file'); +exports.fileServer = require('./file'); exports.jsonRPC = require('./jsonrpc'); exports.router = require('./router'); diff --git a/lib/mime.js b/lib/mime.js index cacfe95..2fed0e9 100644 --- a/lib/mime.js +++ b/lib/mime.js @@ -9,60 +9,104 @@ const assert = require('assert'); const types = { - 'json': ['application/json', true], - 'form': ['application/x-www-form-urlencoded', true], - 'html': ['text/html', true], - 'xhtml': ['application/xhtml+xml', true], - 'xml': ['application/xml', true], - 'js': ['application/javascript', true], - 'css': ['text/css', true], - 'txt': ['text/plain', true], - 'md': ['text/plain', true], + 'atom': ['application/atom+xml', true], 'bin': ['application/octet-stream', false], + 'bmp': ['image/bmp', false], + 'css': ['text/css', true], 'dat': ['application/octet-stream', false], + 'form': ['application/x-www-form-urlencoded', true], 'gif': ['image/gif', false], + 'gz': ['application/x-gzip', false], + 'htc': ['text/x-component', true], + 'html': ['text/html', true], + 'ico': ['image/x-icon', false], 'jpg': ['image/jpeg', false], + 'jpeg': ['image/jpeg', false], + 'js': ['application/javascript', true], + 'json': ['application/json', true], + 'log': ['text/plain', true], + 'manifest': ['text/cache-manifest', false], + 'mathml': ['application/mathml+xml', true], + 'md': ['text/plain', true], + 'mkv': ['video/x-matroska', false], + 'mml': ['application/mathml+xml', true], + 'mp3': ['audio/mpeg', false], + 'mp4': ['video/mp4', false], + 'mpeg': ['video/mpeg', false], + 'mpg': ['video/mpeg', false], + 'oga': ['audio/ogg', false], + 'ogg': ['application/ogg', false], + 'ogv': ['video/ogg', false], + 'otf': ['font/otf', false], + 'pdf': ['application/pdf', false], 'png': ['image/png', false], - 'ico': ['image/x-icon', false], + 'rdf': ['application/rdf+xml', true], + 'rss': ['application/rss+xml', true], 'svg': ['image/svg+xml', false], - 'pdf': ['application/pdf', false], + 'swf': ['application/x-shockwave-flash', false], + 'tar': ['application/x-tar', false], + 'torrent': ['application/x-bittorrent', false], + 'txt': ['text/plain', true], + 'ttf': ['font/ttf', false], 'wav': ['audio/wav', false], - 'mp3': ['audio/mpeg', false], - 'ogg': ['audio/ogg', false], - 'mp4': ['video/mp4', false], - 'mkv': ['video/x-matroska', false], - 'webm': ['video/webm', false] + 'webm': ['video/webm', false], + 'woff': ['font/x-woff', false], + 'xhtml': ['application/xhtml+xml', true], + 'xbl': ['application/xml', true], + 'xml': ['application/xml', true], + 'xsl': ['application/xml', true], + 'xslt': ['application/xslt+xml', true], + 'zip': ['application/zip', false] }; const extensions = { - 'text/x-json': 'json', - 'application/json': 'json', + 'application/atom+xml': 'atom', + 'application/octet-stream': 'bin', + 'image/bmp': 'bmp', + 'text/css': 'css', 'application/x-www-form-urlencoded': 'form', + 'image/gif': 'gif', + 'application/x-gzip': 'gz', + 'text/x-component': 'htc', 'text/html': 'html', - 'application/xhtml+xml': 'xhtml', + 'image/x-icon': 'ico', + 'image/jpeg': 'jpeg', 'text/javascript': 'js', 'application/javascript': 'js', - 'text/css': 'css', + 'text/x-json': 'json', + 'application/json': 'json', 'text/plain': 'txt', - 'application/octet-stream': 'bin', - 'image/gif': 'gif', - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'image/x-icon': 'ico', - 'image/svg+xml': 'svg', + 'text/cache-manifest': 'manifest', + 'application/mathml+xml': 'mml', + 'video/x-matroska': 'mkv', + 'audio/x-matroska': 'mkv', + 'audio/mpeg': 'mp3', + 'audio/mpa': 'mp3', + 'video/mp4': 'mp4', + 'video/mpeg': 'mpg', + 'audio/ogg': 'oga', + 'application/ogg': 'ogg', + 'video/ogg': 'ogv', + 'font/otf': 'otf', 'application/pdf': 'pdf', 'application/x-pdf': 'pdf', + 'image/png': 'png', + 'application/rdf+xml': 'rdf', + 'application/rss+xml': 'rss', + 'image/svg+xml': 'svg', + 'application/x-shockwave-flash': 'swf', + 'application/x-tar': 'tar', + 'application/x-bittorrent': 'torrent', + 'font/ttf': 'ttf', 'audio/wav': 'wav', 'audio/wave': 'wav', - 'audio/mpeg': 'mp3', - 'audio/mpa': 'mp3', - 'audio/ogg': 'ogg', - 'video/ogg': 'ogg', - 'video/mp4': 'mp4', - 'video/x-matroska': 'mkv', - 'audio/x-matroska': 'mkv', + 'video/webm': 'webm', 'audio/webm': 'webm', - 'video/webm': 'webm' + 'font/x-woff': 'woff', + 'application/xhtml+xml': 'xhtml', + 'application/xml': 'xsl', + 'application/xslt+xml': 'xslt', + 'application/zip': 'zip' }; // Filename to extension diff --git a/lib/request.js b/lib/request.js index 4b5c3a0..e2019fe 100644 --- a/lib/request.js +++ b/lib/request.js @@ -11,9 +11,13 @@ const EventEmitter = require('events'); const mime = require('./mime'); const {parseURL} = require('./util'); +/** + * Request + */ + class Request extends EventEmitter { /** - * Request + * Create a request. * @constructor * @ignore */ @@ -35,10 +39,12 @@ class Request extends EventEmitter { this.query = Object.create(null); this.params = Object.create(null); this.body = Object.create(null); + this.cookies = Object.create(null); this.hasBody = false; - this.username = null; this.readable = true; this.writable = false; + this.admin = false; + this.wallet = null; this.init(req, res, url); } @@ -117,4 +123,8 @@ class Request extends EventEmitter { } } +/* + * Expose + */ + module.exports = Request; diff --git a/lib/response.js b/lib/response.js index af9b1a5..e9131bf 100644 --- a/lib/response.js +++ b/lib/response.js @@ -8,12 +8,25 @@ const assert = require('assert'); const EventEmitter = require('events'); +const fs = require('fs'); const qs = require('querystring'); const mime = require('./mime'); +/* + * Constants + */ + +// Taken from: +// https://github.com/jshttp/cookie/blob/master/index.js +const fieldRegex = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/; + +/** + * Response + */ + class Response extends EventEmitter { /** - * Response + * Create a response. * @constructor * @ignore */ @@ -48,39 +61,57 @@ class Response extends EventEmitter { res.on('close', () => { this.emit('close'); }); + + res.on('finish', () => { + this.emit('finish'); + }); } setStatus(code) { - assert(typeof code === 'number', 'Code must be a number.'); + assert((code & 0xffff) === code, 'Code must be a number.'); this.statusCode = code; this.res.statusCode = code; + return this; } setType(type) { this.setHeader('Content-Type', mime.type(type)); + return this; } setLength(length) { - assert(typeof length === 'number'); + assert(Number.isSafeInteger(length) && length >= 0); this.setHeader('Content-Length', length.toString(10)); + return this; + } + + setCookie(key, value, options) { + this.setHeader('Set-Cookie', encodeCookie(key, value, options)); + return this; } destroy() { - return this.res.destroy(); + this.res.destroy(); + return this; } setHeader(key, value) { - return this.res.setHeader(key, value); + this.res.setHeader(key, value); + return this; } getHeader(key) { - return this.res.getHeader(key); + this.res.getHeader(key); + return this; } read(stream) { assert(!this.sent, 'Request already sent.'); - this.sent = true; stream.pipe(this.res); + stream.once('data', () => { + this.sent = true; + }); + return this; } write(data, enc) { @@ -94,15 +125,45 @@ class Response extends EventEmitter { return this.res.end(data, enc); } - redirect(code, url) { - if (!url) { - url = code; - code = 301; + redirect(code, path) { + if (!path) { + path = code; + code = 303; } + assert((code & 0xffff) === code); + assert(typeof path === 'string'); + + const req = this.req; + + if (req.headers.host && path.indexOf('://') === -1) { + if (path.length > 0 && path[0] === '/') + path = path.substring(1); + + const proto = req.socket.encrypted ? 'https' : 'http'; + const host = req.headers.host; + const port = req.socket.localPort; + + let hostname = host; + + if ((!req.socket.encrypted && port !== 80) + || (req.socket.encrypted && port !== 443)) { + hostname += `:${port}`; + } + + path = `${proto}://${hostname}/${path}`; + } + + // HTTP 1.0 user agents do not understand 303's: + // http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html + if (code === 303 && req.httpVersionMinor < 1) + code = 302; + this.setStatus(code); - this.setHeader('Location', url); + this.setHeader('Location', path); this.end(); + + return this; } text(code, msg) { @@ -155,7 +216,7 @@ class Response extends EventEmitter { } catch (e) { ; } - return; + return this; } if (typeof msg === 'string') { @@ -164,13 +225,14 @@ class Response extends EventEmitter { this.setLength(len); try { - this.write(msg, 'utf8'); + if (this.req.method !== 'HEAD') + this.write(msg, 'utf8'); this.end(); } catch (e) { ; } - return; + return this; } assert(Buffer.isBuffer(msg)); @@ -178,12 +240,228 @@ class Response extends EventEmitter { this.setLength(msg.length); try { - this.write(msg); + if (this.req.method !== 'HEAD') + this.write(msg); this.end(); } catch (e) { ; } + + return this; + } + + sendFile(file) { + return new Promise((resolve, reject) => { + fs.stat(file, (err, stat) => { + if (err) { + reject(err); + return; + } + this._sendFile(file, stat, resolve, reject); + }); + }); + } + + _sendFile(file, stat, resolve, reject) { + if (stat.isDirectory()) { + const err = new Error('File not found.'); + err.statusCode = 404; + reject(err); + return; + } + + if (!stat.isFile()) { + const err = new Error('Cannot access file.'); + err.statusCode = 403; + reject(err); + return; + } + + this.setStatus(200); + this.setType(mime.file(file)); + this.setLength(stat.size); + + const hdr = this.req.headers['content-range']; + + let options = null; + + try { + options = parseRange(hdr, stat.size); + } catch (e) { + reject(e); + return; + } + + if (this.req.method === 'HEAD') { + this.end(); + resolve(); + return; + } + + const stream = fs.createReadStream(file, options); + + let done = false; + + this.once('close', () => { + if (done) + return; + done = true; + stream.destroy(); + resolve(); + }); + + this.once('finish', () => { + if (done) + return; + done = true; + resolve(); + }); + + stream.on('error', (err) => { + if (done) + return; + done = true; + stream.destroy(); + reject(err); + }); + + this.read(stream); } } +/* + * Helpers + */ + +function encodeCookie(key, value, options) { + if (options == null) + options = {}; + + assert(typeof key === 'string'); + assert(typeof value === 'string'); + assert(options && typeof options === 'object'); + + if (!fieldRegex.test(key)) + throw new Error('Invalid cookie name.'); + + const val = encodeURIComponent(value); + + if (!fieldRegex.test(val)) + throw new Error('Invalid cookie value.'); + + let str = `${key}=${val}`; + + if (options.maxAge != null) { + assert((options.maxAge >>> 0) === options.maxAge); + str += `; Max-Age=${options.maxAge}`; + } + + if (options.domain != null) { + assert(typeof options.domain === 'string'); + if (!fieldRegex.test(options.domain)) + throw new Error('Invalid domain.'); + str += `; Domain=${options.domain}`; + } + + if (options.path != null) { + assert(typeof options.path === 'string'); + if (!fieldRegex.test(options.path)) + throw new Error('Invalid path.'); + str += `; Path=${options.path}`; + } + + if (options.expires != null) { + assert(Number.isSafeInteger(options.expires) && options.expires >= 0); + const expires = new Date(options.expires); + str += `; Expires=${expires.toUTCString()}`; + } + + if (options.httpOnly) + str += '; HttpOnly'; + + if (options.secure) + str += '; Secure'; + + if (options.sameSite != null) { + if (typeof options.sameSite === 'boolean') { + if (options.sameSite) + str += '; SameSite=Strict'; + } else { + assert(typeof options.sameSite === 'string'); + switch (options.sameSite) { + case 'strict': + str += '; SameSite=Strict'; + break; + case 'lax': + str += '; SameSite=Lax'; + break; + default: + throw new Error('Unknown same site option.'); + } + } + } + + return str; +} + +function rangeError() { + const err = new Error('Invalid range.'); + err.statusCode = 416; + return err; +} + +function parseRange(hdr, size) { + if (!hdr) + return null; + + if (!/^ *bytes=/.test(hdr)) + throw rangeError(); + + const index = hdr.indexOf('='); + assert(index !== -1); + + const parts = hdr.substring(index + 1).split(','); + const ranges = []; + + for (const part of parts) { + const range = part.trim(); + const items = range.split('-'); + + if (items.length < 2) + items.push(''); + + const left = items[0].trim(); + const right = items[1].trim(); + + let start = 0; + let end = size; + + if (left.length === 0) { + end = parseInt(right, 10); + } else if (right.length === 0) { + start = parseInt(left, 10); + } else { + start = parseInt(left, 10); + end = parseInt(right, 10); + } + + if (!Number.isSafeInteger(start) || start < 0 + || !Number.isSafeInteger(end) || end < 0 + || start > end) { + throw rangeError(); + } + + ranges.push({ start, end }); + } + + if (ranges.length === 0) + throw rangeError(); + + return ranges[0]; +} + +/** + * Expose + */ + module.exports = Response; diff --git a/lib/route.js b/lib/route.js index f4b5ff6..f667f28 100644 --- a/lib/route.js +++ b/lib/route.js @@ -8,9 +8,13 @@ const assert = require('assert'); +/** + * Route + */ + class Route { /** - * Route + * Create a route. * @constructor * @ignore */ @@ -78,4 +82,8 @@ class Route { } } +/* + * Expose + */ + module.exports = Route; diff --git a/lib/router.js b/lib/router.js index 8c46292..2a62506 100644 --- a/lib/router.js +++ b/lib/router.js @@ -10,7 +10,16 @@ const assert = require('assert'); const Route = require('./route'); const Hook = require('./hook'); +/** + * Router + */ + class Router { + /** + * Create a router. + * @constructor + */ + constructor() { this._get = []; this._post = []; @@ -199,4 +208,8 @@ class Router { } } +/** + * Expose + */ + module.exports = Router; diff --git a/lib/server-browser.js b/lib/server-browser.js index 65ca2df..48ded2d 100644 --- a/lib/server-browser.js +++ b/lib/server-browser.js @@ -9,9 +9,21 @@ const EventEmitter = require('events'); const RPC = require('./rpc'); +/** + * HTTP Server + * @extends EventEmitter + */ + class Server extends EventEmitter { - constructor() { + /** + * Create an http server. + * @constructor + * @param {Object?} options + */ + + constructor(options) { super(); + this.options = options; this.config = {}; this.server = new EventEmitter(); this.io = new EventEmitter(); @@ -80,7 +92,11 @@ class Server extends EventEmitter { return async () => {}; } - file() { + fileServer() { + return async () => {}; + } + + cookieParser() { return async () => {}; } } diff --git a/lib/server.js b/lib/server.js index 6685a6f..47b027d 100644 --- a/lib/server.js +++ b/lib/server.js @@ -18,9 +18,14 @@ const Hook = require('./hook'); const RPC = require('./rpc'); const middleware = require('./middleware'); +/** + * HTTP Server + * @extends EventEmitter + */ + class Server extends EventEmitter { /** - * Server + * Create an http server. * @constructor * @param {Object?} options */ @@ -597,14 +602,27 @@ class Server extends EventEmitter { * @returns {Function} */ - file(prefix) { - return middleware.file(prefix); + fileServer(prefix) { + return middleware.fileServer(prefix); + } + + /** + * Cookie parsing middleware. + * @returns {Function} + */ + + cookieParser() { + return middleware.cookieParser(); } } +/** + * HTTP Server Options + */ + class ServerOptions { /** - * HTTP Server Options + * Create http server options. * @constructor * @param {Object} options */ diff --git a/package.json b/package.json index 7781161..b7693fc 100644 --- a/package.json +++ b/package.json @@ -23,19 +23,19 @@ "webpack": "webpack --config webpack.config.js" }, "dependencies": { - "bsock": "^0.0.1" + "bsock": "^0.0.0" }, "devDependencies": { - "babelify": "^7.3.0", + "babelify": "^8.0.0", "babel-core": "^6.26.0", "babel-loader": "^7.1.2", "babel-preset-env": "^1.6.1", "browserify": "^14.5.0", - "eslint": "^4.9.0", + "eslint": "^4.14.0", "mocha": "^4.0.1", - "uglifyjs-webpack-plugin": "^1.0.0-beta.3", + "uglifyjs-webpack-plugin": "^1.1.5", "uglify-es": "^3.1.3", - "webpack": "^3.8.1" + "webpack": "^3.10.0" }, "engines": { "node": ">=7.6.0" diff --git a/test/server.js b/test/server.js index 3c25b0d..755b7fe 100644 --- a/test/server.js +++ b/test/server.js @@ -1,7 +1,6 @@ 'use strict'; const Path = require('path'); -const request = require('../lib/request'); const bweb = require('../'); const server = bweb.server({ @@ -10,12 +9,13 @@ const server = bweb.server({ }); server.use('/', server.bodyParser()); +server.use('/', server.cookieParser()); server.use('/', server.jsonRPC()); server.use('/', server.router()); -server.use('/static', server.file(Path.resolve(__dirname, '..'))); +server.use('/static', server.fileServer(Path.resolve(__dirname, '..'))); server.get('/', (req, res) => { - res.html(200, 'static'); + res.html(200, 'static\n'); }); server.add('test', async () => { @@ -28,16 +28,6 @@ server.on('error', (err) => { (async () => { await server.open(); - return; - const res = await request({ - method: 'POST', - url: 'http://localhost:8080', - json: { - method: 'test', - params: {} - } - }); - console.log(res.json()); })().catch((err) => { console.error(err.stack); process.exit(0);