├── haxelib.json ├── src └── foxhole │ ├── App.hx │ └── Web.hx ├── LICENSE └── README.md /haxelib.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foxhole", 3 | "license": "MIT", 4 | "tags": [ 5 | "http", 6 | "neko", 7 | "webserver" 8 | ], 9 | "classPath": "src", 10 | "description": "Standalone neko server!", 11 | "contributors": [ 12 | "back2dos" 13 | ], 14 | "releasenote": "Update for latest tink_http changes.", 15 | "version": "0.0.1", 16 | "url": "http://github.com/back2dos/foxhole/", 17 | "dependencies": { 18 | "tink_tcp": "", 19 | "tink_runloop": "", 20 | "tink_http": "" 21 | } 22 | } -------------------------------------------------------------------------------- /src/foxhole/App.hx: -------------------------------------------------------------------------------- 1 | package foxhole; 2 | 3 | typedef App = { 4 | /** 5 | * Maximum number of concurrently handled requests. Defaults to 256 6 | */ 7 | @:optional var maxConcurrent(default, null):Int; 8 | /** 9 | * If launched in watch mode, the program quits when the neko module is modified. For dev use. 10 | */ 11 | @:optional var watch(default, null):Bool; 12 | /** 13 | * Port to bind. Defaults to 2000 14 | */ 15 | @:optional var port(default, null):Int; 16 | /** 17 | * Defaults to 64 18 | */ 19 | @:optional var threads(default, null):Int; 20 | 21 | function handler():Void; 22 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Foxhole 2 | 3 | Foxhole is a neko based standalone webserver, that closely imitates the `neko.Web` API. At its current stage it is primarily intended as a replacement for `nekotools server`, but it may become a viable option for production use. 4 | 5 | It has support for parsing multipart request bodies and removes post body size limitations. 6 | 7 | ## Basic Usage 8 | 9 | You can write Foxhole based web applications almost exactly the same way you would write `neko.Web` based applications, except for the entry point. With the latter, you compile a neko module and use `mod_neko` or `mod_tora` or `nekotools server` as a container. The main entry point acts as the request handler, unless use use `neko.Web.cacheModule` to change it. Foxhole is different in that by using `-lib foxhole` the server is compiled *into* the module and you will have to launch it using `foxhole.Web.run`. 10 | 11 | Here's what it comes down to: 12 | 13 | ```haxe 14 | class Main { 15 | static function respond() { 16 | //do something here 17 | } 18 | static function main() { 19 | //initialize database, load config etc. 20 | #if foxhole 21 | foxhole.Web.run({ 22 | handler: respond, 23 | }); 24 | #else 25 | neko.Web.cacheModule(respond); 26 | respond(); 27 | #end 28 | } 29 | } 30 | ``` 31 | 32 | Should you not call `foxhole.Web.run`, the application will just quit. Should you call it multiple times, you will bind multiple servers. Whether or not that is actually useful remains to be seen. Doing so is advised against. 33 | 34 | ## Watch mode 35 | 36 | If you launch Foxhole with `{ watch: true }` it will watch its own file and exit as soon as it changes. If your IDE starts your neko module as soon as it is compiled, then this will do the trick. Otherwise using [this tiny helper](https://gist.github.com/back2dos/60015d7c331cff5552ab) you can make it run forever like so 37 | 38 | ``` 39 | haxe --run Forever neko .n 40 | ``` 41 | 42 | ## Parallelism 43 | 44 | Foxhole uses multiple worker threads to execute requests. By default, progress in these threads is mutually exclusive. However with `Foxhole.Web.inParallel(task)` you can execute a `task` while giving other worker threads the opportunity to progress. Good use cases would be: 45 | 46 | - when doing expensive computations that are side effect free, e.g. transcoding some sort of data like parsing or marshalling JSON or XML or using haxe serialization or compressing/decompressing a binary blob 47 | - when doing expensive I/O that is safe to do in parallel, e.g. making a request to some API 48 | 49 | The underlying implementation is really quite simple. There is one common lock, that each worker acquires before processing the request, and then releases when done. If you call `inParallel` the current worker releases that lock and starts executing the supplied task. Once done, it reacquires the lock. When in doubt, don't use `inParallel` and you will be fine. 50 | 51 | ## Implementation details 52 | 53 | Foxhole is based on `tink_http`, which provides an asynchronous cross platform API for handling HTTP. The server runs in an event loop on the main thread. Requests are then handed off to worker threads that handle them synchronously, as explained in the section above. The output is buffered and once the handler has completed, the buffer is streamed onto the outgoing connection. Streaming output will be supported in the future. 54 | 55 | ## Production use 56 | 57 | Foxhole is not field tested (nor properly unit tested). There are no known bugs. Instead, there are - in all likelihood - unknown bugs. You have been warned. 58 | 59 | However, presuming that those bugs can be identified and resolved in the foreseeable future, Foxhole presents an interesting alternative for deploying neko based web applications. It is mostly likely not a very good idea to directly expose it to the outside world, but instead it should be proxied behind a reliable HTTP server as nginx and adequately supervised - [a relatively common setup for nodejs](http://stackoverflow.com/a/5015178/111466). The proxy can take care of HTTPS offloading, static file serving, DoS protection and so forth. 60 | 61 | Preliminary benchmarking seem to indicate that Foxhole introduces reasonable overhead (less than 1ms per request in apache benchmarks) and shows grace under pressure (stays under said 1ms even with 10000 concurrent requests). Since it interfaces with the outside world through HTTP rather than CGI, FastCGI, Tora or whatnot, it is easy to integrate with every webserver that is able to act as an HTTP proxy, which certainly covers all the established ones. 62 | 63 | Given there is quite some room for optimization and its support to parallelize certain task, it may become a good choice for certain classes of performance critical applications. 64 | 65 | ## Other targets 66 | 67 | In general, Foxhole is not neko-specific. Both java and cpp support are within reach. This depends largely on tink_http being properly implemented on those platforms. 68 | -------------------------------------------------------------------------------- /src/foxhole/Web.hx: -------------------------------------------------------------------------------- 1 | package foxhole; 2 | import tink.url.Query; 3 | 4 | #if !neko #error #else 5 | import tink.http.containers.TcpContainer; 6 | import haxe.CallStack; 7 | import tink.concurrent.*; 8 | import haxe.crypto.Base64; 9 | import haxe.io.*; 10 | import tink.io.*; 11 | import tink.io.Sink; 12 | 13 | import tink.concurrent.Thread; 14 | 15 | import tink.http.Container; 16 | import tink.http.*; 17 | import tink.http.Request; 18 | import tink.http.Response; 19 | import tink.http.Header; 20 | 21 | import tink.RunLoop; 22 | import sys.FileSystem; 23 | 24 | using StringTools; 25 | using tink.CoreApi; 26 | 27 | class Web { 28 | static var ctxStore = new Tls(); 29 | 30 | static var ctx(get, set):Web; 31 | 32 | static inline function get_ctx() 33 | return ctxStore.value; 34 | 35 | static inline function set_ctx(param) 36 | return ctxStore.value = param; 37 | 38 | var returnCode = 200; 39 | var headers:Array; 40 | var req:IncomingRequest; 41 | var output:BytesBuffer; 42 | 43 | function new(req) { 44 | 45 | this.req = req; 46 | this.headers = []; 47 | this.output = new BytesBuffer(); 48 | 49 | postData = readPostData; 50 | } 51 | 52 | function respond(body) 53 | return new OutgoingResponse( 54 | new ResponseHeader( 55 | returnCode, 56 | if (returnCode < 400) 'OK' else 'ERROR', 57 | headers 58 | ), 59 | body 60 | ); 61 | 62 | static public function getParams() 63 | return [ 64 | for (raw in [getParamsString(), getPostData()]) 65 | for (p in Query.parseString(raw)) 66 | p.name => p.value 67 | ]; 68 | 69 | static public function getParamValues(param:String):Array { 70 | var ret = new Array(); 71 | 72 | for (raw in [getParamsString(), getPostData()]) 73 | for (p in Query.parseString(raw)) 74 | if (p.name == '$param[]') 75 | ret.push(p.value); 76 | else if (p.name.startsWith(param + '[') && p.name.endsWith(']')) 77 | ret[Std.parseInt(p.name.substr(param.length + 1))] = p.value; 78 | 79 | return ret; 80 | } 81 | 82 | static public function getHostName() 83 | return switch ctx.req.header.get('Host') { 84 | case [v]: v; 85 | default: 'localhost'; 86 | } 87 | 88 | static public function getClientIP() 89 | return ctx.req.clientIp; 90 | 91 | static public function getURI():String 92 | return ctx.req.header.uri; 93 | 94 | static public function redirect(url:String) { 95 | setReturnCode(302); 96 | setHeader('Location', url); 97 | } 98 | 99 | static public function setHeader(h:String, v:String) 100 | ctx.headers.push(new HeaderField(h, v)); 101 | 102 | static public function setReturnCode(r:Int) 103 | ctx.returnCode = r; 104 | 105 | static public function getClientHeader(k:String) 106 | return switch ctx.req.header.get(k) { 107 | case [v]: v; 108 | default: null; 109 | } 110 | 111 | static public function getClientHeaders() { 112 | var list = new List(); 113 | 114 | for (f in ctx.req.header.fields) 115 | list.push({ header: f.name, value: f.value }); 116 | 117 | return list; 118 | } 119 | 120 | static public function getParamsString():String 121 | return ctx.req.header.uri.query; 122 | 123 | var postData:Lazy; 124 | 125 | static public function getPostData() 126 | return ctx.postData.get(); 127 | 128 | function readPostData() { 129 | if (!req.header.byName('Content-Length').isSuccess() && req.header.method != POST) 130 | return ''; 131 | var queue = new Queue>(); 132 | 133 | switch req.body { 134 | case Plain(source): 135 | RunLoop.current.work(function () { 136 | var buf = new BytesOutput(); 137 | source.pipeTo(Sink.ofOutput('HTTP request body buffer', buf)).handle(function (x) queue.add(switch x { 138 | case AllWritten: 139 | Success(buf.getBytes().toString()); 140 | case SourceFailed(e): 141 | Failure(e); 142 | default: 143 | throw 'assert'; 144 | })); 145 | }); 146 | default: 147 | throw 'should never happen'; 148 | } 149 | return queue.await().sure(); 150 | } 151 | 152 | static public function getCookies():Map 153 | return switch getClientHeader('Cookie') { 154 | case null: new Map(); 155 | case v: [for (p in Query.parseString(v, ';')) p.name.toString() => p.value.toString()]; 156 | } 157 | 158 | 159 | static public function setCookie(key:String, value:String, ?expire: Date, ?domain: String, ?path: String, ?secure: Bool, ?httpOnly: Bool) { 160 | var buf = new StringBuf(); 161 | 162 | buf.add(key.urlEncode()+'='+value.urlEncode()); 163 | 164 | if (expire != null) addPair(buf, "expires=", DateTools.format(expire, "%a, %d-%b-%Y %H:%M:%S GMT")); 165 | 166 | addPair(buf, "domain=", domain); 167 | addPair(buf, "path=", path); 168 | 169 | if (secure) addPair(buf, "secure", ""); 170 | if (httpOnly) addPair(buf, "HttpOnly", ""); 171 | 172 | setHeader('Set-Cookie', buf.toString()); 173 | } 174 | 175 | static function addPair(buf:StringBuf, name, value) { 176 | if(value == null) return; 177 | buf.add("; "); 178 | buf.add(name); 179 | buf.add(value); 180 | } 181 | 182 | static public function getAuthorization():{ user:String, pass:String } { 183 | var h = getClientHeader("Authorization"); 184 | var reg = ~/^Basic ([^=]+)=*$/; 185 | if(h != null && reg.match(h)){ 186 | var val = reg.matched(1); 187 | val = Base64.decode(val).toString(); 188 | 189 | var a = val.split(":"); 190 | if(a.length != 2){ 191 | throw "Unable to decode authorization."; 192 | } 193 | return {user: a[0],pass: a[1]}; 194 | } 195 | return null; 196 | } 197 | 198 | static public function getCwd() 199 | return Sys.getCwd(); 200 | 201 | static public function getMultipart(maxSize:Int):Map { 202 | 203 | var h = new Map(), 204 | buf:BytesBuffer = null, 205 | curname = null; 206 | 207 | function next() 208 | if (curname != null) 209 | h[curname] = 210 | #if neko 211 | neko.Lib.stringReference(buf.getBytes()); 212 | #else 213 | buf.getBytes().toString(); 214 | #end 215 | 216 | parseMultipart(function(p, _) { 217 | next(); 218 | curname = p; 219 | buf = new BytesBuffer(); 220 | maxSize -= p.length; 221 | if(maxSize < 0) 222 | throw "Maximum size reached"; 223 | },function(str, pos, len) { 224 | maxSize -= len; 225 | if(maxSize < 0) 226 | throw "Maximum size reached"; 227 | buf.addBytes(str,pos,len); 228 | }); 229 | if(curname != null) 230 | next(); 231 | return h; 232 | } 233 | 234 | static public function parseMultipart(onPart:String -> String -> Void, onData:Bytes -> Int -> Int -> Void):Void { 235 | 236 | var queue = new QueueVoid>>(); 237 | var writer = new MultipartWriter(queue, onData); 238 | var ctx = ctx; 239 | var awaiting = 1; 240 | 241 | function inc() 242 | queue.push(function () awaiting++); 243 | function dec() 244 | queue.push(function () awaiting--); 245 | 246 | RunLoop.current.work(function () { 247 | switch Multipart.check(ctx.req) { 248 | case Some(s): 249 | s.forEach(function (chunk) return 250 | switch chunk.header.byName('Content-Disposition') { 251 | case Success(_.getExtension() => ext) if (ext.exists('name') && (ext.exists('filename'))): 252 | inc(); 253 | queue.add(onPart.bind(ext['name'], ext['filename'])); 254 | chunk.body.pipeTo(writer).handle(function (x) switch x { 255 | case SinkFailed(e) | SourceFailed(e): 256 | queue.add(e.throwSelf); 257 | default: 258 | dec(); 259 | }); 260 | true; 261 | default: 262 | queue.add(function () { throw new Error(BadRequest, 'Missing name and filename'); }); 263 | false; 264 | } 265 | ).handle(function (x) { 266 | queue.add(function () x.sure()); 267 | dec(); 268 | }); 269 | default: 270 | queue.add(null); 271 | } 272 | }); 273 | 274 | while (true) { 275 | queue.await()(); 276 | if (awaiting == 0) break; 277 | } 278 | } 279 | 280 | static public function flush():Void 281 | logMessage('Warning: flush not implemented'); 282 | 283 | static public function getMethod():String 284 | return ctx.req.header.method; 285 | 286 | static public function logMessage(msg:String) 287 | Sys.stdout().writeString('$msg\n'); 288 | 289 | static public var isModNeko(default,null):Bool = false; 290 | static public var isTora(default, null):Bool = false; 291 | 292 | static var mutex = new Mutex(); 293 | 294 | static public function inParallel(task:Lazy) { 295 | 296 | mutex.release(); 297 | 298 | var ret = (function () return task.get()).catchExceptions(function (e:Dynamic) return Error.withData('Failed to execute background task because $e', e)); 299 | 300 | mutex.acquire(); 301 | 302 | return ret.sure(); 303 | } 304 | 305 | static public function run(app:App) { 306 | 307 | var container = new TcpContainer(if (app.port == null) 2000 else app.port); 308 | var queue = new Queue>>(); 309 | var done = Future.trigger(); 310 | 311 | for (i in 0...if (app.threads == null) 64 else app.threads) 312 | new Thread(function () 313 | while (true) { 314 | var req = queue.await(); 315 | req.b.invoke(getResponse(req.a, app.handler)); 316 | } 317 | ); 318 | 319 | if (app.watch != null) 320 | new Thread(function () { 321 | var file = neko.vm.Module.local().name; 322 | 323 | function stamp() 324 | return 325 | try FileSystem.stat(file).mtime.getTime() 326 | catch (e:Dynamic) Math.NaN; 327 | 328 | var initial = stamp(); 329 | 330 | while (true) { 331 | Sys.sleep(.1); 332 | if (stamp() > initial) { 333 | Sys.println('File $file recompiled. Shutting down server'); 334 | Sys.exit(0); 335 | } 336 | } 337 | }); 338 | 339 | untyped Sys.print = function (x:Dynamic) 340 | if (ctx != null) 341 | switch Std.instance(x, Bytes) { 342 | case null: 343 | ctx.output.addString(Std.string(x)); 344 | case v: 345 | ctx.output.addBytes(v, 0, v.length); 346 | } 347 | else 348 | untyped $print(x); 349 | 350 | untyped Sys.println = function (x) { 351 | Sys.print('$x\n'); 352 | } 353 | 354 | container.run( 355 | function (x:IncomingRequest):Future { 356 | var trigger = Future.trigger(); 357 | queue.push(new Pair(x, function (res) RunLoop.current.work(function () trigger.trigger(res)))); 358 | return trigger.asFuture(); 359 | } 360 | ); 361 | } 362 | 363 | static function getResponse(r:IncomingRequest, handler) { 364 | 365 | ctx = new Web(r); 366 | 367 | try 368 | mutex.synchronized(handler) 369 | catch (o:OutgoingResponse) 370 | return o 371 | catch (e:Dynamic) { 372 | var stack = CallStack.exceptionStack(); 373 | logMessage('Uncaught exception in foxhole: ' + Std.string(e)); 374 | logMessage(CallStack.toString(stack)); 375 | ctx.returnCode = 500; 376 | ctx.output = new BytesBuffer(); 377 | } 378 | 379 | var ret = ctx.respond(ctx.output.getBytes()); 380 | ctx = null; 381 | return ret; 382 | } 383 | } 384 | 385 | private class MultipartWriter extends SinkBase { 386 | 387 | var writer:Bytes->Int->Int->Void; 388 | var queue:QueueVoid>; 389 | 390 | public function new(queue, writer) { 391 | this.queue = queue; 392 | this.writer = writer; 393 | } 394 | 395 | function writeBytes(bytes, start, len) { 396 | writer(bytes, start, len); 397 | return len; 398 | } 399 | 400 | override public function write(from:Buffer):Surprise 401 | return Future.async(function (cb) queue.add(function () { 402 | var ret = from.tryWritingTo('Multipart handler', this); 403 | RunLoop.current.work(function () cb(ret)); 404 | })); 405 | } 406 | #end --------------------------------------------------------------------------------