Compare commits

..

3 commits

Author SHA1 Message Date
maddalax
274df0534b test 2024-11-01 06:05:50 -05:00
maddalax
d544be6f04 fix 2024-11-01 06:03:19 -05:00
maddalax
50bc55e9ca gen code for assets 2024-11-01 06:01:18 -05:00
120 changed files with 418 additions and 6394 deletions

View file

@ -1,48 +0,0 @@
name: Build and Deploy ws-test
on:
workflow_run:
workflows: [ "Update HTMGO Framework Dependency" ] # The name of the first workflow
types:
- completed
workflow_dispatch:
push:
branches:
- ws-testing
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get short commit hash
id: vars
run: echo "::set-output name=short_sha::$(echo $GITHUB_SHA | cut -c1-7)"
- name: Build Docker image
run: |
cd ./examples/ws-example && docker build -t ghcr.io/${{ github.repository_owner }}/ws-example:${{ steps.vars.outputs.short_sha }} .
- name: Tag as latest Docker image
run: |
docker tag ghcr.io/${{ github.repository_owner }}/ws-example:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/ws-example:latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push Docker image
run: |
docker push ghcr.io/${{ github.repository_owner }}/ws-example:latest

View file

@ -1,33 +0,0 @@
name: CLI Tests
on:
push:
branches:
- master
pull_request:
branches:
- '**' # Runs on any pull request to any branch
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23' # Specify the Go version you need
- name: Install dependencies
run: cd ./cli/htmgo && go mod download
- name: Run Go tests
run: cd ./cli/htmgo/tasks/astgen && go test ./... -coverprofile=coverage.txt
- name: Upload results to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -3,7 +3,7 @@ name: Framework Tests
on:
push:
branches:
- master
- '**' # Runs on any branch push
pull_request:
branches:
- '**' # Runs on any pull request to any branch

View file

