├── embed ├── extraParams.hxml ├── submit.sh └── haxelib.json ├── tests.hxml ├── .gitignore ├── docs └── console.png ├── examples ├── 02_static_file_server │ ├── public │ │ └── index.html │ ├── build_cpp.hxml │ ├── run_neko.hxml │ ├── run_node.hxml │ ├── run_java.hxml │ ├── Server.hx │ └── README.md └── 01_hello_world │ ├── build_cpp.hxml │ ├── run_neko.hxml │ ├── run_php.hxml │ ├── run_java.hxml │ ├── run_node.hxml │ ├── Server.hx │ └── README.md ├── submit.sh ├── src ├── Monsoon.hx └── monsoon │ ├── middleware │ ├── ThreadServer.hx │ ├── Console.hx │ ├── BasicAuth.hx │ ├── Static.hx │ ├── ByteRange.hx │ └── Compression.hx │ ├── macro │ └── RequestBuilder.hx │ ├── Request.hx │ ├── Layer.hx │ ├── Monsoon.hx │ └── Response.hx ├── tests ├── RunTests.hx ├── TestCompression.hx ├── TestStatic.hx ├── TestMiddleware.hx ├── TestTools.hx ├── TestRouteController.hx ├── TestBasicAuth.hx ├── TestRequest.hx ├── TestByteRange.hx ├── TestResponse.hx └── TestRouter.hx ├── haxelib.json ├── .travis.yml └── README.md /embed/extraParams.hxml: -------------------------------------------------------------------------------- 1 | -D concurrent 2 | -D embed -------------------------------------------------------------------------------- /tests.hxml: -------------------------------------------------------------------------------- 1 | -cp ./tests/ 2 | -lib buddy 3 | -main RunTests 4 | -dce full -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | /tests_old 3 | /build 4 | *.hxproj 5 | /modd.conf 6 | *.mp4 -------------------------------------------------------------------------------- /docs/console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmerckx/monsoon/HEAD/docs/console.png -------------------------------------------------------------------------------- /examples/02_static_file_server/public/index.html: -------------------------------------------------------------------------------- 1 |

Static file server

2 | 3 |

Example

