Moonshark/core/routers/luaRouter.go
2025-05-01 15:01:55 -05:00

521 lines
12 KiB
Go

package routers
import (
"encoding/binary"
"errors"
"hash/fnv"
"os"
"path/filepath"
"strings"
"sync"
"time"
luajit "git.sharkk.net/Sky/LuaJIT-to-Go"
"github.com/VictoriaMetrics/fastcache"
)
// Maximum number of URL parameters per route
const maxParams = 20
// Default cache sizes
const (
defaultBytecodeMaxBytes = 32 * 1024 * 1024 // 32MB for bytecode cache
defaultRouteMaxBytes = 8 * 1024 * 1024 // 8MB for route match cache
)
// Params holds URL parameters with fixed-size arrays to avoid allocations
type Params struct {
Keys [maxParams]string
Values [maxParams]string
Count int
}
// Get returns a parameter value by name
func (p *Params) Get(name string) string {
for i := range p.Count {
if p.Keys[i] == name {
return p.Values[i]
}
}
return ""
}
// LuaRouter is a filesystem-based HTTP router for Lua files
type LuaRouter struct {
routesDir string // Root directory containing route files
routes map[string]*node // Method -> route tree
failedRoutes map[string]*RouteError // Track failed routes
mu sync.RWMutex // Lock for concurrent access to routes
routeCache *fastcache.Cache // Cache for route lookups
bytecodeCache *fastcache.Cache // Cache for compiled bytecode
}
// node represents a node in the routing trie
type node struct {
handler string // Path to Lua file (empty if not an endpoint)
paramName string // Parameter name (if this is a parameter node)
staticChild map[string]*node // Static children by segment name
paramChild *node // Parameter/wildcard child
err error // Compilation error if any
modTime time.Time // Last modification time
}
// NewLuaRouter creates a new LuaRouter instance
func NewLuaRouter(routesDir string) (*LuaRouter, error) {
info, err := os.Stat(routesDir)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, errors.New("routes path is not a directory")
}
r := &LuaRouter{
routesDir: routesDir,
routes: make(map[string]*node),
failedRoutes: make(map[string]*RouteError),
routeCache: fastcache.New(defaultRouteMaxBytes),
bytecodeCache: fastcache.New(defaultBytecodeMaxBytes),
}
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"}
for _, method := range methods {
r.routes[method] = &node{
staticChild: make(map[string]*node),
}
}
err = r.buildRoutes()
// If some routes failed to compile, return the router with a warning error
if len(r.failedRoutes) > 0 {
return r, ErrRoutesCompilationErrors
}
return r, err
}
// buildRoutes scans the routes directory and builds the routing tree
func (r *LuaRouter) buildRoutes() error {
r.failedRoutes = make(map[string]*RouteError)
return filepath.Walk(r.routesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(info.Name(), ".lua") {
return nil
}
method := strings.ToUpper(strings.TrimSuffix(info.Name(), ".lua"))
root, exists := r.routes[method]
if !exists {
return nil
}
relDir, err := filepath.Rel(r.routesDir, filepath.Dir(path))
if err != nil {
return err
}
urlPath := "/"
if relDir != "." {
urlPath = "/" + strings.ReplaceAll(relDir, "\\", "/")
}
r.addRoute(root, urlPath, path, info.ModTime())
return nil
})
}
// addRoute adds a route to the routing tree
func (r *LuaRouter) addRoute(root *node, urlPath, handlerPath string, modTime time.Time) error {
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
current := root
for _, segment := range segments {
if len(segment) >= 2 && segment[0] == '[' && segment[len(segment)-1] == ']' {
if current.paramChild == nil {
current.paramChild = &node{
paramName: segment[1 : len(segment)-1],
staticChild: make(map[string]*node),
}
}
current = current.paramChild
} else {
child, exists := current.staticChild[segment]
if !exists {
child = &node{
staticChild: make(map[string]*node),
}
current.staticChild[segment] = child
}
current = child
}
}
// Set handler path and mod time
current.handler = handlerPath
current.modTime = modTime
// Compile Lua file to bytecode
if err := r.compileHandler(current); err != nil {
// Track the failure but don't fail the entire process
routeKey := getRouteKey(urlPath, handlerPath)
r.failedRoutes[routeKey] = &RouteError{
Path: urlPath,
ScriptPath: handlerPath,
Err: err,
}
}
return nil
}
// getRouteKey generates a unique key for a route
func getRouteKey(path, handler string) string {
return path + ":" + handler
}
// hashString generates a hash for a string
func hashString(s string) uint64 {
h := fnv.New64a()
h.Write([]byte(s))
return h.Sum64()
}
// uint64ToBytes converts a uint64 to bytes for cache key
func uint64ToBytes(n uint64) []byte {
b := make([]byte, 8)
binary.LittleEndian.PutUint64(b, n)
return b
}
// getCacheKey generates a cache key for a method and path
func getCacheKey(method, path string) []byte {
// Simple concatenation with separator to create a unique key
key := hashString(method + ":" + path)
return uint64ToBytes(key)
}
// getBytecodeKey generates a cache key for a handler path
func getBytecodeKey(handlerPath string) []byte {
key := hashString(handlerPath)
return uint64ToBytes(key)
}
// Match finds a handler for the given method and path
// Uses the pre-allocated params struct to avoid allocations
func (r *LuaRouter) Match(method, path string, params *Params) (*node, bool) {
params.Count = 0
r.mu.RLock()
root, exists := r.routes[method]
r.mu.RUnlock()
if !exists {
return nil, false
}
segments := strings.Split(strings.Trim(path, "/"), "/")
return r.matchPath(root, segments, params, 0)
}
// matchPath recursively matches a path against the routing tree
func (r *LuaRouter) matchPath(current *node, segments []string, params *Params, depth int) (*node, bool) {
// Base case: no more segments
if len(segments) == 0 {
if current.handler != "" {
return current, true
}
return nil, false
}
segment := segments[0]
remaining := segments[1:]
// Try static child first (exact match takes precedence)
if child, exists := current.staticChild[segment]; exists {
if node, found := r.matchPath(child, remaining, params, depth+1); found {
return node, true
}
}
// Try parameter child
if current.paramChild != nil {
// Store parameter
if params.Count < maxParams {
params.Keys[params.Count] = current.paramChild.paramName
params.Values[params.Count] = segment
params.Count++
}
if node, found := r.matchPath(current.paramChild, remaining, params, depth+1); found {
return node, true
}
// Backtrack: remove parameter if no match
params.Count--
}
return nil, false
}
// compileHandler compiles a Lua file to bytecode
func (r *LuaRouter) compileHandler(n *node) error {
if n.handler == "" {
return nil
}
content, err := os.ReadFile(n.handler)
if err != nil {
n.err = err
return err
}
state := luajit.New()
if state == nil {
compileErr := errors.New("failed to create Lua state")
n.err = compileErr
return compileErr
}
defer state.Close()
bytecode, err := state.CompileBytecode(string(content), n.handler)
if err != nil {
n.err = err
return err
}
bytecodeKey := getBytecodeKey(n.handler)
r.bytecodeCache.Set(bytecodeKey, bytecode)
n.err = nil
return nil
}
// GetRouteInfo returns the bytecode, script path, and any error for a matched route
func (r *LuaRouter) GetRouteInfo(method, path string, params *Params) ([]byte, string, error, bool) {
routeCacheKey := getCacheKey(method, path)
routeCacheData := r.routeCache.Get(nil, routeCacheKey)
if len(routeCacheData) > 0 {
handlerPath := string(routeCacheData[8:])
bytecodeKey := routeCacheData[:8]
bytecode := r.bytecodeCache.Get(nil, bytecodeKey)
n, exists := r.nodeForHandler(handlerPath)
if !exists {
r.routeCache.Del(routeCacheKey)
return nil, "", nil, false
}
if len(bytecode) > 0 {
return bytecode, handlerPath, n.err, true
}
fileInfo, err := os.Stat(handlerPath)
if err != nil || fileInfo.ModTime().After(n.modTime) {
if err := r.compileHandler(n); err != nil {
return nil, handlerPath, n.err, true
}
newBytecodeKey := getBytecodeKey(handlerPath)
bytecode = r.bytecodeCache.Get(nil, newBytecodeKey)
newCacheData := make([]byte, 8+len(handlerPath))
copy(newCacheData[:8], newBytecodeKey)
copy(newCacheData[8:], handlerPath)
r.routeCache.Set(routeCacheKey, newCacheData)
return bytecode, handlerPath, n.err, true
}
// Strange case - bytecode not in cache but file not modified; recompile
if err := r.compileHandler(n); err != nil {
return nil, handlerPath, n.err, true
}
bytecode = r.bytecodeCache.Get(nil, bytecodeKey)
return bytecode, handlerPath, n.err, true
}
node, found := r.Match(method, path, params)
if !found {
return nil, "", nil, false
}
bytecodeKey := getBytecodeKey(node.handler)
bytecode := r.bytecodeCache.Get(nil, bytecodeKey)
if len(bytecode) == 0 {
if err := r.compileHandler(node); err != nil {
return nil, node.handler, node.err, true
}
bytecode = r.bytecodeCache.Get(nil, bytecodeKey)
}
cacheData := make([]byte, 8+len(node.handler))
copy(cacheData[:8], bytecodeKey)
copy(cacheData[8:], node.handler)
r.routeCache.Set(routeCacheKey, cacheData)
return bytecode, node.handler, node.err, true
}
// nodeForHandler finds a node by its handler path
func (r *LuaRouter) nodeForHandler(handlerPath string) (*node, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, root := range r.routes {
if node := findNodeByHandler(root, handlerPath); node != nil {
return node, true
}
}
return nil, false
}
// findNodeByHandler finds a node by its handler path
func findNodeByHandler(current *node, handlerPath string) *node {
if current == nil {
return nil
}
if current.handler == handlerPath {
return current
}
// Check static children
for _, child := range current.staticChild {
if node := findNodeByHandler(child, handlerPath); node != nil {
return node
}
}
if current.paramChild != nil {
if node := findNodeByHandler(current.paramChild, handlerPath); node != nil {
return node
}
}
return nil
}
// Refresh rebuilds the router by rescanning the routes directory
func (r *LuaRouter) Refresh() error {
r.mu.Lock()
defer r.mu.Unlock()
for method := range r.routes {
r.routes[method] = &node{
staticChild: make(map[string]*node),
}
}
r.failedRoutes = make(map[string]*RouteError)
err := r.buildRoutes()
if len(r.failedRoutes) > 0 {
return ErrRoutesCompilationErrors
}
return err
}
// ReportFailedRoutes returns a list of routes that failed to compile
func (r *LuaRouter) ReportFailedRoutes() []*RouteError {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]*RouteError, 0, len(r.failedRoutes))
for _, re := range r.failedRoutes {
result = append(result, re)
}
return result
}
// ClearCache clears both the route and bytecode caches
func (r *LuaRouter) ClearCache() {
r.routeCache.Reset()
r.bytecodeCache.Reset()
}
// GetCacheStats returns statistics about the cache
func (r *LuaRouter) GetCacheStats() map[string]any {
var routeStats fastcache.Stats
var bytecodeStats fastcache.Stats
r.routeCache.UpdateStats(&routeStats)
r.bytecodeCache.UpdateStats(&bytecodeStats)
return map[string]any{
"routeEntries": routeStats.EntriesCount,
"routeBytes": routeStats.BytesSize,
"routeCollisions": routeStats.Collisions,
"bytecodeEntries": bytecodeStats.EntriesCount,
"bytecodeBytes": bytecodeStats.BytesSize,
"bytecodeCollisions": bytecodeStats.Collisions,
}
}
// GetRouteStats returns statistics about the router
func (r *LuaRouter) GetRouteStats() (int, int64) {
r.mu.RLock()
defer r.mu.RUnlock()
routeCount := 0
bytecodeBytes := int64(0)
for _, root := range r.routes {
count, bytes := countNodesAndBytecode(root)
routeCount += count
bytecodeBytes += bytes
}
return routeCount, bytecodeBytes
}
// countNodesAndBytecode traverses the tree and counts nodes and bytecode size
func countNodesAndBytecode(n *node) (count int, bytecodeBytes int64) {
if n == nil {
return 0, 0
}
if n.handler != "" {
count = 1
// Average of 2KB per script
bytecodeBytes = 2048
}
for _, child := range n.staticChild {
childCount, childBytes := countNodesAndBytecode(child)
count += childCount
bytecodeBytes += childBytes
}
if n.paramChild != nil {
childCount, childBytes := countNodesAndBytecode(n.paramChild)
count += childCount
bytecodeBytes += childBytes
}
return count, bytecodeBytes
}
type NodeWithError struct {
ScriptPath string
Error error
}