diff --git a/.gitignore b/.gitignore index acb4ec1..842e140 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ nimcache/ karax/tools/karun *.code-workspace *.exe +app.js +app.html diff --git a/karax.nimble b/karax.nimble index bc157ab..a5b269c 100644 --- a/karax.nimble +++ b/karax.nimble @@ -8,7 +8,8 @@ license = "MIT" # Dependencies requires "nim >= 0.16.1" - +requires "ws" +requires "dotenv" skipDirs = @["examples", "experiments", "tests"] bin = @["karax/tools/karun"] diff --git a/karax/tools/karun.nim b/karax/tools/karun.nim index 2b948b4..41410ca 100644 --- a/karax/tools/karun.nim +++ b/karax/tools/karun.nim @@ -1,21 +1,16 @@ -## Simple tool to quickly run Karax applications. Generates the HTML -## required to run a Karax app and opens it in a browser. - -import os, - strutils, - parseopt, - browsers, - times, - tables - +import os, strutils, browsers,times, tables +import parseopt +import threadpool +import static_server const css = """ """ - html = """ + +const html = """ @@ -23,33 +18,79 @@ const $1 $2 - -
- + +
+ +$3 """ +const websocket = """ + +""" proc exec(cmd: string) = if os.execShellCmd(cmd) != 0: quit "External command failed: " & cmd -proc build(name: string, rest: string, selectedCss: string, run: bool) = +proc build(rest: string, selectedCss: string, run: bool, watch: bool) = echo("Building...") - exec("nim js --out:" & name & ".js " & rest) - let dest = name & ".html" - writeFile(dest, html % [name, selectedCss]) - if run: openDefaultBrowser(dest) + let cmd = "nim js --out:" & "app" & ".js " & rest + if watch: + discard os.execShellCmd(cmd) + else: + exec cmd + let dest = "app" & ".html" + let script = if run and watch: websocket else: "" + writeFile(dest, html % ["app", selectedCss, script]) + if run: openDefaultBrowser("http://localhost:8080") + +proc watchBuild(filePath: string, selectedCss: string, rest: string) {.thread.} = + var files: Table[string, Time] = {"path": getLastModificationTime(".")}.toTable + while true: + sleep(300) + for path in walkDirRec("."): + if ".git" in path: + continue + + if files.hasKey(path): + if files[path] != getLastModificationTime(path): + echo("File changed: " & path) + build(rest,selectedCss, false, true) + files[path] = getLastModificationTime(path) + else: + if absolutePath(path) in [absolutePath("app" & ".js"),absolutePath("app" & ".html")]: + continue + files[path] = getLastModificationTime(path) + +proc serve(){.thread.} = + serveStatic() proc main = var op = initOptParser() var rest = op.cmdLineRest var file = "" var run = false - var selectedCss = "" var watch = false - var files: Table[string, Time] = {"path": getLastModificationTime(".")}.toTable - + var selectedCss = "" while true: op.next() case op.kind @@ -59,8 +100,11 @@ proc main = run = true rest = rest.replace("--run ") of "css": - selectedCss = css - rest = rest.replace("--css ") + if op.val != "": + selectedCss = readFile(op.val) + else: + selectedCss = css + rest = rest.substr(rest.find(" ")) else: discard of cmdShortOption: if op.key == "r": @@ -73,25 +117,11 @@ proc main = of cmdEnd: break if file.len == 0: quit "filename expected" - let name = file.splitFile.name - build(name, rest, selectedCss, run) + if run: + spawn serve() if watch: - # TODO: launch http server - while true: - sleep(300) - for path in walkDirRec("."): - if ".git" in path: - continue - if files.hasKey(path): - if files[path] != getLastModificationTime(path): - echo("File changed: " & path) - build(name, rest, selectedCss, run) - files[path] = getLastModificationTime(path) - else: - files[path] = getLastModificationTime(path) + spawn watchBuild(file, selectedCss, rest) + build(rest,selectedCss, run, watch) + sync() main() - - - - diff --git a/karax/tools/karun.nims b/karax/tools/karun.nims new file mode 100644 index 0000000..8983496 --- /dev/null +++ b/karax/tools/karun.nims @@ -0,0 +1 @@ +switch("threads", "on") \ No newline at end of file diff --git a/karax/tools/static_server.nim b/karax/tools/static_server.nim new file mode 100644 index 0000000..40375a9 --- /dev/null +++ b/karax/tools/static_server.nim @@ -0,0 +1,170 @@ +import net, os, strutils, uri, mimetypes, asyncnet, asyncdispatch, md5, + logging, httpcore, asyncfile, options +import asynchttpserver +from cgi import decodeUrl +import ws,tables,times +import dotenv + +type + RawHeaders* = seq[tuple[key, val: string]] + +proc toStr(headers: Option[RawHeaders]): string = + return $newHttpHeaders(headers.get(@({:}))) + +proc send( + request: Request, code: HttpCode, headers: Option[RawHeaders], body: string +): Future[void] = + return request.respond( + code, body, newHttpHeaders(headers.get(@({:}))) + ) + +proc statusContent(request: Request, status: HttpCode, content: string, + headers: Option[RawHeaders]): Future[void] = + try: + result = send(request, status, headers, content) + when not defined(release): + logging.debug(" $1 $2" % [$status, toStr(headers)]) + except: + logging.error("Could not send response: $1" % osErrorMsg(osLastError())) + +proc sendStaticIfExists( + req: Request, paths: seq[string] +): Future[HttpCode] {.async.} = + result = Http200 + let mimes = newMimetypes() + for p in paths: + if existsFile(p): + + var fp = getFilePermissions(p) + if not fp.contains(fpOthersRead): + return Http403 + + let fileSize = getFileSize(p) + let ext = p.splitFile.ext + let mimetype = mimes.getMimetype( + if ext.len > 0: ext[1 .. ^1] + else: "" + ) + if fileSize < 10_000_000: # 10 mb + var file = readFile(p) + + var hashed = getMD5(file) + + # If the user has a cached version of this file and it matches our + # version, let them use it + if req.headers.hasKey("If-None-Match") and req.headers["If-None-Match"] == hashed: + await req.statusContent(Http304, "", none[RawHeaders]()) + else: + await req.statusContent(Http200, file, some(@({ + "Content-Type": mimetype, + "ETag": hashed + }))) + else: + let headers = @({ + "Content-Type": mimetype, + "Content-Length": $fileSize + }) + await req.statusContent(Http200, "", some(headers)) + + var fileStream = newFutureStream[string]("sendStaticIfExists") + var file = openAsync(p, fmRead) + # Let `readToStream` write file data into fileStream in the + # background. + asyncCheck file.readToStream(fileStream) + # The `writeFromStream` proc will complete once all the data in the + # `bodyStream` has been written to the file. + while true: + let (hasValue, value) = await fileStream.read() + if hasValue: + await req.client.send(value) + else: + break + file.close() + + return + + # If we get to here then no match could be found. + return Http404 + + +proc handleFileRequest( + req: Request +): Future[HttpCode] {.async.} = + # Find static file. + var reqPath = cgi.decodeUrl(req.url.path) + var publicUrl = getEnv("publicUrl") + var staticDir = getEnv("staticDir") + if not publicUrl.endsWith("/"): + publicUrl = publicUrl & "/" + reqPath = reqPath.substr(publicUrl.len) + + let path = normalizedPath( + staticDir / reqPath + ) + + # Verify that this isn't outside our static dir. + var status = Http400 + let pathDir = path.splitFile.dir / "" + + if pathDir.startsWith(publicUrl): + if existsDir(path): + status = await sendStaticIfExists( + req, + @[path / "index.html", path / "index.htm"] + ) + else: + status = await sendStaticIfExists(req, @[path]) + return status + +proc handleWs(req: Request) {.async.} = + var ws = await newWebSocket(req) + await ws.send("Welcome to simple echo server") + var files: Table[string, Time] = {"path": getLastModificationTime(".")}.toTable + let watchedFiles = [absolutePath "app.js",absolutePath "app.html"] + for path in watchedFiles: + files[path] = getLastModificationTime(path) + while ws.readyState == Open: + await sleepAsync(500) + var changed = false + for path in watchedFiles: + if files[path] != getLastModificationTime(path): + changed = true + files[path] = getLastModificationTime(path) + if changed: + await ws.send("refresh") + changed = false + + +proc serveStatic*() = + if fileExists( "static.env" ): + var env:DotEnv + env = initDotEnv(getCurrentDir(), "static.env") + env.overload() + else: + loadEnvFromString(""" + staticDir="./src/assets/" + publicUrl="public" + """) + + var server = newAsyncHttpServer() + proc cb(req: Request) {.gcsafe, async.} = + if req.url.path == "/ws": + await handleWs(req) + if req.url.path == "/": + await req.respond(Http200, readFile "app.html" ) + elif req.url.path == "/app.js": + let file = absolutePath("app" & ".js") + if not file.fileExists: + logging.error("$1 not exists!" % file ) + if fpUserRead notin os.getFilePermissions(file): + logging.error("Could not read $1!" % file ) + await req.respond(Http200, readFile( file )) + else: + let status = await handleFileRequest(req) + if status != Http200: + await req.respond(status,"") + + waitFor server.serve(Port(8080), cb) + +when isMainModule: + serveStatic() \ No newline at end of file