-------------------------------------------------------------------------------- /examples/01_hello_world/build_cpp.hxml: -------------------------------------------------------------------------------- 1 | -lib monsoon-embed 2 | -main Server 3 | -cpp bin/cpp 4 | -dce full 5 | -D static -------------------------------------------------------------------------------- /examples/02_static_file_server/build_cpp.hxml: -------------------------------------------------------------------------------- 1 | -lib monsoon-embed 2 | -main Server 3 | -cpp bin/cpp 4 | -dce full 5 | -D static -------------------------------------------------------------------------------- /examples/01_hello_world/run_neko.hxml: -------------------------------------------------------------------------------- 1 | -lib monsoon-embed 2 | -main Server 3 | -neko bin/neko/server.n 4 | -cmd neko bin/neko/server.n -------------------------------------------------------------------------------- /examples/01_hello_world/run_php.hxml: -------------------------------------------------------------------------------- 1 | -lib monsoon 2 | -main Server 3 | -php bin/php 4 | -cmd php -S 0.0.0.0:3000 bin/php/index.php -------------------------------------------------------------------------------- /examples/02_static_file_server/run_neko.hxml: -------------------------------------------------------------------------------- 1 | -lib monsoon-embed 2 | -main Server 3 | -neko bin/neko/server.n 4 | -cmd neko bin/neko/server.n -------------------------------------------------------------------------------- /submit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | zip -r monsoon.zip src haxelib.json README.md -x "*/\.*" 3 | haxelib submit monsoon.zip 4 | rm monsoon.zip 2> /dev/null -------------------------------------------------------------------------------- /examples/01_hello_world/run_java.hxml: -------------------------------------------------------------------------------- 1 | -lib monsoon-embed 2 | -lib hxjava 3 | -main Server 4 | -java bin/java/ 5 | -cmd java -jar bin/java/Server.jar -------------------------------------------------------------------------------- /examples/01_hello_world/run_node.hxml: -------------------------------------------------------------------------------- 1 | -lib monsoon 2 | -lib hxnodejs 3 | -main Server 4 | -js bin/node/server.js 5 | -cmd node bin/node/server.js -------------------------------------------------------------------------------- /examples/02_static_file_server/run_node.hxml: -------------------------------------------------------------------------------- 1 | -lib monsoon 2 | -lib hxnodejs 3 | -main Server 4 | -js bin/node/server.js 5 | -cmd node bin/node/server.js -------------------------------------------------------------------------------- /embed/submit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | zip -r monsoon.zip src haxelib.json extraParams.hxml -x "*/\.*" 3 | haxelib submit monsoon.zip 4 | rm monsoon.zip 2> /dev/null -------------------------------------------------------------------------------- /examples/02_static_file_server/run_java.hxml: -------------------------------------------------------------------------------- 1 | -lib monsoon-embed 2 | -lib hxjava 3 | -main Server 4 | -java bin/java/ 5 | -cmd java -jar bin/java/Server.jar -------------------------------------------------------------------------------- /src/Monsoon.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | typedef Monsoon = monsoon.Monsoon; 4 | typedef Response = monsoon.Response; 5 | typedef Layer = monsoon.Layer; 6 | 7 | @:genericBuild(monsoon.macro.RequestBuilder.buildGeneric()) 8 | class Request {} -------------------------------------------------------------------------------- /examples/01_hello_world/Server.hx: -------------------------------------------------------------------------------- 1 | using Monsoon; 2 | 3 | class Server { 4 | public static function main() { 5 | var app = new Monsoon(); 6 | 7 | app.route('/', function (req, res) 8 | res.send('Hello World') 9 | ); 10 | 11 | app.listen(3000); 12 | } 13 | } -------------------------------------------------------------------------------- /examples/02_static_file_server/Server.hx: -------------------------------------------------------------------------------- 1 | using Monsoon; 2 | 3 | import monsoon.middleware.Static; 4 | 5 | class Server { 6 | static function main() { 7 | var port = 5000; 8 | var app = new Monsoon(); 9 | app.use(Static.serve('public')); 10 | app.listen(port); 11 | trace('Server ready and listening on http://localhost:${port}'); 12 | } 13 | } -------------------------------------------------------------------------------- /examples/01_hello_world/README.md: -------------------------------------------------------------------------------- 1 | # 01 2 | 3 | ### Hello world 4 | 5 | The classic hello world. Should respond with 'Hello world' on http://localhost:3000 6 | 7 | #### Run 8 | 9 | To run on `java`, `neko`, `php` or `node` simply run the corresponding hxml file with haxe. 10 | 11 | To run the `cpp` version build it first (`haxe build_cpp.hxml`). 12 | An executable will be available in bin/cpp. -------------------------------------------------------------------------------- /tests/RunTests.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import buddy.*; 4 | 5 | @colors 6 | class RunTests implements Buddy<[ 7 | TestRequest, 8 | TestResponse, 9 | TestRouter, 10 | TestMiddleware, 11 | TestRouteController, 12 | TestStatic, 13 | TestBasicAuth, 14 | TestByteRange, 15 | TestCompression 16 | ]> { 17 | #if php 18 | static function __init__() 19 | untyped __call__('ini_set', 'xdebug.max_nesting_level', 100000); 20 | #end 21 | } -------------------------------------------------------------------------------- /embed/haxelib.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monsoon-embed", 3 | "url" : "https://github.com/benmerckx/monsoon", 4 | "license": "MIT", 5 | "tags": ["js", "html", "web"], 6 | "description": "Embedded webserver", 7 | "version": "0.0.1", 8 | "releasenote": "Alpha", 9 | "contributors": ["benmerckx"], 10 | "dependencies": { 11 | "tink_tcp": "", 12 | "tink_runloop": "", 13 | "tink_concurrent": "", 14 | "tink_http": "", 15 | "monsoon": "" 16 | } 17 | } -------------------------------------------------------------------------------- /examples/02_static_file_server/README.md: -------------------------------------------------------------------------------- 1 | # 02 2 | 3 | ### Static file server 4 | 5 | Monsoon can serve static files using the Static middleware. 6 | This example contains a public directory with an index.html file. 7 | 8 | #### Run 9 | 10 | To run on `java`, `neko` or `node` simply run the corresponding hxml file with haxe. 11 | 12 | To run the `cpp` version build it first (`haxe build_cpp.hxml`). 13 | An executable will be available in bin/cpp. 14 | Move the executable to this directory (so the server can find the public dir) and execute it. -------------------------------------------------------------------------------- /haxelib.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monsoon", 3 | "url" : "https://github.com/benmerckx/monsoon", 4 | "license": "MIT", 5 | "tags": ["js", "html", "web"], 6 | "description": "Minimal haxe web framework", 7 | "version": "0.5.0", 8 | "releasenote": "Don't change body when ending response", 9 | "contributors": ["benmerckx"], 10 | "classPath": "src", 11 | "dependencies": { 12 | "tink_url": "", 13 | "tink_http": "", 14 | "tink_macro": "", 15 | "asys": "", 16 | "mime": "", 17 | "path2ereg": "", 18 | "http-status": "" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | 4 | language: haxe 5 | 6 | os: 7 | - linux 8 | - osx 9 | 10 | haxe: 11 | - "3.2.1" 12 | - development 13 | 14 | matrix: 15 | allow_failures: 16 | - haxe: development 17 | 18 | install: 19 | - haxelib install travix 20 | - haxelib install monsoon-embed --always 21 | - haxelib run travix install 22 | 23 | script: 24 | - haxelib run travix php 25 | - haxelib run travix neko 26 | - haxelib run travix node 27 | - haxelib run travix neko -lib monsoon-embed 28 | - haxelib run travix java -lib monsoon-embed 29 | - haxelib run travix cpp -lib monsoon-embed -------------------------------------------------------------------------------- /src/monsoon/middleware/ThreadServer.hx: -------------------------------------------------------------------------------- 1 | package monsoon.middleware; 2 | 3 | import monsoon.Response; 4 | import tink.http.Request.IncomingRequest; 5 | import tink.http.Response.OutgoingResponse; 6 | 7 | using tink.CoreApi; 8 | 9 | class ThreadServer { 10 | 11 | public static function serve(threads: Int): Layer { 12 | var queue = new tink.concurrent.Queue Void>(); 13 | 14 | for (i in 0 ... threads) { 15 | new tink.concurrent.Thread(function () 16 | while (true) { 17 | var next = queue.await(); 18 | next(); 19 | } 20 | ); 21 | } 22 | 23 | return function (request: Request, response: Response, next: Void -> Void) { 24 | queue.push(next); 25 | } 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /src/monsoon/macro/RequestBuilder.hx: -------------------------------------------------------------------------------- 1 | package monsoon.macro; 2 | 3 | import haxe.macro.Context; 4 | import haxe.macro.Expr; 5 | import haxe.macro.Type; 6 | import haxe.macro.TypeTools; 7 | 8 | class RequestBuilder { 9 | 10 | static public function buildGeneric() { 11 | switch (Context.getLocalType()) { 12 | case TInst(cl, params): 13 | if (params.length == 0) 14 | return (macro: monsoon.Request.MonsoonRequest); 15 | if (params.length == 1) 16 | return ComplexType.TPath({ 17 | pack: ['monsoon'], 18 | name: 'Request', 19 | sub: 'MonsoonRequest', 20 | params: [TPType(TypeTools.toComplexType(Context.follow(params[0])))] 21 | }); 22 | if (params.length > 1) 23 | Context.error('Too many type parameters, expected 0 or 1', Context.currentPos()); 24 | default: 25 | Context.error('Type expected', Context.currentPos()); 26 | } 27 | return null; 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /tests/TestCompression.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import buddy.*; 4 | import haxe.Json; 5 | import monsoon.middleware.Compression; 6 | import tink.http.Method; 7 | import TestTools.*; 8 | import TestTools.TinkResponse; 9 | import monsoon.middleware.Static; 10 | import tink.io.Buffer; 11 | 12 | using Monsoon; 13 | using buddy.Should; 14 | using tink.CoreApi; 15 | 16 | class TestCompression extends BuddySuite { 17 | 18 | public function new() { 19 | var app = new Monsoon(); 20 | 21 | describe('Compression middleware', { 22 | it('should compress via gzip', function(done) { 23 | app.use(Compression.serve()); 24 | app.get(function(req: Request, res: Response) 25 | res.html('ok') 26 | ); 27 | app.serve(request('/', ['accept-encoding' => 'gzip'])).handle(function(res: TinkResponse) { 28 | res.header.byName('content-encoding').should.equal(Success('gzip')); 29 | done(); 30 | }); 31 | }); 32 | }); 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /tests/TestStatic.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import buddy.*; 4 | import haxe.Json; 5 | import tink.http.Method; 6 | import TestTools.*; 7 | import TestTools.TinkResponse; 8 | import monsoon.middleware.Static; 9 | 10 | using Monsoon; 11 | using buddy.Should; 12 | using tink.CoreApi; 13 | 14 | class TestStatic extends BuddySuite { 15 | 16 | public function new() { 17 | var app = new Monsoon(); 18 | 19 | describe('Static middleware', { 20 | it('should serve files', function(done) { 21 | app.use(Static.serve('.')); 22 | app.serve(request('/haxelib.json')).handle(function(res: TinkResponse) { 23 | res.status.should.be(200); 24 | done(); 25 | }); 26 | }); 27 | 28 | it('should serve index files for a directory', function(done) { 29 | app.use(Static.serve('.', {index: ['haxelib.json']})); 30 | app.serve(request('/')).handle(function(res: TinkResponse) { 31 | res.status.should.be(200); 32 | done(); 33 | }); 34 | }); 35 | }); 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /src/monsoon/middleware/Console.hx: -------------------------------------------------------------------------------- 1 | package monsoon.middleware; 2 | 3 | import haxe.Json; 4 | import haxe.PosInfos; 5 | import monsoon.Response; 6 | using tink.CoreApi; 7 | using Monsoon; 8 | 9 | typedef Log = Pair; 10 | 11 | @:access(monsoon.Response) 12 | class Console { 13 | 14 | static var logs: Array = []; 15 | 16 | public function new() { 17 | haxe.Log.trace = 18 | function(v: Dynamic, ?info: PosInfos) 19 | logs.push(new Log(v, info)); 20 | } 21 | 22 | public function process(req: Request, res: Response, next) { 23 | res.after(function(res) { 24 | var type = req.get('content-type'); 25 | if (type != null && type.indexOf('text/html') > -1) 26 | res.body.append('\n'); 27 | return Future.sync(res); 28 | }); 29 | next(); 30 | } 31 | 32 | function logLine(log: Log) { 33 | return 'console.log("%c '+log.b.fileName+':'+log.b.lineNumber+' ", "background: #222; color: white", '+Json.stringify(log.a)+');'; 34 | } 35 | 36 | public static function serve() 37 | return new Console().process; 38 | 39 | } -------------------------------------------------------------------------------- /tests/TestMiddleware.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import buddy.*; 4 | import haxe.Json; 5 | import tink.http.Method; 6 | import TestTools.*; 7 | import TestTools.TinkResponse; 8 | import monsoon.middleware.Console; 9 | 10 | using Monsoon; 11 | using buddy.Should; 12 | using tink.CoreApi; 13 | 14 | class TestMiddleware extends BuddySuite { 15 | 16 | public function new() { 17 | describe('Middleware', { 18 | it('app.use should accept Request -> Response -> Void', function(done) { 19 | var app = new Monsoon(); 20 | app.use(setTestHeader); 21 | app.route('/', function(req, res) res.end()); 22 | app.serve(request('/')).handle(function(res: TinkResponse) { 23 | res.header.byName('test').should.equal(Success('ok')); 24 | done(); 25 | }); 26 | }); 27 | 28 | it('app.use should accept Middleware', function(done) { 29 | var app = new Monsoon(); 30 | app.use({ 31 | process: setTestHeader 32 | }); 33 | app.route('/', function(req, res) res.end()); 34 | app.serve(request('/')).handle(function(res: TinkResponse) { 35 | res.header.byName('test').should.equal(Success('ok')); 36 | done(); 37 | }); 38 | }); 39 | }); 40 | } 41 | 42 | function setTestHeader(req: Request, res: Response, next) { 43 | res.set('test', 'ok'); 44 | next(); 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /src/monsoon/middleware/BasicAuth.hx: -------------------------------------------------------------------------------- 1 | package monsoon.middleware; 2 | 3 | import haxe.crypto.Base64; 4 | 5 | using tink.CoreApi; 6 | using StringTools; 7 | 8 | class BasicAuth { 9 | 10 | static var SHEME = 'Basic '; 11 | 12 | public static function serve(validate: String -> String -> Promise, realm = 'Authorization required') 13 | return function(req: Request, res: Response, next: Void -> Void) { 14 | inline function auth() { 15 | res.status(401) 16 | .set('www-authenticate', 'Basic realm="${realm.split('"').join('\"')}"') 17 | .end(); 18 | } 19 | var authorization = req.get('authorization'); 20 | if (authorization == null) { 21 | return auth(); 22 | } else { 23 | if (!authorization.startsWith(SHEME)) 24 | return res.error(400, 'Malformed authorization header'); 25 | var encoded = authorization.substr(SHEME.length); 26 | var decoded = Base64.decode(encoded).toString().split(':'); 27 | var user = decoded[0], pass = decoded[1]; 28 | if (pass == null) return res.error(400, 'Malformed authorization header'); 29 | validate(user, pass).handle(function (result) 30 | switch result { 31 | case Success(valid): 32 | if (valid) next(); 33 | else auth(); 34 | case Failure(msg): 35 | res.error('$msg'); 36 | } 37 | ); 38 | } 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /tests/TestTools.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import haxe.Json; 4 | import tink.http.Header.HeaderField; 5 | import tink.http.Method; 6 | import tink.http.Request; 7 | import tink.http.Response.OutgoingResponse; 8 | import tink.io.Buffer; 9 | 10 | class TestTools { 11 | 12 | public static function request(?method: Method, path: String, ?fields: TinkHeaderFields, body = '') 13 | return new IncomingRequest('127.0.0.1', 14 | new IncomingRequestHeader(method == null ? GET : method, path, '1.1', fields == null ? [] : fields), 15 | IncomingRequestBody.Plain(body) 16 | ); 17 | 18 | } 19 | 20 | @:forward 21 | abstract TinkResponse(OutgoingResponse) from OutgoingResponse { 22 | public var status(get, never): Int; 23 | function get_status() return this.header.statusCode; 24 | 25 | public var body(get, never): String; 26 | function get_body() { 27 | var buffer = Buffer.alloc(); 28 | this.body.read(buffer); 29 | return buffer.content().toString(); 30 | } 31 | 32 | public var bodyJson(get, never): Dynamic; 33 | function get_bodyJson() 34 | return Json.parse((this: TinkResponse).body); 35 | } 36 | 37 | abstract TinkHeaderFields(Array) from Array to Array { 38 | @:from public static function fromMap(map: Map) 39 | return ([for (key in map.keys()) new HeaderField(key, map.get(key))]: TinkHeaderFields); 40 | } -------------------------------------------------------------------------------- /tests/TestRouteController.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import buddy.*; 4 | import haxe.Json; 5 | import tink.http.Method; 6 | import TestTools.*; 7 | import TestTools.TinkResponse; 8 | 9 | using Monsoon; 10 | using buddy.Should; 11 | using tink.CoreApi; 12 | 13 | class TestRouteController extends BuddySuite { 14 | 15 | public function new() { 16 | describe('RouteController', { 17 | it('app.use should accept RouteController', function(done) { 18 | var app = new Monsoon(); 19 | app.use(this); 20 | app.serve(request('/')).handle(function(res: TinkResponse) { 21 | res.body.should.be('ok'); 22 | done(); 23 | }); 24 | }); 25 | 26 | it('app.use should accept a prefix', function(done) { 27 | var app = new Monsoon(); 28 | app.use('/prefix', this); 29 | app.serve(request('/prefix')).handle(function(res: TinkResponse) { 30 | res.body.should.be('ok'); 31 | done(); 32 | }); 33 | }); 34 | 35 | it('app.use should change the request path', function(done) { 36 | var app = new Monsoon(); 37 | app.use('/prefix', function(req: Request, res: Response) { 38 | req.path.should.be('/abc'); 39 | res.end(); 40 | }); 41 | app.use(function(req: Request, res: Response) { 42 | req.path.should.be('/abc'); 43 | res.end(); 44 | }); 45 | Future.ofMany([ 46 | app.serve(request('/prefix/abc')), 47 | app.serve(request('/abc')) 48 | ]).handle(function(_) done()); 49 | }); 50 | }); 51 | } 52 | 53 | public function createRoutes(router: Monsoon) { 54 | router.route('/', function(req, res) res.send('ok')); 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/monsoon/Request.hx: -------------------------------------------------------------------------------- 1 | package monsoon; 2 | 3 | import tink.http.Request; 4 | import tink.Url; 5 | import tink.http.Method; 6 | 7 | using tink.CoreApi; 8 | 9 | @:allow(monsoon.Monsoon) 10 | class MonsoonRequest extends IncomingRequest { 11 | 12 | public function new(req: IncomingRequest) { 13 | super(req.clientIp, req.header, req.body); 14 | path = header.uri.path; 15 | url = header.uri; 16 | query = cast url.query.toMap(); 17 | method = header.method; 18 | for (header in header.get('set-cookie')) { 19 | var line = (header: String).split(';')[0].split('='); 20 | cookies.set(StringTools.urlDecode(line[0]), (line.length > 1 ? StringTools.urlDecode(line[1]) : null)); 21 | } 22 | } 23 | 24 | public var params(default, null): T; 25 | public var path(default, null): String; 26 | 27 | public var url(default, null): Url; 28 | 29 | public var method(default, null): Method; 30 | 31 | public var hostname(get, never): Null; 32 | inline function get_hostname(): Null { 33 | var headers = this.header.get('host'); 34 | if (headers.length > 0) 35 | return (headers[0]: String).split(':')[0]; 36 | return null; 37 | } 38 | 39 | public var ip(get, never): String; 40 | inline function get_ip(): String 41 | return this.clientIp; 42 | 43 | public var query(default, null): Map = new Map(); 44 | 45 | public function get(name: String): Null { 46 | var found = this.header.get(name); 47 | return found.length > 0 ? found[0] : null; 48 | } 49 | 50 | public var cookies(default, null): Map = new Map(); 51 | 52 | } 53 | 54 | @:genericBuild(monsoon.macro.RequestBuilder.buildGeneric()) 55 | class Request {} -------------------------------------------------------------------------------- /src/monsoon/middleware/Static.hx: -------------------------------------------------------------------------------- 1 | package monsoon.middleware; 2 | 3 | import asys.FileSystem; 4 | import haxe.io.Path; 5 | import monsoon.Request; 6 | import monsoon.Response; 7 | import tink.http.Handler; 8 | import tink.http.Method; 9 | import tink.http.Request.IncomingRequest; 10 | import tink.http.Response.OutgoingResponse; 11 | 12 | using tink.CoreApi; 13 | 14 | typedef StaticOptions = { 15 | index: Array 16 | } 17 | 18 | class Static { 19 | 20 | var directory: String; 21 | var options = { 22 | index: ['index.html', 'index.htm'] 23 | }; 24 | 25 | private function new(directory: String, ?options: StaticOptions) { 26 | this.directory = directory; 27 | if (options != null) 28 | this.options = options; 29 | } 30 | 31 | public function process(req: Request, res: Response, next: Void -> Void) { 32 | var path = FileSystem.absolutePath(directory+req.path); 33 | 34 | if (req.method != GET) { 35 | next(); return; 36 | } 37 | 38 | FileSystem.exists(path).handle(function(exists) { 39 | if (!exists) { 40 | next(); return; 41 | } 42 | FileSystem.isDirectory(path).handle(function(isDir) { 43 | if (!isDir) { 44 | res.sendFile(path); 45 | return; 46 | } 47 | var iter = options.index.iterator(); 48 | function tryNext() { 49 | if (!iter.hasNext()) { 50 | next(); 51 | return; 52 | } 53 | var location = Path.join([path, iter.next()]); 54 | FileSystem.exists(location).handle(function(isFile) { 55 | if (!isFile) { 56 | tryNext(); 57 | return; 58 | } 59 | res.sendFile(location); 60 | }); 61 | } 62 | tryNext(); 63 | }); 64 | }); 65 | } 66 | 67 | public static function serve(directory: String, ?options: StaticOptions) { 68 | return new Static(directory, options); 69 | } 70 | 71 | } -------------------------------------------------------------------------------- /tests/TestBasicAuth.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import buddy.*; 4 | import haxe.Json; 5 | import tink.http.Method; 6 | import TestTools.*; 7 | import TestTools.TinkResponse; 8 | import monsoon.middleware.BasicAuth; 9 | import haxe.crypto.Base64; 10 | import haxe.io.Bytes; 11 | 12 | using Monsoon; 13 | using buddy.Should; 14 | using tink.CoreApi; 15 | 16 | class TestBasicAuth extends BuddySuite { 17 | 18 | public function new() { 19 | var app = new Monsoon(); 20 | var credentials = { 21 | user: 'user', 22 | password: 'password' 23 | } 24 | 25 | app.use(BasicAuth.serve(function(user: String, pass: String) 26 | return user == credentials.user && credentials.password == pass 27 | )); 28 | 29 | app.get('/', function(req, res: Response) 30 | res.html('protected') 31 | ); 32 | 33 | describe('BasicAuth middleware', { 34 | it('should request authorization', function(done) { 35 | app.serve(request('/')).handle(function(res: TinkResponse) { 36 | res.header.byName('www-authenticate').should.equal(Success('Basic realm="Authorization required"')); 37 | done(); 38 | }); 39 | }); 40 | 41 | it('should error on incorrect credentials', function(done) { 42 | app.serve(request('/', [ 43 | 'authorization' => 'uh oh' 44 | ])).handle(function(res: TinkResponse) { 45 | res.status.should.be(400); 46 | done(); 47 | }); 48 | }); 49 | 50 | it('should deny access for incorrect credentials', function(done) { 51 | app.serve(request('/', [ 52 | 'authorization' => 'Basic '+Base64.encode(Bytes.ofString('a:b')) 53 | ])).handle(function(res: TinkResponse) { 54 | res.status.should.be(401); 55 | done(); 56 | }); 57 | }); 58 | 59 | it('should pass the route for correct credentials', function(done) { 60 | app.serve(request('/', [ 61 | 'authorization' => 'Basic '+Base64.encode(Bytes.ofString(credentials.user+':'+credentials.password)) 62 | ])).handle(function(res: TinkResponse) { 63 | res.status.should.be(200); 64 | res.body.should.be('protected'); 65 | done(); 66 | }); 67 | }); 68 | }); 69 | } 70 | 71 | } -------------------------------------------------------------------------------- /src/monsoon/Layer.hx: -------------------------------------------------------------------------------- 1 | package monsoon; 2 | 3 | import monsoon.Layer.Out; 4 | import tink.core.Any; 5 | import tink.http.Request; 6 | import tink.http.Response; 7 | import tink.http.Handler; 8 | import tink.core.Future; 9 | import haxe.Constraints.Function; 10 | import monsoon.Request; 11 | 12 | typedef In = Request; 13 | typedef Out = Response; 14 | 15 | typedef LayerBase = In -> Out -> (Void -> Void) -> Void; 16 | typedef WithProcess = {function process(request: In, response: Out, next: Void -> Void): Void;} 17 | typedef WithCreateRoutes = {function createRoutes(router: Monsoon): Void;} 18 | 19 | @:callable 20 | abstract Layer(LayerBase) from LayerBase to LayerBase { 21 | 22 | inline function new(func) 23 | this = func; 24 | 25 | @:from 26 | public inline static function fromMiddleware(middleware: Handler -> Handler) 27 | return new Layer( 28 | function(req, res, next) 29 | middleware(function(req) { 30 | next(); 31 | return res.future; 32 | }) 33 | .process(req) 34 | .handle(function(outgoing) 35 | @:privateAccess 36 | res.ofOutgoingResponse(outgoing).end() 37 | ) 38 | ); 39 | 40 | @:from 41 | public inline static function fromHandler(func: Handler) 42 | return new Layer(function(req, res, next) 43 | func.process(req).handle(function(outgoing) 44 | @:privateAccess 45 | res.ofOutgoingResponse(outgoing).end() 46 | ) 47 | ); 48 | 49 | @:from 50 | public inline static function fromCreateRoutes(cr: WithCreateRoutes) { 51 | var router = new Monsoon(); 52 | cr.createRoutes(router); 53 | return router.toLayer(); 54 | } 55 | 56 | @:from 57 | public inline static function fromProcess(mw: WithProcess) 58 | return new Layer(mw.process); 59 | 60 | @:from 61 | public inline static function fromBasic(cb: In -> Out -> Void) 62 | return new Layer( 63 | function(req, res, next) 64 | return cb(req, res) 65 | ); 66 | 67 | @:from 68 | public inline static function fromTypedBasic(cb: Request -> Out -> Void) 69 | return new Layer(fromBasic(cast cb)); 70 | 71 | @:from 72 | public inline static function fromTyped(cb: Request -> Out -> (Void -> Void) -> Void) 73 | return new Layer(cast cb); 74 | 75 | @:from 76 | public inline static function fromMap(map: Map): Layer { 77 | var router = new Monsoon(); 78 | for (path in map.keys()) 79 | router.route(path, map.get(path)); 80 | return router; 81 | } 82 | 83 | @:to 84 | public function toHandler(): Handler 85 | return function(req) { 86 | var res = new Out(); 87 | this(new In(req), res, function() 88 | res.error(404, 'Not found') 89 | ); 90 | return res.future; 91 | } 92 | 93 | } -------------------------------------------------------------------------------- /tests/TestRequest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import buddy.*; 4 | import haxe.EnumTools; 5 | import haxe.Json; 6 | import tink.http.Header.HeaderField; 7 | import tink.http.Method; 8 | import TestTools.*; 9 | import TestTools.TinkResponse; 10 | 11 | using Monsoon; 12 | using buddy.Should; 13 | using tink.CoreApi; 14 | 15 | class TestRequest extends BuddySuite { 16 | public function new() { 17 | var app = new Monsoon(); 18 | describe('Request', { 19 | it('should contain the http method'); 20 | // Todo: renable after this gets fixed: 21 | // https://github.com/HaxeFoundation/haxe/issues/5706 22 | /*it('should contain the http method', function(done) { 23 | app.route('/http-method/:method', function(req: Request<{method: String}>, res: Response) { 24 | req.method.should.be(req.params.method.toUpperCase()); 25 | res.end(); 26 | }); 27 | 28 | Future.ofMany( 29 | [GET, HEAD, OPTIONS, POST, PUT, PATCH, DELETE].map(function (m) 30 | return app.serve(request(m, '/http-method/'+(m: String).toLowerCase())) 31 | ) 32 | ).handle(done); 33 | });*/ 34 | 35 | it('should parse query parameters', function(done) { 36 | app.route('/parse-query', function(req: Request, res: Response) { 37 | req.query.get('a').should.be('1'); 38 | req.query.get('b').should.be('2'); 39 | res.end(); 40 | }); 41 | 42 | app.serve(request('/parse-query?a=1&b=2')).handle(done); 43 | }); 44 | 45 | it('should parse cookies', function(done) { 46 | var value = 'testvalue#é_$<>Ϸ'; 47 | app.route('/cookies', function(req: Request, res: Response) { 48 | req.cookies.get('name').should.be(value); 49 | res.end(); 50 | }); 51 | app.serve(request('/cookies', ['set-cookie' => 'name='+StringTools.urlEncode(value)])).handle(done); 52 | }); 53 | 54 | it('should set cookies', function(done) { 55 | var value = 'testvalue#é_$<>Ϸ'; 56 | app.route('/cookies-set', function(req: Request, res: Response) { 57 | res.cookie('name', value).end(); 58 | }); 59 | app.serve(request('/cookies-set')).handle(function(res: TinkResponse) { 60 | res.header.byName('set-cookie').should.equal(Success('name=testvalue%23%C3%A9_%24%3C%3E%CF%B7; HttpOnly')); 61 | done(); 62 | }); 63 | }); 64 | 65 | it('should get client headers', function(done) { 66 | app.route('/client-headers', function(req: Request, res: Response) { 67 | req.get('x-test-header').should.be('ok'); 68 | req.get('unknown').should.be(null); 69 | res.end(); 70 | }); 71 | app.serve(request('/client-headers', ['x-test-header' => 'ok'])).handle(done); 72 | }); 73 | 74 | }); 75 | } 76 | } -------------------------------------------------------------------------------- /tests/TestByteRange.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import buddy.*; 4 | import haxe.Json; 5 | import tink.http.Method; 6 | import TestTools.*; 7 | import TestTools.TinkResponse; 8 | import monsoon.middleware.ByteRange; 9 | import monsoon.middleware.Static; 10 | 11 | using Monsoon; 12 | using buddy.Should; 13 | using tink.CoreApi; 14 | 15 | class TestByteRange extends BuddySuite { 16 | 17 | public function new() { 18 | var app = new Monsoon(); 19 | 20 | app.use(ByteRange.serve()); 21 | app.get('/', function(req, res: Response) 22 | res.set('content-length', '10').send('0123456789') 23 | ); 24 | 25 | describe('Range middleware', { 26 | it('should set the accept-ranges header', function(done) { 27 | app.serve(request('/')).handle(function(res: TinkResponse) { 28 | res.header.byName('accept-ranges').should.equal(Success('bytes')); 29 | done(); 30 | }); 31 | }); 32 | 33 | it('should serve 0-end range', function(done) { 34 | app.serve(request('/', ['range' => 'bytes=0-3'])).handle(function(res: TinkResponse) { 35 | res.status.should.be(206); 36 | res.header.byName('content-length').should.equal(Success('4')); 37 | res.header.byName('content-range').should.equal(Success('bytes 0-3/10')); 38 | res.body.should.be('0123'); 39 | done(); 40 | }); 41 | }); 42 | 43 | it('should serve start-end range', function(done) { 44 | app.serve(request('/', ['range' => 'bytes=1-4'])).handle(function(res: TinkResponse) { 45 | res.status.should.be(206); 46 | res.header.byName('content-length').should.equal(Success('4')); 47 | res.header.byName('content-range').should.equal(Success('bytes 1-4/10')); 48 | res.body.should.be('1234'); 49 | done(); 50 | }); 51 | }); 52 | 53 | it('should serve start- range', function(done) { 54 | app.serve(request('/', ['range' => 'bytes=8-'])).handle(function(res: TinkResponse) { 55 | res.status.should.be(206); 56 | res.header.byName('content-length').should.equal(Success('2')); 57 | res.header.byName('content-range').should.equal(Success('bytes 8-9/10')); 58 | res.body.should.be('89'); 59 | done(); 60 | }); 61 | }); 62 | 63 | it('should serve -end range', function(done) { 64 | app.serve(request('/', ['range' => 'bytes=-2'])).handle(function(res: TinkResponse) { 65 | res.status.should.be(206); 66 | res.header.byName('content-length').should.equal(Success('2')); 67 | res.header.byName('content-range').should.equal(Success('bytes 8-9/10')); 68 | res.body.should.be('89'); 69 | done(); 70 | }); 71 | }); 72 | 73 | it('should fail on incorrect ranges', function(done) { 74 | app.serve(request('/', ['range' => 'bytes=0-11'])).handle(function(res: TinkResponse) { 75 | res.status.should.be(416); 76 | done(); 77 | }); 78 | }); 79 | }); 80 | } 81 | 82 | } -------------------------------------------------------------------------------- /tests/TestResponse.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import buddy.*; 4 | import haxe.Json; 5 | import tink.http.Method; 6 | import TestTools.*; 7 | import TestTools.TinkResponse; 8 | import asys.FileSystem; 9 | 10 | using Monsoon; 11 | using buddy.Should; 12 | using tink.CoreApi; 13 | 14 | class TestResponse extends BuddySuite { 15 | public function new() { 16 | var app = new Monsoon(); 17 | describe('Response', { 18 | it('should send a response body', function(done) { 19 | app.get('/response', function(req, res) res.send('ok')); 20 | app.serve(request('/response')).handle(function(res: TinkResponse) { 21 | res.status.should.be(200); 22 | res.body.should.be('ok'); 23 | done(); 24 | }); 25 | }); 26 | 27 | it('should set the status', function(done) { 28 | app.get('/status', function(req, res) res.status(600).end()); 29 | app.serve(request('/status')).handle(function(res: TinkResponse) { 30 | res.status.should.be(600); 31 | done(); 32 | }); 33 | }); 34 | 35 | it('should respond json', function(done) { 36 | var obj = {test: '123'}; 37 | app.get('/json', function(req, res: Response) res.json(obj)); 38 | app.serve(request('/json')).handle(function(res: TinkResponse) { 39 | res.header.byName('content-type').should.equal(Success('application/json')); 40 | res.body.should.be(Json.stringify(obj)); 41 | done(); 42 | }); 43 | }); 44 | 45 | it('should set headers', function(done) { 46 | app.get('/set-headers', function(req, res: Response) 47 | res.set('x-test', 'abc').set('x-more', '123').end() 48 | ); 49 | app.serve(request('/set-headers')).handle(function(res: TinkResponse) { 50 | res.header.byName('x-test').should.equal(Success('abc')); 51 | res.header.byName('x-more').should.equal(Success('123')); 52 | done(); 53 | }); 54 | }); 55 | 56 | it('should create proper error responses', function(done) { 57 | app.get('/error', function(req, res: Response) 58 | res.error(500, 'failed') 59 | ); 60 | app.serve(request('/error')).handle(function(res: TinkResponse) { 61 | res.status.should.be(500); 62 | res.body.should.be('failed'); 63 | done(); 64 | }); 65 | }); 66 | 67 | it('should serve files with content-type and content-length', function(done) { 68 | var file = 'haxelib.json'; 69 | FileSystem.stat(file).handle(function (res) switch res { 70 | case Success(stat): 71 | app.get('/file', function(req, res: Response) res.sendFile('haxelib.json')); 72 | app.serve(request('/file')).handle(function(res: TinkResponse) { 73 | res.header.byName('content-type').should.equal(Success('application/json; charset=utf-8')); 74 | res.header.byName('content-length').should.equal(Success('${stat.size}')); 75 | done(); 76 | }); 77 | default: 78 | fail('Could not read haxelib.json'); 79 | }); 80 | }); 81 | }); 82 | } 83 | } -------------------------------------------------------------------------------- /tests/TestRouter.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import buddy.*; 4 | import haxe.Json; 5 | import tink.http.Method; 6 | import TestTools.*; 7 | import TestTools.TinkResponse; 8 | import haxe.DynamicAccess; 9 | 10 | using Monsoon; 11 | using buddy.Should; 12 | 13 | class TestRouter extends BuddySuite { 14 | public function new() { 15 | var app = new Monsoon(); 16 | describe('Router', { 17 | it('should serve requests', function(done) { 18 | app.get('/', function(req, res) res.send('hello')); 19 | app.serve(request('/')).handle(function(res: TinkResponse) { 20 | res.status.should.be(200); 21 | res.body.should.be('hello'); 22 | done(); 23 | }); 24 | }); 25 | 26 | it('should add layers in map notation', function(done) { 27 | app.get([ 28 | '/route1' => function(req: Request, res: Response, next) res.send('1'), 29 | '/route2' => function(req: Request, res: Response, next) res.send('2') 30 | ]); 31 | app.serve(request('/route2')).handle(function(res: TinkResponse) { 32 | res.body.should.be('2'); 33 | done(); 34 | }); 35 | }); 36 | 37 | it('should parse params', function(done) { 38 | app.get('/param/:arg', function(req: Request<{arg: String}>, res: Response) 39 | res.json({param: req.params.arg}) 40 | ); 41 | app.serve(request('/param/string')).handle(function(res: TinkResponse) { 42 | res.body.should.be(Json.stringify({param: 'string'})); 43 | done(); 44 | }); 45 | }); 46 | 47 | /*it('should parse params of different types', function(done) { 48 | app.get('/arg/:arg', function(req: Request<{arg: Int}>, res: Response) res.send('Int: '+req.params.arg)); 49 | app.get('/arg/:arg', function(req: Request<{arg: Float}>, res: Response) res.send('Float: '+req.params.arg)); 50 | 51 | app.serve(request(GET, '/arg/123')).handle(function(res: TinkResponse) { 52 | res.body.should.be('Int: 123'); 53 | app.serve(request(GET, '/arg/12.05')).handle(function(res: TinkResponse) { 54 | res.body.should.be('Float: 12.05'); 55 | done(); 56 | }); 57 | }); 58 | });*/ 59 | 60 | it('should parse splat', function(done) { 61 | app.get('/splat/*', function(req: Request>, res: Response) 62 | res.json({splat: req.params.get('0')}) 63 | ); 64 | app.serve(request('/splat/more/than/one/dir')).handle(function(res: TinkResponse) { 65 | res.body.should.be(Json.stringify({splat: 'more/than/one/dir'})); 66 | done(); 67 | }); 68 | }); 69 | 70 | it('should respond with 404 if a page is not found', function(done) { 71 | app.serve(request('/unknown')).handle(function(res: TinkResponse) { 72 | res.status.should.be(404); 73 | done(); 74 | }); 75 | }); 76 | 77 | it('should respond a server error if something goes wrong', function(done) { 78 | app.get('/fail', function(req: Request, res: Response) 79 | throw 'fail' 80 | ); 81 | app.serve(request('/fail')).handle(function(res: TinkResponse) { 82 | res.status.should.be(500); 83 | done(); 84 | }); 85 | }); 86 | }); 87 | } 88 | } -------------------------------------------------------------------------------- /src/monsoon/middleware/ByteRange.hx: -------------------------------------------------------------------------------- 1 | package monsoon.middleware; 2 | 3 | import httpstatus.HttpStatusMessage; 4 | import tink.io.Buffer; 5 | import tink.io.Source; 6 | import tink.io.IdealSink.BlackHole; 7 | import haxe.io.Bytes; 8 | import tink.io.StreamParser; 9 | 10 | using tink.CoreApi; 11 | 12 | typedef Range = { 13 | start: Int, 14 | length: Int 15 | } 16 | 17 | class ByteRange { 18 | 19 | static var HEADER_START = 'bytes='; 20 | 21 | static function bytePos(input: String): Option { 22 | if (input == '') return None; 23 | var number = Std.parseInt(input); 24 | if (number == null) return None; 25 | return Some(number); 26 | } 27 | 28 | static function parseRange(header: String, length: Int): Outcome, Noise> { 29 | if (header.substr(0, HEADER_START.length) != HEADER_START) 30 | return Failure(Noise); 31 | 32 | var ranges = header.substr(HEADER_START.length).split(','), 33 | response = []; 34 | for (range in ranges) { 35 | var parts = range.split('-'); 36 | if (parts.length != 2) 37 | return Failure(Noise); 38 | 39 | switch [bytePos(parts[0]), bytePos(parts[1])] { 40 | case [Some(start), Some(end)]: 41 | response.push({start: start, length: (end-start)+1}); 42 | case [Some(start), None]: 43 | response.push({start: start, length: length-start}); 44 | case [None, Some(end)]: 45 | response.push({start: length-end, length: end}); 46 | default: 47 | return Failure(Noise); 48 | } 49 | } 50 | return Success(response); 51 | } 52 | 53 | public static function serve() 54 | return function(req: Request, res: Response, next: Void -> Void) { 55 | function done() 56 | return Future.sync(res); 57 | 58 | function fail() { 59 | res.error(416, HttpStatusMessage.fromCode(416)); 60 | return Future.sync(res); 61 | } 62 | 63 | res.after(function (res) { 64 | if (res.get('content-length') == null) 65 | return done(); 66 | 67 | var length = Std.parseInt(res.get('content-length')); 68 | res.set('accept-ranges', 'bytes'); 69 | 70 | var header = req.get('range'); 71 | if (header == null) 72 | return done(); 73 | 74 | switch parseRange(req.get('range'), length) { 75 | case Success(ranges): 76 | if (ranges.length != 1) 77 | return fail(); 78 | var range = ranges[0]; 79 | if (range.start+range.length > length) 80 | return fail(); 81 | 82 | var body: Source = res.body; 83 | if (range.start > 0) { 84 | var limited = body.limit(range.start); 85 | limited.pipeTo(BlackHole.INST); 86 | } 87 | 88 | @:privateAccess 89 | res.body = body.limit(range.length).idealize(fail); 90 | 91 | res 92 | .status(206) 93 | .set('content-length', '${range.length}') 94 | .set('content-range', 'bytes ${range.start}-${range.start+range.length-1}/${length}'); 95 | 96 | return done(); 97 | default: 98 | return fail(); 99 | } 100 | 101 | return done(); 102 | }); 103 | 104 | next(); 105 | } 106 | 107 | } -------------------------------------------------------------------------------- /src/monsoon/middleware/Compression.hx: -------------------------------------------------------------------------------- 1 | package monsoon.middleware; 2 | 3 | import haxe.io.BytesOutput; 4 | import haxe.io.Bytes; 5 | import mime.Mime; 6 | import tink.io.IdealSource; 7 | import tink.io.Sink; 8 | import tink.io.Worker; 9 | #if nodejs 10 | import js.node.Buffer; 11 | import js.node.Zlib; 12 | #else 13 | import haxe.io.BytesBuffer; 14 | import haxe.crypto.Crc32; 15 | #end 16 | 17 | using Monsoon; 18 | using tink.CoreApi; 19 | 20 | @:access(monsoon.Response) 21 | class Compression { 22 | 23 | var level: Int = 9; 24 | 25 | public function new(?level: Int) { 26 | if (level != null) 27 | this.level = level; 28 | } 29 | 30 | #if !nodejs 31 | function writeGzipHeader(buffer: BytesBuffer) { 32 | buffer.addByte(0x1f); 33 | buffer.addByte(0x8b); 34 | buffer.addByte(8); 35 | buffer.addByte(0); 36 | buffer.addByte(0); 37 | buffer.addByte(0); 38 | buffer.addByte(0); 39 | buffer.addByte(0); 40 | buffer.addByte(level == 9 ? 2 : 4); 41 | buffer.addByte(0x03); 42 | } 43 | #end 44 | 45 | function finalizeResponse(response: Response, bytes: Bytes) { 46 | response.set('content-encoding', 'gzip'); 47 | response.set('content-length', Std.string(bytes.length)); 48 | response.body = bytes; 49 | } 50 | 51 | public function process(request: Request, response: Response, next: Void -> Void) { 52 | var accept = request.get('accept-encoding'); 53 | 54 | if (accept == null || accept.indexOf('gzip') == -1) { 55 | next(); return; 56 | } 57 | 58 | response.after(function(res) { 59 | // Only compress types for which it makes sense 60 | var type = res.get('content-type').split(';')[0]; // Todo: use header / contentType for this 61 | if (type == null) 62 | return Future.sync(res); 63 | var mime = Mime.db.get(type); 64 | if (mime == null) 65 | return Future.sync(res); 66 | if (mime.compressible == null || !mime.compressible) 67 | return Future.sync(res); 68 | 69 | var trigger = Future.trigger(); 70 | var out = response.body; 71 | var buffer = new BytesOutput(); 72 | var input: Bytes; 73 | 74 | out.pipeTo(Sink.ofOutput('response output buffer', buffer, Worker.EAGER)) 75 | .handle(function (x) switch x { 76 | case AllWritten: 77 | input = buffer.getBytes(); 78 | #if nodejs 79 | Zlib.gzip(Buffer.hxFromBytes(input), {level: level}, function(err, buffer) { 80 | if (err != null) { 81 | response.error(err.message); 82 | return; 83 | } 84 | finalizeResponse(response, buffer.hxToBytes()); 85 | trigger.trigger(res); 86 | }); 87 | #else 88 | var buffer = new BytesBuffer(); 89 | writeGzipHeader(buffer); 90 | var compressed; 91 | #if !php 92 | compressed = haxe.zip.Compress.run(input, level); 93 | #else 94 | var c = untyped __call__("gzcompress", input.toString(), level); 95 | compressed = haxe.io.Bytes.ofString(c); 96 | #end 97 | // Remove zlib header/checksum 98 | buffer.addBytes(compressed, 2, compressed.length-6); 99 | buffer.addInt32(Crc32.make(input)); 100 | buffer.addInt32(input.length); 101 | var bytes = buffer.getBytes(); 102 | finalizeResponse(response, bytes); 103 | trigger.trigger(res); 104 | #end 105 | default: 106 | trigger.trigger(res); 107 | }); 108 | return trigger.asFuture(); 109 | }); 110 | 111 | next(); 112 | } 113 | 114 | public static function serve(?level: Int) 115 | return new Compression(level).process; 116 | 117 | } -------------------------------------------------------------------------------- /src/monsoon/Monsoon.hx: -------------------------------------------------------------------------------- 1 | package monsoon; 2 | 3 | import haxe.DynamicAccess; 4 | import tink.http.Container; 5 | import tink.http.containers.*; 6 | import tink.http.Request; 7 | import tink.http.Response; 8 | import tink.http.Handler; 9 | import tink.http.Method; 10 | import monsoon.Request; 11 | import monsoon.Response; 12 | import monsoon.Layer; 13 | import path2ereg.Path2EReg; 14 | 15 | using Lambda; 16 | using tink.CoreApi; 17 | 18 | @:forward 19 | abstract Monsoon(List) from List { 20 | 21 | public inline function new() 22 | this = new List(); 23 | 24 | public inline function add(layer: Layer): Monsoon { 25 | this.add(layer); 26 | return this; 27 | } 28 | 29 | public inline function get(?path: String, callback: Layer) 30 | return route(GET, path, callback); 31 | 32 | public inline function post(?path: String, callback: Layer) 33 | return route(POST, path, callback); 34 | 35 | public inline function delete(?path: String, callback: Layer) 36 | return route(DELETE, path, callback); 37 | 38 | public inline function head(?path: String, callback: Layer) 39 | return route(HEAD, path, callback); 40 | 41 | public inline function options(?path: String, callback: Layer) 42 | return route(OPTIONS, path, callback); 43 | 44 | public inline function patch(?path: String, callback: Layer) 45 | return route(PATCH, path, callback); 46 | 47 | public inline function put(?path: String, callback: Layer) 48 | return route(PUT, path, callback); 49 | 50 | public function route(?method: Method, ?path: String, callback: Layer, end = true) 51 | return add(function(req: Request, res: Response, next: Void -> Void) { 52 | return 53 | if (path != null) { 54 | var matcher = Path2EReg.toEReg(path, {end: end}); 55 | if (!matcher.ereg.match(req.path)) { 56 | next(); 57 | } else { 58 | var params: DynamicAccess = {}; 59 | for (i in 0 ... matcher.keys.length) 60 | params.set(matcher.keys[i].name, matcher.ereg.matched(i+1)); 61 | req.params = cast params; 62 | req.path = end ? req.path : matcher.ereg.matchedRight(); 63 | callback(req, res, next); 64 | } 65 | } else { 66 | req.params = null; 67 | req.path = req.path == null ? req.url.path : req.path; 68 | callback(req, res, next); 69 | } 70 | }); 71 | 72 | public inline function use(?path: String, callback: Layer) 73 | return route(path, callback, false); 74 | 75 | public function serve(req: IncomingRequest) { 76 | return 77 | try toHandler().process(req) 78 | catch (e: Dynamic) { 79 | var res = new Response(); 80 | res.error('Unexpected exception: $e'); 81 | res.future; 82 | } 83 | } 84 | 85 | public function layer(req, res, last) { 86 | var iter = this.iterator(); 87 | function next() 88 | if (iter.hasNext()) 89 | iter.next()(req, res, next) 90 | else 91 | last(); 92 | next(); 93 | } 94 | 95 | @:to 96 | public inline function toLayer(): Layer 97 | return layer; 98 | 99 | @:to 100 | public inline function toHandler(): Handler 101 | return toLayer(); 102 | 103 | inline function container(port) 104 | return ( 105 | #if embed 106 | new TcpContainer(port) 107 | #elseif php 108 | PhpContainer.inst 109 | #elseif neko 110 | ModnekoContainer.inst 111 | #elseif nodejs 112 | new NodeContainer(port) 113 | #else 114 | #error 115 | #end 116 | ); 117 | 118 | // Todo: add options (watch, notfound, etc) 119 | public inline function listen(port: Int = 80): Future { 120 | var container = container(port); 121 | 122 | #if (embed && haxe_ver < 3.300) 123 | 124 | // Work around for https://github.com/haxetink/tink_runloop/issues/4 125 | var trigger = Future.trigger(); 126 | @:privateAccess tink.RunLoop.create(function() 127 | container.run(serve).handle(trigger.trigger) 128 | ); 129 | return trigger.asFuture(); 130 | 131 | #else 132 | 133 | return container.run(serve); 134 | 135 | #end 136 | } 137 | 138 | } -------------------------------------------------------------------------------- /src/monsoon/Response.hx: -------------------------------------------------------------------------------- 1 | package monsoon; 2 | 3 | import haxe.io.Bytes; 4 | import haxe.Json; 5 | import tink.core.Future.FutureTrigger; 6 | import tink.http.Header.HeaderName; 7 | import tink.http.Response; 8 | import tink.http.Header.HeaderField; 9 | import tink.io.IdealSource; 10 | import asys.io.File; 11 | import asys.FileSystem; 12 | import mime.Mime; 13 | import httpstatus.HttpStatusMessage; 14 | 15 | using tink.CoreApi; 16 | 17 | typedef CookieOptions = { 18 | ?expires: Date, 19 | ?domain: String, 20 | ?path: String, 21 | ?secure: Bool, 22 | ?scriptable: Bool 23 | } 24 | 25 | class Response { 26 | 27 | public var future(default, null): Future; 28 | public var header(default, null): ResponseHeader; 29 | public var body(default, null): IdealSource; 30 | var done: FutureTrigger; 31 | var transform: Response -> Future; 32 | 33 | public function new() 34 | clear(); 35 | 36 | public function status(code: Int) { 37 | @:privateAccess 38 | header.statusCode = code; 39 | @:privateAccess 40 | header.reason = (code: HttpStatusMessage); 41 | return this; 42 | } 43 | 44 | public function clear() { 45 | header = new ResponseHeader(200, 'OK', []); 46 | body = ''; 47 | done = Future.trigger(); 48 | future = done.asFuture(); 49 | transform = function (res) return Future.sync(this); 50 | return this; 51 | } 52 | 53 | public function cookie(name: String, value: String, ?options: CookieOptions) { 54 | header.fields.push(HeaderField.setCookie(name, value, options)); 55 | return this; 56 | } 57 | 58 | public function json(output: Dynamic, ?space) { 59 | set('content-type', 'application/json'); 60 | send(Json.stringify(output, null, space)); 61 | } 62 | 63 | public function redirect(code = 302, url: String) { 64 | clear() 65 | .status(302) 66 | .set('location', url) 67 | .end(); 68 | } 69 | 70 | public function error(code = 500, message: String) { 71 | clear() 72 | .set('content-type', 'text/plain; charset=utf-8') 73 | .status(code) 74 | .send(message); 75 | } 76 | 77 | public function set(key: String, value: String) { 78 | var name: HeaderName = key; 79 | switch header.byName(name) { 80 | case Success(line): 81 | for (field in header.fields) 82 | if (field.name == name) 83 | @:privateAccess field.value = value; 84 | default: 85 | header.fields.push(new HeaderField(name, value)); 86 | } 87 | return this; 88 | } 89 | 90 | public function get(key: String): Null 91 | return switch header.byName(key) { 92 | case Success(v): v; 93 | default: null; 94 | } 95 | 96 | public function end() 97 | finalize(); 98 | 99 | public function send(output: String) { 100 | body = output; 101 | finalize(); 102 | } 103 | 104 | public function html(output: String) { 105 | set('content-type', 'text/html; charset=utf-8'); 106 | send(output); 107 | } 108 | 109 | function finalize() { 110 | transform(this).handle(function(res) 111 | done.trigger(res.toOutgoingResponse()) 112 | ); 113 | } 114 | 115 | public function sendFile(path: String, ?contentType: String) { 116 | function fail(_) 117 | error('Could not read file: '+path); 118 | 119 | FileSystem.stat(path).handle(function (res) switch res { 120 | case Success(stat): 121 | if (contentType == null) { 122 | var type = Mime.lookup(path); 123 | if (type == null) 124 | type = 'application/octet-stream'; 125 | var info = Mime.db.get(type); 126 | contentType = type; 127 | if (info.charset != null) 128 | contentType += '; charset='+info.charset.toLowerCase(); 129 | } 130 | set('content-type', contentType); 131 | set('content-length', '${stat.size}'); 132 | body = File.readStream(path).idealize(fail); 133 | finalize(); 134 | default: 135 | fail(null); 136 | }); 137 | } 138 | 139 | public static function fromOutgoingResponse(res: OutgoingResponse) { 140 | var result = new Response(); 141 | result.header = res.header; 142 | result.body = res.body; 143 | return result; 144 | } 145 | 146 | public function after(cb: Response -> Future) { 147 | var old = transform; 148 | transform = function(res) { 149 | return old(res).flatMap(function(res) { 150 | return cb(res); 151 | }); 152 | } 153 | } 154 | 155 | function ofOutgoingResponse(res: OutgoingResponse) { 156 | header = res.header; 157 | body = res.body; 158 | return this; 159 | } 160 | 161 | function toOutgoingResponse() 162 | return new OutgoingResponse(header, body); 163 | 164 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Monsoon [![Build Status](https://travis-ci.org/benmerckx/monsoon.svg?branch=master)](https://travis-ci.org/benmerckx/monsoon) 2 | 3 | A minimal haxe web framework and embedded webserver using [tink_http](https://github.com/haxetink/tink_http). 4 | 5 | ```haxe 6 | using Monsoon; 7 | 8 | class Main { 9 | public static function main() { 10 | var app = new Monsoon(); 11 | 12 | app.route('/', function (req, res) 13 | res.send('Hello World') 14 | ); 15 | 16 | app.listen(3000); 17 | } 18 | } 19 | 20 | ``` 21 | 22 | # Setup 23 | 24 | Choose a target and lib of one of the implementations below. 25 | 26 | ### Default 27 | 28 | Monsoon runs on platforms that provide their own http implementation. 29 | Runs on: `nodejs`, `php`, `neko` *(mod_neko, mod_tora)* 30 | ``` 31 | haxelib install monsoon 32 | ``` 33 | *Add `-lib monsoon` to your hxml.* 34 | 35 | ### Embedded 36 | 37 | A tcp webserver will be embedded into your application. 38 | Runs on: `neko`, `cpp`, `java` 39 | ``` 40 | haxelib install monsoon-embed 41 | ``` 42 | *Add `-lib monsoon-embed` to your hxml.* 43 | 44 | ### Usage 45 | 46 | You can import all relevant classes with `using Monsoon;`. 47 | 48 | # Routing 49 | 50 | ### Basic routing 51 | 52 | The following http request methods can be used to add routes to your app: 53 | `get`, `post`, `delete`, `put`, `patch`, `head`, `options` 54 | 55 | ```haxe 56 | app.get('/', function (req, res) res.send('Get')); 57 | app.post('/submit', function (req, res) res.send('Got post')); 58 | ``` 59 | 60 | To match all http methods use `route` 61 | 62 | ```haxe 63 | app.route('/', function (req, res) 64 | res.send('Method used: '+req.method) 65 | ); 66 | ``` 67 | 68 | ### Matching 69 | 70 | Matching is done using [a port](https://github.com/benmerckx/path2ereg) of [path-to-regex](https://github.com/pillarjs/path-to-regexp). 71 | You can refer to the [express docs](https://expressjs.com/en/guide/routing.html#route-paths) on routing and test the rules with [Express Route Tester](http://forbeslindesay.github.io/express-route-tester/). 72 | 73 | #### Parameters 74 | 75 | A segment of the path can be matched by using a `:param`. To use the parameter later in your callback, it has to be typed in the type parameter of `Request`. 76 | ```haxe 77 | app.get('/blog/:item', function(req: Request<{item: String}>, res) 78 | res.send('Blog item: '+req.params.item) 79 | ); 80 | ``` 81 | 82 | # Middleware 83 | 84 | ### Bundled middleware 85 | 86 | Bundled middleware can be found in `monsoon.middleware`. 87 | 88 | #### Compression 89 | 90 | Compresses the result of your response using gzip, if accepted by the client. 91 | Takes one optional argument: `?level: Int`, the compression level of 0-9. 92 | 93 | ```haxe 94 | app.use(Compression.serve()); 95 | ``` 96 | 97 | #### Static 98 | 99 | The static middleware can be used to serve static files (js, css, html etc.). It is recommended to use seperate software (nginx, varnish) to serve your static files but this can be used during development or on low traffic websites. 100 | 101 | If a file is found it will be served with the correct content-type. If no file is found the route is passed. 102 | 103 | ```haxe 104 | // Any file in the public folder will be served 105 | app.use(Static.serve('public')); 106 | // You can change the index files it looks for (default is index.html, index.htm) 107 | app.use(Static.serve('public', {index: ['index.txt', 'index.html']})); 108 | // It can be prefixed like any other route 109 | app.use('/assets', Static.serve('public')); 110 | ``` 111 | 112 | #### ByteRange 113 | 114 | Supports client requests for ranged responses. 115 | 116 | ```haxe 117 | app.use(ByteRange.serve()); 118 | ``` 119 | 120 | #### Basic Authentication 121 | 122 | Request basic authentication. Pass a function which validates the username and password given by the user. 123 | It expects a [`Promise`](https://github.com/haxetink/tink_core/blob/master/src/tink/core/Promise.hx) (returning `Bool` will automatically be cast to a `Promise`). 124 | 125 | ```haxe 126 | app.use(BasicAuth.serve(function (user, password) 127 | return user = 'monsoon' && password = 'mypassword' 128 | )); 129 | ``` 130 | 131 | #### Console 132 | 133 | The Console is a debugging tool which will bundle any traces created during the processing of the request and send them with your response to the browser. They are packaged as a single `