feat: complete Phase 3 + new production roadmap for Web/ERP

- Thread-safety: locks in LSMTree and Graph engines
- Raft network transport: async TCP, serialization, heartbeat, 3-node election test
- CI/CD: GitHub Actions workflow
- Cleanup: remove dead code, unused imports, build artifacts
- New PLAN.md targeting production Web/ERP readiness
- 216 tests passing
This commit is contained in:
2026-05-06 10:40:34 +03:00
parent f8909f155c
commit 096c8347cf
26 changed files with 1007 additions and 642 deletions
-150
View File
@@ -1,150 +0,0 @@
## HTTP/REST API — JSON endpoint
import std/asynchttpserver
import std/asyncdispatch
import std/json
import std/strutils
import std/tables
type
HttpMethod* = enum
hmGet = "GET"
hmPost = "POST"
hmPut = "PUT"
hmDelete = "DELETE"
hmPatch = "PATCH"
hmOptions = "OPTIONS"
RouteHandler* = proc(req: Request): Future[JsonNode] {.gcsafe.}
HttpRouter* = ref object
routes: Table[string, Table[string, RouteHandler]] # method -> path -> handler
middlewares: seq[RouteHandler]
port*: int
address*: string
Request* = ref object
httpMethod*: HttpMethod
path*: string
query*: Table[string, string]
headers*: Table[string, string]
body*: string
contentType*: string
Response* = object
status*: int
body*: JsonNode
headers*: Table[string, string]
proc newHttpRouter*(port: int = 8080, address: string = "0.0.0.0"): HttpRouter =
HttpRouter(
routes: initTable[string, Table[string, RouteHandler]](),
middlewares: @[],
port: port,
address: address,
)
proc addRoute*(router: HttpRouter, meth: HttpMethod, path: string, handler: RouteHandler) =
let m = $meth
if m notin router.routes:
router.routes[m] = initTable[string, RouteHandler]()
router.routes[m][path] = handler
proc get*(router: HttpRouter, path: string, handler: RouteHandler) =
router.addRoute(hmGet, path, handler)
proc post*(router: HttpRouter, path: string, handler: RouteHandler) =
router.addRoute(hmPost, path, handler)
proc put*(router: HttpRouter, path: string, handler: RouteHandler) =
router.addRoute(hmPut, path, handler)
proc delete*(router: HttpRouter, path: string, handler: RouteHandler) =
router.addRoute(hmDelete, path, handler)
proc jsonResponse*(status: int, data: JsonNode, headers: Table[string, string] = initTable[string, string]()): Response =
Response(status: status, body: data, headers: headers)
proc errorResponse*(status: int, message: string): Response =
jsonResponse(status, %*{"error": message})
proc successResponse*(data: JsonNode): Response =
jsonResponse(200, data)
proc parseQuery*(queryString: string): Table[string, string] =
result = initTable[string, string]()
if queryString.len == 0:
return
for pair in queryString.split("&"):
let parts = pair.split("=", 1)
if parts.len == 2:
result[parts[0]] = parts[1]
elif parts.len == 1:
result[parts[0]] = ""
proc matchPath*(pattern, path: string): Table[string, string] =
result = initTable[string, string]()
let patternParts = pattern.split("/")
let pathParts = path.split("/")
if patternParts.len != pathParts.len:
return
for i in 0..<patternParts.len:
if patternParts[i].startsWith(":"):
result[patternParts[i][1..^1]] = pathParts[i]
elif patternParts[i] != pathParts[i]:
result.clear()
return
proc corsHeaders*(): Table[string, string] =
result = initTable[string, string]()
result["Access-Control-Allow-Origin"] = "*"
result["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS"
result["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
proc jsonHeaders*(): Table[string, string] =
result = initTable[string, string]()
result["Content-Type"] = "application/json"
proc handleCors*(req: Request): Response =
if req.httpMethod == hmOptions:
return jsonResponse(204, newJNull(), corsHeaders())
return nil
proc parseRequest*(httpMethod: string, path: string, headers: Table[string, string], body: string): Request =
let methodMap = {
"GET": hmGet, "POST": hmPost, "PUT": hmPut,
"DELETE": hmDelete, "PATCH": hmPatch, "OPTIONS": hmOptions,
}.toTable
var query = initTable[string, string]()
var cleanPath = path
let qPos = path.find('?')
if qPos >= 0:
cleanPath = path[0..<qPos]
query = parseQuery(path[qPos+1..^1])
Request(
httpMethod: methodMap.getOrDefault(httpMethod, hmGet),
path: cleanPath,
query: query,
headers: headers,
body: body,
contentType: headers.getOrDefault("Content-Type", ""),
)
proc handleRequest*(router: HttpRouter, req: Request): Future[Response] {.async.} =
# CORS
let corsResp = handleCors(req)
if corsResp != nil:
return corsResp
let methodStr = $req.httpMethod
if methodStr in router.routes:
for pattern, handler in router.routes[methodStr]:
let params = matchPath(pattern, req.path)
if params.len > 0 or pattern == req.path:
try:
return jsonResponse(200, await handler(req), jsonHeaders())
except CatchableError as e:
return errorResponse(500, e.msg)
return errorResponse(404, "Not found: " & req.path)
-100
View File
@@ -1,100 +0,0 @@
## TLS/SSL — transport layer security wrapper
import std/os
import std/strutils
type
TLSVersion* = enum
tls12 = "TLSv1.2"
tls13 = "TLSv1.3"
TLSConfig* = object
certFile*: string
keyFile*: string
caFile*: string
minVersion*: TLSVersion
verifyPeer*: bool
cipherSuites*: seq[string]
TLSState* = enum
tsDisconnected
tsHandshaking
tsConnected
tsError
TLSConnection* = ref object
config*: TLSConfig
state*: TLSState
host*: string
port*: int
proc defaultTLSConfig*(): TLSConfig =
TLSConfig(
certFile: "",
keyFile: "",
caFile: "",
minVersion: tls12,
verifyPeer: false,
cipherSuites: @[
"TLS_AES_256_GCM_SHA384",
"TLS_CHACHA20_POLY1305_SHA256",
"TLS_AES_128_GCM_SHA256",
],
)
proc newTLSConfig*(certFile, keyFile: string, caFile: string = "",
minVersion: TLSVersion = tls12,
verifyPeer: bool = false): TLSConfig =
TLSConfig(
certFile: certFile,
keyFile: keyFile,
caFile: caFile,
minVersion: minVersion,
verifyPeer: verifyPeer,
cipherSuites: @[
"TLS_AES_256_GCM_SHA384",
"TLS_CHACHA20_POLY1305_SHA256",
"TLS_AES_128_GCM_SHA256",
],
)
proc validateConfig*(config: TLSConfig): seq[string] =
result = @[]
if config.certFile.len == 0:
result.add("Certificate file not specified")
elif not fileExists(config.certFile):
result.add("Certificate file not found: " & config.certFile)
if config.keyFile.len == 0:
result.add("Key file not specified")
elif not fileExists(config.keyFile):
result.add("Key file not found: " & config.keyFile)
if config.caFile.len > 0 and not fileExists(config.caFile):
result.add("CA file not found: " & config.caFile)
proc isValid*(config: TLSConfig): bool =
return config.validateConfig().len == 0
proc newTLSConnection*(config: TLSConfig, host: string, port: int): TLSConnection =
TLSConnection(config: config, state: tsDisconnected, host: host, port: port)
proc state*(conn: TLSConnection): TLSState = conn.state
# Self-signed certificate generation helper
proc generateSelfSignedCert*(outputDir: string): (string, string) =
let certPath = outputDir / "server.crt"
let keyPath = outputDir / "server.key"
createDir(outputDir)
# Use openssl to generate self-signed cert
let cmd = "openssl req -x509 -newkey rsa:2048 -keyout " & keyPath &
" -out " & certPath & " -days 365 -nodes -subj '/CN=localhost' 2>/dev/null"
discard execShellCmd(cmd)
return (certPath, keyPath)
proc certificateInfo*(certPath: string): Table[string, string] =
result = initTable[string, string]()
if not fileExists(certPath):
return
# Would use openssl to parse cert in production
result["path"] = certPath
result["exists"] = "true"
-216
View File
@@ -1,216 +0,0 @@
## WebSocket — streaming protocol support
import std/asyncdispatch
import std/asyncnet
import std/strutils
import std/base64
import std/sha1
import std/hashes
import std/tables
const
WS_FIN* = 0x80'u8
WS_TEXT* = 0x01'u8
WS_BINARY* = 0x02'u8
WS_CLOSE* = 0x08'u8
WS_PING* = 0x09'u8
WS_PONG* = 0x0A'u8
WS_MAX_FRAME* = 65536
type
WsFrame* = object
fin*: bool
opcode*: uint8
payload*: seq[byte]
masked*: bool
WebSocket* = ref object
socket: AsyncSocket
connected*: bool
onMessage*: proc(data: seq[byte]) {.gcsafe.}
onClose*: proc() {.gcsafe.}
onPing*: proc(data: seq[byte]) {.gcsafe.}
onPong*: proc(data: seq[byte]) {.gcsafe.}
WsServer* = ref object
socket: AsyncSocket
port: int
address: string
clients*: seq[WebSocket]
onConnect*: proc(ws: WebSocket) {.gcsafe.}
onDisconnect*: proc(ws: WebSocket) {.gcsafe.}
onMessage*: proc(ws: WebSocket, data: seq[byte]) {.gcsafe.}
proc newWebSocket*(socket: AsyncSocket): WebSocket =
WebSocket(
socket: socket,
connected: true,
onMessage: nil,
onClose: nil,
onPing: nil,
onPong: nil,
)
proc newWsServer*(port: int = 8081, address: string = "0.0.0.0"): WsServer =
WsServer(
socket: newAsyncSocket(),
port: port,
address: address,
clients: @[],
onConnect: nil,
onDisconnect: nil,
onMessage: nil,
)
proc wsHandshakeKey(clientKey: string): string =
let magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
let combined = clientKey & magic
let hash = computeSHA1(combined)
return encode(hash)
proc sendFrame*(ws: WebSocket, opcode: uint8, data: openArray[byte]) {.async.} =
var frame: seq[byte] = @[]
frame.add(opcode or WS_FIN)
if data.len < 126:
frame.add(byte(data.len))
elif data.len < 65536:
frame.add(126'u8)
frame.add(byte((data.len shr 8) and 0xFF))
frame.add(byte(data.len and 0xFF))
else:
frame.add(127'u8)
for i in 0..7:
frame.add(byte((data.len shr (56 - i * 8)) and 0xFF))
for b in data:
frame.add(b)
await ws.socket.send(cast[string](frame))
proc sendText*(ws: WebSocket, text: string) {.async.} =
await ws.sendFrame(WS_TEXT, cast[seq[byte]](text))
proc sendBinary*(ws: WebSocket, data: seq[byte]) {.async.} =
await ws.sendFrame(WS_BINARY, data)
proc sendPing*(ws: WebSocket, data: seq[byte] = @[]) {.async.} =
await ws.sendFrame(WS_PING, data)
proc sendPong*(ws: WebSocket, data: seq[byte] = @[]) {.async.} =
await ws.sendFrame(WS_PONG, data)
proc close*(ws: WebSocket) {.async.} =
if ws.connected:
ws.connected = false
await ws.sendFrame(WS_CLOSE, @[])
ws.socket.close()
if ws.onClose != nil:
ws.onClose()
proc readFrame*(ws: WebSocket): Future[WsFrame] {.async.} =
var header: array[2, byte]
let read1 = await ws.socket.recv(2)
if read1.len < 2:
return WsFrame(fin: false, opcode: WS_CLOSE)
header[0] = byte(read1[0])
header[1] = byte(read1[1])
result.fin = (header[0] and WS_FIN) != 0
result.opcode = header[0] and 0x0F
result.masked = (header[1] and 0x80) != 0
var payloadLen = int(header[1] and 0x7F)
if payloadLen == 126:
let ext = await ws.socket.recv(2)
if ext.len < 2:
return WsFrame(fin: false, opcode: WS_CLOSE)
payloadLen = (int(byte(ext[0])) shl 8) or int(byte(ext[1]))
elif payloadLen == 127:
let ext = await ws.socket.recv(8)
if ext.len < 8:
return WsFrame(fin: false, opcode: WS_CLOSE)
payloadLen = 0
for i in 0..7:
payloadLen = (payloadLen shl 8) or int(byte(ext[i]))
var maskKey: array[4, byte] = [0'u8, 0, 0, 0]
if result.masked:
let mk = await ws.socket.recv(4)
if mk.len < 4:
return WsFrame(fin: false, opcode: WS_CLOSE)
for i in 0..3:
maskKey[i] = byte(mk[i])
let payloadData = await ws.socket.recv(payloadLen)
if payloadData.len < payloadLen:
return WsFrame(fin: false, opcode: WS_CLOSE)
result.payload = newSeq[byte](payloadLen)
for i in 0..<payloadLen:
if result.masked:
result.payload[i] = byte(payloadData[i]) xor maskKey[i mod 4]
else:
result.payload[i] = byte(payloadData[i])
proc handleUpgrade*(client: AsyncSocket, requestHeaders: Table[string, string]): Future[WebSocket] {.async.} =
let wsKey = requestHeaders.getOrDefault("Sec-WebSocket-Key", "")
if wsKey.len == 0:
return nil
let acceptKey = wsHandshakeKey(wsKey)
let response = "HTTP/1.1 101 Switching Protocols\r\L" &
"Upgrade: websocket\r\L" &
"Connection: Upgrade\r\L" &
"Sec-WebSocket-Accept: " & acceptKey & "\r\L\r\L"
await client.send(response)
return newWebSocket(client)
proc run*(server: WsServer) {.async.} =
server.socket.setSockOpt(OptReuseAddr, true)
server.socket.bindAddr(Port(server.port), server.address)
server.socket.listen()
while true:
let client = await server.socket.accept()
let ws = newWebSocket(client)
server.clients.add(ws)
if server.onConnect != nil:
server.onConnect(ws)
# Read loop
try:
while ws.connected:
let frame = await ws.readFrame()
case frame.opcode
of WS_TEXT, WS_BINARY:
if server.onMessage != nil:
server.onMessage(ws, frame.payload)
of WS_PING:
await ws.sendPong(frame.payload)
of WS_CLOSE:
ws.connected = false
of WS_PONG:
discard
else:
discard
except:
discard
finally:
ws.connected = false
server.clients = server.clients.filterIt(it != ws)
if server.onDisconnect != nil:
server.onDisconnect(ws)
proc broadcast*(server: WsServer, data: seq[byte]) {.async.} =
for client in server.clients:
if client.connected:
await client.sendBinary(data)
proc broadcastText*(server: WsServer, text: string) {.async.} =
for client in server.clients:
if client.connected:
await client.sendText(text)
proc clientCount*(server: WsServer): int = server.clients.len