@ -8,9 +8,6 @@
[![Go Reference](https://pkg.go.dev/badge/github.com/maddalax/htmgo/framework@v1.0.2/h.svg)](https://htmgo.dev/docs)
[![codecov](https://codecov.io/github/maddalax/htmgo/graph/badge.svg?token=ANPD11LSGN)](https://codecov.io/github/maddalax/htmgo)
[![Join Discord](https://img.shields.io/badge/Join%20Discord-gray?style=flat&logo=discord&logoColor=white&link=https://htmgo.dev/discord)](https://htmgo.dev/discord)
![GitHub Sponsors](https://img.shields.io/github/sponsors/maddalax)
<sup>looking for a python version? check out: https://fastht.ml</sup>

View file

@ -3,25 +3,13 @@ module github.com/maddalax/htmgo/cli/htmgo
go 1.23.0
require (
github.com/dave/jennifer v1.7.1
github.com/fsnotify/fsnotify v1.7.0
github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20250703190716-06f01b3d7c1b
github.com/stretchr/testify v1.9.0
golang.org/x/mod v0.21.0
golang.org/x/sys v0.26.0
golang.org/x/net v0.29.0
golang.org/x/sys v0.25.0
golang.org/x/tools v0.25.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
)
require (
github.com/bmatcuk/doublestar/v4 v4.7.1
github.com/go-chi/chi/v5 v5.1.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/text v0.19.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect

View file

@ -1,32 +1,16 @@
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20250703190716-06f01b3d7c1b h1:jvfp35fig2TzBjAgw82fe8+7cvaLX9EbipZUlj8FDDY=
github.com/maddalax/htmgo/tools/html-to-htmgo v0.0.0-20250703190716-06f01b3d7c1b/go.mod h1:FraJsj3NRuLBQDk83ZVa+psbNRNLe+rajVtVhYMEme4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -16,15 +16,14 @@ import (
"log/slog"
"os"
"strings"
"sync"
)
const version = "1.0.6"
func main() {
needsSignals := true
commandMap := make(map[string]*flag.FlagSet)
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate", "format", "version"}
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate", "format"}
for _, command := range commands {
commandMap[command] = flag.NewFlagSet(command, flag.ExitOnError)
@ -78,9 +77,21 @@ func main() {
fmt.Printf("Generating CSS...\n")
css.GenerateCss(process.ExitOnError)
// generate ast needs to be run after css generation
astgen.GenAst(process.ExitOnError)
run.EntGenerate()
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
astgen.GenAst(process.ExitOnError)
}()
wg.Add(1)
go func() {
defer wg.Done()
run.EntGenerate()
}()
wg.Wait()
fmt.Printf("Starting server...\n")
process.KillAll()
@ -89,10 +100,6 @@ func main() {
}()
startWatcher(reloader.OnFileChange)
} else {
if taskName == "version" {
fmt.Printf("htmgo cli version %s\n", version)
os.Exit(0)
}
if taskName == "format" {
if len(os.Args) < 3 {
fmt.Println(fmt.Sprintf("Usage: htmgo format <file>"))
@ -118,7 +125,6 @@ func main() {
} else if taskName == "css" {
_ = css.GenerateCss(process.ExitOnError)
} else if taskName == "ast" {
css.GenerateCss(process.ExitOnError)
_ = astgen.GenAst(process.ExitOnError)
} else if taskName == "run" {
run.MakeBuildable()

View file

@ -2,21 +2,19 @@ package astgen
import (
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/framework/h"
"go/ast"
"go/parser"
"go/token"
"golang.org/x/mod/modfile"
"io/fs"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"unicode"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/maddalax/htmgo/framework/h"
"golang.org/x/mod/modfile"
)
type Page struct {
@ -101,32 +99,6 @@ func sliceCommonPrefix(dir1, dir2 string) string {
return normalizePath(slicedDir2)
}
func hasOnlyReqContextParam(funcType *ast.FuncType) bool {
if len(funcType.Params.List) != 1 {
return false
}
if funcType.Params.List[0].Names == nil {
return false
}
if len(funcType.Params.List[0].Names) != 1 {
return false
}
t := funcType.Params.List[0].Type
name, ok := t.(*ast.StarExpr)
if !ok {
return false
}
selectorExpr, ok := name.X.(*ast.SelectorExpr)
if !ok {
return false
}
ident, ok := selectorExpr.X.(*ast.Ident)
if !ok {
return false
}
return ident.Name == "h" && selectorExpr.Sel.Name == "RequestContext"
}
func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial) bool) ([]Partial, error) {
var partials []Partial
cwd := process.GetWorkingDir()
@ -163,7 +135,7 @@ func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
// Check if the package name is 'h' and type is 'Partial'.
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
if selectorExpr.Sel.Name == "Partial" && hasOnlyReqContextParam(funcDecl.Type) {
if selectorExpr.Sel.Name == "Partial" {
p := Partial{
Package: node.Name.Name,
Path: normalizePath(sliceCommonPrefix(cwd, path)),
@ -230,7 +202,7 @@ func findPublicFuncsReturningHPage(dir string) ([]Page, error) {
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
// Check if the package name is 'h' and type is 'Partial'.
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
if selectorExpr.Sel.Name == "Page" && hasOnlyReqContextParam(funcDecl.Type) {
if selectorExpr.Sel.Name == "Page" {
pages = append(pages, Page{
Package: node.Name.Name,
Import: normalizePath(filepath.Dir(path)),
@ -260,34 +232,59 @@ func findPublicFuncsReturningHPage(dir string) ([]Page, error) {
}
func buildGetPartialFromContext(builder *CodeBuilder, partials []Partial) {
moduleName := GetModuleName()
fName := "GetPartialFromContext"
var routerHandlerMethod = func(path string, caller string) string {
return fmt.Sprintf(`
router.Handle("%s", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
partial := %s(cc)
body := `
path := r.URL.Path
`
if len(partials) == 0 {
body = ""
}
moduleName := GetModuleName()
for _, f := range partials {
if f.FuncName == fName {
continue
}
caller := fmt.Sprintf("%s.%s", f.Package, f.FuncName)
path := fmt.Sprintf("/%s/%s.%s", moduleName, f.Import, f.FuncName)
body += fmt.Sprintf(`
if path == "%s" || path == "%s" {
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
return %s(cc)
}
`, f.FuncName, path, caller)
}
body += "return nil"
f := Function{
Name: fName,
Parameters: []NameType{
{Name: "r", Type: "*http.Request"},
},
Return: []ReturnType{
{Type: "*h.Partial"},
},
Body: body,
}
builder.Append(builder.BuildFunction(f))
registerFunction := fmt.Sprintf(`
func RegisterPartials(router *chi.Mux) {
router.Handle("/%s/partials*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
partial := GetPartialFromContext(r)
if partial == nil {
w.WriteHeader(404)
return
}
h.PartialView(w, partial)
}))`, path, caller)
}
handlerMethods := make([]string, 0)
for _, f := range partials {
caller := fmt.Sprintf("%s.%s", f.Package, f.FuncName)
path := fmt.Sprintf("/%s/%s.%s", moduleName, f.Import, f.FuncName)
handlerMethods = append(handlerMethods, routerHandlerMethod(path, caller))
}
registerFunction := fmt.Sprintf(`
func RegisterPartials(router *chi.Mux) {
%s
}))
}
`, strings.Join(handlerMethods, "\n"))
`, moduleName)
builder.AppendLine(registerFunction)
}
@ -296,7 +293,7 @@ func writePartialsFile() {
config := dirutil.GetConfig()
cwd := process.GetWorkingDir()
partialPath := filepath.Join(cwd)
partialPath := filepath.Join(cwd, "partials")
partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool {
return partial.FuncName != "GetPartialFromContext"
})
@ -313,13 +310,10 @@ func writePartialsFile() {
builder := NewCodeBuilder(nil)
builder.AppendLine(GeneratedFileLine)
builder.AppendLine(PackageName)
builder.AddImport(ModuleName)
builder.AddImport(HttpModuleName)
builder.AddImport(ChiModuleName)
if len(partials) > 0 {
builder.AddImport(ModuleName)
builder.AddImport(HttpModuleName)
}
moduleName := GetModuleName()
for _, partial := range partials {
builder.AddImport(fmt.Sprintf(`%s/%s`, moduleName, partial.Import))
@ -427,9 +421,6 @@ func writePagesFile() {
func writeAssetsFile() {
cwd := process.GetWorkingDir()
config := dirutil.GetConfig()
slog.Debug("writing assets file", slog.String("cwd", cwd), slog.String("config", config.PublicAssetPath))
distAssets := filepath.Join(cwd, "assets", "dist")
hasAssets := false
@ -452,7 +443,7 @@ func writeAssetsFile() {
}
path = strings.ReplaceAll(path, distAssets, "")
httpUrl := normalizePath(fmt.Sprintf("%s%s", config.PublicAssetPath, path))
httpUrl := fmt.Sprintf("%s%s", config.PublicAssetPath, path)
path = normalizePath(path)
path = strings.ReplaceAll(path, "/", "_")
@ -484,36 +475,9 @@ func writeAssetsFile() {
}
func HasModuleFile(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
func CheckPagesDirectory(path string) error {
pagesPath := filepath.Join(path, "pages")
_, err := os.Stat(pagesPath)
if err != nil {
return fmt.Errorf("The directory pages does not exist.")
}
return nil
}
func GetModuleName() string {
wd := process.GetWorkingDir()
modPath := filepath.Join(wd, "go.mod")
if HasModuleFile(modPath) == false {
fmt.Fprintf(os.Stderr, "Module not found: go.mod file does not exist.")
return ""
}
checkDir := CheckPagesDirectory(wd)
if checkDir != nil {
fmt.Fprintf(os.Stderr, checkDir.Error())
return ""
}
goModBytes, err := os.ReadFile(modPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading go.mod: %v\n", err)

View file

@ -1,6 +0,0 @@
/assets/dist
tmp
node_modules
.idea
__htmgo
dist

View file

@ -1,13 +0,0 @@
//go:build !prod
// +build !prod
package main
import (
"astgen-project-sample/internal/embedded"
"io/fs"
)
func GetStaticAssets() fs.FS {
return embedded.NewOsFs()
}

View file

@ -1,16 +0,0 @@
//go:build prod
// +build prod
package main
import (
"embed"
"io/fs"
)
//go:embed assets/dist/*
var staticAssets embed.FS
func GetStaticAssets() fs.FS {
return staticAssets
}

View file

@ -1,11 +0,0 @@
module astgen-project-sample
go 1.23.0
require github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
require (
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,18 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,21 +0,0 @@
# htmgo configuration
# if tailwindcss is enabled, htmgo will automatically compile your tailwind and output it to assets/dist
tailwind: true
# which directories to ignore when watching for changes, supports glob patterns through https://github.com/bmatcuk/doublestar
watch_ignore: [".git", "node_modules", "dist/*"]
# files to watch for changes, supports glob patterns through https://github.com/bmatcuk/doublestar
watch_files: ["**/*.go", "**/*.css", "**/*.md"]
# files or directories to ignore when automatically registering routes for pages
# supports glob patterns through https://github.com/bmatcuk/doublestar
automatic_page_routing_ignore: ["root.go"]
# files or directories to ignore when automatically registering routes for partials
# supports glob patterns through https://github.com/bmatcuk/doublestar
automatic_partial_routing_ignore: []
# url path of where the public assets are located
public_asset_path: "/public"

View file

@ -1,17 +0,0 @@
package embedded
import (
"io/fs"
"os"
)
type OsFs struct {
}
func (receiver OsFs) Open(name string) (fs.File, error) {
return os.Open(name)
}
func NewOsFs() OsFs {
return OsFs{}
}

View file

@ -1,36 +0,0 @@
package main
import (
"astgen-project-sample/__htmgo"
"fmt"
"github.com/maddalax/htmgo/framework/config"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"io/fs"
"net/http"
)
func main() {
locator := service.NewLocator()
cfg := config.Get()
h.Start(h.AppOpts{
ServiceLocator: locator,
LiveReload: true,
Register: func(app *h.App) {
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
if err != nil {
panic(err)
}
http.FileServerFS(sub)
// change this in htmgo.yml (public_asset_path)
app.Router.Handle(fmt.Sprintf("%s/*", cfg.PublicAssetPath),
http.StripPrefix(cfg.PublicAssetPath, http.FileServerFS(sub)))
__htmgo.Register(app.Router)
},
})
}

View file

@ -1,30 +0,0 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func IndexPage(ctx *h.RequestContext) *h.Page {
return RootPage(
h.Div(
h.Class("flex flex-col gap-4 items-center pt-24 min-h-screen bg-neutral-100"),
h.H3(
h.Id("intro-text"),
h.Text("hello htmgo"),
h.Class("text-5xl"),
),
h.Div(
h.Class("mt-3"),
),
h.Div(),
),
)
}
func TestPartial(ctx *h.RequestContext) *h.Partial {
return h.NewPartial(
h.Div(
h.Text("Hello World"),
),
)
}

View file

@ -1,40 +0,0 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func RootPage(children ...h.Ren) *h.Page {
title := "htmgo template"
description := "an example of the htmgo template"
author := "htmgo"
url := "https://htmgo.dev"
return h.NewPage(
h.Html(
h.HxExtensions(
h.BaseExtensions(),
),
h.Head(
h.Title(
h.Text(title),
),
h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Meta("title", title),
h.Meta("charset", "utf-8"),
h.Meta("author", author),
h.Meta("description", description),
h.Meta("og:title", title),
h.Meta("og:url", url),
h.Link("canonical", url),
h.Meta("og:description", description),
),
h.Body(
h.Div(
h.Class("flex flex-col gap-2 bg-white h-full"),
h.Fragment(children...),
),
),
),
)
}

View file

@ -1,18 +0,0 @@
package partials
import "github.com/maddalax/htmgo/framework/h"
func CountersPartial(ctx *h.RequestContext) *h.Partial {
return h.NewPartial(
h.Div(
h.Text("my counter"),
),
)
}
func SwapFormError(ctx *h.RequestContext, error string) *h.Partial {
return h.SwapPartial(
ctx,
h.Div(),
)
}

View file

@ -1,66 +0,0 @@
package astgen
import (
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/internal/dirutil"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"github.com/stretchr/testify/assert"
"net/http"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestAstGen(t *testing.T) {
t.Parallel()
workingDir, err := filepath.Abs("./project-sample")
assert.NoError(t, err)
process.SetWorkingDir(workingDir)
assert.NoError(t, os.Chdir(workingDir))
err = dirutil.DeleteDir(filepath.Join(process.GetWorkingDir(), "__htmgo"))
assert.NoError(t, err)
err = process.Run(process.NewRawCommand("", "go build ."))
assert.Error(t, err)
err = GenAst()
assert.NoError(t, err)
go func() {
// project was buildable after astgen, confirmed working
err = process.Run(process.NewRawCommand("server", "go run ."))
assert.NoError(t, err)
}()
time.Sleep(time.Second * 1)
urls := []string{
"/astgen-project-sample/partials.CountersPartial",
"/",
"/astgen-project-sample/pages.TestPartial",
}
defer func() {
serverProcess := process.GetProcessByName("server")
assert.NotNil(t, serverProcess)
process.KillProcess(*serverProcess)
}()
wg := sync.WaitGroup{}
for _, url := range urls {
wg.Add(1)
go func() {
defer wg.Done()
// ensure we can get a 200 response on the partials
resp, e := http.Get(fmt.Sprintf("http://localhost:3000%s", url))
assert.NoError(t, e)
assert.Equal(t, http.StatusOK, resp.StatusCode, fmt.Sprintf("%s was not a 200 response", url))
}()
}
wg.Wait()
}

View file

@ -78,7 +78,7 @@ func downloadTailwindCli() {
log.Fatal(fmt.Sprintf("Unsupported OS/ARCH: %s/%s", os, arch))
}
fileName := fmt.Sprintf(`tailwindcss-%s`, distro)
url := fmt.Sprintf(`https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.16/%s`, fileName)
url := fmt.Sprintf(`https://github.com/tailwindlabs/tailwindcss/releases/latest/download/%s`, fileName)
cmd := fmt.Sprintf(`curl -LO %s`, url)
process.Run(process.NewRawCommand("tailwind-cli-download", cmd, process.ExitOnError))

View file

@ -11,21 +11,14 @@ import (
func MakeBuildable() {
copyassets.CopyAssets()
css.GenerateCss(process.ExitOnError)
astgen.GenAst(process.ExitOnError)
css.GenerateCss(process.ExitOnError)
}
func Build() {
MakeBuildable()
_ = os.RemoveAll("./dist")
err := os.Mkdir("./dist", 0755)
if err != nil {
fmt.Println("Error creating dist directory", err)
os.Exit(1)
}
process.RunOrExit(process.NewRawCommand("", "mkdir -p ./dist"))
if os.Getenv("SKIP_GO_BUILD") != "1" {
process.RunOrExit(process.NewRawCommand("", fmt.Sprintf("go build -tags prod -o ./dist")))

View file

@ -1,42 +1,7 @@
package run
import (
"fmt"
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
"io/fs"
"os"
"path/filepath"
)
import "github.com/maddalax/htmgo/cli/htmgo/tasks/process"
func Server(flags ...process.RunFlag) error {
buildDir := "./__htmgo/temp-build"
_ = os.RemoveAll(buildDir)
err := os.Mkdir(buildDir, 0755)
if err != nil {
return err
}
process.RunOrExit(process.NewRawCommand("", fmt.Sprintf("go build -o %s", buildDir)))
binaryPath := ""
// find the binary that was built
err = filepath.WalkDir(buildDir, func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
return nil
}
binaryPath = path
return nil
})
if err != nil {
return err
}
if binaryPath == "" {
return fmt.Errorf("could not find the binary")
}
return process.Run(process.NewRawCommand("run-server", fmt.Sprintf("./%s", binaryPath), flags...))
return process.Run(process.NewRawCommand("run-server", "go run .", flags...))
}

View file

@ -89,7 +89,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
if !ok {
return
}
slog.Error("error:", slog.String("error", err.Error()))
slog.Error("error:", err.Error())
}
}
}()
@ -118,7 +118,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
if info.IsDir() {
err = watcher.Add(path)
if err != nil {
slog.Error("Error adding directory to watcher:", slog.String("error", err.Error()))
slog.Error("Error adding directory to watcher:", err)
} else {
slog.Debug("Watching directory:", slog.String("path", path))
}

View file

@ -5,7 +5,7 @@ go 1.23.0
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63
github.com/mattn/go-sqlite3 v1.14.23
github.com/puzpuzpuz/xsync/v3 v3.4.0
)

View file

@ -4,8 +4,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63 h1:HV+1TUsoFnZoWXbvh9NvYyTt86tETKoGokXjMhA6IC0=
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View file

@ -50,6 +50,7 @@ func Handle() http.HandlerFunc {
defer manager.Disconnect(sessionId)
defer func() {
fmt.Printf("empting channels\n")
for len(writer) > 0 {
<-writer
}

View file

@ -70,14 +70,16 @@ func (manager *SocketManager) Listen(listener chan SocketEvent) {
}
func (manager *SocketManager) dispatch(event SocketEvent) {
fmt.Printf("dispatching event: %s\n", event.Type)
done := make(chan struct{}, 1)
go func() {
for {
select {
case <-done:
fmt.Printf("dispatched event: %s\n", event.Type)
return
case <-time.After(5 * time.Second):
fmt.Printf("havent dispatched listener event after 5s, chan blocked: %s\n", event.Type)
fmt.Printf("havent dispatched event after 5s, chan blocked: %s\n", event.Type)
}
}
}()

View file

@ -3,7 +3,7 @@ module hackernews
go 1.23.0
require (
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63
github.com/microcosm-cc/bluemonday v1.0.27
)

View file

@ -8,8 +8,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63 h1:HV+1TUsoFnZoWXbvh9NvYyTt86tETKoGokXjMhA6IC0=
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View file

@ -1,6 +0,0 @@
/assets/dist
tmp
node_modules
.idea
__htmgo
dist

View file

@ -1,8 +0,0 @@
Minimal example that just uses htmgo for html rendering / js support and nothing else.
Removes automatic support for:
1. live reloading
2. tailwind recompilation
3. page/partial route registration
4. Single binary (since /public/ assets is required to be there), normally htmgo uses the embedded file system in other examples such as https://github.com/maddalax/htmgo/blob/master/templates/starter/assets_prod.go

View file

@ -1,10 +0,0 @@
module minimal-htmgo
go 1.23.0
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
)
require github.com/google/uuid v1.6.0 // indirect

View file

@ -1,16 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,44 +0,0 @@
package main
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/js"
"time"
)
func Index(ctx *h.RequestContext) *h.Page {
return h.NewPage(
h.Html(
h.HxExtensions(
h.BaseExtensions(),
),
h.Head(
h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Script("/public/htmgo.js"),
),
h.Body(
h.Pf("hello htmgo"),
h.Div(
h.Get("/current-time", "load, every 1s"),
),
h.Div(
h.Button(
h.Text("Click me"),
h.OnClick(
js.EvalJs(`
console.log("you evalulated javascript");
alert("you clicked me");
`),
),
),
),
),
),
)
}
func CurrentTime(ctx *h.RequestContext) *h.Partial {
return h.NewPartial(
h.Pf("It is %s", time.Now().String()),
)
}

View file

@ -1,23 +0,0 @@
package main
import (
"github.com/go-chi/chi/v5"
"net/http"
)
func main() {
router := chi.NewRouter()
fileServer := http.StripPrefix("/public", http.FileServer(http.Dir("./public")))
router.Handle("/public/*", fileServer)
router.Get("/", func(writer http.ResponseWriter, request *http.Request) {
RenderPage(request, writer, Index)
})
router.Get("/current-time", func(writer http.ResponseWriter, request *http.Request) {
RenderPartial(request, writer, CurrentTime)
})
http.ListenAndServe(":3000", router)
}

File diff suppressed because one or more lines are too long

View file

@ -1,26 +0,0 @@
package main
import (
"github.com/maddalax/htmgo/framework/h"
"net/http"
)
func RenderToString(element *h.Element) string {
return h.Render(element)
}
func RenderPage(req *http.Request, w http.ResponseWriter, page func(ctx *h.RequestContext) *h.Page) {
ctx := h.RequestContext{
Request: req,
Response: w,
}
h.HtmlView(w, page(&ctx))
}
func RenderPartial(req *http.Request, w http.ResponseWriter, partial func(ctx *h.RequestContext) *h.Partial) {
ctx := h.RequestContext{
Request: req,
Response: w,
}
h.PartialView(w, partial(&ctx))
}

View file

@ -3,7 +3,7 @@ module simpleauth
go 1.23.0
require (
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63
github.com/mattn/go-sqlite3 v1.14.24
golang.org/x/crypto v0.28.0
)

View file

@ -4,8 +4,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63 h1:HV+1TUsoFnZoWXbvh9NvYyTt86tETKoGokXjMhA6IC0=
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View file

@ -5,7 +5,7 @@ go 1.23.0
require (
entgo.io/ent v0.14.1
github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63
github.com/mattn/go-sqlite3 v1.14.23
)

View file

@ -33,8 +33,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63 h1:HV+1TUsoFnZoWXbvh9NvYyTt86tETKoGokXjMhA6IC0=
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=

View file

@ -1,11 +0,0 @@
# Project exclude paths
/tmp/
node_modules/
dist/
js/dist
js/node_modules
go.work
go.work.sum
.idea
!framework/assets/dist
__htmgo

View file

@ -1,6 +0,0 @@
/assets/dist
tmp
node_modules
.idea
__htmgo
dist

View file

@ -1,38 +0,0 @@
# Stage 1: Build the Go binary
FROM golang:1.23-alpine AS builder
RUN apk update
RUN apk add git
RUN apk add curl
# Set the working directory inside the container
WORKDIR /app
# Copy go.mod and go.sum files
COPY go.mod go.sum ./
# Download and cache the Go modules
RUN go mod download
# Copy the source code into the container
COPY . .
# Build the Go binary for Linux
RUN GOPRIVATE=github.com/maddalax GOPROXY=direct go run github.com/maddalax/htmgo/cli/htmgo@latest build
# Stage 2: Create the smallest possible image
FROM gcr.io/distroless/base-debian11
# Set the working directory inside the container
WORKDIR /app
# Copy the Go binary from the builder stage
COPY --from=builder /app/dist .
# Expose the necessary port (replace with your server port)
EXPOSE 3000
# Command to run the binary
CMD ["./ws-example"]

View file

@ -1,20 +0,0 @@
version: '3'
tasks:
run:
cmds:
- go run github.com/maddalax/htmgo/cli/htmgo@latest run
silent: true
build:
cmds:
- go run github.com/maddalax/htmgo/cli/htmgo@latest build
docker:
cmds:
- docker build .
watch:
cmds:
- go run github.com/maddalax/htmgo/cli/htmgo@latest watch
silent: true

View file

@ -1,13 +0,0 @@
//go:build !prod
// +build !prod
package main
import (
"io/fs"
"ws-example/internal/embedded"
)
func GetStaticAssets() fs.FS {
return embedded.NewOsFs()
}

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -1,16 +0,0 @@
//go:build prod
// +build prod
package main
import (
"embed"
"io/fs"
)
//go:embed assets/dist/*
var staticAssets embed.FS
func GetStaticAssets() fs.FS {
return staticAssets
}

View file

@ -1,18 +0,0 @@
module ws-example
go 1.23.0
require (
github.com/maddalax/htmgo/extensions/websocket v0.0.0-20241109180553-34e816ff7c8a
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
)
require (
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
golang.org/x/sys v0.6.0 // indirect
)

View file

@ -1,28 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/extensions/websocket v0.0.0-20241109180553-34e816ff7c8a h1:BYVo9NCLHgXvf5pCGUnVg8UE7d9mWOyLgWXYTgVTkyA=
github.com/maddalax/htmgo/extensions/websocket v0.0.0-20241109180553-34e816ff7c8a/go.mod h1:r6/VqntLp7VlAUpIXy3MWZMHs2EkPKJP5rJdDL8lFP4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,17 +0,0 @@
package embedded
import (
"io/fs"
"os"
)
type OsFs struct {
}
func (receiver OsFs) Open(name string) (fs.File, error) {
return os.Open(name)
}
func NewOsFs() OsFs {
return OsFs{}
}

View file

@ -1,48 +0,0 @@
package main
import (
"github.com/maddalax/htmgo/extensions/websocket"
ws2 "github.com/maddalax/htmgo/extensions/websocket/opts"
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"io/fs"
"net/http"
"ws-example/__htmgo"
)
func main() {
locator := service.NewLocator()
h.Start(h.AppOpts{
ServiceLocator: locator,
LiveReload: true,
Register: func(app *h.App) {
app.Use(func(ctx *h.RequestContext) {
session.CreateSession(ctx)
})
websocket.EnableExtension(app, ws2.ExtensionOpts{
WsPath: "/ws",
RoomName: func(ctx *h.RequestContext) string {
return "all"
},
SessionId: func(ctx *h.RequestContext) string {
return ctx.QueryParam("sessionId")
},
})
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
if err != nil {
panic(err)
}
http.FileServerFS(sub)
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
__htmgo.Register(app.Router)
},
})
}

View file

@ -1,57 +0,0 @@
package pages
import (
"fmt"
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/extensions/websocket/ws"
"github.com/maddalax/htmgo/framework/h"
"ws-example/partials"
)
func IndexPage(ctx *h.RequestContext) *h.Page {
sessionId := session.GetSessionId(ctx)
return h.NewPage(
RootPage(
ctx,
h.Div(
h.Attribute("ws-connect", fmt.Sprintf("/ws?sessionId=%s", sessionId)),
h.Class("flex flex-col gap-4 items-center pt-24 min-h-screen bg-neutral-100"),
h.H3(
h.Id("intro-text"),
h.Text("Repeater Example"),
h.Class("text-2xl"),
),
h.Div(
h.Id("ws-metrics"),
),
partials.CounterForm(ctx, partials.CounterProps{Id: "counter-1"}),
partials.Repeater(ctx, partials.RepeaterProps{
Id: "repeater-1",
OnAdd: func(data ws.HandlerData) {
//ws.BroadcastServerSideEvent("increment", map[string]any{})
},
OnRemove: func(data ws.HandlerData, index int) {
//ws.BroadcastServerSideEvent("decrement", map[string]any{})
},
AddButton: h.Button(
h.Text("+ Add Item"),
),
RemoveButton: func(index int, children ...h.Ren) *h.Element {
return h.Button(
h.Text("Remove"),
h.Children(children...),
)
},
Item: func(index int) *h.Element {
return h.Input(
"text",
h.Class("border border-gray-300 rounded p-2"),
h.Value(fmt.Sprintf("item %d", index)),
)
},
}),
),
),
)
}

View file

@ -1,26 +0,0 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func RootPage(ctx *h.RequestContext, children ...h.Ren) h.Ren {
return h.Html(
h.JoinExtensions(
h.HxExtension(
h.BaseExtensions(),
),
h.HxExtension("ws"),
),
h.Head(
h.Link("/public/main.css", "stylesheet"),
h.Script("/public/htmgo.js"),
),
h.Body(
h.Div(
h.Class("flex flex-col gap-2 bg-white h-full"),
h.Fragment(children...),
),
),
)
}

View file

@ -1,129 +0,0 @@
package ws
import (
"fmt"
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/extensions/websocket/ws"
"github.com/maddalax/htmgo/framework/h"
"runtime"
"time"
"ws-example/pages"
)
func Metrics(ctx *h.RequestContext) *h.Page {
ws.RunOnConnected(ctx, func() {
ws.PushElementCtx(
ctx,
metricsView(ctx),
)
})
ws.Every(ctx, time.Second, func() bool {
return ws.PushElementCtx(
ctx,
metricsView(ctx),
)
})
return h.NewPage(
pages.RootPage(
ctx,
h.Div(
h.Attribute("ws-connect", fmt.Sprintf("/ws?sessionId=%s", session.GetSessionId(ctx))),
h.Class("flex flex-col gap-4 items-center min-h-screen max-w-2xl mx-auto mt-8"),
h.H3(
h.Id("intro-text"),
h.Text("Websocket Metrics"),
h.Class("text-2xl"),
),
h.Div(
h.Id("ws-metrics"),
),
),
),
)
}
func metricsView(ctx *h.RequestContext) *h.Element {
metrics := ws.MetricsFromCtx(ctx)
return h.Div(
h.Id("ws-metrics"),
List(metrics),
)
}
func List(metrics ws.Metrics) *h.Element {
return h.Body(
h.Div(
h.Class("flow-root rounded-lg border border-gray-100 py-3 shadow-sm"),
h.Dl(
h.Class("-my-3 divide-y divide-gray-100 text-sm"),
ListItem("Current Time", time.Now().Format("15:04:05")),
ListItem("Seconds Elapsed", fmt.Sprintf("%d", metrics.Manager.SecondsElapsed)),
ListItem("Total Messages", fmt.Sprintf("%d", metrics.Manager.TotalMessages)),
ListItem("Messages Per Second", fmt.Sprintf("%d", metrics.Manager.MessagesPerSecond)),
ListItem("Total Goroutines For ws.Every", fmt.Sprintf("%d", metrics.Manager.RunningGoroutines)),
ListItem("Total Goroutines In System", fmt.Sprintf("%d", runtime.NumGoroutine())),
ListItem("Sockets", fmt.Sprintf("%d", metrics.Manager.TotalSockets)),
ListItem("Rooms", fmt.Sprintf("%d", metrics.Manager.TotalRooms)),
ListItem("Session Id To Hashes", fmt.Sprintf("%d", metrics.Handler.SessionIdToHashesCount)),
ListItem("Total Handlers", fmt.Sprintf("%d", metrics.Handler.TotalHandlers)),
ListItem("Server Event Names To Hash", fmt.Sprintf("%d", metrics.Handler.ServerEventNamesToHashCount)),
ListItem("Total Listeners", fmt.Sprintf("%d", metrics.Manager.TotalListeners)),
h.IterMap(metrics.Manager.SocketsPerRoom, func(key string, value []string) *h.Element {
return ListBlock(
fmt.Sprintf("Sockets In Room - %s", key),
h.IfElse(
len(value) > 100,
h.Div(
h.Pf("%d total sockets", len(value)),
),
h.Div(
h.List(value, func(item string, index int) *h.Element {
return h.Div(
h.Pf("%s", item),
)
}),
),
),
)
}),
),
),
)
}
func ListItem(term, description string) *h.Element {
return h.Div(
h.Class("grid grid-cols-1 gap-1 p-3 even:bg-gray-50 sm:grid-cols-3 sm:gap-4"),
DescriptionTerm(term),
DescriptionDetail(description),
)
}
func ListBlock(title string, children *h.Element) *h.Element {
return h.Div(
h.Class("grid grid-cols-1 gap-1 p-3 even:bg-gray-50 sm:grid-cols-3 sm:gap-4"),
DescriptionTerm(title),
h.Dd(
h.Class("text-gray-700 sm:col-span-2"),
children,
),
)
}
func DescriptionTerm(term string) *h.Element {
return h.Dt(
h.Class("font-medium text-gray-900"),
h.Text(term),
)
}
func DescriptionDetail(detail string) *h.Element {
return h.Dd(
h.Class("text-gray-700 sm:col-span-2"),
h.Text(detail),
)
}

View file

@ -1,72 +0,0 @@
package partials
import (
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/extensions/websocket/ws"
"github.com/maddalax/htmgo/framework/h"
)
type Counter struct {
Count func() int
Increment func()
Decrement func()
}
func UseCounter(ctx *h.RequestContext, id string) Counter {
sessionId := session.GetSessionId(ctx)
get, set := session.UseState(sessionId, id, 0)
var increment = func() {
set(get() + 1)
}
var decrement = func() {
set(get() - 1)
}
return Counter{
Count: get,
Increment: increment,
Decrement: decrement,
}
}
type CounterProps struct {
Id string
}
func CounterForm(ctx *h.RequestContext, props CounterProps) *h.Element {
if props.Id == "" {
props.Id = h.GenId(6)
}
counter := UseCounter(ctx, props.Id)
return h.Div(
h.Attribute("hx-swap", "none"),
h.Class("flex flex-col gap-3 items-center"),
h.Id(props.Id),
h.P(
h.Id("counter-text-"+props.Id),
h.AttributePairs(
"id", "counter",
"class", "text-xl",
"name", "count",
"text", "count",
),
h.TextF("Count: %d", counter.Count()),
),
h.Button(
h.Class("bg-rose-400 hover:bg-rose-500 text-white font-bold py-2 px-4 rounded"),
h.Type("submit"),
h.Text("Increment"),
ws.OnServerEvent(ctx, "increment", func(data ws.HandlerData) {
counter.Increment()
ws.PushElement(data, CounterForm(ctx, props))
}),
ws.OnServerEvent(ctx, "decrement", func(data ws.HandlerData) {
counter.Decrement()
ws.PushElement(data, CounterForm(ctx, props))
}),
),
)
}

View file

@ -1,84 +0,0 @@
package partials
import (
"fmt"
"github.com/maddalax/htmgo/extensions/websocket/ws"
"github.com/maddalax/htmgo/framework/h"
)
type RepeaterProps struct {
Item func(index int) *h.Element
RemoveButton func(index int, children ...h.Ren) *h.Element
AddButton *h.Element
DefaultItems []*h.Element
Id string
currentIndex int
OnAdd func(data ws.HandlerData)
OnRemove func(data ws.HandlerData, index int)
}
func (props *RepeaterProps) itemId(index int) string {
return fmt.Sprintf("%s-repeater-item-%d", props.Id, index)
}
func (props *RepeaterProps) addButtonId() string {
return fmt.Sprintf("%s-repeater-add-button", props.Id)
}
func repeaterItem(ctx *h.RequestContext, item *h.Element, index int, props *RepeaterProps) *h.Element {
id := props.itemId(index)
return h.Div(
h.Class("flex gap-2 items-center"),
h.Id(id),
item,
props.RemoveButton(
index,
h.ClassIf(index == 0, "opacity-0 disabled"),
h.If(
index == 0,
h.Disabled(),
),
ws.OnClick(ctx, func(data ws.HandlerData) {
props.OnRemove(data, index)
props.currentIndex--
ws.PushElement(
data,
h.Div(
h.Attribute("hx-swap-oob", fmt.Sprintf("delete:#%s", id)),
h.Div(),
),
)
}),
),
)
}
func Repeater(ctx *h.RequestContext, props RepeaterProps) *h.Element {
if props.Id == "" {
props.Id = h.GenId(6)
}
return h.Div(
h.Class("flex flex-col gap-2"),
h.List(props.DefaultItems, func(item *h.Element, index int) *h.Element {
return repeaterItem(ctx, item, index, &props)
}),
h.Div(
h.Id(props.addButtonId()),
h.Class("flex justify-center"),
props.AddButton,
ws.OnClick(ctx, func(data ws.HandlerData) {
props.OnAdd(data)
ws.PushElement(
data,
h.Div(
h.Attribute("hx-swap-oob", "beforebegin:#"+props.addButtonId()),
repeaterItem(
ctx, props.Item(props.currentIndex), props.currentIndex, &props,
),
),
)
props.currentIndex++
}),
),
)
}

View file

@ -1,5 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["**/*.go"],
plugins: [],
};

View file

@ -1,21 +0,0 @@
module github.com/maddalax/htmgo/extensions/websocket
go 1.23.0
require (
github.com/gobwas/ws v1.4.0
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
github.com/puzpuzpuz/xsync/v3 v3.4.0
github.com/stretchr/testify v1.9.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,28 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,31 +0,0 @@
package websocket
import (
"github.com/maddalax/htmgo/extensions/websocket/internal/wsutil"
"github.com/maddalax/htmgo/extensions/websocket/opts"
"github.com/maddalax/htmgo/extensions/websocket/ws"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
)
func EnableExtension(app *h.App, opts opts.ExtensionOpts) {
if app.Opts.ServiceLocator == nil {
app.Opts.ServiceLocator = service.NewLocator()
}
if opts.WsPath == "" {
panic("websocket: WsPath is required")
}
if opts.SessionId == nil {
panic("websocket: SessionId func is required")
}
service.Set[wsutil.SocketManager](app.Opts.ServiceLocator, service.Singleton, func() *wsutil.SocketManager {
manager := wsutil.NewSocketManager(&opts)
manager.StartMetrics()
return manager
})
ws.StartListener(app.Opts.ServiceLocator)
app.Router.Handle(opts.WsPath, wsutil.WsHttpHandler(&opts))
}

View file

@ -1,115 +0,0 @@
package wsutil
import (
"encoding/json"
"fmt"
"github.com/gobwas/ws"
"github.com/gobwas/ws/wsutil"
ws2 "github.com/maddalax/htmgo/extensions/websocket/opts"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"log/slog"
"net/http"
"sync"
"time"
)
func WsHttpHandler(opts *ws2.ExtensionOpts) http.HandlerFunc {
if opts.RoomName == nil {
opts.RoomName = func(ctx *h.RequestContext) string {
return "all"
}
}
return func(w http.ResponseWriter, r *http.Request) {
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
locator := cc.ServiceLocator()
manager := service.Get[SocketManager](locator)
sessionId := opts.SessionId(cc)
if sessionId == "" {
w.WriteHeader(http.StatusUnauthorized)
return
}
conn, _, _, err := ws.UpgradeHTTP(r, w)
if err != nil {
slog.Info("failed to upgrade", slog.String("error", err.Error()))
return
}
roomId := opts.RoomName(cc)
/*
Large buffer in case the client disconnects while we are writing
we don't want to block the writer
*/
done := make(chan bool, 1000)
writer := make(WriterChan, 1000)
wg := sync.WaitGroup{}
manager.Add(roomId, sessionId, writer, done)
/*
* This goroutine is responsible for writing messages to the client
*/
wg.Add(1)
go func() {
defer manager.Disconnect(sessionId)
defer wg.Done()
defer func() {
for len(writer) > 0 {
<-writer
}
for len(done) > 0 {
<-done
}
}()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
fmt.Printf("closing connection: \n")
return
case <-ticker.C:
manager.Ping(sessionId)
case message := <-writer:
err = wsutil.WriteServerMessage(conn, ws.OpText, []byte(message))
if err != nil {
return
}
}
}
}()
/*
* This goroutine is responsible for reading messages from the client
*/
go func() {
defer conn.Close()
for {
msg, op, err := wsutil.ReadClientData(conn)
if err != nil {
return
}
if op != ws.OpText {
return
}
m := make(map[string]any)
err = json.Unmarshal(msg, &m)
if err != nil {
return
}
manager.OnMessage(sessionId, m)
}
}()
wg.Wait()
}
}

View file

@ -1,365 +0,0 @@
package wsutil
import (
"fmt"
"github.com/maddalax/htmgo/extensions/websocket/opts"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"github.com/puzpuzpuz/xsync/v3"
"log/slog"
"strings"
"sync"
"sync/atomic"
"time"
)
type EventType string
type WriterChan chan string
type DoneChan chan bool
const (
ConnectedEvent EventType = "connected"
DisconnectedEvent EventType = "disconnected"
MessageEvent EventType = "message"
)
type SocketEvent struct {
SessionId string
RoomId string
Type EventType
Payload map[string]any
}
type CloseEvent struct {
Code int
Reason string
}
type SocketConnection struct {
Id string
RoomId string
Done DoneChan
Writer WriterChan
}
type ManagerMetrics struct {
RunningGoroutines int32
TotalSockets int
TotalRooms int
TotalListeners int
SocketsPerRoomCount map[string]int
SocketsPerRoom map[string][]string
TotalMessages int64
MessagesPerSecond int
SecondsElapsed int
}
type SocketManager struct {
sockets *xsync.MapOf[string, *xsync.MapOf[string, SocketConnection]]
idToRoom *xsync.MapOf[string, string]
listeners []chan SocketEvent
goroutinesRunning atomic.Int32
opts *opts.ExtensionOpts
lock sync.Mutex
totalMessages atomic.Int64
messagesPerSecond int
secondsElapsed int
}
func (manager *SocketManager) StartMetrics() {
go func() {
for {
time.Sleep(time.Second)
manager.lock.Lock()
manager.secondsElapsed++
totalMessages := manager.totalMessages.Load()
manager.messagesPerSecond = int(float64(totalMessages) / float64(manager.secondsElapsed))
manager.lock.Unlock()
}
}()
}
func (manager *SocketManager) Metrics() ManagerMetrics {
manager.lock.Lock()
defer manager.lock.Unlock()
count := manager.goroutinesRunning.Load()
metrics := ManagerMetrics{
RunningGoroutines: count,
TotalSockets: 0,
TotalRooms: 0,
TotalListeners: len(manager.listeners),
SocketsPerRoom: make(map[string][]string),
SocketsPerRoomCount: make(map[string]int),
TotalMessages: manager.totalMessages.Load(),
MessagesPerSecond: manager.messagesPerSecond,
SecondsElapsed: manager.secondsElapsed,
}
roomMap := make(map[string]int)
manager.idToRoom.Range(func(socketId string, roomId string) bool {
roomMap[roomId]++
return true
})
metrics.TotalRooms = len(roomMap)
manager.sockets.Range(func(roomId string, sockets *xsync.MapOf[string, SocketConnection]) bool {
metrics.SocketsPerRoomCount[roomId] = sockets.Size()
sockets.Range(func(socketId string, conn SocketConnection) bool {
if metrics.SocketsPerRoom[roomId] == nil {
metrics.SocketsPerRoom[roomId] = []string{}
}
metrics.SocketsPerRoom[roomId] = append(metrics.SocketsPerRoom[roomId], socketId)
metrics.TotalSockets++
return true
})
return true
})
return metrics
}
func SocketManagerFromCtx(ctx *h.RequestContext) *SocketManager {
locator := ctx.ServiceLocator()
return service.Get[SocketManager](locator)
}
func NewSocketManager(opts *opts.ExtensionOpts) *SocketManager {
return &SocketManager{
sockets: xsync.NewMapOf[string, *xsync.MapOf[string, SocketConnection]](),
idToRoom: xsync.NewMapOf[string, string](),
opts: opts,
goroutinesRunning: atomic.Int32{},
}
}
func (manager *SocketManager) ForEachSocket(roomId string, cb func(conn SocketConnection)) {
sockets, ok := manager.sockets.Load(roomId)
if !ok {
return
}
sockets.Range(func(id string, conn SocketConnection) bool {
cb(conn)
return true
})
}
func (manager *SocketManager) RunIntervalWithSocket(socketId string, interval time.Duration, cb func() bool) {
socketIdSlog := slog.String("socketId", socketId)
slog.Debug("ws-extension: starting every loop", socketIdSlog, slog.Duration("duration", interval))
go func() {
manager.goroutinesRunning.Add(1)
defer manager.goroutinesRunning.Add(-1)
tries := 0
for {
socket := manager.Get(socketId)
// This can run before the socket is established, lets try a few times and kill it if socket isn't connected after a bit.
if socket == nil {
if tries > 200 {
slog.Debug("ws-extension: socket disconnected, killing goroutine", socketIdSlog)
return
} else {
time.Sleep(time.Millisecond * 15)
tries++
slog.Debug("ws-extension: socket not connected yet, trying again", socketIdSlog, slog.Int("attempt", tries))
continue
}
}
success := cb()
if !success {
return
}
time.Sleep(interval)
}
}()
}
func (manager *SocketManager) Listen(listener chan SocketEvent) {
if manager.listeners == nil {
manager.listeners = make([]chan SocketEvent, 0)
}
if listener != nil {
manager.listeners = append(manager.listeners, listener)
}
}
func (manager *SocketManager) dispatch(event SocketEvent) {
done := make(chan struct{}, 1)
go func() {
for {
select {
case <-done:
return
case <-time.After(5 * time.Second):
fmt.Printf("havent dispatched event after 5s, chan blocked: %s\n", event.Type)
}
}
}()
for _, listener := range manager.listeners {
listener <- event
}
done <- struct{}{}
}
func (manager *SocketManager) OnMessage(id string, message map[string]any) {
socket := manager.Get(id)
if socket == nil {
return
}
manager.totalMessages.Add(1)
manager.dispatch(SocketEvent{
SessionId: id,
Type: MessageEvent,
Payload: message,
RoomId: socket.RoomId,
})
}
func (manager *SocketManager) Add(roomId string, id string, writer WriterChan, done DoneChan) {
manager.idToRoom.Store(id, roomId)
sockets, ok := manager.sockets.LoadOrCompute(roomId, func() *xsync.MapOf[string, SocketConnection] {
return xsync.NewMapOf[string, SocketConnection]()
})
sockets.Store(id, SocketConnection{
Id: id,
Writer: writer,
RoomId: roomId,
Done: done,
})
s, ok := sockets.Load(id)
if !ok {
return
}
manager.dispatch(SocketEvent{
SessionId: s.Id,
Type: ConnectedEvent,
RoomId: s.RoomId,
Payload: map[string]any{},
})
}
func (manager *SocketManager) OnClose(id string) {
socket := manager.Get(id)
if socket == nil {
return
}
slog.Debug("ws-extension: removing socket from manager", slog.String("socketId", id))
manager.dispatch(SocketEvent{
SessionId: id,
Type: DisconnectedEvent,
RoomId: socket.RoomId,
Payload: map[string]any{},
})
roomId, ok := manager.idToRoom.Load(id)
if !ok {
return
}
sockets, ok := manager.sockets.Load(roomId)
if !ok {
return
}
sockets.Delete(id)
manager.idToRoom.Delete(id)
slog.Debug("ws-extension: removed socket from manager", slog.String("socketId", id))
}
func (manager *SocketManager) CloseWithMessage(id string, message string) {
conn := manager.Get(id)
if conn != nil {
defer manager.OnClose(id)
manager.writeText(*conn, message)
conn.Done <- true
}
}
func (manager *SocketManager) Disconnect(id string) {
conn := manager.Get(id)
if conn != nil {
manager.OnClose(id)
conn.Done <- true
}
}
func (manager *SocketManager) Get(id string) *SocketConnection {
roomId, ok := manager.idToRoom.Load(id)
if !ok {
return nil
}
sockets, ok := manager.sockets.Load(roomId)
if !ok {
return nil
}
conn, ok := sockets.Load(id)
return &conn
}
func (manager *SocketManager) Ping(id string) bool {
conn := manager.Get(id)
if conn != nil {
return manager.writeText(*conn, "ping")
}
return false
}
func (manager *SocketManager) writeCloseRaw(writer WriterChan, message string) {
manager.writeTextRaw(writer, message)
}
func (manager *SocketManager) writeTextRaw(writer WriterChan, message string) {
timeout := 3 * time.Second
select {
case writer <- message:
case <-time.After(timeout):
fmt.Printf("could not send %s to channel after %s\n", message, timeout)
}
}
func (manager *SocketManager) writeText(socket SocketConnection, message string) bool {
if socket.Writer == nil {
return false
}
manager.writeTextRaw(socket.Writer, message)
return true
}
func (manager *SocketManager) BroadcastText(roomId string, message string, predicate func(conn SocketConnection) bool) {
sockets, ok := manager.sockets.Load(roomId)
if !ok {
return
}
sockets.Range(func(id string, conn SocketConnection) bool {
if predicate(conn) {
manager.writeText(conn, message)
}
return true
})
}
func (manager *SocketManager) SendHtml(id string, message string) bool {
conn := manager.Get(id)
minified := strings.ReplaceAll(message, "\n", "")
minified = strings.ReplaceAll(minified, "\t", "")
minified = strings.TrimSpace(minified)
if conn != nil {
return manager.writeText(*conn, minified)
}
return false
}
func (manager *SocketManager) SendText(id string, message string) bool {
conn := manager.Get(id)
if conn != nil {
return manager.writeText(*conn, message)
}
return false
}

View file

@ -1,202 +0,0 @@
package wsutil
import (
ws2 "github.com/maddalax/htmgo/extensions/websocket/opts"
"github.com/maddalax/htmgo/framework/h"
"github.com/stretchr/testify/assert"
"testing"
)
func createManager() *SocketManager {
return NewSocketManager(&ws2.ExtensionOpts{
WsPath: "/ws",
SessionId: func(ctx *h.RequestContext) string {
return "test"
},
})
}
func addSocket(manager *SocketManager, roomId string, id string) (socketId string, writer WriterChan, done DoneChan) {
writer = make(chan string, 10)
done = make(chan bool, 10)
manager.Add(roomId, id, writer, done)
return id, writer, done
}
func TestManager(t *testing.T) {
manager := createManager()
socketId, _, _ := addSocket(manager, "123", "456")
socket := manager.Get(socketId)
assert.NotNil(t, socket)
assert.Equal(t, socketId, socket.Id)
manager.OnClose(socketId)
socket = manager.Get(socketId)
assert.Nil(t, socket)
}
func TestManagerForEachSocket(t *testing.T) {
manager := createManager()
addSocket(manager, "all", "456")
addSocket(manager, "all", "789")
var count int
manager.ForEachSocket("all", func(conn SocketConnection) {
count++
})
assert.Equal(t, 2, count)
}
func TestSendText(t *testing.T) {
manager := createManager()
socketId, writer, done := addSocket(manager, "all", "456")
manager.SendText(socketId, "hello")
assert.Equal(t, "hello", <-writer)
manager.SendText(socketId, "hello2")
assert.Equal(t, "hello2", <-writer)
done <- true
assert.Equal(t, true, <-done)
}
func TestBroadcastText(t *testing.T) {
manager := createManager()
_, w1, d1 := addSocket(manager, "all", "456")
_, w2, d2 := addSocket(manager, "all", "789")
manager.BroadcastText("all", "hello", func(conn SocketConnection) bool {
return true
})
assert.Equal(t, "hello", <-w1)
assert.Equal(t, "hello", <-w2)
d1 <- true
d2 <- true
assert.Equal(t, true, <-d1)
assert.Equal(t, true, <-d2)
}
func TestBroadcastTextWithPredicate(t *testing.T) {
manager := createManager()
_, w1, _ := addSocket(manager, "all", "456")
_, w2, _ := addSocket(manager, "all", "789")
manager.BroadcastText("all", "hello", func(conn SocketConnection) bool {
return conn.Id != "456"
})
assert.Equal(t, 0, len(w1))
assert.Equal(t, 1, len(w2))
}
func TestSendHtml(t *testing.T) {
manager := createManager()
socketId, writer, _ := addSocket(manager, "all", "456")
rendered := h.Render(
h.Div(
h.P(
h.Text("hello"),
),
))
manager.SendHtml(socketId, rendered)
assert.Equal(t, "<div><p>hello</p></div>", <-writer)
}
func TestOnMessage(t *testing.T) {
manager := createManager()
socketId, _, _ := addSocket(manager, "all", "456")
listener := make(chan SocketEvent, 10)
manager.Listen(listener)
manager.OnMessage(socketId, map[string]any{
"message": "hello",
})
event := <-listener
assert.Equal(t, "hello", event.Payload["message"])
assert.Equal(t, "456", event.SessionId)
assert.Equal(t, MessageEvent, event.Type)
assert.Equal(t, "all", event.RoomId)
}
func TestOnClose(t *testing.T) {
manager := createManager()
socketId, _, _ := addSocket(manager, "all", "456")
listener := make(chan SocketEvent, 10)
manager.Listen(listener)
manager.OnClose(socketId)
event := <-listener
assert.Equal(t, "456", event.SessionId)
assert.Equal(t, DisconnectedEvent, event.Type)
assert.Equal(t, "all", event.RoomId)
}
func TestOnAdd(t *testing.T) {
manager := createManager()
listener := make(chan SocketEvent, 10)
manager.Listen(listener)
socketId, _, _ := addSocket(manager, "all", "456")
event := <-listener
assert.Equal(t, socketId, event.SessionId)
assert.Equal(t, ConnectedEvent, event.Type)
assert.Equal(t, "all", event.RoomId)
}
func TestCloseWithMessage(t *testing.T) {
manager := createManager()
socketId, w, _ := addSocket(manager, "all", "456")
manager.CloseWithMessage(socketId, "internal error")
assert.Equal(t, "internal error", <-w)
assert.Nil(t, manager.Get(socketId))
}
func TestDisconnect(t *testing.T) {
manager := createManager()
socketId, _, _ := addSocket(manager, "all", "456")
manager.Disconnect(socketId)
assert.Nil(t, manager.Get(socketId))
}
func TestPing(t *testing.T) {
manager := createManager()
socketId, w, _ := addSocket(manager, "all", "456")
manager.Ping(socketId)
assert.Equal(t, "ping", <-w)
}
func TestMultipleRooms(t *testing.T) {
manager := createManager()
socketId1, _, _ := addSocket(manager, "room1", "456")
socketId2, _, _ := addSocket(manager, "room2", "789")
room1Count := 0
room2Count := 0
manager.ForEachSocket("room1", func(conn SocketConnection) {
room1Count++
})
manager.ForEachSocket("room2", func(conn SocketConnection) {
room2Count++
})
assert.Equal(t, 1, room1Count)
assert.Equal(t, 1, room2Count)
room1Count = 0
room2Count = 0
manager.OnClose(socketId1)
manager.OnClose(socketId2)
manager.ForEachSocket("room1", func(conn SocketConnection) {
room1Count++
})
manager.ForEachSocket("room2", func(conn SocketConnection) {
room2Count++
})
assert.Equal(t, 0, room1Count)
assert.Equal(t, 0, room2Count)
}

View file

@ -1,9 +0,0 @@
package opts
import "github.com/maddalax/htmgo/framework/h"
type ExtensionOpts struct {
WsPath string
RoomName func(ctx *h.RequestContext) string
SessionId func(ctx *h.RequestContext) string
}

View file

@ -1,77 +0,0 @@
package session
import (
"fmt"
"github.com/maddalax/htmgo/framework/h"
"github.com/puzpuzpuz/xsync/v3"
)
type Id string
var cache = xsync.NewMapOf[Id, *xsync.MapOf[string, any]]()
type State struct {
SessionId Id
}
func NewState(ctx *h.RequestContext) *State {
id := GetSessionId(ctx)
cache.Store(id, xsync.NewMapOf[string, any]())
return &State{
SessionId: id,
}
}
func CreateSession(ctx *h.RequestContext) Id {
sessionId := fmt.Sprintf("session-id-%s", h.GenId(30))
ctx.Set("session-id", sessionId)
return Id(sessionId)
}
func GetSessionId(ctx *h.RequestContext) Id {
sessionIdRaw := ctx.Get("session-id")
sessionId := ""
if sessionIdRaw == "" || sessionIdRaw == nil {
panic("session id is not set, please use session.CreateSession(ctx) in middleware to create a session id")
} else {
sessionId = sessionIdRaw.(string)
}
return Id(sessionId)
}
func Update[T any](sessionId Id, key string, compute func(prev T) T) T {
actual := Get[T](sessionId, key, *new(T))
next := compute(actual)
Set(sessionId, key, next)
return next
}
func Get[T any](sessionId Id, key string, fallback T) T {
actual, _ := cache.LoadOrCompute(sessionId, func() *xsync.MapOf[string, any] {
return xsync.NewMapOf[string, any]()
})
value, exists := actual.Load(key)
if exists {
return value.(T)
}
return fallback
}
func Set(sessionId Id, key string, value any) {
actual, _ := cache.LoadOrCompute(sessionId, func() *xsync.MapOf[string, any] {
return xsync.NewMapOf[string, any]()
})
actual.Store(key, value)
}
func UseState[T any](sessionId Id, key string, initial T) (func() T, func(T)) {
var get = func() T {
return Get[T](sessionId, key, initial)
}
var set = func(value T) {
Set(sessionId, key, value)
}
return get, set
}

View file

@ -1,10 +0,0 @@
package ws
import (
"github.com/maddalax/htmgo/extensions/websocket/internal/wsutil"
"github.com/maddalax/htmgo/framework/h"
)
func ManagerFromCtx(ctx *h.RequestContext) *wsutil.SocketManager {
return wsutil.SocketManagerFromCtx(ctx)
}

View file

@ -1,20 +0,0 @@
package ws
import "github.com/maddalax/htmgo/framework/h"
func OnClick(ctx *h.RequestContext, handler Handler) *h.AttributeMapOrdered {
return AddClientSideHandler(ctx, "click", handler)
}
func OnClientEvent(ctx *h.RequestContext, eventName string, handler Handler) *h.AttributeMapOrdered {
return AddClientSideHandler(ctx, eventName, handler)
}
func OnServerEvent(ctx *h.RequestContext, eventName string, handler Handler) h.Ren {
AddServerSideHandler(ctx, eventName, handler)
return h.Attribute("data-handler-id", "")
}
func OnMouseOver(ctx *h.RequestContext, handler Handler) *h.AttributeMapOrdered {
return AddClientSideHandler(ctx, "mouseover", handler)
}

View file

@ -1,47 +0,0 @@
package ws
import (
"github.com/maddalax/htmgo/extensions/websocket/internal/wsutil"
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
)
// PushServerSideEvent sends a server side event this specific session
func PushServerSideEvent(data HandlerData, event string, value map[string]any) {
serverSideMessageListener <- ServerSideEvent{
Event: event,
Payload: value,
SessionId: data.SessionId,
}
}
// BroadcastServerSideEvent sends a server side event to all clients that have a handler for the event, not just the current session
func BroadcastServerSideEvent(event string, value map[string]any) {
serverSideMessageListener <- ServerSideEvent{
Event: event,
Payload: value,
SessionId: "*",
}
}
// PushElement sends an element to the current session and swaps it into the page
func PushElement(data HandlerData, el *h.Element) bool {
return data.Manager.SendHtml(data.Socket.Id, h.Render(el))
}
// PushElementCtx sends an element to the current session and swaps it into the page
func PushElementCtx(ctx *h.RequestContext, el *h.Element) bool {
locator := ctx.ServiceLocator()
socketManager := service.Get[wsutil.SocketManager](locator)
socketId := session.GetSessionId(ctx)
socket := socketManager.Get(string(socketId))
if socket == nil {
return false
}
return PushElement(HandlerData{
Socket: socket,
Manager: socketManager,
SessionId: socketId,
}, el)
}

View file

@ -1,29 +0,0 @@
package ws
import (
"github.com/maddalax/htmgo/extensions/websocket/internal/wsutil"
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"time"
)
// Every executes the given callback every interval, until the socket is disconnected, or the callback returns false.
func Every(ctx *h.RequestContext, interval time.Duration, cb func() bool) {
socketId := session.GetSessionId(ctx)
locator := ctx.ServiceLocator()
manager := service.Get[wsutil.SocketManager](locator)
manager.RunIntervalWithSocket(string(socketId), interval, cb)
}
func Once(ctx *h.RequestContext, cb func()) {
// time is irrelevant, we just need to run the callback once, it will exit after because of the false return
Every(ctx, time.Millisecond, func() bool {
cb()
return false
})
}
func RunOnConnected(ctx *h.RequestContext, cb func()) {
Once(ctx, cb)
}

View file

@ -1,90 +0,0 @@
package ws
import (
"fmt"
"github.com/maddalax/htmgo/extensions/websocket/internal/wsutil"
"github.com/maddalax/htmgo/extensions/websocket/session"
"sync"
)
type MessageHandler struct {
manager *wsutil.SocketManager
}
func NewMessageHandler(manager *wsutil.SocketManager) *MessageHandler {
return &MessageHandler{manager: manager}
}
func (h *MessageHandler) OnServerSideEvent(e ServerSideEvent) {
fmt.Printf("received server side event: %s\n", e.Event)
hashes, ok := serverEventNamesToHash.Load(e.Event)
// If we are not broadcasting to everyone, filter it down to just the current session that invoked the event
// TODO optimize this
if e.SessionId != "*" {
hashesForSession, ok2 := sessionIdToHashes.Load(e.SessionId)
if ok2 {
subset := make(map[KeyHash]bool)
for hash := range hashes {
if _, ok := hashesForSession[hash]; ok {
subset[hash] = true
}
}
hashes = subset
}
}
if ok {
lock.Lock()
callingHandler.Store(true)
wg := sync.WaitGroup{}
for hash := range hashes {
cb, ok := handlers.Load(hash)
if ok {
wg.Add(1)
go func(e ServerSideEvent) {
defer wg.Done()
sessionId, ok2 := hashesToSessionId.Load(hash)
if ok2 {
cb(HandlerData{
SessionId: sessionId,
Socket: h.manager.Get(string(sessionId)),
Manager: h.manager,
})
}
}(e)
}
}
wg.Wait()
callingHandler.Store(false)
lock.Unlock()
}
}
func (h *MessageHandler) OnClientSideEvent(handlerId string, sessionId session.Id) {
cb, ok := handlers.Load(handlerId)
if ok {
cb(HandlerData{
SessionId: sessionId,
Socket: h.manager.Get(string(sessionId)),
Manager: h.manager,
})
}
}
func (h *MessageHandler) OnDomElementRemoved(handlerId string) {
handlers.Delete(handlerId)
}
func (h *MessageHandler) OnSocketDisconnected(event wsutil.SocketEvent) {
sessionId := session.Id(event.SessionId)
hashes, ok := sessionIdToHashes.Load(sessionId)
if ok {
for hash := range hashes {
hashesToSessionId.Delete(hash)
handlers.Delete(hash)
}
sessionIdToHashes.Delete(sessionId)
}
}

View file

@ -1,46 +0,0 @@
package ws
import (
"github.com/maddalax/htmgo/extensions/websocket/internal/wsutil"
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/framework/service"
)
func StartListener(locator *service.Locator) {
manager := service.Get[wsutil.SocketManager](locator)
manager.Listen(socketMessageListener)
handler := NewMessageHandler(manager)
go func() {
for {
handle(handler)
}
}()
}
func handle(handler *MessageHandler) {
select {
case event := <-serverSideMessageListener:
handler.OnServerSideEvent(event)
case event := <-socketMessageListener:
switch event.Type {
case wsutil.DisconnectedEvent:
handler.OnSocketDisconnected(event)
case wsutil.MessageEvent:
handlerId, ok := event.Payload["id"].(string)
eventName, ok2 := event.Payload["event"].(string)
if !ok || !ok2 {
return
}
sessionId := session.Id(event.SessionId)
if eventName == "dom-element-removed" {
handler.OnDomElementRemoved(handlerId)
return
} else {
handler.OnClientSideEvent(handlerId, sessionId)
}
}
}
}

View file

@ -1,19 +0,0 @@
package ws
import (
"github.com/maddalax/htmgo/extensions/websocket/internal/wsutil"
"github.com/maddalax/htmgo/framework/h"
)
type Metrics struct {
Manager wsutil.ManagerMetrics
Handler HandlerMetrics
}
func MetricsFromCtx(ctx *h.RequestContext) Metrics {
manager := ManagerFromCtx(ctx)
return Metrics{
Manager: manager.Metrics(),
Handler: GetHandlerMetics(),
}
}

View file

@ -1,92 +0,0 @@
package ws
import (
"github.com/maddalax/htmgo/extensions/websocket/internal/wsutil"
"github.com/maddalax/htmgo/extensions/websocket/session"
"github.com/maddalax/htmgo/framework/h"
"github.com/puzpuzpuz/xsync/v3"
"sync"
"sync/atomic"
)
type HandlerData struct {
SessionId session.Id
Socket *wsutil.SocketConnection
Manager *wsutil.SocketManager
}
type Handler func(data HandlerData)
type ServerSideEvent struct {
Event string
Payload map[string]any
SessionId session.Id
}
type KeyHash = string
var handlers = xsync.NewMapOf[KeyHash, Handler]()
var sessionIdToHashes = xsync.NewMapOf[session.Id, map[KeyHash]bool]()
var hashesToSessionId = xsync.NewMapOf[KeyHash, session.Id]()
var serverEventNamesToHash = xsync.NewMapOf[string, map[KeyHash]bool]()
var socketMessageListener = make(chan wsutil.SocketEvent, 100)
var serverSideMessageListener = make(chan ServerSideEvent, 100)
var lock = sync.Mutex{}
var callingHandler = atomic.Bool{}
type HandlerMetrics struct {
TotalHandlers int
ServerEventNamesToHashCount int
SessionIdToHashesCount int
}
func GetHandlerMetics() HandlerMetrics {
metrics := HandlerMetrics{
TotalHandlers: handlers.Size(),
ServerEventNamesToHashCount: serverEventNamesToHash.Size(),
SessionIdToHashesCount: sessionIdToHashes.Size(),
}
return metrics
}
func makeId() string {
return h.GenId(30)
}
func AddServerSideHandler(ctx *h.RequestContext, event string, handler Handler) *h.AttributeMapOrdered {
// If we are already in a handler, we don't want to add another handler
// this can happen if the handler renders another element that has a handler
if callingHandler.Load() {
return h.NewAttributeMap()
}
sessionId := session.GetSessionId(ctx)
hash := makeId()
handlers.LoadOrStore(hash, handler)
m, _ := serverEventNamesToHash.LoadOrCompute(event, func() map[KeyHash]bool {
return make(map[KeyHash]bool)
})
m[hash] = true
storeHashForSession(sessionId, hash)
storeSessionIdForHash(sessionId, hash)
return h.AttributePairs("data-handler-id", hash, "data-handler-event", event)
}
func AddClientSideHandler(ctx *h.RequestContext, event string, handler Handler) *h.AttributeMapOrdered {
hash := makeId()
handlers.LoadOrStore(hash, handler)
sessionId := session.GetSessionId(ctx)
storeHashForSession(sessionId, hash)
storeSessionIdForHash(sessionId, hash)
return h.AttributePairs("data-handler-id", hash, "data-handler-event", event)
}
func storeHashForSession(sessionId session.Id, hash KeyHash) {
m, _ := sessionIdToHashes.LoadOrCompute(sessionId, func() map[KeyHash]bool {
return make(map[KeyHash]bool)
})
m[hash] = true
}
func storeSessionIdForHash(sessionId session.Id, hash KeyHash) {
hashesToSessionId.Store(hash, sessionId)
}

View file

@ -2,7 +2,7 @@ module github.com/maddalax/htmgo/framework-ui
go 1.23.0
require github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
require github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63
require (
github.com/go-chi/chi/v5 v5.1.0 // indirect

View file

@ -4,8 +4,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b h1:m+xI+HBEQdie/Rs+mYI0HTFTMlYQSCv0l/siPDoywA4=
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63 h1:HV+1TUsoFnZoWXbvh9NvYyTt86tETKoGokXjMhA6IC0=
github.com/maddalax/htmgo/framework v1.0.3-0.20241031165923-032159149c63/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=

File diff suppressed because one or more lines are too long

View file

@ -7,8 +7,6 @@ import "./htmxextensions/mutation-error";
import "./htmxextensions/livereload"
import "./htmxextensions/htmgo";
import "./htmxextensions/sse"
import "./htmxextensions/ws"
import "./htmxextensions/ws-event-handler"
// @ts-ignore
window.htmx = htmx;
@ -46,6 +44,7 @@ function onUrlChange(newUrl: string) {
for (let [key, values] of url.searchParams) {
let eventName = "qs:" + key;
if (triggers.includes(eventName)) {
console.log("triggering", eventName);
htmx.trigger(element, eventName, null);
break;
}

View file

@ -1,13 +0,0 @@
export function hasExtension(name: string): boolean {
for (const element of Array.from(document.querySelectorAll("[hx-ext]"))) {
const value = element.getAttribute("hx-ext");
if(value != null) {
const split = value.split(" ").map(s => s.replace(",", ""))
if(split.includes(name)) {
return true;
}
}
}
return false;
}

View file

@ -1,18 +1,24 @@
import htmx from "htmx.org";
import {hasExtension} from "./extension";
let lastVersion = "";
htmx.defineExtension("livereload", {
init: function () {
let enabled = hasExtension("livereload")
let enabled = false
for (const element of Array.from(htmx.findAll("[hx-ext]"))) {
const value = element.getAttribute("hx-ext");
if(value?.split(" ").includes("livereload")) {
enabled = true
break;
}
}
if(!enabled) {
return
}
console.info('livereload extension initialized.');
console.log('livereload extension initialized.');
// Create a new EventSource object and point it to your SSE endpoint
const eventSource = new EventSource('/dev/livereload');
// Listen for messages from the server

View file

@ -1,73 +0,0 @@
import {ws} from "./ws";
import {hasExtension} from "./extension";
window.onload = function () {
if(hasExtension("ws")) {
addWsEventHandlers()
}
};
function sendWs(message: Record<string, any>) {
if(ws != null && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
function walk(node: Node, cb: (node: Node) => void) {
cb(node);
for (let child of Array.from(node.childNodes)) {
walk(child, cb);
}
}
export function addWsEventHandlers() {
const observer = new MutationObserver(register)
observer.observe(document.body, {childList: true, subtree: true})
let added = new Set<string>();
function register(mutations: MutationRecord[]) {
for (let mutation of mutations) {
for (let removedNode of Array.from(mutation.removedNodes)) {
walk(removedNode, (node) => {
if (node instanceof HTMLElement) {
const handlerId = node.getAttribute("data-handler-id")
if(handlerId) {
added.delete(handlerId)
sendWs({id: handlerId, event: 'dom-element-removed'})
}
}
})
}
}
let ids = new Set<string>();
document.querySelectorAll("[data-handler-id]").forEach(element => {
const id = element.getAttribute("data-handler-id");
const event = element.getAttribute("data-handler-event");
if(id == null || event == null) {
return;
}
ids.add(id);
if (added.has(id)) {
return;
}
added.add(id);
element.addEventListener(event, (e) => {
sendWs({id, event})
});
})
for (let id of added) {
if (!ids.has(id)) {
added.delete(id);
}
}
}
register([])
}

View file

@ -1,87 +0,0 @@
import htmx from 'htmx.org'
import {removeAssociatedScripts} from "./htmgo";
let api : any = null;
let processed = new Set<string>()
export let ws: WebSocket | null = null;
htmx.defineExtension("ws", {
init: function (apiRef) {
api = apiRef;
},
// @ts-ignore
onEvent: function (name, evt) {
const target = evt.target;
if(!(target instanceof HTMLElement)) {
return
}
if(name === 'htmx:beforeCleanupElement') {
removeAssociatedScripts(target);
}
if(name === 'htmx:beforeProcessNode') {
const elements = document.querySelectorAll('[ws-connect]');
for (let element of Array.from(elements)) {
const url = element.getAttribute("ws-connect")!;
if(url && !processed.has(url)) {
connectWs(element, url)
processed.add(url)
}
}
}
}
})
function exponentialBackoff(attempt: number, baseDelay = 100, maxDelay = 10000) {
// Exponential backoff: baseDelay * (2 ^ attempt) with jitter
const jitter = Math.random(); // Adding randomness to prevent collisions
return Math.min(baseDelay * Math.pow(2, attempt) * jitter, maxDelay);
}
function connectWs(ele: Element, url: string, attempt: number = 0) {
if(!url) {
return
}
if(!url.startsWith('ws://') && !url.startsWith('wss://')) {
const isSecure = window.location.protocol === 'https:'
url = (isSecure ? 'wss://' : 'ws://') + window.location.host + url
}
console.info('connecting to ws', url)
ws = new WebSocket(url);
ws.addEventListener("close", function(event) {
htmx.trigger(ele, "htmx:wsClose", {event: event});
const delay = exponentialBackoff(attempt);
setTimeout(() => {
connectWs(ele, url, attempt + 1)
}, delay)
})
ws.addEventListener("open", function(event) {
htmx.trigger(ele, "htmx:wsOpen", {event: event});
})
ws.addEventListener("error", function(event) {
htmx.trigger(ele, "htmx:wsError", {event: event});
})
ws.addEventListener("message", function(event) {
const settleInfo = api.makeSettleInfo(ele);
htmx.trigger(ele, "htmx:wsBeforeMessage", {event: event});
const response = event.data
const fragment = api.makeFragment(response) as DocumentFragment;
const children = Array.from(fragment.children);
for (let child of children) {
api.oobSwap(api.getAttributeValue(child, 'hx-swap-oob') || 'true', child, settleInfo);
// support htmgo eval__ scripts
if(child.tagName === 'SCRIPT' && child.id.startsWith("__eval")) {
document.body.appendChild(child);
}
}
htmx.trigger(ele, "htmx:wsAfterMessage", {event: event});
})
return ws
}

View file

@ -135,7 +135,6 @@ type AppOpts struct {
LiveReload bool
ServiceLocator *service.Locator
Register func(app *App)
Port int
}
type App struct {
@ -175,16 +174,6 @@ func (app *App) UseWithContext(h func(w http.ResponseWriter, r *http.Request, co
})
}
func (app *App) Use(h func(ctx *RequestContext)) {
app.Router.Use(func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cc := r.Context().Value(RequestContextKey).(*RequestContext)
h(cc)
handler.ServeHTTP(w, r)
})
})
}
func GetLogLevel() slog.Level {
// Get the log level from the environment variable
logLevel := os.Getenv("LOG_LEVEL")
@ -230,22 +219,6 @@ func (app *App) start() {
}
port := ":3000"
isDefaultPort := true
if os.Getenv("PORT") != "" {
port = fmt.Sprintf(":%s", os.Getenv("PORT"))
isDefaultPort = false
}
if app.Opts.Port != 0 {
port = fmt.Sprintf(":%d", app.Opts.Port)
isDefaultPort = false
}
if isDefaultPort {
slog.Info("Using default port 3000, set PORT environment variable to change it or use AppOpts.Port")
}
slog.Info(fmt.Sprintf("Server started at localhost%s", port))
if err := http.ListenAndServe(port, app.Router); err != nil {

View file

@ -4,7 +4,6 @@ import (
"fmt"
"github.com/maddalax/htmgo/framework/datastructure/orderedmap"
"github.com/maddalax/htmgo/framework/hx"
"github.com/maddalax/htmgo/framework/internal/util"
"strings"
)
@ -359,7 +358,3 @@ func AriaHidden(value bool) *AttributeR {
func TabIndex(value int) *AttributeR {
return Attribute("tabindex", fmt.Sprintf("%d", value))
}
func GenId(len int) string {
return util.RandSeq(len)
}

View file

@ -1,19 +1,21 @@
package h
import (
"flag"
"log/slog"
"sync"
"time"
"github.com/maddalax/htmgo/framework/h/cache"
)
// A single key to represent the cache entry for non-per-key components.
const _singleCacheKey = "__htmgo_single_cache_key__"
type CachedNode struct {
cb func() *Element
isByKey bool
duration time.Duration
cache cache.Store[any, string]
cb func() *Element
isByKey bool
byKeyCache map[any]*Entry
byKeyExpiration map[any]time.Time
mutex sync.Mutex
duration time.Duration
expiration time.Time
html string
}
type Entry struct {
@ -33,45 +35,33 @@ type GetElementFuncT2WithKey[K comparable, T any, T2 any] func(T, T2) (K, GetEle
type GetElementFuncT3WithKey[K comparable, T any, T2 any, T3 any] func(T, T2, T3) (K, GetElementFunc)
type GetElementFuncT4WithKey[K comparable, T any, T2 any, T3 any, T4 any] func(T, T2, T3, T4) (K, GetElementFunc)
// CacheOption defines a function that configures a CachedNode.
type CacheOption func(*CachedNode)
// WithCacheStore allows providing a custom cache implementation for a cached component.
func WithCacheStore(store cache.Store[any, string]) CacheOption {
return func(c *CachedNode) {
c.cache = store
}
}
// DefaultCacheProvider is a package-level function that creates a default cache instance.
// Initially, this uses a TTL-based map cache, but could be swapped for an LRU cache later.
// Advanced users can override this for the entire application.
var DefaultCacheProvider = func() cache.Store[any, string] {
return cache.NewTTLStore[any, string]()
func startExpiredCacheCleaner(node *CachedNode) {
isTests := flag.Lookup("test.v") != nil
go func() {
for {
if isTests {
time.Sleep(time.Second)
} else {
time.Sleep(time.Minute)
}
node.ClearExpired()
}
}()
}
// Cached caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
// Please note this element is globally cached, and not per unique identifier / user.
// Use CachedPerKey to cache elements per unique identifier.
func Cached(duration time.Duration, cb GetElementFunc, opts ...CacheOption) func() *Element {
node := &CachedNode{
cb: cb,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
// Use CachedPerKey to cache elements per unqiue identifier.
func Cached(duration time.Duration, cb GetElementFunc) func() *Element {
element := &Element{
tag: CachedNodeTag,
meta: node,
tag: CachedNodeTag,
meta: &CachedNode{
cb: cb,
html: "",
duration: duration,
},
}
startExpiredCacheCleaner(element.meta.(*CachedNode))
return func() *Element {
return element
}
@ -79,25 +69,17 @@ func Cached(duration time.Duration, cb GetElementFunc, opts ...CacheOption) func
// CachedPerKey caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
// The element is cached by the unique identifier that is returned by the callback function.
func CachedPerKey[K comparable](duration time.Duration, cb GetElementFuncWithKey[K], opts ...CacheOption) func() *Element {
node := &CachedNode{
isByKey: true,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
func CachedPerKey[K comparable](duration time.Duration, cb GetElementFuncWithKey[K]) func() *Element {
element := &Element{
tag: CachedNodeTag,
meta: node,
tag: CachedNodeTag,
meta: &CachedNode{
isByKey: true,
cb: nil,
html: "",
duration: duration,
},
}
startExpiredCacheCleaner(element.meta.(*CachedNode))
return func() *Element {
key, componentFunc := cb()
return &Element{
@ -119,25 +101,17 @@ type ByKeyEntry struct {
// CachedPerKeyT caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
// The element is cached by the unique identifier that is returned by the callback function.
func CachedPerKeyT[K comparable, T any](duration time.Duration, cb GetElementFuncTWithKey[K, T], opts ...CacheOption) func(T) *Element {
node := &CachedNode{
isByKey: true,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
func CachedPerKeyT[K comparable, T any](duration time.Duration, cb GetElementFuncTWithKey[K, T]) func(T) *Element {
element := &Element{
tag: CachedNodeTag,
meta: node,
tag: CachedNodeTag,
meta: &CachedNode{
isByKey: true,
cb: nil,
html: "",
duration: duration,
},
}
startExpiredCacheCleaner(element.meta.(*CachedNode))
return func(data T) *Element {
key, componentFunc := cb(data)
return &Element{
@ -153,25 +127,17 @@ func CachedPerKeyT[K comparable, T any](duration time.Duration, cb GetElementFun
// CachedPerKeyT2 caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
// The element is cached by the unique identifier that is returned by the callback function.
func CachedPerKeyT2[K comparable, T any, T2 any](duration time.Duration, cb GetElementFuncT2WithKey[K, T, T2], opts ...CacheOption) func(T, T2) *Element {
node := &CachedNode{
isByKey: true,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
func CachedPerKeyT2[K comparable, T any, T2 any](duration time.Duration, cb GetElementFuncT2WithKey[K, T, T2]) func(T, T2) *Element {
element := &Element{
tag: CachedNodeTag,
meta: node,
tag: CachedNodeTag,
meta: &CachedNode{
isByKey: true,
cb: nil,
html: "",
duration: duration,
},
}
startExpiredCacheCleaner(element.meta.(*CachedNode))
return func(data T, data2 T2) *Element {
key, componentFunc := cb(data, data2)
return &Element{
@ -187,25 +153,17 @@ func CachedPerKeyT2[K comparable, T any, T2 any](duration time.Duration, cb GetE
// CachedPerKeyT3 caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
// The element is cached by the unique identifier that is returned by the callback function.
func CachedPerKeyT3[K comparable, T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3WithKey[K, T, T2, T3], opts ...CacheOption) func(T, T2, T3) *Element {
node := &CachedNode{
isByKey: true,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
func CachedPerKeyT3[K comparable, T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3WithKey[K, T, T2, T3]) func(T, T2, T3) *Element {
element := &Element{
tag: CachedNodeTag,
meta: node,
tag: CachedNodeTag,
meta: &CachedNode{
isByKey: true,
cb: nil,
html: "",
duration: duration,
},
}
startExpiredCacheCleaner(element.meta.(*CachedNode))
return func(data T, data2 T2, data3 T3) *Element {
key, componentFunc := cb(data, data2, data3)
return &Element{
@ -221,25 +179,17 @@ func CachedPerKeyT3[K comparable, T any, T2 any, T3 any](duration time.Duration,
// CachedPerKeyT4 caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
// The element is cached by the unique identifier that is returned by the callback function.
func CachedPerKeyT4[K comparable, T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4WithKey[K, T, T2, T3, T4], opts ...CacheOption) func(T, T2, T3, T4) *Element {
node := &CachedNode{
isByKey: true,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
func CachedPerKeyT4[K comparable, T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4WithKey[K, T, T2, T3, T4]) func(T, T2, T3, T4) *Element {
element := &Element{
tag: CachedNodeTag,
meta: node,
tag: CachedNodeTag,
meta: &CachedNode{
isByKey: true,
cb: nil,
html: "",
duration: duration,
},
}
startExpiredCacheCleaner(element.meta.(*CachedNode))
return func(data T, data2 T2, data3 T3, data4 T4) *Element {
key, componentFunc := cb(data, data2, data3, data4)
return &Element{
@ -255,27 +205,19 @@ func CachedPerKeyT4[K comparable, T any, T2 any, T3 any, T4 any](duration time.D
// CachedT caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
// Please note this element is globally cached, and not per unique identifier / user.
// Use CachedPerKey to cache elements per unique identifier.
func CachedT[T any](duration time.Duration, cb GetElementFuncT[T], opts ...CacheOption) func(T) *Element {
node := &CachedNode{
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
// Use CachedPerKey to cache elements per unqiue identifier.
func CachedT[T any](duration time.Duration, cb GetElementFuncT[T]) func(T) *Element {
element := &Element{
tag: CachedNodeTag,
meta: node,
tag: CachedNodeTag,
meta: &CachedNode{
html: "",
duration: duration,
mutex: sync.Mutex{},
},
}
startExpiredCacheCleaner(element.meta.(*CachedNode))
return func(data T) *Element {
node.cb = func() *Element {
element.meta.(*CachedNode).cb = func() *Element {
return cb(data)
}
return element
@ -284,27 +226,18 @@ func CachedT[T any](duration time.Duration, cb GetElementFuncT[T], opts ...Cache
// CachedT2 caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
// Please note this element is globally cached, and not per unique identifier / user.
// Use CachedPerKey to cache elements per unique identifier.
func CachedT2[T any, T2 any](duration time.Duration, cb GetElementFuncT2[T, T2], opts ...CacheOption) func(T, T2) *Element {
node := &CachedNode{
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
// Use CachedPerKey to cache elements per unqiue identifier.
func CachedT2[T any, T2 any](duration time.Duration, cb GetElementFuncT2[T, T2]) func(T, T2) *Element {
element := &Element{
tag: CachedNodeTag,
meta: node,
tag: CachedNodeTag,
meta: &CachedNode{
html: "",
duration: duration,
},
}
startExpiredCacheCleaner(element.meta.(*CachedNode))
return func(data T, data2 T2) *Element {
node.cb = func() *Element {
element.meta.(*CachedNode).cb = func() *Element {
return cb(data, data2)
}
return element
@ -313,27 +246,18 @@ func CachedT2[T any, T2 any](duration time.Duration, cb GetElementFuncT2[T, T2],
// CachedT3 caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
// Please note this element is globally cached, and not per unique identifier / user.
// Use CachedPerKey to cache elements per unique identifier.
func CachedT3[T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3[T, T2, T3], opts ...CacheOption) func(T, T2, T3) *Element {
node := &CachedNode{
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
// Use CachedPerKey to cache elements per unqiue identifier.
func CachedT3[T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3[T, T2, T3]) func(T, T2, T3) *Element {
element := &Element{
tag: CachedNodeTag,
meta: node,
tag: CachedNodeTag,
meta: &CachedNode{
html: "",
duration: duration,
},
}
startExpiredCacheCleaner(element.meta.(*CachedNode))
return func(data T, data2 T2, data3 T3) *Element {
node.cb = func() *Element {
element.meta.(*CachedNode).cb = func() *Element {
return cb(data, data2, data3)
}
return element
@ -342,27 +266,18 @@ func CachedT3[T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3
// CachedT4 caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
// Please note this element is globally cached, and not per unique identifier / user.
// Use CachedPerKey to cache elements per unique identifier.
func CachedT4[T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4[T, T2, T3, T4], opts ...CacheOption) func(T, T2, T3, T4) *Element {
node := &CachedNode{
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
// Use CachedPerKey to cache elements per unqiue identifier.
func CachedT4[T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4[T, T2, T3, T4]) func(T, T2, T3, T4) *Element {
element := &Element{
tag: CachedNodeTag,
meta: node,
tag: CachedNodeTag,
meta: &CachedNode{
html: "",
duration: duration,
},
}
startExpiredCacheCleaner(element.meta.(*CachedNode))
return func(data T, data2 T2, data3 T3, data4 T4) *Element {
node.cb = func() *Element {
element.meta.(*CachedNode).cb = func() *Element {
return cb(data, data2, data3, data4)
}
return element
@ -371,24 +286,70 @@ func CachedT4[T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetEleme
// ClearCache clears the cached HTML of the element. This is called automatically by the framework.
func (c *CachedNode) ClearCache() {
c.cache.Purge()
c.html = ""
if c.byKeyCache != nil {
for key := range c.byKeyCache {
delete(c.byKeyCache, key)
}
}
if c.byKeyExpiration != nil {
for key := range c.byKeyExpiration {
delete(c.byKeyExpiration, key)
}
}
}
// ClearExpired is deprecated and does nothing. Cache expiration is now handled by the Store implementation.
// ClearExpired clears all expired cached HTML of the element. This is called automatically by the framework.
func (c *CachedNode) ClearExpired() {
// No-op for backward compatibility
c.mutex.Lock()
defer c.mutex.Unlock()
deletedCount := 0
if c.isByKey {
if c.byKeyCache != nil && c.byKeyExpiration != nil {
for key := range c.byKeyCache {
expir, ok := c.byKeyExpiration[key]
if ok && expir.Before(time.Now()) {
delete(c.byKeyCache, key)
delete(c.byKeyExpiration, key)
deletedCount++
}
}
}
} else {
now := time.Now()
expiration := c.expiration
if c.html != "" && expiration.Before(now) {
c.html = ""
deletedCount++
}
}
if deletedCount > 0 {
slog.Debug("Deleted expired cache entries", slog.Int("count", deletedCount))
}
}
func (c *CachedNode) Render(ctx *RenderContext) {
if c.isByKey {
panic("CachedPerKey should not be rendered directly")
} else {
// For simple cached components, we use a single key
// Use GetOrCompute for atomic check-and-set
html := c.cache.GetOrCompute(_singleCacheKey, func() string {
return Render(c.cb())
}, c.duration)
ctx.builder.WriteString(html)
c.mutex.Lock()
defer c.mutex.Unlock()
now := time.Now()
expiration := c.expiration
if expiration.IsZero() || expiration.Before(now) {
c.html = ""
c.expiration = now.Add(c.duration)
}
if c.html != "" {
ctx.builder.WriteString(c.html)
} else {
c.html = Render(c.cb())
ctx.builder.WriteString(c.html)
}
}
}
@ -396,9 +357,47 @@ func (c *ByKeyEntry) Render(ctx *RenderContext) {
key := c.key
parentMeta := c.parent.meta.(*CachedNode)
// Use GetOrCompute for atomic check-and-set
html := parentMeta.cache.GetOrCompute(key, func() string {
return Render(c.cb())
}, parentMeta.duration)
ctx.builder.WriteString(html)
parentMeta.mutex.Lock()
defer parentMeta.mutex.Unlock()
if parentMeta.byKeyCache == nil {
parentMeta.byKeyCache = make(map[any]*Entry)
}
if parentMeta.byKeyExpiration == nil {
parentMeta.byKeyExpiration = make(map[any]time.Time)
}
var setAndWrite = func() {
html := Render(c.cb())
parentMeta.byKeyCache[key] = &Entry{
expiration: parentMeta.expiration,
html: html,
}
ctx.builder.WriteString(html)
}
expEntry, ok := parentMeta.byKeyExpiration[key]
if !ok {
parentMeta.byKeyExpiration[key] = time.Now().Add(parentMeta.duration)
} else {
// key is expired
if expEntry.Before(time.Now()) {
parentMeta.byKeyExpiration[key] = time.Now().Add(parentMeta.duration)
setAndWrite()
return
}
}
entry := parentMeta.byKeyCache[key]
// not in cache
if entry == nil {
setAndWrite()
return
}
// exists in cache and not expired
ctx.builder.WriteString(entry.html)
}

View file

@ -1,292 +0,0 @@
# Pluggable Cache System for htmgo
## Overview
The htmgo framework now supports a pluggable cache system that allows developers to provide their own caching
implementations. This addresses potential memory exhaustion vulnerabilities in the previous TTL-only caching approach
and provides greater flexibility for production deployments.
## Motivation
The previous caching mechanism relied exclusively on Time-To-Live (TTL) expiration, which could lead to:
- **Unbounded memory growth**: High-cardinality cache keys could consume all available memory
- **DDoS vulnerability**: Attackers could exploit this by generating many unique cache keys
- **Limited flexibility**: No support for size-bounded caches or distributed caching solutions
## Architecture
The new system introduces a generic `Store[K comparable, V any]` interface:
```go
package main
import "time"
type Store[K comparable, V any] interface {
// Set adds or updates an entry in the cache with the given TTL
Set(key K, value V, ttl time.Duration)
// GetOrCompute atomically gets an existing value or computes and stores a new value
// This prevents duplicate computation when multiple goroutines request the same key
GetOrCompute(key K, compute func() V, ttl time.Duration) V
// Delete removes an entry from the cache
Delete(key K)
// Purge removes all items from the cache
Purge()
// Close releases any resources used by the cache
Close()
}
```
### Atomic Guarantees
The `GetOrCompute` method provides **atomic guarantees** to prevent cache stampedes and duplicate computations:
- When multiple goroutines request the same uncached key simultaneously, only one will execute the compute function
- Other goroutines will wait and receive the computed result
- This eliminates race conditions that could cause duplicate expensive operations like database queries or renders
## Usage
### Using the Default Cache
By default, htmgo continues to use a TTL-based cache for backward compatibility:
```go
// No changes needed - works exactly as before
UserProfile := h.CachedPerKeyT(
15*time.Minute,
func(userID int) (int, h.GetElementFunc) {
return userID, func() *h.Element {
return h.Div(h.Text("User profile"))
}
},
)
```
### Using a Custom Cache
You can provide your own cache implementation using the `WithCacheStore` option:
```go
package main
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/h/cache"
"time"
)
var (
// Create a memory-bounded LRU cache
lruCache = cache.NewLRUStore[any, string](10_000) // Max 10,000 items
// Use it with a cached component
UserProfile = h.CachedPerKeyT(
15*time.Minute,
func (userID int) (int, h.GetElementFunc) {
return userID, func () *h.Element {
return h.Div(h.Text("User profile"))
}
},
h.WithCacheStore(lruCache), // Pass the custom cache
)
)
```
### Changing the Default Cache Globally
You can override the default cache provider for your entire application:
```go
package main
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/h/cache"
)
func init() {
// All cached components will use LRU by default
h.DefaultCacheProvider = func () cache.Store[any, string] {
return cache.NewLRUStore[any, string](50_000)
}
}
```
## Example Implementations
### Built-in Stores
1. **TTLStore** (default): Time-based expiration with periodic cleanup
2. **LRUStore** (example): Least Recently Used eviction with size limits
### Integrating Third-Party Libraries
Here's an example of integrating the high-performance `go-freelru` library:
```go
import (
"time"
"github.com/elastic/go-freelru"
"github.com/maddalax/htmgo/framework/h/cache"
)
type FreeLRUAdapter[K comparable, V any] struct {
lru *freelru.LRU[K, V]
}
func NewFreeLRUAdapter[K comparable, V any](size uint32) cache.Store[K, V] {
lru, err := freelru.New[K, V](size, nil)
if err != nil {
panic(err)
}
return &FreeLRUAdapter[K, V]{lru: lru}
}
func (s *FreeLRUAdapter[K, V]) Set(key K, value V, ttl time.Duration) {
// Note: go-freelru doesn't support per-item TTL
s.lru.Add(key, value)
}
func (s *FreeLRUAdapter[K, V]) GetOrCompute(key K, compute func() V, ttl time.Duration) V {
// Check if exists in cache
if val, ok := s.lru.Get(key); ok {
return val
}
// Not in cache, compute and store
// Note: This simple implementation doesn't provide true atomic guarantees
// For production use, you'd need additional synchronization
value := compute()
s.lru.Add(key, value)
return value
}
func (s *FreeLRUAdapter[K, V]) Delete(key K) {
s.lru.Remove(key)
}
func (s *FreeLRUAdapter[K, V]) Purge() {
s.lru.Clear()
}
func (s *FreeLRUAdapter[K, V]) Close() {
// No-op for this implementation
}
```
### Redis-based Distributed Cache
```go
type RedisStore struct {
client *redis.Client
prefix string
}
func (s *RedisStore) Set(key any, value string, ttl time.Duration) {
keyStr := fmt.Sprintf("%s:%v", s.prefix, key)
s.client.Set(context.Background(), keyStr, value, ttl)
}
func (s *RedisStore) GetOrCompute(key any, compute func() string, ttl time.Duration) string {
keyStr := fmt.Sprintf("%s:%v", s.prefix, key)
ctx := context.Background()
// Try to get from Redis
val, err := s.client.Get(ctx, keyStr).Result()
if err == nil {
return val
}
// Not in cache, compute new value
// For true atomic guarantees, use Redis SET with NX option
value := compute()
s.client.Set(ctx, keyStr, value, ttl)
return value
}
// ... implement other methods
```
## Migration Guide
### For Existing Applications
The changes are backward compatible. Existing applications will continue to work without modifications. The function
signatures now accept optional `CacheOption` parameters, but these can be omitted.
### Recommended Migration Path
1. **Assess your caching needs**: Determine if you need memory bounds or distributed caching
2. **Choose an implementation**: Use the built-in LRUStore or integrate a third-party library
3. **Update critical components**: Start with high-traffic or high-cardinality cached components
4. **Monitor memory usage**: Ensure your cache size limits are appropriate
## Security Considerations
### Memory-Bounded Caches
For public-facing applications, we strongly recommend using a memory-bounded cache to prevent DoS attacks:
```go
// Limit cache to reasonable size based on your server's memory
cache := cache.NewLRUStore[any, string](100_000)
// Use for all user-specific caching
UserContent := h.CachedPerKey(
5*time.Minute,
getUserContent,
h.WithCacheStore(cache),
)
```
### Cache Key Validation
When using user input as cache keys, always validate and sanitize:
```go
func cacheKeyForUser(userInput string) string {
// Limit length and remove special characters
key := strings.TrimSpace(userInput)
if len(key) > 100 {
key = key[:100]
}
return regexp.MustCompile(`[^a-zA-Z0-9_-]`).ReplaceAllString(key, "")
}
```
## Performance Considerations
1. **TTLStore**: Best for small caches with predictable key patterns
2. **LRUStore**: Good general-purpose choice with memory bounds
3. **Third-party stores**: Consider `go-freelru` or `theine-go` for high-performance needs
4. **Distributed stores**: Use Redis/Memcached for multi-instance deployments
5. **Atomic Operations**: The `GetOrCompute` method prevents duplicate computations, significantly improving performance under high concurrency
### Concurrency Benefits
The atomic `GetOrCompute` method provides significant performance benefits:
- **Prevents Cache Stampedes**: When a popular cache entry expires, only one goroutine will recompute it
- **Reduces Load**: Expensive operations (database queries, API calls, complex renders) are never duplicated
- **Improves Response Times**: Waiting goroutines get results faster than computing themselves
## Best Practices
1. **Set appropriate cache sizes**: Balance memory usage with hit rates
2. **Use consistent TTLs**: Align with your data update patterns
3. **Monitor cache metrics**: Track hit rates, evictions, and memory usage
4. **Handle cache failures gracefully**: Caches should enhance, not break functionality
5. **Close caches properly**: Call `Close()` during graceful shutdown
6. **Implement atomic guarantees**: Ensure your `GetOrCompute` implementation prevents concurrent computation
7. **Test concurrent access**: Verify your cache handles simultaneous requests correctly
## Future Enhancements
- Built-in metrics and monitoring hooks
- Automatic size estimation for cached values
- Warming and preloading strategies
- Cache invalidation patterns

View file

@ -1,318 +0,0 @@
package cache_test
import (
"fmt"
"sync"
"time"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/h/cache"
)
// Example demonstrates basic caching with the default TTL store
func ExampleCached() {
renderCount := 0
// Create a cached component that expires after 5 minutes
CachedHeader := h.Cached(5*time.Minute, func() *h.Element {
renderCount++
return h.Header(
h.H1(h.Text("Welcome to our site")),
h.P(h.Text(fmt.Sprintf("Rendered %d times", renderCount))),
)
})
// First render - will execute the function
html1 := h.Render(CachedHeader())
fmt.Println("Render count:", renderCount)
// Second render - will use cached HTML
html2 := h.Render(CachedHeader())
fmt.Println("Render count:", renderCount)
fmt.Println("Same HTML:", html1 == html2)
// Output:
// Render count: 1
// Render count: 1
// Same HTML: true
}
// Example demonstrates per-key caching for user-specific content
func ExampleCachedPerKeyT() {
type User struct {
ID int
Name string
}
renderCounts := make(map[int]int)
// Create a per-user cached component
UserProfile := h.CachedPerKeyT(15*time.Minute, func(user User) (int, h.GetElementFunc) {
// Use user ID as the cache key
return user.ID, func() *h.Element {
renderCounts[user.ID]++
return h.Div(
h.Class("user-profile"),
h.H2(h.Text(user.Name)),
h.P(h.Text(fmt.Sprintf("User ID: %d", user.ID))),
)
}
})
alice := User{ID: 1, Name: "Alice"}
bob := User{ID: 2, Name: "Bob"}
// Render Alice's profile - will execute
h.Render(UserProfile(alice))
fmt.Printf("Alice render count: %d\n", renderCounts[1])
// Render Bob's profile - will execute
h.Render(UserProfile(bob))
fmt.Printf("Bob render count: %d\n", renderCounts[2])
// Render Alice's profile again - will use cache
h.Render(UserProfile(alice))
fmt.Printf("Alice render count after cache hit: %d\n", renderCounts[1])
// Output:
// Alice render count: 1
// Bob render count: 1
// Alice render count after cache hit: 1
}
// Example demonstrates using a memory-bounded LRU cache
func ExampleWithCacheStore_lru() {
// Create an LRU cache that holds maximum 1000 items
lruStore := cache.NewLRUStore[any, string](1000)
defer lruStore.Close()
renderCount := 0
// Use the LRU cache for a component
ProductCard := h.CachedPerKeyT(1*time.Hour,
func(productID int) (int, h.GetElementFunc) {
return productID, func() *h.Element {
renderCount++
// Simulate fetching product data
return h.Div(
h.H3(h.Text(fmt.Sprintf("Product #%d", productID))),
h.P(h.Text("$99.99")),
)
}
},
h.WithCacheStore(lruStore), // Use custom cache store
)
// Render many products
for i := 0; i < 1500; i++ {
h.Render(ProductCard(i))
}
// Due to LRU eviction, only 1000 items are cached
// Earlier items (0-499) were evicted
fmt.Printf("Total renders: %d\n", renderCount)
fmt.Printf("Expected renders: %d (due to LRU eviction)\n", 1500)
// Accessing an evicted item will cause a re-render
h.Render(ProductCard(0))
fmt.Printf("After accessing evicted item: %d\n", renderCount)
// Output:
// Total renders: 1500
// Expected renders: 1500 (due to LRU eviction)
// After accessing evicted item: 1501
}
// MockDistributedCache simulates a distributed cache like Redis
type MockDistributedCache struct {
data map[string]string
mutex sync.RWMutex
}
// DistributedCacheAdapter makes MockDistributedCache compatible with cache.Store interface
type DistributedCacheAdapter struct {
cache *MockDistributedCache
}
func (a *DistributedCacheAdapter) Set(key any, value string, ttl time.Duration) {
a.cache.mutex.Lock()
defer a.cache.mutex.Unlock()
// In a real implementation, you'd set TTL in Redis
keyStr := fmt.Sprintf("htmgo:%v", key)
a.cache.data[keyStr] = value
}
func (a *DistributedCacheAdapter) Delete(key any) {
a.cache.mutex.Lock()
defer a.cache.mutex.Unlock()
keyStr := fmt.Sprintf("htmgo:%v", key)
delete(a.cache.data, keyStr)
}
func (a *DistributedCacheAdapter) Purge() {
a.cache.mutex.Lock()
defer a.cache.mutex.Unlock()
a.cache.data = make(map[string]string)
}
func (a *DistributedCacheAdapter) Close() {
// Clean up connections in real implementation
}
func (a *DistributedCacheAdapter) GetOrCompute(key any, compute func() string, ttl time.Duration) string {
a.cache.mutex.Lock()
defer a.cache.mutex.Unlock()
keyStr := fmt.Sprintf("htmgo:%v", key)
// Check if exists
if val, ok := a.cache.data[keyStr]; ok {
return val
}
// Compute and store
value := compute()
a.cache.data[keyStr] = value
// In a real implementation, you'd also set TTL in Redis
return value
}
// Example demonstrates creating a custom cache adapter
func ExampleDistributedCacheAdapter() {
// Create the distributed cache
distCache := &MockDistributedCache{
data: make(map[string]string),
}
adapter := &DistributedCacheAdapter{cache: distCache}
// Use it with a cached component
SharedComponent := h.Cached(10*time.Minute, func() *h.Element {
return h.Div(h.Text("Shared across all servers"))
}, h.WithCacheStore(adapter))
html := h.Render(SharedComponent())
fmt.Printf("Cached in distributed store: %v\n", len(distCache.data) > 0)
fmt.Printf("HTML length: %d\n", len(html))
// Output:
// Cached in distributed store: true
// HTML length: 36
}
// Example demonstrates overriding the default cache provider globally
func ExampleDefaultCacheProvider() {
// Save the original provider to restore it later
originalProvider := h.DefaultCacheProvider
defer func() {
h.DefaultCacheProvider = originalProvider
}()
// Override the default to use LRU for all cached components
h.DefaultCacheProvider = func() cache.Store[any, string] {
// All cached components will use 10,000 item LRU cache by default
return cache.NewLRUStore[any, string](10_000)
}
// Now all cached components use LRU by default
renderCount := 0
AutoLRUComponent := h.Cached(1*time.Hour, func() *h.Element {
renderCount++
return h.Div(h.Text("Using LRU by default"))
})
h.Render(AutoLRUComponent())
fmt.Printf("Render count: %d\n", renderCount)
// Output:
// Render count: 1
}
// Example demonstrates caching with complex keys
func ExampleCachedPerKeyT3() {
type FilterOptions struct {
Category string
MinPrice float64
MaxPrice float64
}
renderCount := 0
// Cache filtered product lists with composite keys
FilteredProducts := h.CachedPerKeyT3(30*time.Minute,
func(category string, minPrice, maxPrice float64) (FilterOptions, h.GetElementFunc) {
// Create composite key from all parameters
key := FilterOptions{
Category: category,
MinPrice: minPrice,
MaxPrice: maxPrice,
}
return key, func() *h.Element {
renderCount++
// Simulate database query with filters
return h.Div(
h.H3(h.Text(fmt.Sprintf("Products in %s", category))),
h.P(h.Text(fmt.Sprintf("Price range: $%.2f - $%.2f", minPrice, maxPrice))),
h.Ul(
h.Li(h.Text("Product 1")),
h.Li(h.Text("Product 2")),
h.Li(h.Text("Product 3")),
),
)
}
},
)
// First query - will render
h.Render(FilteredProducts("Electronics", 100.0, 500.0))
fmt.Printf("Render count: %d\n", renderCount)
// Same query - will use cache
h.Render(FilteredProducts("Electronics", 100.0, 500.0))
fmt.Printf("Render count after cache hit: %d\n", renderCount)
// Different query - will render
h.Render(FilteredProducts("Electronics", 200.0, 600.0))
fmt.Printf("Render count after new query: %d\n", renderCount)
// Output:
// Render count: 1
// Render count after cache hit: 1
// Render count after new query: 2
}
// Example demonstrates cache expiration and refresh
func ExampleCached_expiration() {
renderCount := 0
now := time.Now()
// Cache with very short TTL for demonstration
TimeSensitive := h.Cached(100*time.Millisecond, func() *h.Element {
renderCount++
return h.Div(
h.Text(fmt.Sprintf("Generated at: %s (render #%d)",
now.Format("15:04:05"), renderCount)),
)
})
// First render
h.Render(TimeSensitive())
fmt.Printf("Render count: %d\n", renderCount)
// Immediate second render - uses cache
h.Render(TimeSensitive())
fmt.Printf("Render count (cached): %d\n", renderCount)
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Render after expiration - will re-execute
h.Render(TimeSensitive())
fmt.Printf("Render count (after expiration): %d\n", renderCount)
// Output:
// Render count: 1
// Render count (cached): 1
// Render count (after expiration): 2
}

View file

@ -1,186 +0,0 @@
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/h/cache"
)
// This example demonstrates the atomic guarantees of GetOrCompute,
// showing how it prevents duplicate expensive computations when
// multiple goroutines request the same uncached key simultaneously.
func main() {
fmt.Println("=== Atomic Cache Example ===")
// Demonstrate the problem without atomic guarantees
demonstrateProblem()
fmt.Println("\n=== Now with GetOrCompute atomic guarantees ===")
// Show the solution with GetOrCompute
demonstrateSolution()
}
// demonstrateProblem shows what happens without atomic guarantees
func demonstrateProblem() {
fmt.Println("Without atomic guarantees (simulated):")
fmt.Println("Multiple goroutines checking cache and computing...")
var computeCount int32
var wg sync.WaitGroup
// Simulate 10 goroutines trying to get the same uncached value
for i := range 10 {
wg.Add(1)
go func(id int) {
defer wg.Done()
// Simulate checking cache (not found)
time.Sleep(time.Millisecond) // Small delay to increase collision chance
// All goroutines think the value is not cached
// so they all compute it
atomic.AddInt32(&computeCount, 1)
fmt.Printf("Goroutine %d: Computing expensive value...\n", id)
// Simulate expensive computation
time.Sleep(50 * time.Millisecond)
}(i)
}
wg.Wait()
fmt.Printf("\nResult: Computed %d times (wasteful!)\n", computeCount)
}
// demonstrateSolution shows how GetOrCompute solves the problem
func demonstrateSolution() {
// Create a cache store
store := cache.NewTTLStore[string, string]()
defer store.Close()
var computeCount int32
var wg sync.WaitGroup
fmt.Println("With GetOrCompute atomic guarantees:")
fmt.Println("Multiple goroutines requesting the same key...")
startTime := time.Now()
// Launch 10 goroutines trying to get the same value
for i := range 10 {
wg.Add(1)
go func(id int) {
defer wg.Done()
// All goroutines call GetOrCompute at the same time
result := store.GetOrCompute("expensive-key", func() string {
// Only ONE goroutine will execute this function
count := atomic.AddInt32(&computeCount, 1)
fmt.Printf("Goroutine %d: Computing expensive value (computation #%d)\n", id, count)
// Simulate expensive computation
time.Sleep(50 * time.Millisecond)
return fmt.Sprintf("Expensive result computed by goroutine %d", id)
}, 1*time.Hour)
fmt.Printf("Goroutine %d: Got result: %s\n", id, result)
}(i)
}
wg.Wait()
elapsed := time.Since(startTime)
fmt.Printf("\nResult: Computed only %d time (efficient!)\n", computeCount)
fmt.Printf("Total time: %v (vs ~500ms if all computed)\n", elapsed)
}
// Example with htmgo cached components
func ExampleCachedComponent() {
fmt.Println("\n=== Real-world htmgo Example ===")
var renderCount int32
// Create a cached component that simulates fetching user data
UserProfile := h.CachedPerKeyT(5*time.Minute, func(userID int) (int, h.GetElementFunc) {
return userID, func() *h.Element {
count := atomic.AddInt32(&renderCount, 1)
fmt.Printf("Fetching and rendering user %d (render #%d)\n", userID, count)
// Simulate database query
time.Sleep(100 * time.Millisecond)
return h.Div(
h.H2(h.Text(fmt.Sprintf("User Profile #%d", userID))),
h.P(h.Text("This was expensive to compute!")),
)
}
})
// Simulate multiple concurrent requests for the same user
var wg sync.WaitGroup
for i := range 5 {
wg.Add(1)
go func(requestID int) {
defer wg.Done()
// All requests are for user 123
html := h.Render(UserProfile(123))
fmt.Printf("Request %d: Received %d bytes of HTML\n", requestID, len(html))
}(i)
}
wg.Wait()
fmt.Printf("\nTotal renders: %d (only one, despite 5 concurrent requests!)\n", renderCount)
}
// Example showing cache stampede prevention
func ExampleCacheStampedePrevention() {
fmt.Println("\n=== Cache Stampede Prevention ===")
store := cache.NewLRUStore[string, string](100)
defer store.Close()
var dbQueries int32
// Simulate a popular cache key expiring
fetchPopularData := func(key string) string {
return store.GetOrCompute(key, func() string {
queries := atomic.AddInt32(&dbQueries, 1)
fmt.Printf("Database query #%d for key: %s\n", queries, key)
// Simulate slow database query
time.Sleep(200 * time.Millisecond)
return fmt.Sprintf("Popular data for %s", key)
}, 100*time.Millisecond) // Short TTL to simulate expiration
}
// First, populate the cache
_ = fetchPopularData("trending-posts")
fmt.Println("Cache populated")
// Wait for it to expire
time.Sleep(150 * time.Millisecond)
fmt.Println("\nCache expired, simulating traffic spike...")
// Simulate 20 concurrent requests right after expiration
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
data := fetchPopularData("trending-posts")
fmt.Printf("Request %d: Got data: %s\n", id, data)
}(i)
}
wg.Wait()
fmt.Printf("\nTotal database queries: %d (prevented 19 redundant queries!)\n", dbQueries)
}

View file

@ -1,28 +0,0 @@
package cache
import (
"time"
)
// Store defines the interface for a pluggable cache.
// This allows users to provide their own caching implementations, such as LRU, LFU,
// or even distributed caches. The cache implementation is responsible for handling
// its own eviction policies (TTL, size limits, etc.).
type Store[K comparable, V any] interface {
// Set adds or updates an entry in the cache. The implementation should handle the TTL.
Set(key K, value V, ttl time.Duration)
// GetOrCompute atomically gets an existing value or computes and stores a new value.
// This method prevents duplicate computation when multiple goroutines request the same key.
// The compute function is called only if the key is not found or has expired.
GetOrCompute(key K, compute func() V, ttl time.Duration) V
// Delete removes an entry from the cache.
Delete(key K)
// Purge removes all items from the cache.
Purge()
// Close releases any resources used by the cache, such as background goroutines.
Close()
}

View file

@ -1,200 +0,0 @@
package cache
import (
"container/list"
"sync"
"time"
)
// LRUStore is an example of a memory-bounded cache implementation using
// the Least Recently Used (LRU) eviction policy. This demonstrates how
// to create a custom cache store that prevents unbounded memory growth.
//
// This is a simple example implementation. For production use, consider
// using optimized libraries like github.com/elastic/go-freelru or
// github.com/Yiling-J/theine-go.
type LRUStore[K comparable, V any] struct {
maxSize int
cache map[K]*list.Element
lru *list.List
mutex sync.RWMutex
closeChan chan struct{}
closeOnce sync.Once
}
type lruEntry[K comparable, V any] struct {
key K
value V
expiration time.Time
}
// NewLRUStore creates a new LRU cache with the specified maximum size.
// When the cache reaches maxSize, the least recently used items are evicted.
func NewLRUStore[K comparable, V any](maxSize int) Store[K, V] {
if maxSize <= 0 {
panic("LRUStore maxSize must be positive")
}
s := &LRUStore[K, V]{
maxSize: maxSize,
cache: make(map[K]*list.Element),
lru: list.New(),
closeChan: make(chan struct{}),
}
// Start a goroutine to periodically clean up expired entries
go s.cleanupExpired()
return s
}
// Set adds or updates an entry in the cache with the given TTL.
// If the cache is at capacity, the least recently used item is evicted.
func (s *LRUStore[K, V]) Set(key K, value V, ttl time.Duration) {
s.mutex.Lock()
defer s.mutex.Unlock()
expiration := time.Now().Add(ttl)
// Check if key already exists
if elem, exists := s.cache[key]; exists {
// Update existing entry and move to front
entry := elem.Value.(*lruEntry[K, V])
entry.value = value
entry.expiration = expiration
s.lru.MoveToFront(elem)
return
}
// Add new entry
entry := &lruEntry[K, V]{
key: key,
value: value,
expiration: expiration,
}
elem := s.lru.PushFront(entry)
s.cache[key] = elem
// Evict oldest if over capacity
if s.lru.Len() > s.maxSize {
oldest := s.lru.Back()
if oldest != nil {
s.removeElement(oldest)
}
}
}
// GetOrCompute atomically gets an existing value or computes and stores a new value.
func (s *LRUStore[K, V]) GetOrCompute(key K, compute func() V, ttl time.Duration) V {
s.mutex.Lock()
defer s.mutex.Unlock()
// Check if key already exists
if elem, exists := s.cache[key]; exists {
entry := elem.Value.(*lruEntry[K, V])
// Check if expired
if time.Now().Before(entry.expiration) {
// Move to front (mark as recently used)
s.lru.MoveToFront(elem)
return entry.value
}
// Expired, remove it
s.removeElement(elem)
}
// Compute the value while holding the lock
value := compute()
expiration := time.Now().Add(ttl)
// Add new entry
entry := &lruEntry[K, V]{
key: key,
value: value,
expiration: expiration,
}
elem := s.lru.PushFront(entry)
s.cache[key] = elem
// Evict oldest if over capacity
if s.lru.Len() > s.maxSize {
oldest := s.lru.Back()
if oldest != nil {
s.removeElement(oldest)
}
}
return value
}
// Delete removes an entry from the cache.
func (s *LRUStore[K, V]) Delete(key K) {
s.mutex.Lock()
defer s.mutex.Unlock()
if elem, exists := s.cache[key]; exists {
s.removeElement(elem)
}
}
// Purge removes all items from the cache.
func (s *LRUStore[K, V]) Purge() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.cache = make(map[K]*list.Element)
s.lru.Init()
}
// Close stops the background cleanup goroutine.
func (s *LRUStore[K, V]) Close() {
s.closeOnce.Do(func() {
close(s.closeChan)
})
}
// removeElement removes an element from both the map and the list.
// Must be called with the mutex held.
func (s *LRUStore[K, V]) removeElement(elem *list.Element) {
entry := elem.Value.(*lruEntry[K, V])
delete(s.cache, entry.key)
s.lru.Remove(elem)
}
// cleanupExpired periodically removes expired entries.
func (s *LRUStore[K, V]) cleanupExpired() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.removeExpired()
case <-s.closeChan:
return
}
}
}
// removeExpired scans the cache and removes expired entries.
func (s *LRUStore[K, V]) removeExpired() {
s.mutex.Lock()
defer s.mutex.Unlock()
now := time.Now()
// Create a slice to hold elements to remove to avoid modifying list during iteration
var toRemove []*list.Element
for elem := s.lru.Back(); elem != nil; elem = elem.Prev() {
entry := elem.Value.(*lruEntry[K, V])
if now.After(entry.expiration) {
toRemove = append(toRemove, elem)
}
}
// Remove expired elements
for _, elem := range toRemove {
s.removeElement(elem)
}
}

View file

@ -1,676 +0,0 @@
package cache
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestLRUStore_SetAndGet(t *testing.T) {
store := NewLRUStore[string, string](10)
defer store.Close()
// Test basic set and get
store.Set("key1", "value1", 1*time.Hour)
val := store.GetOrCompute("key1", func() string {
t.Error("Should not compute for existing key")
return "should-not-compute"
}, 1*time.Hour)
if val != "value1" {
t.Errorf("Expected value1, got %s", val)
}
// Test getting non-existent key
computeCalled := false
val = store.GetOrCompute("nonexistent", func() string {
computeCalled = true
return "computed-value"
}, 1*time.Hour)
if !computeCalled {
t.Error("Expected compute function to be called for non-existent key")
}
if val != "computed-value" {
t.Errorf("Expected computed-value for non-existent key, got %s", val)
}
}
// TestLRUStore_SizeLimit tests are commented out because they rely on
// being able to check cache contents without modifying LRU order,
// which is not possible with GetOrCompute-only interface
/*
func TestLRUStore_SizeLimit(t *testing.T) {
// Create store with capacity of 3
store := NewLRUStore[int, string](3)
defer store.Close()
// Add 3 items
store.Set(1, "one", 1*time.Hour)
store.Set(2, "two", 1*time.Hour)
store.Set(3, "three", 1*time.Hour)
// Add fourth item, should evict least recently used (key 1)
store.Set(4, "four", 1*time.Hour)
// Key 1 should be evicted
computeCalled := false
val := store.GetOrCompute(1, func() string {
computeCalled = true
return "recomputed-one"
}, 1*time.Hour)
if !computeCalled {
t.Error("Expected key 1 to be evicted and recomputed")
}
if val != "recomputed-one" {
t.Errorf("Expected recomputed value for key 1, got %s", val)
}
// At this point, cache has keys: 1 (just added), 2, 3, 4
// But capacity is 3, so one of the original keys was evicted
// Let's just verify we have exactly 3 items and key 1 is now present
count := 0
for i := 1; i <= 4; i++ {
localI := i
computed := false
store.GetOrCompute(localI, func() string {
computed = true
return fmt.Sprintf("recomputed-%d", localI)
}, 1*time.Hour)
if !computed {
count++
}
}
// We should have found 3 items in cache (since capacity is 3)
// The 4th check would have caused another eviction and recomputation
if count != 3 {
t.Errorf("Expected exactly 3 items in cache, found %d", count)
}
}
*/
func TestLRUStore_LRUBehavior(t *testing.T) {
store := NewLRUStore[string, string](3)
defer store.Close()
// Add items in order: c (MRU), b, a (LRU)
store.Set("a", "A", 1*time.Hour)
store.Set("b", "B", 1*time.Hour)
store.Set("c", "C", 1*time.Hour)
// Access "a" to make it recently used
// Now order is: a (MRU), c, b (LRU)
val := store.GetOrCompute("a", func() string {
t.Error("Should not compute for existing key")
return "should-not-compute"
}, 1*time.Hour)
if val != "A" {
t.Errorf("Expected 'A', got %s", val)
}
// Add "d", should evict "b" (least recently used)
// Now we have: d (MRU), a, c
store.Set("d", "D", 1*time.Hour)
// Verify "b" was evicted
computeCalled := false
val = store.GetOrCompute("b", func() string {
computeCalled = true
return "recomputed-b"
}, 1*time.Hour)
if !computeCalled {
t.Error("Expected 'b' to be evicted")
}
// Now cache has: b (MRU), d, a
// and "c" should have been evicted when we added "b" back
// Verify the current state matches expectations
// We'll collect all values without modifying order too much
presentKeys := make(map[string]bool)
for _, key := range []string{"a", "b", "c", "d"} {
localKey := key
computed := false
store.GetOrCompute(localKey, func() string {
computed = true
return "recomputed"
}, 1*time.Hour)
if !computed {
presentKeys[localKey] = true
}
}
// We should have exactly 3 keys in cache
if len(presentKeys) > 3 {
t.Errorf("Cache has more than 3 items: %v", presentKeys)
}
}
func TestLRUStore_UpdateMovesToFront(t *testing.T) {
store := NewLRUStore[string, string](3)
defer store.Close()
// Fill cache
store.Set("a", "A", 1*time.Hour)
store.Set("b", "B", 1*time.Hour)
store.Set("c", "C", 1*time.Hour)
// Update "a" with new value - should move to front
store.Set("a", "A_updated", 1*time.Hour)
// Add new item - should evict "b" not "a"
store.Set("d", "D", 1*time.Hour)
val := store.GetOrCompute("a", func() string {
t.Error("Should not compute for existing key 'a'")
return "should-not-compute"
}, 1*time.Hour)
if val != "A_updated" {
t.Errorf("Expected updated value, got %s", val)
}
computeCalled := false
store.GetOrCompute("b", func() string {
computeCalled = true
return "recomputed-b"
}, 1*time.Hour)
if !computeCalled {
t.Error("Expected 'b' to be evicted and recomputed")
}
}
func TestLRUStore_Expiration(t *testing.T) {
store := NewLRUStore[string, string](10)
defer store.Close()
// Set with short TTL
store.Set("shortlived", "value", 100*time.Millisecond)
// Should exist immediately
val := store.GetOrCompute("shortlived", func() string {
t.Error("Should not compute for existing key")
return "should-not-compute"
}, 100*time.Millisecond)
if val != "value" {
t.Errorf("Expected value, got %s", val)
}
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should be expired now
computeCalled := false
val = store.GetOrCompute("shortlived", func() string {
computeCalled = true
return "recomputed-after-expiry"
}, 100*time.Millisecond)
if !computeCalled {
t.Error("Expected compute function to be called for expired key")
}
if val != "recomputed-after-expiry" {
t.Errorf("Expected recomputed value for expired key, got %s", val)
}
}
func TestLRUStore_Delete(t *testing.T) {
store := NewLRUStore[string, string](10)
defer store.Close()
store.Set("key1", "value1", 1*time.Hour)
// Verify it exists
val := store.GetOrCompute("key1", func() string {
t.Error("Should not compute for existing key")
return "should-not-compute"
}, 1*time.Hour)
if val != "value1" {
t.Errorf("Expected value1, got %s", val)
}
// Delete it
store.Delete("key1")
// Verify it's gone
computeCalled := false
val = store.GetOrCompute("key1", func() string {
computeCalled = true
return "recomputed-after-delete"
}, 1*time.Hour)
if !computeCalled {
t.Error("Expected compute function to be called after deletion")
}
if val != "recomputed-after-delete" {
t.Errorf("Expected recomputed value after deletion, got %s", val)
}
// Delete non-existent key should not panic
store.Delete("nonexistent")
}
func TestLRUStore_Purge(t *testing.T) {
store := NewLRUStore[string, string](10)
defer store.Close()
// Add multiple items
store.Set("key1", "value1", 1*time.Hour)
store.Set("key2", "value2", 1*time.Hour)
store.Set("key3", "value3", 1*time.Hour)
// Verify they exist
for i := 1; i <= 3; i++ {
key := "key" + string(rune('0'+i))
val := store.GetOrCompute(key, func() string {
t.Errorf("Should not compute for existing key %s", key)
return "should-not-compute"
}, 1*time.Hour)
expectedVal := "value" + string(rune('0'+i))
if val != expectedVal {
t.Errorf("Expected to find %s with value %s, got %s", key, expectedVal, val)
}
}
// Purge all
store.Purge()
// Verify all are gone
for i := 1; i <= 3; i++ {
key := "key" + string(rune('0'+i))
computeCalled := false
store.GetOrCompute(key, func() string {
computeCalled = true
return "recomputed-after-purge"
}, 1*time.Hour)
if !computeCalled {
t.Errorf("Expected %s to be purged and recomputed", key)
}
}
}
func TestLRUStore_ConcurrentAccess(t *testing.T) {
// Need capacity for all unique keys: 100 goroutines * 100 operations = 10,000
store := NewLRUStore[int, int](10000)
defer store.Close()
const numGoroutines = 100
const numOperations = 100
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Concurrent writes and reads
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := (id * numOperations) + j
store.Set(key, key*2, 1*time.Hour)
// Immediately read it back
val := store.GetOrCompute(key, func() int {
t.Errorf("Goroutine %d: Should not compute for just-set key %d", id, key)
return -1
}, 1*time.Hour)
if val != key*2 {
t.Errorf("Goroutine %d: Expected value %d, got %d", id, key*2, val)
}
}
}(i)
}
wg.Wait()
}
func TestLRUStore_ExpiredEntriesCleanup(t *testing.T) {
store := NewLRUStore[string, string](100)
defer store.Close()
// Add many short-lived entries
for i := 0; i < 50; i++ {
key := "key" + string(rune('0'+i))
store.Set(key, "value", 100*time.Millisecond)
}
// Add some long-lived entries
for i := 50; i < 60; i++ {
key := "key" + string(rune('0'+i))
store.Set(key, "value", 1*time.Hour)
}
// Wait for short-lived entries to expire and cleanup to run
time.Sleep(1200 * time.Millisecond)
// Check that expired entries are gone
for i := 0; i < 50; i++ {
key := "key" + string(rune('0'+i))
computeCalled := false
store.GetOrCompute(key, func() string {
computeCalled = true
return "recomputed-after-expiry"
}, 100*time.Millisecond)
if !computeCalled {
t.Errorf("Expected expired key %s to be cleaned up and recomputed", key)
}
}
// Long-lived entries should still exist
for i := 50; i < 60; i++ {
key := "key" + string(rune('0'+i))
val := store.GetOrCompute(key, func() string {
t.Errorf("Should not compute for long-lived key %s", key)
return "should-not-compute"
}, 1*time.Hour)
if val != "value" {
t.Errorf("Expected long-lived key %s to still exist with value 'value', got %s", key, val)
}
}
}
func TestLRUStore_InvalidSize(t *testing.T) {
// Test that creating store with invalid size panics
defer func() {
if r := recover(); r == nil {
t.Error("Expected panic for zero size")
}
}()
NewLRUStore[string, string](0)
}
func TestLRUStore_Close(t *testing.T) {
store := NewLRUStore[string, string](10)
// Close should not panic
store.Close()
// Multiple closes should not panic
store.Close()
store.Close()
}
// TestLRUStore_ComplexEvictionScenario is commented out because
// checking cache state with GetOrCompute modifies the LRU order
/*
func TestLRUStore_ComplexEvictionScenario(t *testing.T) {
store := NewLRUStore[string, string](4)
defer store.Close()
// Fill cache: d (MRU), c, b, a (LRU)
store.Set("a", "A", 1*time.Hour)
store.Set("b", "B", 1*time.Hour)
store.Set("c", "C", 1*time.Hour)
store.Set("d", "D", 1*time.Hour)
// Access in specific order to control LRU order
store.GetOrCompute("b", func() string { return "B" }, 1*time.Hour) // b (MRU), d, c, a (LRU)
store.GetOrCompute("d", func() string { return "D" }, 1*time.Hour) // d (MRU), b, c, a (LRU)
store.GetOrCompute("a", func() string { return "A" }, 1*time.Hour) // a (MRU), d, b, c (LRU)
// Record initial state
initialOrder := "a (MRU), d, b, c (LRU)"
_ = initialOrder // for documentation
// Add two new items
store.Set("e", "E", 1*time.Hour) // Should evict c (LRU) -> a, d, b, e
store.Set("f", "F", 1*time.Hour) // Should evict b (LRU) -> a, d, e, f
// Check if our expectations match by counting present keys
// We'll check each key once to minimize LRU order changes
evicted := []string{}
present := []string{}
for _, key := range []string{"a", "b", "c", "d", "e", "f"} {
localKey := key
computeCalled := false
store.GetOrCompute(localKey, func() string {
computeCalled = true
return "recomputed-" + localKey
}, 1*time.Hour)
if computeCalled {
evicted = append(evicted, localKey)
} else {
present = append(present, localKey)
}
// After checking all 6 keys, we'll have at most 4 in cache
if len(present) > 4 {
break
}
}
// We expect c and b to have been evicted
expectedEvicted := map[string]bool{"b": true, "c": true}
for _, key := range evicted {
if !expectedEvicted[key] {
t.Errorf("Unexpected key %s was evicted", key)
}
}
// Verify we have exactly 4 items in cache
if len(present) > 4 {
t.Errorf("Cache has more than 4 items: %v", present)
}
}
*/
func TestLRUStore_GetOrCompute(t *testing.T) {
store := NewLRUStore[string, string](10)
defer store.Close()
computeCount := 0
// Test computing when not in cache
result := store.GetOrCompute("key1", func() string {
computeCount++
return "computed-value"
}, 1*time.Hour)
if result != "computed-value" {
t.Errorf("Expected computed-value, got %s", result)
}
if computeCount != 1 {
t.Errorf("Expected compute to be called once, called %d times", computeCount)
}
// Test returning cached value
result = store.GetOrCompute("key1", func() string {
computeCount++
return "should-not-compute"
}, 1*time.Hour)
if result != "computed-value" {
t.Errorf("Expected cached value, got %s", result)
}
if computeCount != 1 {
t.Errorf("Expected compute to not be called again, total calls: %d", computeCount)
}
}
func TestLRUStore_GetOrCompute_Expiration(t *testing.T) {
store := NewLRUStore[string, string](10)
defer store.Close()
computeCount := 0
// Set with short TTL
result := store.GetOrCompute("shortlived", func() string {
computeCount++
return "value1"
}, 100*time.Millisecond)
if result != "value1" {
t.Errorf("Expected value1, got %s", result)
}
if computeCount != 1 {
t.Errorf("Expected 1 compute, got %d", computeCount)
}
// Should return cached value immediately
result = store.GetOrCompute("shortlived", func() string {
computeCount++
return "value2"
}, 100*time.Millisecond)
if result != "value1" {
t.Errorf("Expected cached value1, got %s", result)
}
if computeCount != 1 {
t.Errorf("Expected still 1 compute, got %d", computeCount)
}
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should compute new value after expiration
result = store.GetOrCompute("shortlived", func() string {
computeCount++
return "value2"
}, 100*time.Millisecond)
if result != "value2" {
t.Errorf("Expected new value2, got %s", result)
}
if computeCount != 2 {
t.Errorf("Expected 2 computes after expiration, got %d", computeCount)
}
}
func TestLRUStore_GetOrCompute_Concurrent(t *testing.T) {
store := NewLRUStore[string, string](100)
defer store.Close()
var computeCount int32
const numGoroutines = 100
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Launch many goroutines trying to compute the same key
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
result := store.GetOrCompute("shared-key", func() string {
// Increment atomically to count calls
atomic.AddInt32(&computeCount, 1)
// Simulate some work
time.Sleep(10 * time.Millisecond)
return "shared-value"
}, 1*time.Hour)
if result != "shared-value" {
t.Errorf("Goroutine %d: Expected shared-value, got %s", id, result)
}
}(i)
}
wg.Wait()
// Only one goroutine should have computed the value
if computeCount != 1 {
t.Errorf("Expected exactly 1 compute for concurrent access, got %d", computeCount)
}
}
func TestLRUStore_GetOrCompute_WithEviction(t *testing.T) {
// Small cache to test eviction behavior
store := NewLRUStore[int, string](3)
defer store.Close()
computeCounts := make(map[int]int)
// Fill cache to capacity
for i := 1; i <= 3; i++ {
store.GetOrCompute(i, func() string {
computeCounts[i]++
return fmt.Sprintf("value-%d", i)
}, 1*time.Hour)
}
// All should be computed once
for i := 1; i <= 3; i++ {
if computeCounts[i] != 1 {
t.Errorf("Key %d: Expected 1 compute, got %d", i, computeCounts[i])
}
}
// Add fourth item - should evict key 1
store.GetOrCompute(4, func() string {
computeCounts[4]++
return "value-4"
}, 1*time.Hour)
// Try to get key 1 again - should need to recompute
result := store.GetOrCompute(1, func() string {
computeCounts[1]++
return "value-1-recomputed"
}, 1*time.Hour)
if result != "value-1-recomputed" {
t.Errorf("Expected recomputed value, got %s", result)
}
if computeCounts[1] != 2 {
t.Errorf("Key 1: Expected 2 computes after eviction, got %d", computeCounts[1])
}
}
// TestLRUStore_GetOrCompute_UpdatesLRU is commented out because
// verifying cache state with GetOrCompute modifies the LRU order
/*
func TestLRUStore_GetOrCompute_UpdatesLRU(t *testing.T) {
store := NewLRUStore[string, string](3)
defer store.Close()
// Fill cache: c (MRU), b, a (LRU)
store.GetOrCompute("a", func() string { return "A" }, 1*time.Hour)
store.GetOrCompute("b", func() string { return "B" }, 1*time.Hour)
store.GetOrCompute("c", func() string { return "C" }, 1*time.Hour)
// Access "a" again - should move to front
// Order becomes: a (MRU), c, b (LRU)
val := store.GetOrCompute("a", func() string { return "A-new" }, 1*time.Hour)
if val != "A" {
t.Errorf("Expected existing value 'A', got %s", val)
}
// Add new item - should evict "b" (least recently used)
// Order becomes: d (MRU), a, c
store.GetOrCompute("d", func() string { return "D" }, 1*time.Hour)
// Verify "b" was evicted by trying to get it
computeCalled := false
val = store.GetOrCompute("b", func() string {
computeCalled = true
return "B-recomputed"
}, 1*time.Hour)
if !computeCalled {
t.Error("Expected 'b' to be evicted and recomputed")
}
if val != "B-recomputed" {
t.Errorf("Expected 'B-recomputed', got %s", val)
}
// At this point, the cache contains b (just added), d, a
// and c was evicted when b was re-added
// Let's verify by checking the cache has exactly 3 items
presentCount := 0
for _, key := range []string{"a", "b", "c", "d"} {
localKey := key
computed := false
store.GetOrCompute(localKey, func() string {
computed = true
return "check-" + localKey
}, 1*time.Hour)
if !computed {
presentCount++
}
}
if presentCount != 3 {
t.Errorf("Expected exactly 3 items in cache, found %d", presentCount)
}
}
*/

View file

@ -1,133 +0,0 @@
package cache
import (
"flag"
"log/slog"
"sync"
"time"
)
// TTLStore is a time-to-live based cache implementation that mimics
// the original htmgo caching behavior. It stores values with expiration
// times and periodically cleans up expired entries.
type TTLStore[K comparable, V any] struct {
cache map[K]*entry[V]
mutex sync.RWMutex
closeOnce sync.Once
closeChan chan struct{}
}
type entry[V any] struct {
value V
expiration time.Time
}
// NewTTLStore creates a new TTL-based cache store.
func NewTTLStore[K comparable, V any]() Store[K, V] {
s := &TTLStore[K, V]{
cache: make(map[K]*entry[V]),
closeChan: make(chan struct{}),
}
s.startCleaner()
return s
}
// Set adds or updates an entry in the cache with the given TTL.
func (s *TTLStore[K, V]) Set(key K, value V, ttl time.Duration) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.cache[key] = &entry[V]{
value: value,
expiration: time.Now().Add(ttl),
}
}
// GetOrCompute atomically gets an existing value or computes and stores a new value.
func (s *TTLStore[K, V]) GetOrCompute(key K, compute func() V, ttl time.Duration) V {
s.mutex.Lock()
defer s.mutex.Unlock()
// Check if exists and not expired
if e, ok := s.cache[key]; ok && time.Now().Before(e.expiration) {
return e.value
}
// Compute while holding lock
value := compute()
// Store the result
s.cache[key] = &entry[V]{
value: value,
expiration: time.Now().Add(ttl),
}
return value
}
// Delete removes an entry from the cache.
func (s *TTLStore[K, V]) Delete(key K) {
s.mutex.Lock()
defer s.mutex.Unlock()
delete(s.cache, key)
}
// Purge removes all items from the cache.
func (s *TTLStore[K, V]) Purge() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.cache = make(map[K]*entry[V])
}
// Close stops the background cleaner goroutine.
func (s *TTLStore[K, V]) Close() {
s.closeOnce.Do(func() {
close(s.closeChan)
})
}
// startCleaner starts a background goroutine that periodically removes expired entries.
func (s *TTLStore[K, V]) startCleaner() {
isTests := flag.Lookup("test.v") != nil
go func() {
ticker := time.NewTicker(time.Minute)
if isTests {
ticker = time.NewTicker(time.Second)
}
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.clearExpired()
case <-s.closeChan:
return
}
}
}()
}
// clearExpired removes all expired entries from the cache.
func (s *TTLStore[K, V]) clearExpired() {
s.mutex.Lock()
defer s.mutex.Unlock()
now := time.Now()
deletedCount := 0
for key, e := range s.cache {
if now.After(e.expiration) {
delete(s.cache, key)
deletedCount++
}
}
if deletedCount > 0 {
slog.Debug("Deleted expired cache entries", slog.Int("count", deletedCount))
}
}

View file

@ -1,443 +0,0 @@
package cache
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestTTLStore_SetAndGet(t *testing.T) {
store := NewTTLStore[string, string]()
defer store.Close()
// Test basic set and get
store.Set("key1", "value1", 1*time.Hour)
val := store.GetOrCompute("key1", func() string {
t.Error("Should not compute for existing key")
return "should-not-compute"
}, 1*time.Hour)
if val != "value1" {
t.Errorf("Expected value1, got %s", val)
}
// Test getting non-existent key
computeCalled := false
val = store.GetOrCompute("nonexistent", func() string {
computeCalled = true
return "computed-value"
}, 1*time.Hour)
if !computeCalled {
t.Error("Expected compute function to be called for non-existent key")
}
if val != "computed-value" {
t.Errorf("Expected computed-value for non-existent key, got %s", val)
}
}
func TestTTLStore_Expiration(t *testing.T) {
store := NewTTLStore[string, string]()
defer store.Close()
// Set with short TTL
store.Set("shortlived", "value", 100*time.Millisecond)
// Should exist immediately
val := store.GetOrCompute("shortlived", func() string {
t.Error("Should not compute for existing key")
return "should-not-compute"
}, 100*time.Millisecond)
if val != "value" {
t.Errorf("Expected value, got %s", val)
}
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should be expired now
computeCalled := false
val = store.GetOrCompute("shortlived", func() string {
computeCalled = true
return "recomputed-after-expiry"
}, 100*time.Millisecond)
if !computeCalled {
t.Error("Expected compute function to be called for expired key")
}
if val != "recomputed-after-expiry" {
t.Errorf("Expected recomputed value for expired key, got %s", val)
}
}
func TestTTLStore_Delete(t *testing.T) {
store := NewTTLStore[string, string]()
defer store.Close()
store.Set("key1", "value1", 1*time.Hour)
// Verify it exists
val := store.GetOrCompute("key1", func() string {
t.Error("Should not compute for existing key")
return "should-not-compute"
}, 1*time.Hour)
if val != "value1" {
t.Errorf("Expected value1, got %s", val)
}
// Delete it
store.Delete("key1")
// Verify it's gone
computeCalled := false
val = store.GetOrCompute("key1", func() string {
computeCalled = true
return "recomputed-after-delete"
}, 1*time.Hour)
if !computeCalled {
t.Error("Expected compute function to be called after deletion")
}
if val != "recomputed-after-delete" {
t.Errorf("Expected recomputed value after deletion, got %s", val)
}
// Delete non-existent key should not panic
store.Delete("nonexistent")
}
func TestTTLStore_Purge(t *testing.T) {
store := NewTTLStore[string, string]()
defer store.Close()
// Add multiple items
store.Set("key1", "value1", 1*time.Hour)
store.Set("key2", "value2", 1*time.Hour)
store.Set("key3", "value3", 1*time.Hour)
// Verify they exist
for i := 1; i <= 3; i++ {
key := "key" + string(rune('0'+i))
val := store.GetOrCompute(key, func() string {
t.Errorf("Should not compute for existing key %s", key)
return "should-not-compute"
}, 1*time.Hour)
expectedVal := "value" + string(rune('0'+i))
if val != expectedVal {
t.Errorf("Expected to find %s with value %s, got %s", key, expectedVal, val)
}
}
// Purge all
store.Purge()
// Verify all are gone
for i := 1; i <= 3; i++ {
key := "key" + string(rune('0'+i))
computeCalled := false
store.GetOrCompute(key, func() string {
computeCalled = true
return "recomputed-after-purge"
}, 1*time.Hour)
if !computeCalled {
t.Errorf("Expected %s to be purged and recomputed", key)
}
}
}
func TestTTLStore_ConcurrentAccess(t *testing.T) {
store := NewTTLStore[int, int]()
defer store.Close()
const numGoroutines = 100
const numOperations = 1000
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Concurrent writes and reads
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := (id * numOperations) + j
store.Set(key, key*2, 1*time.Hour)
// Immediately read it back
val := store.GetOrCompute(key, func() int {
t.Errorf("Goroutine %d: Should not compute for just-set key %d", id, key)
return -1
}, 1*time.Hour)
if val != key*2 {
t.Errorf("Goroutine %d: Expected value %d, got %d", id, key*2, val)
}
}
}(i)
}
wg.Wait()
}
func TestTTLStore_UpdateExisting(t *testing.T) {
store := NewTTLStore[string, string]()
defer store.Close()
// Set initial value
store.Set("key1", "value1", 100*time.Millisecond)
// Update with new value and longer TTL
store.Set("key1", "value2", 1*time.Hour)
// Verify new value
val := store.GetOrCompute("key1", func() string {
t.Error("Should not compute for existing key")
return "should-not-compute"
}, 1*time.Hour)
if val != "value2" {
t.Errorf("Expected value2, got %s", val)
}
// Wait for original TTL to pass
time.Sleep(150 * time.Millisecond)
// Should still exist with new TTL
val = store.GetOrCompute("key1", func() string {
t.Error("Should not compute for key with new TTL")
return "should-not-compute"
}, 1*time.Hour)
if val != "value2" {
t.Errorf("Expected value2, got %s", val)
}
}
func TestTTLStore_CleanupGoroutine(t *testing.T) {
// This test verifies that expired entries are cleaned up automatically
store := NewTTLStore[string, string]()
defer store.Close()
// Add many short-lived entries
for i := 0; i < 100; i++ {
key := "key" + string(rune('0'+i))
store.Set(key, "value", 100*time.Millisecond)
}
// Cast to access internal state for testing
ttlStore := store.(*TTLStore[string, string])
// Check initial count
ttlStore.mutex.RLock()
initialCount := len(ttlStore.cache)
ttlStore.mutex.RUnlock()
if initialCount != 100 {
t.Errorf("Expected 100 entries initially, got %d", initialCount)
}
// Wait for expiration and cleanup cycle
// In test mode, cleanup runs every second
time.Sleep(1200 * time.Millisecond)
// Check that entries were cleaned up
ttlStore.mutex.RLock()
finalCount := len(ttlStore.cache)
ttlStore.mutex.RUnlock()
if finalCount != 0 {
t.Errorf("Expected 0 entries after cleanup, got %d", finalCount)
}
}
func TestTTLStore_Close(t *testing.T) {
store := NewTTLStore[string, string]()
// Close should not panic
store.Close()
// Multiple closes should not panic
store.Close()
store.Close()
}
func TestTTLStore_DifferentTypes(t *testing.T) {
// Test with different key and value types
intStore := NewTTLStore[int, string]()
defer intStore.Close()
intStore.Set(42, "answer", 1*time.Hour)
val := intStore.GetOrCompute(42, func() string {
t.Error("Should not compute for existing key")
return "should-not-compute"
}, 1*time.Hour)
if val != "answer" {
t.Error("Failed with int key")
}
// Test with struct values
type User struct {
ID int
Name string
}
userStore := NewTTLStore[string, User]()
defer userStore.Close()
user := User{ID: 1, Name: "Alice"}
userStore.Set("user1", user, 1*time.Hour)
retrievedUser := userStore.GetOrCompute("user1", func() User {
t.Error("Should not compute for existing user")
return User{}
}, 1*time.Hour)
if retrievedUser.ID != 1 || retrievedUser.Name != "Alice" {
t.Error("Retrieved user data doesn't match")
}
}
func TestTTLStore_GetOrCompute(t *testing.T) {
store := NewTTLStore[string, string]()
defer store.Close()
computeCount := 0
// Test computing when not in cache
result := store.GetOrCompute("key1", func() string {
computeCount++
return "computed-value"
}, 1*time.Hour)
if result != "computed-value" {
t.Errorf("Expected computed-value, got %s", result)
}
if computeCount != 1 {
t.Errorf("Expected compute to be called once, called %d times", computeCount)
}
// Test returning cached value
result = store.GetOrCompute("key1", func() string {
computeCount++
return "should-not-compute"
}, 1*time.Hour)
if result != "computed-value" {
t.Errorf("Expected cached value, got %s", result)
}
if computeCount != 1 {
t.Errorf("Expected compute to not be called again, total calls: %d", computeCount)
}
}
func TestTTLStore_GetOrCompute_Expiration(t *testing.T) {
store := NewTTLStore[string, string]()
defer store.Close()
computeCount := 0
// Set with short TTL
result := store.GetOrCompute("shortlived", func() string {
computeCount++
return "value1"
}, 100*time.Millisecond)
if result != "value1" {
t.Errorf("Expected value1, got %s", result)
}
if computeCount != 1 {
t.Errorf("Expected 1 compute, got %d", computeCount)
}
// Should return cached value immediately
result = store.GetOrCompute("shortlived", func() string {
computeCount++
return "value2"
}, 100*time.Millisecond)
if result != "value1" {
t.Errorf("Expected cached value1, got %s", result)
}
if computeCount != 1 {
t.Errorf("Expected still 1 compute, got %d", computeCount)
}
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should compute new value after expiration
result = store.GetOrCompute("shortlived", func() string {
computeCount++
return "value2"
}, 100*time.Millisecond)
if result != "value2" {
t.Errorf("Expected new value2, got %s", result)
}
if computeCount != 2 {
t.Errorf("Expected 2 computes after expiration, got %d", computeCount)
}
}
func TestTTLStore_GetOrCompute_Concurrent(t *testing.T) {
store := NewTTLStore[string, string]()
defer store.Close()
var computeCount int32
const numGoroutines = 100
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Launch many goroutines trying to compute the same key
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
result := store.GetOrCompute("shared-key", func() string {
// Increment atomically to count calls
atomic.AddInt32(&computeCount, 1)
// Simulate some work
time.Sleep(10 * time.Millisecond)
return "shared-value"
}, 1*time.Hour)
if result != "shared-value" {
t.Errorf("Goroutine %d: Expected shared-value, got %s", id, result)
}
}(i)
}
wg.Wait()
// Only one goroutine should have computed the value
if computeCount != 1 {
t.Errorf("Expected exactly 1 compute for concurrent access, got %d", computeCount)
}
}
func TestTTLStore_GetOrCompute_MultipleKeys(t *testing.T) {
store := NewTTLStore[int, int]()
defer store.Close()
computeCounts := make(map[int]int)
var mu sync.Mutex
// Test multiple different keys
for i := 0; i < 10; i++ {
for j := 0; j < 3; j++ { // Access each key 3 times
result := store.GetOrCompute(i, func() int {
mu.Lock()
computeCounts[i]++
mu.Unlock()
return i * 10
}, 1*time.Hour)
if result != i*10 {
t.Errorf("Expected %d, got %d", i*10, result)
}
}
}
// Each key should be computed exactly once
for i := 0; i < 10; i++ {
if computeCounts[i] != 1 {
t.Errorf("Key %d: Expected 1 compute, got %d", i, computeCounts[i])
}
}
}

View file

@ -1,448 +0,0 @@
package h
import (
"fmt"
"sync"
"testing"
"time"
"github.com/maddalax/htmgo/framework/h/cache"
)
func TestCached_WithDefaultStore(t *testing.T) {
callCount := 0
// Create a cached component
CachedDiv := Cached(1*time.Hour, func() *Element {
callCount++
return Div(Text(fmt.Sprintf("Rendered %d times", callCount)))
})
// First render
html1 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Second render should use cache
html2 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", callCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCached_WithCustomStore(t *testing.T) {
// Use LRU store with small capacity
lruStore := cache.NewLRUStore[any, string](10)
defer lruStore.Close()
callCount := 0
// Create cached component with custom store
CachedDiv := Cached(1*time.Hour, func() *Element {
callCount++
return Div(Text(fmt.Sprintf("Rendered %d times", callCount)))
}, WithCacheStore(lruStore))
// First render
html1 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Second render should use cache
html2 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", callCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCachedPerKey_WithDefaultStore(t *testing.T) {
renderCounts := make(map[int]int)
// Create per-key cached component
UserProfile := CachedPerKeyT(1*time.Hour, func(userID int) (int, GetElementFunc) {
return userID, func() *Element {
renderCounts[userID]++
return Div(Text(fmt.Sprintf("User %d (rendered %d times)", userID, renderCounts[userID])))
}
})
// Render for different users
html1_user1 := Render(UserProfile(1))
html1_user2 := Render(UserProfile(2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected each user to be rendered once")
}
// Render again - should use cache
html2_user1 := Render(UserProfile(1))
html2_user2 := Render(UserProfile(2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected renders to be cached")
}
if html1_user1 != html2_user1 || html1_user2 != html2_user2 {
t.Error("Expected same HTML from cache")
}
// Different users should have different content
if html1_user1 == html1_user2 {
t.Error("Expected different content for different users")
}
}
func TestCachedPerKey_WithLRUStore(t *testing.T) {
// Small LRU cache that can only hold 2 items
lruStore := cache.NewLRUStore[any, string](2)
defer lruStore.Close()
renderCounts := make(map[int]int)
// Create per-key cached component with LRU store
UserProfile := CachedPerKeyT(1*time.Hour, func(userID int) (int, GetElementFunc) {
return userID, func() *Element {
renderCounts[userID]++
return Div(Text(fmt.Sprintf("User %d", userID)))
}
}, WithCacheStore(lruStore))
// Render 2 users - fill cache to capacity
Render(UserProfile(1))
Render(UserProfile(2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected each user to be rendered once")
}
// Render user 3 - should evict user 1 (least recently used)
Render(UserProfile(3))
if renderCounts[3] != 1 {
t.Error("Expected user 3 to be rendered once")
}
// Render user 1 again - should re-render (was evicted)
Render(UserProfile(1))
if renderCounts[1] != 2 {
t.Errorf("Expected user 1 to be re-rendered after eviction, got %d renders", renderCounts[1])
}
// Render user 2 again - should re-render (was evicted when user 1 was added back)
Render(UserProfile(2))
if renderCounts[2] != 2 {
t.Errorf("Expected user 2 to be re-rendered after eviction, got %d renders", renderCounts[2])
}
// At this point, cache contains users 1 and 2 (most recently used)
// Render user 1 again - should be cached
Render(UserProfile(1))
if renderCounts[1] != 2 {
t.Errorf("Expected user 1 to still be cached, got %d renders", renderCounts[1])
}
}
func TestCachedT_WithDefaultStore(t *testing.T) {
type Product struct {
ID int
Name string
Price float64
}
renderCount := 0
// Create cached component that takes typed data
ProductCard := CachedT(1*time.Hour, func(p Product) *Element {
renderCount++
return Div(
H3(Text(p.Name)),
P(Text(fmt.Sprintf("$%.2f", p.Price))),
)
})
product := Product{ID: 1, Name: "Widget", Price: 9.99}
// First render
html1 := Render(ProductCard(product))
if renderCount != 1 {
t.Errorf("Expected 1 render, got %d", renderCount)
}
// Second render should use cache
html2 := Render(ProductCard(product))
if renderCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", renderCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCachedPerKeyT_WithCustomStore(t *testing.T) {
type Article struct {
ID int
Title string
Content string
}
ttlStore := cache.NewTTLStore[any, string]()
defer ttlStore.Close()
renderCounts := make(map[int]int)
// Create per-key cached component with custom store
ArticleView := CachedPerKeyT(1*time.Hour, func(a Article) (int, GetElementFunc) {
return a.ID, func() *Element {
renderCounts[a.ID]++
return Div(
H1(Text(a.Title)),
P(Text(a.Content)),
)
}
}, WithCacheStore(ttlStore))
article1 := Article{ID: 1, Title: "First", Content: "Content 1"}
article2 := Article{ID: 2, Title: "Second", Content: "Content 2"}
// Render articles
Render(ArticleView(article1))
Render(ArticleView(article2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected each article to be rendered once")
}
// Render again - should use cache
Render(ArticleView(article1))
Render(ArticleView(article2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected renders to be cached")
}
}
func TestDefaultCacheProvider_Override(t *testing.T) {
// Save original provider
originalProvider := DefaultCacheProvider
defer func() {
DefaultCacheProvider = originalProvider
}()
// Track which cache is used
customCacheUsed := false
// Override default provider
DefaultCacheProvider = func() cache.Store[any, string] {
customCacheUsed = true
return cache.NewLRUStore[any, string](100)
}
// Create cached component without specifying store
CachedDiv := Cached(1*time.Hour, func() *Element {
return Div(Text("Content"))
})
// Render to trigger cache creation
Render(CachedDiv())
if !customCacheUsed {
t.Error("Expected custom default cache provider to be used")
}
}
func TestCachedPerKey_ConcurrentAccess(t *testing.T) {
lruStore := cache.NewLRUStore[any, string](1000)
defer lruStore.Close()
UserProfile := CachedPerKeyT(1*time.Hour, func(userID int) (int, GetElementFunc) {
return userID, func() *Element {
// Simulate some work
time.Sleep(10 * time.Millisecond)
return Div(Text(fmt.Sprintf("User %d", userID)))
}
}, WithCacheStore(lruStore))
const numGoroutines = 50
const numUsers = 20
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Many goroutines accessing overlapping user IDs
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numUsers; j++ {
userID := j % 10 // Reuse user IDs to test cache hits
html := Render(UserProfile(userID))
expectedContent := fmt.Sprintf("User %d", userID)
if !contains(html, expectedContent) {
t.Errorf("Goroutine %d: Expected content for user %d", id, userID)
}
}
}(i)
}
wg.Wait()
}
func TestCachedT2_MultipleParameters(t *testing.T) {
renderCount := 0
// Component that takes two parameters
CombinedView := CachedT2(1*time.Hour, func(title string, count int) *Element {
renderCount++
return Div(
H2(Text(title)),
P(Text(fmt.Sprintf("Count: %d", count))),
)
})
// First render
html1 := Render(CombinedView("Test", 42))
if renderCount != 1 {
t.Errorf("Expected 1 render, got %d", renderCount)
}
// Second render with same params should use cache
html2 := Render(CombinedView("Test", 42))
if renderCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", renderCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCachedPerKeyT3_ComplexKey(t *testing.T) {
type CompositeKey struct {
UserID int
ProductID int
Timestamp int64
}
renderCount := 0
// Component with composite key
UserProductView := CachedPerKeyT3(1*time.Hour,
func(userID int, productID int, timestamp int64) (CompositeKey, GetElementFunc) {
key := CompositeKey{UserID: userID, ProductID: productID, Timestamp: timestamp}
return key, func() *Element {
renderCount++
return Div(Text(fmt.Sprintf("User %d viewed product %d at %d", userID, productID, timestamp)))
}
},
)
// Render with specific combination
ts := time.Now().Unix()
html1 := Render(UserProductView(1, 100, ts))
if renderCount != 1 {
t.Errorf("Expected 1 render, got %d", renderCount)
}
// Same combination should use cache
html2 := Render(UserProductView(1, 100, ts))
if renderCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", renderCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
// Different combination should render again
Render(UserProductView(1, 101, ts))
if renderCount != 2 {
t.Errorf("Expected 2 renders for different key, got %d", renderCount)
}
}
func TestCached_Expiration(t *testing.T) {
callCount := 0
// Create cached component with short TTL
CachedDiv := Cached(100*time.Millisecond, func() *Element {
callCount++
return Div(Text(fmt.Sprintf("Render %d", callCount)))
})
// First render
Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Immediate second render should use cache
Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", callCount)
}
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should render again after expiration
Render(CachedDiv())
if callCount != 2 {
t.Errorf("Expected 2 renders after expiration, got %d", callCount)
}
}
func TestCachedNode_ClearCache(t *testing.T) {
lruStore := cache.NewLRUStore[any, string](10)
defer lruStore.Close()
callCount := 0
CachedDiv := Cached(1*time.Hour, func() *Element {
callCount++
return Div(Text("Content"))
}, WithCacheStore(lruStore))
// Render and cache
element := CachedDiv()
Render(element)
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Clear cache
node := element.meta.(*CachedNode)
node.ClearCache()
// Should render again after cache clear
Render(element)
if callCount != 2 {
t.Errorf("Expected 2 renders after cache clear, got %d", callCount)
}
}
// Helper function
func contains(s, substr string) bool {
return len(s) >= len(substr) && s[0:len(substr)] == substr ||
len(s) > len(substr) && contains(s[1:], substr)
}

View file

@ -7,7 +7,6 @@ import (
"regexp"
"strings"
"testing"
"time"
)
func findScriptById(n *html.Node, id string) *html.Node {
@ -88,7 +87,7 @@ func TestJsEval(t *testing.T) {
}
func TestSetText(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, SetText("Hello World")), "(self||this).innerText = 'Hello World';")
compareIgnoreSpaces(t, renderJs(t, SetText("Hello World")), "this.innerText = 'Hello World';")
}
func TestSetTextOnChildren(t *testing.T) {
@ -101,42 +100,42 @@ func TestSetTextOnChildren(t *testing.T) {
}
func TestIncrement(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, Increment(5)), "(self||this).innerText = parseInt((self||this).innerText) + 5;")
compareIgnoreSpaces(t, renderJs(t, Increment(5)), "this.innerText = parseInt(this.innerText) + 5;")
}
func TestSetInnerHtml(t *testing.T) {
htmlContent := Div(Span(UnsafeRaw("inner content")))
compareIgnoreSpaces(t, renderJs(t, SetInnerHtml(htmlContent)), "(self||this).innerHTML = `<div><span>inner content</span></div>`;")
compareIgnoreSpaces(t, renderJs(t, SetInnerHtml(htmlContent)), "this.innerHTML = `<div><span>inner content</span></div>`;")
}
func TestSetOuterHtml(t *testing.T) {
htmlContent := Div(Span(UnsafeRaw("outer content")))
compareIgnoreSpaces(t, renderJs(t, SetOuterHtml(htmlContent)), "(self||this).outerHTML = `<div><span>outer content</span></div>`;")
compareIgnoreSpaces(t, renderJs(t, SetOuterHtml(htmlContent)), "this.outerHTML = `<div><span>outer content</span></div>`;")
}
func TestAddAttribute(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, AddAttribute("data-id", "123")), "(self||this).setAttribute('data-id', '123');")
compareIgnoreSpaces(t, renderJs(t, AddAttribute("data-id", "123")), "this.setAttribute('data-id', '123');")
}
func TestSetDisabled(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, SetDisabled(true)), "(self||this).setAttribute('disabled', 'true');")
compareIgnoreSpaces(t, renderJs(t, SetDisabled(false)), "(self||this).removeAttribute('disabled');")
compareIgnoreSpaces(t, renderJs(t, SetDisabled(true)), "this.setAttribute('disabled', 'true');")
compareIgnoreSpaces(t, renderJs(t, SetDisabled(false)), "this.removeAttribute('disabled');")
}
func TestRemoveAttribute(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, RemoveAttribute("data-id")), "(self||this).removeAttribute('data-id');")
compareIgnoreSpaces(t, renderJs(t, RemoveAttribute("data-id")), "this.removeAttribute('data-id');")
}
func TestAddClass(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, AddClass("active")), "(self||this).classList.add('active');")
compareIgnoreSpaces(t, renderJs(t, AddClass("active")), "this.classList.add('active');")
}
func TestRemoveClass(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, RemoveClass("active")), "(self||this).classList.remove('active');")
compareIgnoreSpaces(t, renderJs(t, RemoveClass("active")), "this.classList.remove('active');")
}
func TestToggleClass(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, ToggleClass("hidden")), "(self||this).classList.toggle('hidden');")
compareIgnoreSpaces(t, renderJs(t, ToggleClass("hidden")), "this.classList.toggle('hidden');")
}
func TestToggleClassOnElement(t *testing.T) {
@ -207,7 +206,7 @@ func TestAlert(t *testing.T) {
}
func TestRemove(t *testing.T) {
compareIgnoreSpaces(t, renderJs(t, Remove()), "(self||this).remove();")
compareIgnoreSpaces(t, renderJs(t, Remove()), "this.remove();")
}
func TestSubmitFormOnEnter(t *testing.T) {
@ -395,23 +394,5 @@ func TestConsoleLog(t *testing.T) {
func TestSetValue(t *testing.T) {
t.Parallel()
compareIgnoreSpaces(t, renderJs(t, SetValue("New Value")), "(self||this).value = 'New Value';")
}
func TestRunAfterTimeout(t *testing.T) {
t.Parallel()
compareIgnoreSpaces(t, renderJs(t, RunAfterTimeout(time.Second*5, SetText("Hello"))), `
setTimeout(function() {
(self||this).innerText = 'Hello'
}, 5000)
`)
}
func TestRunOnInterval(t *testing.T) {
t.Parallel()
compareIgnoreSpaces(t, renderJs(t, RunOnInterval(time.Second, SetText("Hello"))), `
setInterval(function() {
(self||this).innerText = 'Hello'
}, 1000)
`)
compareIgnoreSpaces(t, renderJs(t, SetValue("New Value")), "this.value = 'New Value';")
}

View file

@ -6,7 +6,6 @@ import (
"github.com/maddalax/htmgo/framework/hx"
"github.com/maddalax/htmgo/framework/internal/util"
"strings"
"time"
)
type LifeCycle struct {
@ -164,7 +163,7 @@ func NewComplexJsCommand(command string) ComplexJsCommand {
// SetText sets the inner text of the element.
func SetText(text string) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("(self || this).innerText = '%s'", text)}
return SimpleJsCommand{Command: fmt.Sprintf("this.innerText = '%s'", text)}
}
// SetTextOnChildren sets the inner text of all the children of the element that match the selector.
@ -181,25 +180,25 @@ func SetTextOnChildren(selector, text string) ComplexJsCommand {
// Increment increments the inner text of the element by the given amount.
func Increment(amount int) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("(self || this).innerText = parseInt((self || this).innerText) + %d", amount)}
return SimpleJsCommand{Command: fmt.Sprintf("this.innerText = parseInt(this.innerText) + %d", amount)}
}
// SetInnerHtml sets the inner HTML of the element.
func SetInnerHtml(r Ren) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("(self || this).innerHTML = `%s`", Render(r))}
return SimpleJsCommand{Command: fmt.Sprintf("this.innerHTML = `%s`", Render(r))}
}
// SetOuterHtml sets the outer HTML of the element.
func SetOuterHtml(r Ren) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("(self || this).outerHTML = `%s`", Render(r))}
return SimpleJsCommand{Command: fmt.Sprintf("this.outerHTML = `%s`", Render(r))}
}
// AddAttribute adds the given attribute to the element.
func AddAttribute(name, value string) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("(self || this).setAttribute('%s', '%s')", name, value)}
return SimpleJsCommand{Command: fmt.Sprintf("this.setAttribute('%s', '%s')", name, value)}
}
// SetDisabled sets the disabled attribute on the element.
@ -214,25 +213,25 @@ func SetDisabled(disabled bool) SimpleJsCommand {
// RemoveAttribute removes the given attribute from the element.
func RemoveAttribute(name string) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("(self || this).removeAttribute('%s')", name)}
return SimpleJsCommand{Command: fmt.Sprintf("this.removeAttribute('%s')", name)}
}
// AddClass adds the given class to the element.
func AddClass(class string) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("(self || this).classList.add('%s')", class)}
return SimpleJsCommand{Command: fmt.Sprintf("this.classList.add('%s')", class)}
}
// RemoveClass removes the given class from the element.
func RemoveClass(class string) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("(self || this).classList.remove('%s')", class)}
return SimpleJsCommand{Command: fmt.Sprintf("this.classList.remove('%s')", class)}
}
// ToggleClass toggles the given class on the element.
func ToggleClass(class string) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("(self || this).classList.toggle('%s')", class)}
return SimpleJsCommand{Command: fmt.Sprintf("this.classList.toggle('%s')", class)}
}
// ToggleText toggles the given text on the element.
@ -392,7 +391,7 @@ func Alert(text string) SimpleJsCommand {
// Remove removes the element from the DOM.
func Remove() SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: "(self || this).remove()"}
return SimpleJsCommand{Command: "this.remove()"}
}
// EvalJs evaluates the given JavaScript code.
@ -400,21 +399,15 @@ func EvalJs(js string) ComplexJsCommand {
return NewComplexJsCommand(js)
}
func CombineCommands(cmds ...Command) string {
func EvalCommandsOnSelector(selector string, cmds ...Command) ComplexJsCommand {
lines := make([]string, len(cmds))
for i, cmd := range cmds {
lines[i] = Render(cmd)
lines[i] = strings.ReplaceAll(lines[i], "(self || this).", "self.")
lines[i] = strings.ReplaceAll(lines[i], "this.", "self.")
// some commands set the element we need to fix it so we arent redeclaring it
lines[i] = strings.ReplaceAll(lines[i], "let element =", "element =")
}
code := strings.Join(lines, "\n")
return code
}
func EvalCommandsOnSelector(selector string, cmds ...Command) ComplexJsCommand {
code := CombineCommands(cmds...)
return EvalJs(fmt.Sprintf(`
let element = document.querySelector("%s");
@ -451,7 +444,7 @@ func ConsoleLog(text string) SimpleJsCommand {
// SetValue sets the value of the element.
func SetValue(value string) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("(self || this).value = '%s'", value)}
return SimpleJsCommand{Command: fmt.Sprintf("this.value = '%s'", value)}
}
// SubmitFormOnEnter submits the form when the user presses the enter key.
@ -485,31 +478,3 @@ func InjectScriptIfNotExist(src string) ComplexJsCommand {
}
`, src, src))
}
func RunOnInterval(time time.Duration, cmds ...Command) ComplexJsCommand {
code := strings.Builder{}
for _, cmd := range cmds {
code.WriteString(fmt.Sprintf(`
setInterval(function() {
%s
}, %d)
`, Render(cmd), time.Milliseconds()))
}
return EvalJs(code.String())
}
func RunAfterTimeout(time time.Duration, cmds ...Command) ComplexJsCommand {
code := strings.Builder{}
for _, cmd := range cmds {
code.WriteString(fmt.Sprintf(`
setTimeout(function() {
%s
}, %d)
`, Render(cmd), time.Milliseconds()))
}
return EvalJs(code.String())
}

View file

@ -1,13 +1,13 @@
package h
import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestRendererShouldRenderDocType(t *testing.T) {
@ -48,7 +48,7 @@ func TestRender(t *testing.T) {
div.attributes.Set("data-attr-1", "value")
expected := `<div data-attr-1="value" id="my-div" data-attr-2="value" data-attr-3="value" hx-on::before-request="(self || this).innerText = &#39;before request&#39;;" hx-on::after-request="(self || this).innerText = &#39;after request&#39;;"><div>hello, world</div>hello, child</div>`
expected := `<div data-attr-1="value" id="my-div" data-attr-2="value" data-attr-3="value" hx-on::before-request="this.innerText = &#39;before request&#39;;" hx-on::after-request="this.innerText = &#39;after request&#39;;"><div>hello, world</div>hello, child</div>`
result := Render(div)
assert.Equal(t,
@ -474,112 +474,76 @@ func TestCacheByKeyT1Expired_2(t *testing.T) {
}
func TestClearExpiredCached(t *testing.T) {
t.Parallel()
renderCount := 0
cachedItem := Cached(time.Millisecond*2, func() *Element {
cachedItem := Cached(time.Millisecond*3, func() *Element {
renderCount++
return Div(Text("hello"))
return Pf("hello")
})
// First render
Render(cachedItem())
assert.Equal(t, 1, renderCount)
// Should use cache immediately
Render(cachedItem())
node := cachedItem().meta.(*CachedNode)
assert.Equal(t, 1, renderCount)
assert.NotEmpty(t, node.html)
// Wait for expiration
time.Sleep(time.Millisecond * 3)
node.ClearExpired()
// Should re-render after expiration
Render(cachedItem())
assert.Equal(t, 2, renderCount)
assert.Empty(t, node.html)
}
func TestClearExpiredCacheByKey(t *testing.T) {
t.Parallel()
renderCount := 0
// Create two cached functions with different TTLs
shortLivedCache := CachedPerKeyT(time.Millisecond*1, func(key int) (int, GetElementFunc) {
cachedItem := CachedPerKeyT(time.Millisecond, func(key int) (any, GetElementFunc) {
return key, func() *Element {
renderCount++
return Div(Text("short-lived"))
return Pf(strconv.Itoa(key))
}
})
longLivedCache := CachedPerKeyT(time.Hour, func(key int) (int, GetElementFunc) {
return key, func() *Element {
renderCount++
return Div(Text("long-lived"))
}
})
// Render 100 short-lived items
for i := 0; i < 100; i++ {
Render(shortLivedCache(i))
Render(cachedItem(i))
}
assert.Equal(t, 100, renderCount)
// Render a long-lived item
Render(longLivedCache(999))
assert.Equal(t, 101, renderCount)
node := cachedItem(0).meta.(*ByKeyEntry).parent.meta.(*CachedNode)
assert.Equal(t, 100, len(node.byKeyExpiration))
assert.Equal(t, 100, len(node.byKeyCache))
// Wait for expiration of the short-lived items
time.Sleep(time.Millisecond * 3)
time.Sleep(time.Millisecond * 2)
// Re-render some expired items - should trigger new renders
for i := 0; i < 10; i++ {
Render(shortLivedCache(i))
}
assert.Equal(t, 111, renderCount) // 101 + 10 re-renders
Render(cachedItem(0))
node.ClearExpired()
// The long-lived item should still be cached
Render(longLivedCache(999))
assert.Equal(t, 111, renderCount) // No additional render
assert.Equal(t, 1, len(node.byKeyExpiration))
assert.Equal(t, 1, len(node.byKeyCache))
// Clear cache manually on both
shortNode := shortLivedCache(0).meta.(*ByKeyEntry).parent.meta.(*CachedNode)
shortNode.ClearCache()
node.ClearCache()
longNode := longLivedCache(0).meta.(*ByKeyEntry).parent.meta.(*CachedNode)
longNode.ClearCache()
// Everything should re-render now
Render(shortLivedCache(0))
assert.Equal(t, 112, renderCount)
Render(longLivedCache(999))
assert.Equal(t, 113, renderCount)
assert.Equal(t, 0, len(node.byKeyExpiration))
assert.Equal(t, 0, len(node.byKeyCache))
}
func TestBackgroundCleaner(t *testing.T) {
renderCount := 0
cachedItem := CachedPerKeyT(time.Millisecond*100, func(key int) (int, GetElementFunc) {
t.Parallel()
cachedItem := CachedPerKeyT(time.Second*2, func(key int) (any, GetElementFunc) {
return key, func() *Element {
renderCount++
return Div(Text("hello"))
return Pf(strconv.Itoa(key))
}
})
// Render 100 items
for i := 0; i < 100; i++ {
Render(cachedItem(i))
}
assert.Equal(t, 100, renderCount)
// Items should be cached immediately
for i := 0; i < 10; i++ {
Render(cachedItem(i))
}
assert.Equal(t, 100, renderCount) // No additional renders
node := cachedItem(0).meta.(*ByKeyEntry).parent.meta.(*CachedNode)
assert.Equal(t, 100, len(node.byKeyExpiration))
assert.Equal(t, 100, len(node.byKeyCache))
// Wait for expiration and cleanup
time.Sleep(time.Second * 3)
// Items should be expired and need re-rendering
for i := 0; i < 10; i++ {
Render(cachedItem(i))
}
assert.Equal(t, 110, renderCount) // 10 re-renders after expiration
assert.Equal(t, 0, len(node.byKeyExpiration))
assert.Equal(t, 0, len(node.byKeyCache))
}
func TestEscapeHtml(t *testing.T) {

View file

@ -161,18 +161,6 @@ func Div(children ...Ren) *Element {
return Tag("div", children...)
}
func Dl(children ...Ren) *Element {
return Tag("dl", children...)
}
func Dt(children ...Ren) *Element {
return Tag("dt", children...)
}
func Dd(children ...Ren) *Element {
return Tag("dd", children...)
}
func Article(children ...Ren) *Element {
return Tag("article", children...)
}

Some files were not shown because too many files have changed in this diff Show more