obol
Owner: IIIlllIIIllI URL: git@git.0x00nyx.xyz:seb/obolserver.git
obol.nim
# (c) 2025 Sebastian Michalk <sebastian.michalk@pm.me>
import std/asynchttpserver
import std/asyncdispatch
import std/httpcore
import std/json
import std/os
import std/options
import std/sets
import std/strutils
import std/tables
import std/times
import std/uri
const
defaultPort = 8080
defaultMimeType = "application/octet-stream"
# ----------------
# Request/Response
# ----------------
type
Headers = Table[string, string]
Request = object
httpMethod: string
path: string
headers: Headers
body: string
params: Headers
Response = object
status: int
headers: Headers
body: string
proc newHeaders(): Headers =
initTable[string, string]()
proc newResponse(status: int = 200, body: string = ""): Response =
result.status = status
result.headers = newHeaders()
result.body = body
proc notFoundResponse(): Response =
newResponse(404, "not found")
proc serverErrorResponse(): Response =
newResponse(500, "internal server error")
# ------
# Router
# ------
type
SegmentKind = enum
segLiteral
segParam
RouteSegment = object
kind: SegmentKind
name: string
value: string
Handler = proc (req: Request): Response {.closure, gcsafe.}
Route = object
httpMethod: string
segments: seq[RouteSegment]
paramCount: int
handler: Handler
Router = object
exact: Table[string, Handler]
parametric: seq[Route]
DataSegmentKind = enum
dskLiteral
dskParam
DataSegment = object
kind: DataSegmentKind
value: string
proc normalizeUrlPath(raw: string): string =
var cleaned = raw.strip()
if cleaned.len == 0:
return "/"
let queryPos = cleaned.find('?')
if queryPos >= 0:
cleaned = cleaned[0 ..< queryPos]
if cleaned.len == 0:
return "/"
if not cleaned.startsWith('/'):
cleaned = "/" & cleaned
var parts: seq[string]
for part in cleaned.split('/'):
if part.len > 0:
parts.add(part)
if parts.len == 0:
return "/"
"/" & parts.join("/")
proc splitPath(path: string): seq[string] =
let normalized = normalizeUrlPath(path)
if normalized == "/":
return @[]
for part in normalized[1 .. ^1].split('/'):
if part.len > 0:
result.add(part)
proc isValidParamName(name: string): bool =
if name.len == 0:
return false
for ch in name:
if not (ch.isAlphaNumeric or ch == '_'):
return false
true
proc parsePattern(pattern: string): Route =
let segments = splitPath(pattern)
var route = Route()
route.segments = @[]
for raw in segments:
if raw.len == 0:
continue
if raw[0] == ':':
let name = raw[1 .. ^1]
if not isValidParamName(name):
raise newException(ValueError, "invalid parameter name in pattern '" & pattern & "'")
route.segments.add(RouteSegment(kind: segParam, name: name))
inc route.paramCount
else:
if raw.contains(':'):
raise newException(ValueError, "unexpected ':' in literal segment of pattern '" & pattern & "'")
route.segments.add(RouteSegment(kind: segLiteral, value: raw))
route
proc parseDataPath(pattern: string, allowed: HashSet[string]): seq[DataSegment] =
if pattern.len == 0:
return @[]
var idx = 0
var literal = newStringOfCap(pattern.len)
while idx < pattern.len:
let ch = pattern[idx]
if ch == '{':
inc idx
var name = ""
while idx < pattern.len and pattern[idx] != '}':
name.add(pattern[idx])
inc idx
if idx >= pattern.len or pattern[idx] != '}':
raise newException(ValueError, "unterminated parameter in data path '" & pattern & "'")
if literal.len > 0:
result.add(DataSegment(kind: dskLiteral, value: literal))
literal.setLen(0)
if not isValidParamName(name):
raise newException(ValueError, "invalid parameter name '" & name & "' in data path '" & pattern & "'")
if name notin allowed:
raise newException(ValueError, "data path references unknown parameter '" & name & "'")
result.add(DataSegment(kind: dskParam, value: name))
elif ch == '}':
raise newException(ValueError, "unexpected '}' in data path '" & pattern & "'")
else:
literal.add(ch)
inc idx
if literal.len > 0:
result.add(DataSegment(kind: dskLiteral, value: literal))
proc decodeComponent(value: string): string =
try:
result = value.decodeUrl()
except ValueError:
result = value
proc routeMatches(route: Route, httpMethod: string, pathSegments: seq[string], params: var Headers): bool =
if route.httpMethod != httpMethod:
return false
if pathSegments.len != route.segments.len:
return false
for idx, segment in route.segments:
let value = pathSegments[idx]
case segment.kind
of segLiteral:
if value != segment.value:
return false
of segParam:
params[segment.name] = decodeComponent(value)
true
proc initRouter(): Router =
Router(
exact: initTable[string, Handler](),
parametric: @[]
)
proc routeKey(httpMethod, path: string): string =
httpMethod & "\x1f" & path
proc addRoute(router: var Router, httpMethod, path: string, handler: Handler) =
let normalized = normalizeUrlPath(path)
var pattern = parsePattern(normalized)
pattern.httpMethod = httpMethod.toUpperAscii()
pattern.handler = handler
if pattern.paramCount == 0:
let key = routeKey(pattern.httpMethod, normalized)
if key in router.exact:
raise newException(ValueError, "duplicate route for " & httpMethod & " " & normalized)
router.exact[key] = handler
else:
var inserted = false
for idx, existing in router.parametric:
if pattern.paramCount < existing.paramCount:
router.parametric.insert(pattern, idx)
inserted = true
break
if not inserted:
router.parametric.add(pattern)
proc match(router: Router, httpMethod, path: string): Option[tuple[handler: Handler, params: Headers]] =
let normalized = normalizeUrlPath(path)
let methodUpper = httpMethod.toUpperAscii()
let key = routeKey(methodUpper, normalized)
if key in router.exact:
return some((router.exact[key], newHeaders()))
let segments = splitPath(normalized)
for route in router.parametric:
var params = newHeaders()
if routeMatches(route, methodUpper, segments, params):
return some((route.handler, params))
none(tuple[handler: Handler, params: Headers])
# ----------------
# Template Engine
# ----------------
type
NodeKind = enum
nkText
nkVar
nkIf
nkRange
nkInclude
nkDefine
TemplateNode = ref object
kind: NodeKind
text: string
name: string
children: seq[TemplateNode]
elseBranch: seq[TemplateNode]
Template = object
nodes: seq[TemplateNode]
definitions: TemplateDefs
TemplateEntry = object
compiled: Template
mtime: Time
TemplateCache = object
dir: string
entries: Table[string, TemplateEntry]
TemplateDefs = TableRef[string, seq[TemplateNode]]
proc newTemplateDefs(): TemplateDefs =
newTable[string, seq[TemplateNode]]()
proc newTextNode(content: string): TemplateNode =
TemplateNode(kind: nkText, text: content)
proc newVarNode(name: string): TemplateNode =
TemplateNode(kind: nkVar, name: name)
proc newBlockNode(kind: NodeKind, name: string): TemplateNode =
TemplateNode(kind: kind, name: name, children: @[], elseBranch: @[])
proc addChild(stack: seq[TemplateNode], inElse: seq[bool], child: TemplateNode) =
if stack.len == 0:
return
let current = stack[^1]
if current.kind == nkIf and inElse[^1]:
current.elseBranch.add(child)
else:
current.children.add(child)
proc pushText(stack: seq[TemplateNode], inElse: seq[bool], content: string) =
if content.len == 0:
return
addChild(stack, inElse, newTextNode(content))
proc parseQuotedName(raw: string, directive: string): string =
if raw.len < 2 or raw[0] != '"' or raw[^1] != '"':
raise newException(ValueError, directive & " expects quoted identifier")
let name = raw[1 ..< raw.len - 1]
if name.len == 0:
raise newException(ValueError, directive & " received empty name")
name
proc parseTemplateString(source: string): Template =
var tpl = Template(nodes: @[], definitions: newTemplateDefs())
var root = newBlockNode(nkIf, "__root__")
var stack = @[root]
var inElse = @[false]
var cursor = 0
while cursor < source.len:
let openIdx = source.find("{{", cursor)
if openIdx < 0:
pushText(stack, inElse, source[cursor .. ^1])
break
pushText(stack, inElse, source[cursor ..< openIdx])
let closeIdx = source.find("}}", openIdx + 2)
if closeIdx < 0:
raise newException(ValueError, "unclosed template tag")
let rawTag = source[(openIdx + 2) ..< closeIdx].strip()
cursor = closeIdx + 2
if rawTag.len == 0:
continue
if rawTag.startsWith("if "):
let name = rawTag[3 .. ^1].strip()
let node = newBlockNode(nkIf, name)
addChild(stack, inElse, node)
stack.add(node)
inElse.add(false)
elif rawTag == "end":
if stack.len <= 1:
raise newException(ValueError, "unexpected {{end}}")
let finished = stack.pop()
discard inElse.pop()
if finished.kind == nkDefine:
tpl.definitions[finished.name] = finished.children
elif rawTag == "else":
if stack.len <= 1 or stack[^1].kind != nkIf:
raise newException(ValueError, "{{else}} without matching {{if}}")
if inElse[^1]:
raise newException(ValueError, "duplicate {{else}}")
inElse[^1] = true
elif rawTag.startsWith("range "):
let name = rawTag[6 .. ^1].strip()
let node = newBlockNode(nkRange, name)
addChild(stack, inElse, node)
stack.add(node)
inElse.add(false)
elif rawTag.startsWith("template "):
let arg = rawTag[8 .. ^1].strip()
if arg.len == 0:
raise newException(ValueError, "{{template}} requires a name")
let name = parseQuotedName(arg, "template")
addChild(stack, inElse, TemplateNode(kind: nkInclude, name: name))
elif rawTag.startsWith("define "):
if stack.len != 1:
raise newException(ValueError, "{{define}} must appear at top level")
let arg = rawTag[6 .. ^1].strip()
if arg.len == 0:
raise newException(ValueError, "{{define}} requires a name")
let name = parseQuotedName(arg, "define")
let node = TemplateNode(kind: nkDefine, name: name, children: @[], elseBranch: @[])
stack.add(node)
inElse.add(false)
else:
addChild(stack, inElse, newVarNode(rawTag))
if stack.len != 1:
raise newException(ValueError, "unclosed block in template")
tpl.nodes = root.children
result = tpl
proc initTemplateCache(dir: string): TemplateCache =
TemplateCache(dir: dir, entries: initTable[string, TemplateEntry]())
proc templatePath(cache: TemplateCache, name: string): string =
if cache.dir.len == 0:
return name
if cache.dir.isAbsolute():
return cache.dir / name
getCurrentDir() / cache.dir / name
proc loadTemplate(cache: var TemplateCache, name: string): Template {.gcsafe.} =
let path = templatePath(cache, name)
if not fileExists(path):
raise newException(IOError, "template not found: " & path)
let info = getFileInfo(path)
if name in cache.entries:
let entry = cache.entries[name]
if entry.mtime == info.lastWriteTime:
return entry.compiled
let content = readFile(path)
let tpl = parseTemplateString(content)
cache.entries[name] = TemplateEntry(compiled: tpl, mtime: info.lastWriteTime)
tpl
proc lookupField(node: JsonNode, parts: seq[string]): JsonNode {.gcsafe.} =
var current = node
for part in parts:
if part == ".":
continue
if current.kind == JObject and part in current:
current = current[part]
else:
return newJNull()
current
proc resolveValue(identifier: string, ctx, parent: JsonNode): JsonNode {.gcsafe.} =
if identifier == ".":
return ctx
let parts = identifier.split('.')
var value = lookupField(ctx, parts)
if value.kind != JNull:
return value
lookupField(parent, parts)
proc isTruthy(node: JsonNode): bool {.gcsafe.} =
case node.kind
of JNull:
false
of JBool:
node.getBool()
of JInt:
node.getInt() != 0
of JFloat:
node.getFloat() != 0.0
of JString:
node.getStr().len > 0
of JArray:
node.len > 0
of JObject:
node.len > 0
const
maxIncludeDepth = 10
proc combineDefinitions(base, overrides: TemplateDefs): TemplateDefs {.gcsafe.} =
let combined = newTemplateDefs()
if base != nil:
for key, nodes in base.pairs:
combined[key] = nodes
if overrides != nil:
for key, nodes in overrides.pairs:
combined[key] = nodes
combined
proc renderNodes(cache: var TemplateCache, nodes: seq[TemplateNode], ctx, parent: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.}
proc renderInclude(cache: var TemplateCache, name: string, ctx: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.} =
if depth > maxIncludeDepth:
raise newException(ValueError, "template include depth exceeded")
if defs != nil and defs.hasKey(name):
renderNodes(cache, defs[name], ctx, ctx, acc, defs, stack, depth + 1)
return
let fileName = if name.endsWith(".html"): name else: name & ".html"
for existing in stack:
if existing == fileName:
raise newException(ValueError, "circular template include: " & stack.join(" -> ") & " -> " & fileName)
stack.add(fileName)
let tpl = loadTemplate(cache, fileName)
let combined = combineDefinitions(tpl.definitions, defs)
renderNodes(cache, tpl.nodes, ctx, newJNull(), acc, combined, stack, depth + 1)
discard stack.pop()
proc renderNode(cache: var TemplateCache, node: TemplateNode, ctx, parent: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.} =
case node.kind
of nkText:
acc.add(node.text)
of nkVar:
let value = resolveValue(node.name, ctx, parent)
if value.kind == JString:
acc.add(value.getStr())
elif value.kind in {JInt, JFloat, JBool}:
acc.add($value)
of nkIf:
let value = resolveValue(node.name, ctx, parent)
if isTruthy(value):
renderNodes(cache, node.children, ctx, parent, acc, defs, stack, depth)
elif node.elseBranch.len > 0:
renderNodes(cache, node.elseBranch, ctx, parent, acc, defs, stack, depth)
of nkRange:
let value = resolveValue(node.name, ctx, parent)
if value.kind == JArray:
for element in value.items:
renderNodes(cache, node.children, element, ctx, acc, defs, stack, depth)
elif value.kind == JObject:
renderNodes(cache, node.children, value, ctx, acc, defs, stack, depth)
of nkInclude:
renderInclude(cache, node.name, ctx, acc, defs, stack, depth)
of nkDefine:
discard
proc renderNodes(cache: var TemplateCache, nodes: seq[TemplateNode], ctx, parent: JsonNode, acc: var string, defs: TemplateDefs, stack: var seq[string], depth: int) {.gcsafe.} =
for node in nodes:
renderNode(cache, node, ctx, parent, acc, defs, stack, depth)
proc renderTemplate(cache: var TemplateCache, tpl: Template, data: JsonNode, overrides: TemplateDefs, stack: var seq[string], depth: int, acc: var string) {.gcsafe.} =
let defs = combineDefinitions(tpl.definitions, overrides)
renderNodes(cache, tpl.nodes, data, newJNull(), acc, defs, stack, depth)
proc render(cache: var TemplateCache, name: string, data: JsonNode): string {.gcsafe.} =
let tpl = loadTemplate(cache, name)
var acc = newStringOfCap(256)
var stack: seq[string] = @[]
stack.add(name)
renderTemplate(cache, tpl, data, tpl.definitions, stack, 0, acc)
discard stack.pop()
result = acc
# ------------------
# Static File Server
# ------------------
proc guessMimeType(path: string): string =
let ext = splitFile(path).ext.toLowerAscii()
case ext
of ".html", ".htm": result = "text/html; charset=utf-8"
of ".css": result = "text/css"
of ".js": result = "application/javascript"
of ".json": result = "application/json"
of ".png": result = "image/png"
of ".jpg", ".jpeg": result = "image/jpeg"
of ".gif": result = "image/gif"
of ".svg": result = "image/svg+xml"
of ".txt": result = "text/plain; charset=utf-8"
else: result = defaultMimeType
proc serveFile(path: string): Response =
if not fileExists(path):
return notFoundResponse()
var resp = newResponse(200, readFile(path))
resp.headers["content-type"] = guessMimeType(path)
resp
proc cleanStaticPath(baseDir, rel: string): string =
let baseAbs = absolutePath(baseDir)
var segments: seq[string]
for raw in rel.split({'/', '\\'}):
let part = raw.strip()
if part.len == 0 or part == ".":
continue
if part == "..":
return ""
segments.add(part)
var target = baseAbs
for segment in segments:
target = joinPath(target, segment)
if not target.startsWith(baseAbs):
return ""
target
proc serveDir(baseDir, urlPrefix: string): Handler =
let prefix = normalizeUrlPath(urlPrefix)
result = proc (req: Request): Response {.gcsafe.} =
var relative = ""
if prefix == "/":
relative = req.path
else:
if not req.path.startsWith(prefix):
return notFoundResponse()
if req.path.len > prefix.len:
relative = req.path[prefix.len .. ^1]
else:
relative = ""
if relative.startsWith('/'):
if relative.len == 1:
relative = ""
else:
relative = relative[1 .. ^1]
let target = cleanStaticPath(baseDir, relative)
if target.len == 0 or dirExists(target) or not fileExists(target):
return notFoundResponse()
serveFile(target)
# ---------------------
# Site configuration
# ---------------------
type
PageSpec = object
path: string
templateFile: string
dataFile: string
raw: JsonNode
paramNames: seq[string]
dataSegments: seq[DataSegment]
Site = ref object
dir: string
configPath: string
contentDir: string
pagesDir: string
templatesDir: string
staticDir: string
configNode: JsonNode
globalData: JsonNode
notFoundTemplate: string
pages: seq[PageSpec]
devMode: bool
configMtime: Time
globalMtime: Time
cache: TemplateCache
staticHandler: Handler
proc sitePath(site: Site, parts: varargs[string]): string =
var path = site.dir
for part in parts:
path = path / part
path
proc readJsonFile(path: string): JsonNode =
if not fileExists(path):
raise newException(IOError, "missing JSON file: " & path)
let text = readFile(path)
try:
result = parseJson(text)
except JsonParsingError as err:
raise newException(ValueError, "invalid JSON in " & path & ": " & err.msg)
proc requireString(node: JsonNode, key: string): string =
if node.kind != JObject or not node.hasKey(key):
raise newException(ValueError, "missing required key '" & key & "'")
let value = node[key]
if value.kind != JString:
raise newException(ValueError, "key '" & key & "' must be a string")
value.getStr()
proc getString(node: JsonNode, key, fallback: string): string =
if node.kind != JObject or not node.hasKey(key):
return fallback
let value = node[key]
if value.kind != JString:
return fallback
value.getStr()
proc initSite(dir: string, devMode: bool): Site =
result = Site(
dir: absolutePath(dir),
configPath: absolutePath(dir / "config.json"),
contentDir: absolutePath(dir / "content"),
pagesDir: absolutePath(dir / "content" / "pages"),
templatesDir: absolutePath(dir / "templates"),
staticDir: absolutePath(dir / "static"),
notFoundTemplate: "404.html",
devMode: devMode,
configNode: newJObject(),
globalData: newJObject(),
pages: @[],
cache: initTemplateCache(absolutePath(dir / "templates")),
staticHandler: nil
)
proc ensureSiteLayout(site: Site) =
if not dirExists(site.dir):
raise newException(IOError, "site directory not found: " & site.dir)
if not fileExists(site.configPath):
raise newException(IOError, "missing config.json in site directory")
if not dirExists(site.templatesDir):
raise newException(IOError, "templates directory missing in site")
if not dirExists(site.contentDir):
raise newException(IOError, "content directory missing in site")
if not dirExists(site.pagesDir):
raise newException(IOError, "content/pages directory missing in site")
proc loadSiteConfig(site: Site) =
let info = getFileInfo(site.configPath)
site.configMtime = info.lastWriteTime
let node = readJsonFile(site.configPath)
if node.kind != JObject:
raise newException(ValueError, "config.json must contain a JSON object")
site.configNode = node
site.notFoundTemplate = getString(node, "notFoundTemplate", "404.html")
site.pages.setLen(0)
if node.hasKey("pages"):
let pagesNode = node["pages"]
if pagesNode.kind != JArray:
raise newException(ValueError, "config.pages must be an array")
for pageNode in pagesNode.items:
if pageNode.kind != JObject:
raise newException(ValueError, "page entry must be an object")
let rawPath = requireString(pageNode, "path")
let templateFile = requireString(pageNode, "template")
let dataFile = getString(pageNode, "data", "")
let normalizedPath = normalizeUrlPath(rawPath)
var pattern = parsePattern(normalizedPath)
var paramNames: seq[string] = @[]
var allowed = initHashSet[string]()
for segment in pattern.segments:
if segment.kind == segParam:
paramNames.add(segment.name)
allowed.incl(segment.name)
var dataSegments: seq[DataSegment] = @[]
if dataFile.len > 0:
dataSegments = parseDataPath(dataFile, allowed)
site.pages.add(PageSpec(
path: normalizedPath,
templateFile: templateFile,
dataFile: dataFile,
raw: pageNode,
paramNames: paramNames,
dataSegments: dataSegments
))
else:
raise newException(ValueError, "config.json must define a 'pages' array")
proc loadGlobalData(site: Site) =
let globalPath = sitePath(site, "content", "global.json")
if fileExists(globalPath):
let info = getFileInfo(globalPath)
site.globalMtime = info.lastWriteTime
try:
site.globalData = readJsonFile(globalPath)
except CatchableError:
site.globalData = newJObject()
else:
site.globalData = newJObject()
site.globalMtime = Time()
proc refreshSite(site: Site) =
if not site.devMode:
return
try:
let cfgInfo = getFileInfo(site.configPath)
if cfgInfo.lastWriteTime > site.configMtime:
loadSiteConfig(site)
except OSError:
discard
let globalPath = sitePath(site, "content", "global.json")
if fileExists(globalPath):
let info = getFileInfo(globalPath)
if info.lastWriteTime > site.globalMtime:
loadGlobalData(site)
proc resolveContentPath(site: Site, relative: string, baseDir: string): string =
if relative.len == 0:
return ""
if relative.startsWith('/') or relative.startsWith('\\'):
return ""
let baseAbs = absolutePath(baseDir)
let target = absolutePath(baseDir / relative)
if not target.startsWith(baseAbs):
return ""
target
proc loadPageData(site: Site, spec: PageSpec, params: Headers): tuple[data: JsonNode, found: bool] =
if spec.dataSegments.len == 0:
return (newJObject(), true)
var builder = newStringOfCap(64)
for segment in spec.dataSegments:
case segment.kind
of dskLiteral:
builder.add(segment.value)
of dskParam:
if segment.value notin params:
echo "[info] missing route parameter '" & segment.value & "' for data path in " & spec.path
return (newJObject(), false)
builder.add(params[segment.value])
let relativePath = builder
let baseDir =
if spec.dataFile.find({'/', '\\'}) >= 0: site.contentDir else: site.pagesDir
let fullPath = resolveContentPath(site, relativePath, baseDir)
if fullPath.len == 0:
echo "[warn] unsafe data path resolved for " & spec.path & ": " & relativePath
return (newJObject(), false)
if not fileExists(fullPath):
echo "[info] data file not found for " & spec.path & ": " & relativePath
return (newJObject(), false)
result.found = true
result.data = readJsonFile(fullPath)
proc cloneOrEmpty(node: JsonNode): JsonNode =
if node.isNil:
return newJObject()
node.copy()
proc mergeData(site: Site, pageData: JsonNode, req: Request, pageConfig: JsonNode): JsonNode =
result = newJObject()
result["config"] = cloneOrEmpty(site.configNode)
result["global"] = cloneOrEmpty(site.globalData)
result["page"] = if pageData.isNil: newJObject() else: pageData.copy()
if not pageConfig.isNil:
result["pageConfig"] = pageConfig.copy()
var requestNode = newJObject()
requestNode["path"] = %req.path
requestNode["method"] = %req.httpMethod
var paramsNode = newJObject()
for key, value in req.params.pairs:
paramsNode[key] = %value
requestNode["params"] = paramsNode
result["request"] = requestNode
proc envBool(name: string): bool =
let raw = getEnv(name, "").toLowerAscii()
raw in ["1", "true", "yes", "on"]
proc makePageHandler(site: Site, spec: PageSpec): Handler =
result = proc (req: Request): Response {.gcsafe.} =
if site.devMode:
refreshSite(site)
try:
let (pageData, found) = site.loadPageData(spec, req.params)
if not found:
return notFoundResponse()
if site.devMode:
var paramPairs: seq[string] = @[]
for key, val in req.params.pairs:
paramPairs.add(key & "=" & val)
echo "[debug] page data for ", spec.path, " (", paramPairs.join(","), ") => ", $pageData
let merged = mergeData(site, pageData, req, spec.raw)
if site.devMode:
echo "[debug] merged payload => ", $merged
let body = render(site.cache, spec.templateFile, merged)
var resp = newResponse(200, body)
resp.headers["content-type"] = "text/html; charset=utf-8"
return resp
except CatchableError as err:
echo "[error] page render failed for " & spec.path & ": " & err.msg
return serverErrorResponse()
proc renderNotFound(site: Site, req: Request): Response =
if site.devMode:
refreshSite(site)
try:
let data = mergeData(site, newJObject(), req, newJObject())
let tplName = site.notFoundTemplate
if tplName.len > 0:
let body = render(site.cache, tplName, data)
var resp = newResponse(404, body)
resp.headers["content-type"] = "text/html; charset=utf-8"
return resp
except CatchableError as err:
echo "[warn] 404 template failed: " & err.msg
newResponse(404, "page not found")
# ----------------------
# Async server plumbing
# ----------------------
proc headersToTable(src: HttpHeaders): Headers =
var table = newHeaders()
for key, value in src.pairs:
table[key.toLowerAscii()] = value
table
proc tableToHttpHeaders(src: Headers): HttpHeaders =
var pairs: seq[(string, string)]
for key, value in src.pairs:
pairs.add((key, value))
newHttpHeaders(pairs)
proc toFrameworkRequest(req: asynchttpserver.Request, params: Headers): Request =
result.httpMethod = $req.reqMethod
result.path = normalizeUrlPath(req.url.path)
result.headers = headersToTable(req.headers)
result.body = req.body
result.params = params
proc respondAsync(req: asynchttpserver.Request, resp: Response): Future[void] =
var headers = resp.headers
if "content-type" notin headers:
headers["content-type"] = "text/plain; charset=utf-8"
let status = if resp.status in 100..599: HttpCode(resp.status) else: Http200
return req.respond(status, resp.body, tableToHttpHeaders(headers))
proc runServer(port: int, site: Site, router: Router) {.async.} =
var server = newAsyncHttpServer()
server.listen(Port(port))
let actualPort = server.getPort
echo "listening on http://127.0.0.1:" & $actualPort.uint16
proc onRequest(req: asynchttpserver.Request) {.async, gcsafe.} =
try:
if site.devMode:
refreshSite(site)
let normalizedPath = normalizeUrlPath(req.url.path)
let methodStr = $req.reqMethod
if not site.staticHandler.isNil and (methodStr == "GET" or methodStr == "HEAD") and normalizedPath.startsWith("/static"):
var staticReq = Request(
httpMethod: methodStr,
path: normalizedPath,
headers: headersToTable(req.headers),
body: "",
params: newHeaders()
)
let resp = site.staticHandler(staticReq)
await req.respondAsync(resp)
return
let matchResult = match(router, methodStr, normalizedPath)
if matchResult.isNone:
let frameworkReq = toFrameworkRequest(req, newHeaders())
await req.respondAsync(renderNotFound(site, frameworkReq))
return
let (handler, routeParams) = matchResult.get()
var frameworkReq = toFrameworkRequest(req, routeParams)
echo $req.reqMethod & " " & req.url.path
let response = handler(frameworkReq)
await req.respondAsync(response)
except CatchableError:
await req.respondAsync(serverErrorResponse())
while true:
if server.shouldAcceptRequest():
await server.acceptRequest(onRequest)
else:
await sleepAsync(50)
# ----
# Main
# ----
proc main() {.async.} =
let portStr = getEnv("PORT", "")
var port = defaultPort
if portStr.len > 0:
try:
port = parseInt(portStr)
except ValueError:
echo "[warn] invalid PORT value '" & portStr & "', falling back to " & $defaultPort
port = defaultPort
let siteDir = getEnv("SITE_DIR", ".")
let devMode = envBool("DEV_MODE")
var site = initSite(siteDir, devMode)
try:
ensureSiteLayout(site)
loadSiteConfig(site)
loadGlobalData(site)
except CatchableError as err:
echo "[fatal] site initialization failed: " & err.msg
return
if site.devMode:
echo "[info] development mode enabled"
var router = initRouter()
var hasHealthRoute = false
for spec in site.pages:
if spec.path == "/health":
hasHealthRoute = true
router.addRoute("GET", spec.path, makePageHandler(site, spec))
if dirExists(site.staticDir):
site.staticHandler = serveDir(site.staticDir, "/static")
else:
echo "[info] static directory missing, /static disabled"
if not hasHealthRoute:
router.addRoute("GET", "/health", proc (req: Request): Response {.gcsafe.} =
var resp = newResponse(200, "ok\n")
resp.headers["content-type"] = "text/plain; charset=utf-8"
resp
)
await runServer(port, site, router)
when isMainModule:
waitFor main()