Compare commits
105 commits
script-enh
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c171709e | ||
|
|
5dba9d0167 | ||
|
|
6f29b307ec | ||
|
|
06f01b3d7c | ||
|
|
d555e5337f | ||
|
|
c406b5f068 | ||
|
|
c52f10f92d | ||
|
|
d9c7fb3936 | ||
|
|
66b6dfffd3 | ||
|
|
24b41a7604 | ||
|
|
0c84e42160 | ||
|
|
ca4faf103e | ||
|
|
4f537567ad | ||
|
|
0d61b12561 | ||
|
|
3f719d7011 | ||
|
|
331f4cde82 | ||
|
|
ab50eaecf4 | ||
|
|
baf10419f7 | ||
|
|
c924f63ffb | ||
|
|
ab342535d3 | ||
|
|
15655d5c02 | ||
|
|
14272d6507 | ||
|
|
d325677a1f | ||
|
|
e0bb30b976 | ||
|
|
baf5292212 | ||
|
|
9b69b25d0b | ||
|
|
495e759689 | ||
|
|
ba8c0106d9 | ||
|
|
4c942a0a16 | ||
|
|
407cc12079 | ||
|
|
01e4568c48 | ||
|
|
158a6264a9 | ||
|
|
909d38c7f4 | ||
|
|
825c4dd7ec | ||
|
|
ef83e34b1e | ||
|
|
a1af01a480 | ||
|
|
971f05c005 | ||
|
|
b06d1b14bd | ||
|
|
423fd3f429 | ||
|
|
257def3b53 | ||
|
|
97a5687f2e | ||
|
|
d2d8e449ae | ||
|
|
a2d3a367d1 | ||
|
|
dc8a62313c | ||
|
|
6ec582a834 | ||
|
|
b3834bf559 | ||
|
|
b234ead964 | ||
|
|
a756a0484f | ||
|
|
34e816ff7c | ||
|
|
841262341a | ||
|
|
142411c0e5 | ||
|
|
e424dac826 | ||
|
|
6acfc74a65 | ||
|
|
aeb3a7be64 | ||
|
|
ea997b41de | ||
|
|
7d04d8861f | ||
|
|
bf9cf2bf96 | ||
|
|
2346708ab1 | ||
|
|
25c216e2b6 | ||
|
|
af0091c370 | ||
|
|
2c4ac8b286 | ||
|
|
4c6187e18d | ||
|
|
b75dadf00e | ||
|
|
e067a17f53 | ||
|
|
9643e08232 | ||
|
|
06792019f8 | ||
|
|
f952d6ed3e | ||
|
|
f0b8118e00 | ||
|
|
e27cda5779 | ||
|
|
f25c6bd8f5 | ||
|
|
64f201f4a3 | ||
|
|
032159149c | ||
|
|
2d6ab078be | ||
|
|
479df08d63 | ||
|
|
0a4bcfa7a3 | ||
|
|
248e485ff0 | ||
|
|
51995681e3 | ||
|
|
92f33c8cff | ||
|
|
e268a581ce | ||
|
|
f42351e94f | ||
|
|
9ed353ebe3 | ||
|
|
8a00828232 | ||
|
|
01cde545ce | ||
|
|
6db280907a | ||
|
|
ca946f2cba | ||
|
|
7da172cfad | ||
|
|
de0e06155b | ||
|
|
44461b1ec7 | ||
|
|
833708e38a | ||
|
|
7f5274b34d | ||
|
|
f6556b579f | ||
|
|
3cd7577b06 | ||
|
|
e6223a36b7 | ||
|
|
60a37b65d6 | ||
|
|
5feb271aed | ||
|
|
cb6fcdd676 | ||
|
|
129c230c72 | ||
|
|
965a8487b9 | ||
|
|
1ce5f37fc4 | ||
|
|
2e60b84c83 | ||
|
|
61b215436b | ||
|
|
101bb022c9 | ||
|
|
35877a1b2e | ||
|
|
df9c7f9cf7 | ||
|
|
d85737bfb8 |
214 changed files with 9879 additions and 1712 deletions
48
.github/workflows/release-ws-test.yml
vendored
Normal file
48
.github/workflows/release-ws-test.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
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
|
||||||
33
.github/workflows/run-cli-tests.yml
vendored
Normal file
33
.github/workflows/run-cli-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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 }}
|
||||||
9
.github/workflows/run-framework-tests.yml
vendored
9
.github/workflows/run-framework-tests.yml
vendored
|
|
@ -3,7 +3,7 @@ name: Framework Tests
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '**' # Runs on any branch push
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- '**' # Runs on any pull request to any branch
|
- '**' # Runs on any pull request to any branch
|
||||||
|
|
@ -25,4 +25,9 @@ jobs:
|
||||||
run: cd ./framework && go mod download
|
run: cd ./framework && go mod download
|
||||||
|
|
||||||
- name: Run Go tests
|
- name: Run Go tests
|
||||||
run: cd ./framework && go test ./...
|
run: cd ./framework && go test ./... -coverprofile=coverage.txt
|
||||||
|
|
||||||
|
- name: Upload results to Codecov
|
||||||
|
uses: codecov/codecov-action@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@
|
||||||
-------
|
-------
|
||||||
[](https://goreportcard.com/report/github.com/maddalax/htmgo)
|
[](https://goreportcard.com/report/github.com/maddalax/htmgo)
|
||||||

|

|
||||||
|
[](https://htmgo.dev/docs)
|
||||||
|
[](https://codecov.io/github/maddalax/htmgo)
|
||||||
|
[](https://htmgo.dev/discord)
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<sup>looking for a python version? check out: https://fastht.ml</sup>
|
<sup>looking for a python version? check out: https://fastht.ml</sup>
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,25 @@ module github.com/maddalax/htmgo/cli/htmgo
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dave/jennifer v1.7.1
|
|
||||||
github.com/fsnotify/fsnotify v1.7.0
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
github.com/google/uuid v1.6.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/mod v0.21.0
|
||||||
golang.org/x/net v0.29.0
|
golang.org/x/sys v0.26.0
|
||||||
golang.org/x/sys v0.25.0
|
|
||||||
golang.org/x/tools v0.25.0
|
golang.org/x/tools v0.25.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,32 @@
|
||||||
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
|
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/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
|
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=
|
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=
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,15 @@ import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const version = "1.0.6"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
needsSignals := true
|
needsSignals := true
|
||||||
|
|
||||||
commandMap := make(map[string]*flag.FlagSet)
|
commandMap := make(map[string]*flag.FlagSet)
|
||||||
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate", "format"}
|
commands := []string{"template", "run", "watch", "build", "setup", "css", "schema", "generate", "format", "version"}
|
||||||
|
|
||||||
for _, command := range commands {
|
for _, command := range commands {
|
||||||
commandMap[command] = flag.NewFlagSet(command, flag.ExitOnError)
|
commandMap[command] = flag.NewFlagSet(command, flag.ExitOnError)
|
||||||
|
|
@ -77,21 +78,9 @@ func main() {
|
||||||
fmt.Printf("Generating CSS...\n")
|
fmt.Printf("Generating CSS...\n")
|
||||||
css.GenerateCss(process.ExitOnError)
|
css.GenerateCss(process.ExitOnError)
|
||||||
|
|
||||||
wg := sync.WaitGroup{}
|
// generate ast needs to be run after css generation
|
||||||
|
astgen.GenAst(process.ExitOnError)
|
||||||
wg.Add(1)
|
run.EntGenerate()
|
||||||
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")
|
fmt.Printf("Starting server...\n")
|
||||||
process.KillAll()
|
process.KillAll()
|
||||||
|
|
@ -100,6 +89,10 @@ func main() {
|
||||||
}()
|
}()
|
||||||
startWatcher(reloader.OnFileChange)
|
startWatcher(reloader.OnFileChange)
|
||||||
} else {
|
} else {
|
||||||
|
if taskName == "version" {
|
||||||
|
fmt.Printf("htmgo cli version %s\n", version)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
if taskName == "format" {
|
if taskName == "format" {
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 3 {
|
||||||
fmt.Println(fmt.Sprintf("Usage: htmgo format <file>"))
|
fmt.Println(fmt.Sprintf("Usage: htmgo format <file>"))
|
||||||
|
|
@ -125,6 +118,7 @@ func main() {
|
||||||
} else if taskName == "css" {
|
} else if taskName == "css" {
|
||||||
_ = css.GenerateCss(process.ExitOnError)
|
_ = css.GenerateCss(process.ExitOnError)
|
||||||
} else if taskName == "ast" {
|
} else if taskName == "ast" {
|
||||||
|
css.GenerateCss(process.ExitOnError)
|
||||||
_ = astgen.GenAst(process.ExitOnError)
|
_ = astgen.GenAst(process.ExitOnError)
|
||||||
} else if taskName == "run" {
|
} else if taskName == "run" {
|
||||||
run.MakeBuildable()
|
run.MakeBuildable()
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,21 @@ package astgen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"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/ast"
|
||||||
"go/parser"
|
"go/parser"
|
||||||
"go/token"
|
"go/token"
|
||||||
"golang.org/x/mod/modfile"
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"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 {
|
type Page struct {
|
||||||
|
|
@ -37,6 +41,32 @@ const ModuleName = "github.com/maddalax/htmgo/framework/h"
|
||||||
var PackageName = fmt.Sprintf("package %s", GeneratedDirName)
|
var PackageName = fmt.Sprintf("package %s", GeneratedDirName)
|
||||||
var GeneratedFileLine = fmt.Sprintf("// Package %s THIS FILE IS GENERATED. DO NOT EDIT.", GeneratedDirName)
|
var GeneratedFileLine = fmt.Sprintf("// Package %s THIS FILE IS GENERATED. DO NOT EDIT.", GeneratedDirName)
|
||||||
|
|
||||||
|
func toPascaleCase(input string) string {
|
||||||
|
words := strings.Split(input, "_")
|
||||||
|
for i := range words {
|
||||||
|
words[i] = strings.Title(strings.ToLower(words[i]))
|
||||||
|
}
|
||||||
|
return strings.Join(words, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidGoVariableName(name string) bool {
|
||||||
|
// Variable name must not be empty
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// First character must be a letter or underscore
|
||||||
|
if !unicode.IsLetter(rune(name[0])) && name[0] != '_' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Remaining characters must be letters, digits, or underscores
|
||||||
|
for _, char := range name[1:] {
|
||||||
|
if !unicode.IsLetter(char) && !unicode.IsDigit(char) && char != '_' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func normalizePath(path string) string {
|
func normalizePath(path string) string {
|
||||||
return strings.ReplaceAll(path, `\`, "/")
|
return strings.ReplaceAll(path, `\`, "/")
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +101,32 @@ func sliceCommonPrefix(dir1, dir2 string) string {
|
||||||
return normalizePath(slicedDir2)
|
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) {
|
func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial) bool) ([]Partial, error) {
|
||||||
var partials []Partial
|
var partials []Partial
|
||||||
cwd := process.GetWorkingDir()
|
cwd := process.GetWorkingDir()
|
||||||
|
|
@ -107,7 +163,7 @@ func findPublicFuncsReturningHPartial(dir string, predicate func(partial Partial
|
||||||
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
|
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
|
||||||
// Check if the package name is 'h' and type is 'Partial'.
|
// Check if the package name is 'h' and type is 'Partial'.
|
||||||
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
|
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
|
||||||
if selectorExpr.Sel.Name == "Partial" {
|
if selectorExpr.Sel.Name == "Partial" && hasOnlyReqContextParam(funcDecl.Type) {
|
||||||
p := Partial{
|
p := Partial{
|
||||||
Package: node.Name.Name,
|
Package: node.Name.Name,
|
||||||
Path: normalizePath(sliceCommonPrefix(cwd, path)),
|
Path: normalizePath(sliceCommonPrefix(cwd, path)),
|
||||||
|
|
@ -174,7 +230,7 @@ func findPublicFuncsReturningHPage(dir string) ([]Page, error) {
|
||||||
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
|
if selectorExpr, ok := starExpr.X.(*ast.SelectorExpr); ok {
|
||||||
// Check if the package name is 'h' and type is 'Partial'.
|
// Check if the package name is 'h' and type is 'Partial'.
|
||||||
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
|
if ident, ok := selectorExpr.X.(*ast.Ident); ok && ident.Name == "h" {
|
||||||
if selectorExpr.Sel.Name == "Page" {
|
if selectorExpr.Sel.Name == "Page" && hasOnlyReqContextParam(funcDecl.Type) {
|
||||||
pages = append(pages, Page{
|
pages = append(pages, Page{
|
||||||
Package: node.Name.Name,
|
Package: node.Name.Name,
|
||||||
Import: normalizePath(filepath.Dir(path)),
|
Import: normalizePath(filepath.Dir(path)),
|
||||||
|
|
@ -204,59 +260,34 @@ func findPublicFuncsReturningHPage(dir string) ([]Page, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildGetPartialFromContext(builder *CodeBuilder, partials []Partial) {
|
func buildGetPartialFromContext(builder *CodeBuilder, partials []Partial) {
|
||||||
fName := "GetPartialFromContext"
|
|
||||||
|
|
||||||
body := `
|
|
||||||
path := r.URL.Path
|
|
||||||
`
|
|
||||||
|
|
||||||
if len(partials) == 0 {
|
|
||||||
body = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
moduleName := GetModuleName()
|
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(`
|
var routerHandlerMethod = func(path string, caller string) string {
|
||||||
if path == "%s" || path == "%s" {
|
return fmt.Sprintf(`
|
||||||
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
|
router.Handle("%s", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
return %s(cc)
|
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
|
||||||
}
|
partial := %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 {
|
if partial == nil {
|
||||||
w.WriteHeader(404)
|
w.WriteHeader(404)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.PartialView(w, partial)
|
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
|
||||||
}
|
}
|
||||||
`, moduleName)
|
`, strings.Join(handlerMethods, "\n"))
|
||||||
|
|
||||||
builder.AppendLine(registerFunction)
|
builder.AppendLine(registerFunction)
|
||||||
}
|
}
|
||||||
|
|
@ -265,7 +296,7 @@ func writePartialsFile() {
|
||||||
config := dirutil.GetConfig()
|
config := dirutil.GetConfig()
|
||||||
|
|
||||||
cwd := process.GetWorkingDir()
|
cwd := process.GetWorkingDir()
|
||||||
partialPath := filepath.Join(cwd, "partials")
|
partialPath := filepath.Join(cwd)
|
||||||
partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool {
|
partials, err := findPublicFuncsReturningHPartial(partialPath, func(partial Partial) bool {
|
||||||
return partial.FuncName != "GetPartialFromContext"
|
return partial.FuncName != "GetPartialFromContext"
|
||||||
})
|
})
|
||||||
|
|
@ -282,10 +313,13 @@ func writePartialsFile() {
|
||||||
builder := NewCodeBuilder(nil)
|
builder := NewCodeBuilder(nil)
|
||||||
builder.AppendLine(GeneratedFileLine)
|
builder.AppendLine(GeneratedFileLine)
|
||||||
builder.AppendLine(PackageName)
|
builder.AppendLine(PackageName)
|
||||||
builder.AddImport(ModuleName)
|
|
||||||
builder.AddImport(HttpModuleName)
|
|
||||||
builder.AddImport(ChiModuleName)
|
builder.AddImport(ChiModuleName)
|
||||||
|
|
||||||
|
if len(partials) > 0 {
|
||||||
|
builder.AddImport(ModuleName)
|
||||||
|
builder.AddImport(HttpModuleName)
|
||||||
|
}
|
||||||
|
|
||||||
moduleName := GetModuleName()
|
moduleName := GetModuleName()
|
||||||
for _, partial := range partials {
|
for _, partial := range partials {
|
||||||
builder.AddImport(fmt.Sprintf(`%s/%s`, moduleName, partial.Import))
|
builder.AddImport(fmt.Sprintf(`%s/%s`, moduleName, partial.Import))
|
||||||
|
|
@ -390,9 +424,96 @@ 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
|
||||||
|
|
||||||
|
builder := strings.Builder{}
|
||||||
|
|
||||||
|
builder.WriteString(`package assets`)
|
||||||
|
builder.WriteString("\n")
|
||||||
|
|
||||||
|
filepath.WalkDir(distAssets, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(d.Name(), ".") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
path = strings.ReplaceAll(path, distAssets, "")
|
||||||
|
httpUrl := normalizePath(fmt.Sprintf("%s%s", config.PublicAssetPath, path))
|
||||||
|
|
||||||
|
path = normalizePath(path)
|
||||||
|
path = strings.ReplaceAll(path, "/", "_")
|
||||||
|
path = strings.ReplaceAll(path, "//", "_")
|
||||||
|
|
||||||
|
name := strings.ReplaceAll(path, ".", "_")
|
||||||
|
name = strings.ReplaceAll(name, "-", "_")
|
||||||
|
|
||||||
|
name = toPascaleCase(name)
|
||||||
|
|
||||||
|
if isValidGoVariableName(name) {
|
||||||
|
builder.WriteString(fmt.Sprintf(`const %s = "%s"`, name, httpUrl))
|
||||||
|
builder.WriteString("\n")
|
||||||
|
hasAssets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
builder.WriteString("\n")
|
||||||
|
|
||||||
|
str := builder.String()
|
||||||
|
|
||||||
|
if hasAssets {
|
||||||
|
WriteFile(filepath.Join(GeneratedDirName, "assets", "assets-generated.go"), func(content *ast.File) string {
|
||||||
|
return str
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
func GetModuleName() string {
|
||||||
wd := process.GetWorkingDir()
|
wd := process.GetWorkingDir()
|
||||||
modPath := filepath.Join(wd, "go.mod")
|
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)
|
goModBytes, err := os.ReadFile(modPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error reading go.mod: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error reading go.mod: %v\n", err)
|
||||||
|
|
@ -411,6 +532,7 @@ func GenAst(flags ...process.RunFlag) error {
|
||||||
}
|
}
|
||||||
writePartialsFile()
|
writePartialsFile()
|
||||||
writePagesFile()
|
writePagesFile()
|
||||||
|
writeAssetsFile()
|
||||||
|
|
||||||
WriteFile("__htmgo/setup-generated.go", func(content *ast.File) string {
|
WriteFile("__htmgo/setup-generated.go", func(content *ast.File) string {
|
||||||
|
|
||||||
|
|
|
||||||
6
cli/htmgo/tasks/astgen/project-sample/.gitignore
vendored
Normal file
6
cli/htmgo/tasks/astgen/project-sample/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/assets/dist
|
||||||
|
tmp
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
__htmgo
|
||||||
|
dist
|
||||||
13
cli/htmgo/tasks/astgen/project-sample/assets.go
Normal file
13
cli/htmgo/tasks/astgen/project-sample/assets.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//go:build !prod
|
||||||
|
// +build !prod
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"astgen-project-sample/internal/embedded"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetStaticAssets() fs.FS {
|
||||||
|
return embedded.NewOsFs()
|
||||||
|
}
|
||||||
16
cli/htmgo/tasks/astgen/project-sample/assets_prod.go
Normal file
16
cli/htmgo/tasks/astgen/project-sample/assets_prod.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//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
|
||||||
|
}
|
||||||
11
cli/htmgo/tasks/astgen/project-sample/go.mod
Normal file
11
cli/htmgo/tasks/astgen/project-sample/go.mod
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
18
cli/htmgo/tasks/astgen/project-sample/go.sum
Normal file
18
cli/htmgo/tasks/astgen/project-sample/go.sum
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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=
|
||||||
|
|
@ -1,10 +1,3 @@
|
||||||
## Htmgo Configuration:
|
|
||||||
|
|
||||||
Certain aspects of htmgo can be configured via a `htmgo.yml` file in the root of your project.
|
|
||||||
|
|
||||||
Here is an example configuration file:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# htmgo configuration
|
# htmgo configuration
|
||||||
|
|
||||||
# if tailwindcss is enabled, htmgo will automatically compile your tailwind and output it to assets/dist
|
# if tailwindcss is enabled, htmgo will automatically compile your tailwind and output it to assets/dist
|
||||||
|
|
@ -23,4 +16,6 @@ automatic_page_routing_ignore: ["root.go"]
|
||||||
# files or directories to ignore when automatically registering routes for partials
|
# files or directories to ignore when automatically registering routes for partials
|
||||||
# supports glob patterns through https://github.com/bmatcuk/doublestar
|
# supports glob patterns through https://github.com/bmatcuk/doublestar
|
||||||
automatic_partial_routing_ignore: []
|
automatic_partial_routing_ignore: []
|
||||||
```
|
|
||||||
|
# url path of where the public assets are located
|
||||||
|
public_asset_path: "/public"
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
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{}
|
||||||
|
}
|
||||||
36
cli/htmgo/tasks/astgen/project-sample/main.go
Normal file
36
cli/htmgo/tasks/astgen/project-sample/main.go
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
30
cli/htmgo/tasks/astgen/project-sample/pages/index.go
Normal file
30
cli/htmgo/tasks/astgen/project-sample/pages/index.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
40
cli/htmgo/tasks/astgen/project-sample/pages/root.go
Normal file
40
cli/htmgo/tasks/astgen/project-sample/pages/root.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
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...),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
18
cli/htmgo/tasks/astgen/project-sample/partials/index.go
Normal file
18
cli/htmgo/tasks/astgen/project-sample/partials/index.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
66
cli/htmgo/tasks/astgen/registration_test.go
Normal file
66
cli/htmgo/tasks/astgen/registration_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
@ -78,7 +78,7 @@ func downloadTailwindCli() {
|
||||||
log.Fatal(fmt.Sprintf("Unsupported OS/ARCH: %s/%s", os, arch))
|
log.Fatal(fmt.Sprintf("Unsupported OS/ARCH: %s/%s", os, arch))
|
||||||
}
|
}
|
||||||
fileName := fmt.Sprintf(`tailwindcss-%s`, distro)
|
fileName := fmt.Sprintf(`tailwindcss-%s`, distro)
|
||||||
url := fmt.Sprintf(`https://github.com/tailwindlabs/tailwindcss/releases/latest/download/%s`, fileName)
|
url := fmt.Sprintf(`https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.16/%s`, fileName)
|
||||||
|
|
||||||
cmd := fmt.Sprintf(`curl -LO %s`, url)
|
cmd := fmt.Sprintf(`curl -LO %s`, url)
|
||||||
process.Run(process.NewRawCommand("tailwind-cli-download", cmd, process.ExitOnError))
|
process.Run(process.NewRawCommand("tailwind-cli-download", cmd, process.ExitOnError))
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,21 @@ import (
|
||||||
|
|
||||||
func MakeBuildable() {
|
func MakeBuildable() {
|
||||||
copyassets.CopyAssets()
|
copyassets.CopyAssets()
|
||||||
astgen.GenAst(process.ExitOnError)
|
|
||||||
css.GenerateCss(process.ExitOnError)
|
css.GenerateCss(process.ExitOnError)
|
||||||
|
astgen.GenAst(process.ExitOnError)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Build() {
|
func Build() {
|
||||||
MakeBuildable()
|
MakeBuildable()
|
||||||
|
|
||||||
process.RunOrExit(process.NewRawCommand("", "mkdir -p ./dist"))
|
_ = os.RemoveAll("./dist")
|
||||||
|
|
||||||
|
err := os.Mkdir("./dist", 0755)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error creating dist directory", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
if os.Getenv("SKIP_GO_BUILD") != "1" {
|
if os.Getenv("SKIP_GO_BUILD") != "1" {
|
||||||
process.RunOrExit(process.NewRawCommand("", fmt.Sprintf("go build -tags prod -o ./dist")))
|
process.RunOrExit(process.NewRawCommand("", fmt.Sprintf("go build -tags prod -o ./dist")))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,42 @@
|
||||||
package run
|
package run
|
||||||
|
|
||||||
import "github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/maddalax/htmgo/cli/htmgo/tasks/process"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
func Server(flags ...process.RunFlag) error {
|
func Server(flags ...process.RunFlag) error {
|
||||||
return process.Run(process.NewRawCommand("run-server", "go run .", flags...))
|
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...))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Error("error:", err.Error())
|
slog.Error("error:", slog.String("error", err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -118,7 +118,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
err = watcher.Add(path)
|
err = watcher.Add(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error adding directory to watcher:", err)
|
slog.Error("Error adding directory to watcher:", slog.String("error", err.Error()))
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("Watching directory:", slog.String("path", path))
|
slog.Debug("Watching directory:", slog.String("path", path))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ go 1.23.0
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.1.0
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/maddalax/htmgo/framework v1.0.3-0.20241029104813-cb012a4d824c
|
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
|
||||||
github.com/mattn/go-sqlite3 v1.14.23
|
github.com/mattn/go-sqlite3 v1.14.23
|
||||||
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/maddalax/htmgo/framework v1.0.3-0.20241029104813-cb012a4d824c h1:eIXXG18EB07E5uLqa9dJV2CWytQr5Q/HKrvlHNHXovM=
|
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.3-0.20241029104813-cb012a4d824c/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
||||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
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/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ func Handle() http.HandlerFunc {
|
||||||
defer manager.Disconnect(sessionId)
|
defer manager.Disconnect(sessionId)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
fmt.Printf("empting channels\n")
|
|
||||||
for len(writer) > 0 {
|
for len(writer) > 0 {
|
||||||
<-writer
|
<-writer
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,16 +70,14 @@ func (manager *SocketManager) Listen(listener chan SocketEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *SocketManager) dispatch(event SocketEvent) {
|
func (manager *SocketManager) dispatch(event SocketEvent) {
|
||||||
fmt.Printf("dispatching event: %s\n", event.Type)
|
|
||||||
done := make(chan struct{}, 1)
|
done := make(chan struct{}, 1)
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
fmt.Printf("dispatched event: %s\n", event.Type)
|
|
||||||
return
|
return
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
fmt.Printf("havent dispatched event after 5s, chan blocked: %s\n", event.Type)
|
fmt.Printf("havent dispatched listener event after 5s, chan blocked: %s\n", event.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ module hackernews
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/maddalax/htmgo/framework v1.0.3-0.20241029104813-cb012a4d824c
|
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/maddalax/htmgo/framework v1.0.3-0.20241029104813-cb012a4d824c h1:eIXXG18EB07E5uLqa9dJV2CWytQr5Q/HKrvlHNHXovM=
|
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.3-0.20241029104813-cb012a4d824c/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
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/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|
|
||||||
6
examples/minimal-htmgo/.gitignore
vendored
Normal file
6
examples/minimal-htmgo/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/assets/dist
|
||||||
|
tmp
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
__htmgo
|
||||||
|
dist
|
||||||
8
examples/minimal-htmgo/README.md
Normal file
8
examples/minimal-htmgo/README.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
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
|
||||||
10
examples/minimal-htmgo/go.mod
Normal file
10
examples/minimal-htmgo/go.mod
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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
|
||||||
16
examples/minimal-htmgo/go.sum
Normal file
16
examples/minimal-htmgo/go.sum
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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=
|
||||||
44
examples/minimal-htmgo/index.go
Normal file
44
examples/minimal-htmgo/index.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
23
examples/minimal-htmgo/main.go
Normal file
23
examples/minimal-htmgo/main.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
1
examples/minimal-htmgo/public/htmgo.js
Normal file
1
examples/minimal-htmgo/public/htmgo.js
Normal file
File diff suppressed because one or more lines are too long
26
examples/minimal-htmgo/render.go
Normal file
26
examples/minimal-htmgo/render.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ module simpleauth
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/maddalax/htmgo/framework v1.0.3-0.20241029104813-cb012a4d824c
|
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
golang.org/x/crypto v0.28.0
|
golang.org/x/crypto v0.28.0
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/maddalax/htmgo/framework v1.0.3-0.20241029104813-cb012a4d824c h1:eIXXG18EB07E5uLqa9dJV2CWytQr5Q/HKrvlHNHXovM=
|
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.3-0.20241029104813-cb012a4d824c/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
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/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ go 1.23.0
|
||||||
require (
|
require (
|
||||||
entgo.io/ent v0.14.1
|
entgo.io/ent v0.14.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/maddalax/htmgo/framework v1.0.3-0.20241029104813-cb012a4d824c
|
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
|
||||||
github.com/mattn/go-sqlite3 v1.14.23
|
github.com/mattn/go-sqlite3 v1.14.23
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/maddalax/htmgo/framework v1.0.3-0.20241029104813-cb012a4d824c h1:eIXXG18EB07E5uLqa9dJV2CWytQr5Q/HKrvlHNHXovM=
|
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.3-0.20241029104813-cb012a4d824c/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
||||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
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/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=
|
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
|
||||||
|
|
|
||||||
11
examples/ws-example/.dockerignore
Normal file
11
examples/ws-example/.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Project exclude paths
|
||||||
|
/tmp/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
js/dist
|
||||||
|
js/node_modules
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
.idea
|
||||||
|
!framework/assets/dist
|
||||||
|
__htmgo
|
||||||
6
examples/ws-example/.gitignore
vendored
Normal file
6
examples/ws-example/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/assets/dist
|
||||||
|
tmp
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
__htmgo
|
||||||
|
dist
|
||||||
38
examples/ws-example/Dockerfile
Normal file
38
examples/ws-example/Dockerfile
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# 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"]
|
||||||
20
examples/ws-example/Taskfile.yml
Normal file
20
examples/ws-example/Taskfile.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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
|
||||||
13
examples/ws-example/assets.go
Normal file
13
examples/ws-example/assets.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//go:build !prod
|
||||||
|
// +build !prod
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"ws-example/internal/embedded"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetStaticAssets() fs.FS {
|
||||||
|
return embedded.NewOsFs()
|
||||||
|
}
|
||||||
3
examples/ws-example/assets/css/input.css
Normal file
3
examples/ws-example/assets/css/input.css
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
16
examples/ws-example/assets_prod.go
Normal file
16
examples/ws-example/assets_prod.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//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
|
||||||
|
}
|
||||||
18
examples/ws-example/go.mod
Normal file
18
examples/ws-example/go.mod
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
28
examples/ws-example/go.sum
Normal file
28
examples/ws-example/go.sum
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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=
|
||||||
17
examples/ws-example/internal/embedded/os.go
Normal file
17
examples/ws-example/internal/embedded/os.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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{}
|
||||||
|
}
|
||||||
0
examples/ws-example/k6.js
Normal file
0
examples/ws-example/k6.js
Normal file
48
examples/ws-example/main.go
Normal file
48
examples/ws-example/main.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
57
examples/ws-example/pages/index.go
Normal file
57
examples/ws-example/pages/index.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
26
examples/ws-example/pages/root.go
Normal file
26
examples/ws-example/pages/root.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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...),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
129
examples/ws-example/pages/ws/metrics.go
Normal file
129
examples/ws-example/pages/ws/metrics.go
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
72
examples/ws-example/partials/index.go
Normal file
72
examples/ws-example/partials/index.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
84
examples/ws-example/partials/repeater.go
Normal file
84
examples/ws-example/partials/repeater.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
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++
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
5
examples/ws-example/tailwind.config.js
Normal file
5
examples/ws-example/tailwind.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["**/*.go"],
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
21
extensions/websocket/go.mod
Normal file
21
extensions/websocket/go.mod
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
28
extensions/websocket/go.sum
Normal file
28
extensions/websocket/go.sum
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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=
|
||||||
31
extensions/websocket/init.go
Normal file
31
extensions/websocket/init.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
115
extensions/websocket/internal/wsutil/handler.go
Normal file
115
extensions/websocket/internal/wsutil/handler.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
365
extensions/websocket/internal/wsutil/manager.go
Normal file
365
extensions/websocket/internal/wsutil/manager.go
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
202
extensions/websocket/internal/wsutil/manager_test.go
Normal file
202
extensions/websocket/internal/wsutil/manager_test.go
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
9
extensions/websocket/opts/opts.go
Normal file
9
extensions/websocket/opts/opts.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
77
extensions/websocket/session/session.go
Normal file
77
extensions/websocket/session/session.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
10
extensions/websocket/ws/access.go
Normal file
10
extensions/websocket/ws/access.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
20
extensions/websocket/ws/attribute.go
Normal file
20
extensions/websocket/ws/attribute.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
47
extensions/websocket/ws/dispatch.go
Normal file
47
extensions/websocket/ws/dispatch.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
29
extensions/websocket/ws/every.go
Normal file
29
extensions/websocket/ws/every.go
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
90
extensions/websocket/ws/handler.go
Normal file
90
extensions/websocket/ws/handler.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
46
extensions/websocket/ws/listener.go
Normal file
46
extensions/websocket/ws/listener.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
extensions/websocket/ws/metrics.go
Normal file
19
extensions/websocket/ws/metrics.go
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
92
extensions/websocket/ws/register.go
Normal file
92
extensions/websocket/ws/register.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ module github.com/maddalax/htmgo/framework-ui
|
||||||
|
|
||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
require github.com/maddalax/htmgo/framework v1.0.3-0.20241029104813-cb012a4d824c
|
require github.com/maddalax/htmgo/framework v1.0.7-0.20250703190716-06f01b3d7c1b
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
||||||
|
|
|
||||||
|
|
@ -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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/maddalax/htmgo/framework v1.0.3-0.20241029104813-cb012a4d824c h1:eIXXG18EB07E5uLqa9dJV2CWytQr5Q/HKrvlHNHXovM=
|
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.3-0.20241029104813-cb012a4d824c/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
|
|
||||||
2
framework/assets/dist/htmgo.js
vendored
2
framework/assets/dist/htmgo.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -7,6 +7,8 @@ import "./htmxextensions/mutation-error";
|
||||||
import "./htmxextensions/livereload"
|
import "./htmxextensions/livereload"
|
||||||
import "./htmxextensions/htmgo";
|
import "./htmxextensions/htmgo";
|
||||||
import "./htmxextensions/sse"
|
import "./htmxextensions/sse"
|
||||||
|
import "./htmxextensions/ws"
|
||||||
|
import "./htmxextensions/ws-event-handler"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.htmx = htmx;
|
window.htmx = htmx;
|
||||||
|
|
@ -44,7 +46,6 @@ function onUrlChange(newUrl: string) {
|
||||||
for (let [key, values] of url.searchParams) {
|
for (let [key, values] of url.searchParams) {
|
||||||
let eventName = "qs:" + key;
|
let eventName = "qs:" + key;
|
||||||
if (triggers.includes(eventName)) {
|
if (triggers.includes(eventName)) {
|
||||||
console.log("triggering", eventName);
|
|
||||||
htmx.trigger(element, eventName, null);
|
htmx.trigger(element, eventName, null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
framework/assets/js/htmxextensions/extension.ts
Normal file
13
framework/assets/js/htmxextensions/extension.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,18 @@
|
||||||
import htmx from "htmx.org";
|
import htmx from "htmx.org";
|
||||||
|
import {hasExtension} from "./extension";
|
||||||
|
|
||||||
let lastVersion = "";
|
let lastVersion = "";
|
||||||
|
|
||||||
htmx.defineExtension("livereload", {
|
htmx.defineExtension("livereload", {
|
||||||
init: function () {
|
init: function () {
|
||||||
|
|
||||||
let enabled = false
|
let enabled = hasExtension("livereload")
|
||||||
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) {
|
if(!enabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('livereload extension initialized.');
|
console.info('livereload extension initialized.');
|
||||||
// Create a new EventSource object and point it to your SSE endpoint
|
// Create a new EventSource object and point it to your SSE endpoint
|
||||||
const eventSource = new EventSource('/dev/livereload');
|
const eventSource = new EventSource('/dev/livereload');
|
||||||
// Listen for messages from the server
|
// Listen for messages from the server
|
||||||
|
|
|
||||||
73
framework/assets/js/htmxextensions/ws-event-handler.ts
Normal file
73
framework/assets/js/htmxextensions/ws-event-handler.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
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([])
|
||||||
|
}
|
||||||
|
|
||||||
87
framework/assets/js/htmxextensions/ws.ts
Normal file
87
framework/assets/js/htmxextensions/ws.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ type ProjectConfig struct {
|
||||||
WatchFiles []string `yaml:"watch_files"`
|
WatchFiles []string `yaml:"watch_files"`
|
||||||
AutomaticPageRoutingIgnore []string `yaml:"automatic_page_routing_ignore"`
|
AutomaticPageRoutingIgnore []string `yaml:"automatic_page_routing_ignore"`
|
||||||
AutomaticPartialRoutingIgnore []string `yaml:"automatic_partial_routing_ignore"`
|
AutomaticPartialRoutingIgnore []string `yaml:"automatic_partial_routing_ignore"`
|
||||||
|
PublicAssetPath string `yaml:"public_asset_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultProjectConfig() *ProjectConfig {
|
func DefaultProjectConfig() *ProjectConfig {
|
||||||
|
|
@ -25,6 +26,7 @@ func DefaultProjectConfig() *ProjectConfig {
|
||||||
WatchFiles: []string{
|
WatchFiles: []string{
|
||||||
"**/*.go", "**/*.html", "**/*.css", "**/*.js", "**/*.json", "**/*.yaml", "**/*.yml", "**/*.md",
|
"**/*.go", "**/*.html", "**/*.css", "**/*.js", "**/*.json", "**/*.yaml", "**/*.yml", "**/*.md",
|
||||||
},
|
},
|
||||||
|
PublicAssetPath: "/public",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,9 +59,22 @@ func (cfg *ProjectConfig) Enhance() *ProjectConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.PublicAssetPath == "" {
|
||||||
|
cfg.PublicAssetPath = "/public"
|
||||||
|
}
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Get() *ProjectConfig {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return DefaultProjectConfig()
|
||||||
|
}
|
||||||
|
config := FromConfigFile(cwd)
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
func FromConfigFile(workingDir string) *ProjectConfig {
|
func FromConfigFile(workingDir string) *ProjectConfig {
|
||||||
defaultCfg := DefaultProjectConfig()
|
defaultCfg := DefaultProjectConfig()
|
||||||
names := []string{"htmgo.yaml", "htmgo.yml", "_htmgo.yaml", "_htmgo.yml"}
|
names := []string{"htmgo.yaml", "htmgo.yml", "_htmgo.yaml", "_htmgo.yml"}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,21 @@ func TestShouldPrefixAutomaticPartialRoutingIgnore_1(t *testing.T) {
|
||||||
assert.Equal(t, []string{"partials/somefile/*"}, cfg.AutomaticPartialRoutingIgnore)
|
assert.Equal(t, []string{"partials/somefile/*"}, cfg.AutomaticPartialRoutingIgnore)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublicAssetPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := DefaultProjectConfig()
|
||||||
|
assert.Equal(t, "/public", cfg.PublicAssetPath)
|
||||||
|
|
||||||
|
cfg.PublicAssetPath = "/assets"
|
||||||
|
assert.Equal(t, "/assets", cfg.PublicAssetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigGet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := Get()
|
||||||
|
assert.Equal(t, "/public", cfg.PublicAssetPath)
|
||||||
|
}
|
||||||
|
|
||||||
func writeConfigFile(t *testing.T, content string) string {
|
func writeConfigFile(t *testing.T, content string) string {
|
||||||
temp := os.TempDir()
|
temp := os.TempDir()
|
||||||
os.Mkdir(temp, 0755)
|
os.Mkdir(temp, 0755)
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,33 @@ func TestOrderedMap(t *testing.T) {
|
||||||
assert.False(t, ok)
|
assert.False(t, ok)
|
||||||
assert.Equal(t, 0, value)
|
assert.Equal(t, 0, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOrderedMapEach(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
om := New[string, int]()
|
||||||
|
om.Set("one", 1)
|
||||||
|
om.Set("two", 2)
|
||||||
|
om.Set("three", 3)
|
||||||
|
|
||||||
|
expected := map[string]int{"one": 1, "two": 2, "three": 3}
|
||||||
|
actual := make(map[string]int)
|
||||||
|
|
||||||
|
om.Each(func(key string, value int) {
|
||||||
|
actual[key] = value
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderedMapValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
om := New[string, int]()
|
||||||
|
om.Set("first", 10)
|
||||||
|
om.Set("second", 20)
|
||||||
|
om.Set("third", 30)
|
||||||
|
|
||||||
|
values := om.Values()
|
||||||
|
expectedValues := []int{10, 20, 30}
|
||||||
|
|
||||||
|
assert.Equal(t, expectedValues, values)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ type AppOpts struct {
|
||||||
LiveReload bool
|
LiveReload bool
|
||||||
ServiceLocator *service.Locator
|
ServiceLocator *service.Locator
|
||||||
Register func(app *App)
|
Register func(app *App)
|
||||||
|
Port int
|
||||||
}
|
}
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
|
|
@ -174,6 +175,16 @@ 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 {
|
func GetLogLevel() slog.Level {
|
||||||
// Get the log level from the environment variable
|
// Get the log level from the environment variable
|
||||||
logLevel := os.Getenv("LOG_LEVEL")
|
logLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
|
@ -219,6 +230,22 @@ func (app *App) start() {
|
||||||
}
|
}
|
||||||
|
|
||||||
port := ":3000"
|
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))
|
slog.Info(fmt.Sprintf("Server started at localhost%s", port))
|
||||||
|
|
||||||
if err := http.ListenAndServe(port, app.Router); err != nil {
|
if err := http.ListenAndServe(port, app.Router); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/maddalax/htmgo/framework/datastructure/orderedmap"
|
"github.com/maddalax/htmgo/framework/datastructure/orderedmap"
|
||||||
"github.com/maddalax/htmgo/framework/hx"
|
"github.com/maddalax/htmgo/framework/hx"
|
||||||
|
"github.com/maddalax/htmgo/framework/internal/util"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -196,6 +197,10 @@ func Hidden() Ren {
|
||||||
return Attribute("style", "display:none")
|
return Attribute("style", "display:none")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Controls() Ren {
|
||||||
|
return Attribute("controls", "")
|
||||||
|
}
|
||||||
|
|
||||||
func Class(value ...string) *AttributeR {
|
func Class(value ...string) *AttributeR {
|
||||||
return Attribute("class", MergeClasses(value...))
|
return Attribute("class", MergeClasses(value...))
|
||||||
}
|
}
|
||||||
|
|
@ -354,3 +359,7 @@ func AriaHidden(value bool) *AttributeR {
|
||||||
func TabIndex(value int) *AttributeR {
|
func TabIndex(value int) *AttributeR {
|
||||||
return Attribute("tabindex", fmt.Sprintf("%d", value))
|
return Attribute("tabindex", fmt.Sprintf("%d", value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GenId(len int) string {
|
||||||
|
return util.RandSeq(len)
|
||||||
|
}
|
||||||
|
|
|
||||||
160
framework/h/attribute_test.go
Normal file
160
framework/h/attribute_test.go
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
package h
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/maddalax/htmgo/framework/hx"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAttributes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
attribute *AttributeR
|
||||||
|
expectedKey string
|
||||||
|
expectedValue string
|
||||||
|
}{
|
||||||
|
{"NoSwap", NoSwap(), "hx-swap", "none"},
|
||||||
|
{"Checked", Checked().(*AttributeR), "checked", ""},
|
||||||
|
{"Id", Id("myID").(*AttributeR), "id", "myID"},
|
||||||
|
{"Disabled", Disabled(), "disabled", ""},
|
||||||
|
{"HxTarget", HxTarget("#myTarget").(*AttributeR), "hx-target", "#myTarget"},
|
||||||
|
{"Name", Name("myName").(*AttributeR), "name", "myName"},
|
||||||
|
{"HxConfirm", HxConfirm("Are you sure?").(*AttributeR), "hx-confirm", "Are you sure?"},
|
||||||
|
{"Class", Class("class1", "class2"), "class", "class1 class2 "},
|
||||||
|
{"ReadOnly", ReadOnly(), "readonly", ""},
|
||||||
|
{"Required", Required(), "required", ""},
|
||||||
|
{"Multiple", Multiple(), "multiple", ""},
|
||||||
|
{"Selected", Selected(), "selected", ""},
|
||||||
|
{"MaxLength", MaxLength(10), "maxlength", "10"},
|
||||||
|
{"MinLength", MinLength(5), "minlength", "5"},
|
||||||
|
{"Size", Size(3), "size", "3"},
|
||||||
|
{"Width", Width(100), "width", "100"},
|
||||||
|
{"Height", Height(200), "height", "200"},
|
||||||
|
{"Download", Download(true), "download", "true"},
|
||||||
|
{"Rel", Rel("noopener"), "rel", "noopener"},
|
||||||
|
{"Pattern", Pattern("[A-Za-z]+"), "pattern", "[A-Za-z]+"},
|
||||||
|
{"Action", Action("/submit"), "action", "/submit"},
|
||||||
|
{"Method", Method("POST"), "method", "POST"},
|
||||||
|
{"Enctype", Enctype("multipart/form-data"), "enctype", "multipart/form-data"},
|
||||||
|
{"AutoComplete", AutoComplete("on"), "autocomplete", "on"},
|
||||||
|
{"AutoFocus", AutoFocus(), "autofocus", ""},
|
||||||
|
{"NoValidate", NoValidate(), "novalidate", ""},
|
||||||
|
{"Step", Step("0.1"), "step", "0.1"},
|
||||||
|
{"Max", Max("100"), "max", "100"},
|
||||||
|
{"Min", Min("0"), "min", "0"},
|
||||||
|
{"Cols", Cols(30), "cols", "30"},
|
||||||
|
{"Rows", Rows(10), "rows", "10"},
|
||||||
|
{"Wrap", Wrap("soft"), "wrap", "soft"},
|
||||||
|
{"Role", Role("button"), "role", "button"},
|
||||||
|
{"AriaLabel", AriaLabel("Close Dialog"), "aria-label", "Close Dialog"},
|
||||||
|
{"AriaHidden", AriaHidden(true), "aria-hidden", "true"},
|
||||||
|
{"TabIndex", TabIndex(1), "tabindex", "1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expectedKey, tt.attribute.Name)
|
||||||
|
assert.Equal(t, tt.expectedValue, tt.attribute.Value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassF(t *testing.T) {
|
||||||
|
attribute := ClassF("class-%d", 123)
|
||||||
|
assert.Equal(t, "class", attribute.Name)
|
||||||
|
assert.Equal(t, "class-123", attribute.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassX(t *testing.T) {
|
||||||
|
classMap := ClassMap{"visible": true, "hidden": false}
|
||||||
|
attribute := ClassX("base", classMap).(*AttributeR)
|
||||||
|
assert.Equal(t, "class", attribute.Name)
|
||||||
|
assert.Equal(t, "base visible ", attribute.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinAttributes(t *testing.T) {
|
||||||
|
attr1 := Attribute("data-attr", "one")
|
||||||
|
attr2 := Attribute("data-attr", "two")
|
||||||
|
joined := JoinAttributes(", ", attr1, attr2)
|
||||||
|
assert.Equal(t, "data-attr", joined.Name)
|
||||||
|
assert.Equal(t, "one, two", joined.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTarget(t *testing.T) {
|
||||||
|
attr := Target("_blank")
|
||||||
|
assert.Equal(t, "target", attr.(*AttributeR).Name)
|
||||||
|
assert.Equal(t, "_blank", attr.(*AttributeR).Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestD(t *testing.T) {
|
||||||
|
attr := D("M10 10 H 90 V 90 H 10 Z")
|
||||||
|
assert.Equal(t, "d", attr.(*AttributeR).Name)
|
||||||
|
assert.Equal(t, "M10 10 H 90 V 90 H 10 Z", attr.(*AttributeR).Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHxExtension(t *testing.T) {
|
||||||
|
attr := HxExtension("trigger-children")
|
||||||
|
assert.Equal(t, "hx-ext", attr.Name)
|
||||||
|
assert.Equal(t, "trigger-children", attr.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHxExtensions(t *testing.T) {
|
||||||
|
attr := HxExtensions("foo", "bar")
|
||||||
|
assert.Equal(t, "hx-ext", attr.(*AttributeR).Name)
|
||||||
|
assert.Equal(t, "foo,bar", attr.(*AttributeR).Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHxTrigger(t *testing.T) {
|
||||||
|
trigger := hx.NewTrigger(hx.OnClick()) // This assumes hx.NewTrigger is a correct call
|
||||||
|
attr := HxTrigger(hx.OnClick())
|
||||||
|
assert.Equal(t, "hx-trigger", attr.Name)
|
||||||
|
assert.Equal(t, trigger.ToString(), attr.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHxTriggerClick(t *testing.T) {
|
||||||
|
attr := HxTriggerClick() // Assuming no options for simplicity
|
||||||
|
assert.Equal(t, "hx-trigger", attr.Name)
|
||||||
|
assert.Equal(t, "click", attr.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTriggerChildren(t *testing.T) {
|
||||||
|
attr := TriggerChildren()
|
||||||
|
assert.Equal(t, "hx-ext", attr.Name)
|
||||||
|
assert.Equal(t, "trigger-children", attr.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHxInclude(t *testing.T) {
|
||||||
|
attr := HxInclude(".include-selector")
|
||||||
|
assert.Equal(t, "hx-include", attr.(*AttributeR).Name)
|
||||||
|
assert.Equal(t, ".include-selector", attr.(*AttributeR).Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHxIndicator(t *testing.T) {
|
||||||
|
attr := HxIndicator("#my-indicator")
|
||||||
|
assert.Equal(t, "hx-indicator", attr.Name)
|
||||||
|
assert.Equal(t, "#my-indicator", attr.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHidden(t *testing.T) {
|
||||||
|
attr := Hidden()
|
||||||
|
assert.Equal(t, "style", attr.(*AttributeR).Name)
|
||||||
|
assert.Equal(t, "display:none", attr.(*AttributeR).Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControls(t *testing.T) {
|
||||||
|
attr := Controls()
|
||||||
|
assert.Equal(t, "controls", attr.(*AttributeR).Name)
|
||||||
|
assert.Equal(t, "", attr.(*AttributeR).Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaceholder(t *testing.T) {
|
||||||
|
attr := Placeholder("Enter text")
|
||||||
|
assert.Equal(t, "placeholder", attr.(*AttributeR).Name)
|
||||||
|
assert.Equal(t, "Enter text", attr.(*AttributeR).Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoost(t *testing.T) {
|
||||||
|
attr := Boost()
|
||||||
|
assert.Equal(t, "hx-boost", attr.(*AttributeR).Name)
|
||||||
|
assert.Equal(t, "true", attr.(*AttributeR).Value)
|
||||||
|
}
|
||||||
|
|
@ -71,8 +71,12 @@ func SwapPartial(ctx *RequestContext, swap *Element) *Partial {
|
||||||
SwapMany(ctx, swap))
|
SwapMany(ctx, swap))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsEmptyPartial(partial *Partial) bool {
|
||||||
|
return partial.Root.tag == "" && len(partial.Root.children) == 0
|
||||||
|
}
|
||||||
|
|
||||||
func EmptyPartial() *Partial {
|
func EmptyPartial() *Partial {
|
||||||
return NewPartial(Fragment())
|
return NewPartial(Empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
func SwapManyPartial(ctx *RequestContext, swaps ...*Element) *Partial {
|
func SwapManyPartial(ctx *RequestContext, swaps ...*Element) *Partial {
|
||||||
|
|
|
||||||
141
framework/h/base_test.go
Normal file
141
framework/h/base_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
package h
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/maddalax/htmgo/framework/hx"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewPage(t *testing.T) {
|
||||||
|
root := Div()
|
||||||
|
page := NewPage(root)
|
||||||
|
|
||||||
|
assert.Equal(t, http.MethodGet, page.HttpMethod)
|
||||||
|
assert.Equal(t, root, page.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyPage(t *testing.T) {
|
||||||
|
page := EmptyPage()
|
||||||
|
|
||||||
|
assert.Equal(t, http.MethodGet, page.HttpMethod)
|
||||||
|
assert.Equal(t, Empty(), page.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPageWithHttpMethod(t *testing.T) {
|
||||||
|
root := Div()
|
||||||
|
page := NewPageWithHttpMethod(http.MethodPost, root)
|
||||||
|
|
||||||
|
assert.Equal(t, http.MethodPost, page.HttpMethod)
|
||||||
|
assert.Equal(t, root, page.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPartial(t *testing.T) {
|
||||||
|
root := Div()
|
||||||
|
partial := NewPartial(root)
|
||||||
|
|
||||||
|
assert.Nil(t, partial.Headers)
|
||||||
|
assert.Equal(t, root, partial.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPartialWithHeaders(t *testing.T) {
|
||||||
|
root := Div()
|
||||||
|
headers := NewHeaders("Content-Type", "application/json")
|
||||||
|
partial := NewPartialWithHeaders(headers, root)
|
||||||
|
|
||||||
|
assert.Equal(t, headers, partial.Headers)
|
||||||
|
assert.Equal(t, root, partial.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwapManyPartialWithHeaders(t *testing.T) {
|
||||||
|
ctx := &RequestContext{isHxRequest: true}
|
||||||
|
headers := NewHeaders("HX-Trigger", "reload")
|
||||||
|
elements := []*Element{Div(), Span()}
|
||||||
|
|
||||||
|
partial := SwapManyPartialWithHeaders(ctx, headers, elements...)
|
||||||
|
|
||||||
|
assert.Equal(t, headers, partial.Headers)
|
||||||
|
assert.Equal(t, SwapMany(ctx, elements...), partial.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectPartial(t *testing.T) {
|
||||||
|
partial := RedirectPartial("/new-path")
|
||||||
|
headers := NewHeaders("HX-Redirect", "/new-path")
|
||||||
|
|
||||||
|
assert.Equal(t, headers, partial.Headers)
|
||||||
|
assert.Equal(t, Empty(), partial.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedirectPartialWithHeaders(t *testing.T) {
|
||||||
|
extraHeaders := NewHeaders("X-Custom", "value")
|
||||||
|
partial := RedirectPartialWithHeaders("/redirect-path", extraHeaders)
|
||||||
|
|
||||||
|
expectedHeaders := NewHeaders("HX-Redirect", "/redirect-path", "X-Custom", "value")
|
||||||
|
assert.Equal(t, expectedHeaders, partial.Headers)
|
||||||
|
assert.Equal(t, Empty(), partial.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsEmptyPartial(t *testing.T) {
|
||||||
|
emptyPartial := EmptyPartial()
|
||||||
|
nonEmptyPartial := NewPartial(Div())
|
||||||
|
|
||||||
|
assert.True(t, IsEmptyPartial(emptyPartial))
|
||||||
|
assert.False(t, IsEmptyPartial(nonEmptyPartial))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPartialPath(t *testing.T) {
|
||||||
|
partial := func(ctx *RequestContext) *Partial {
|
||||||
|
return &Partial{}
|
||||||
|
}
|
||||||
|
path := GetPartialPath(partial)
|
||||||
|
|
||||||
|
expectedSegment := "github.com/maddalax/htmgo/framework/h.TestGetPartialPath.func1"
|
||||||
|
assert.Contains(t, path, expectedSegment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPartialPathWithQs(t *testing.T) {
|
||||||
|
partial := func(ctx *RequestContext) *Partial {
|
||||||
|
return &Partial{}
|
||||||
|
}
|
||||||
|
qs := NewQs("param1", "value1", "param2", "value2")
|
||||||
|
pathWithQs := GetPartialPathWithQs(partial, qs)
|
||||||
|
|
||||||
|
assert.Contains(t, pathWithQs, "param1=value1¶m2=value2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwapManyPartial(t *testing.T) {
|
||||||
|
ctx := &RequestContext{isHxRequest: true}
|
||||||
|
element1 := Div()
|
||||||
|
element2 := Span()
|
||||||
|
|
||||||
|
partial := SwapManyPartial(ctx, element1, element2)
|
||||||
|
|
||||||
|
// Ensuring the elements have been marked for swap
|
||||||
|
assert.Equal(t, 1, len(element1.children))
|
||||||
|
assert.Equal(t, 1, len(element2.children))
|
||||||
|
|
||||||
|
assert.Equal(t, Attribute(hx.SwapOobAttr, hx.SwapTypeTrue), element1.children[0])
|
||||||
|
assert.Equal(t, Attribute(hx.SwapOobAttr, hx.SwapTypeTrue), element2.children[0])
|
||||||
|
|
||||||
|
// Test with non-HX request context
|
||||||
|
ctx.isHxRequest = false
|
||||||
|
partial = SwapManyPartial(ctx, element1, element2)
|
||||||
|
assert.True(t, IsEmptyPartial(partial))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwapPartial(t *testing.T) {
|
||||||
|
ctx := &RequestContext{isHxRequest: true}
|
||||||
|
element := Div()
|
||||||
|
|
||||||
|
partial := SwapPartial(ctx, element)
|
||||||
|
|
||||||
|
// Ensuring the element has been marked for swap
|
||||||
|
assert.Equal(t, 1, len(element.children))
|
||||||
|
assert.Equal(t, Attribute(hx.SwapOobAttr, hx.SwapTypeTrue), element.children[0])
|
||||||
|
|
||||||
|
// Test with non-HX request context
|
||||||
|
ctx.isHxRequest = false
|
||||||
|
partial = SwapPartial(ctx, element)
|
||||||
|
assert.True(t, IsEmptyPartial(partial))
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
package h
|
package h
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
|
||||||
"log/slog"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"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 {
|
type CachedNode struct {
|
||||||
cb func() *Element
|
cb func() *Element
|
||||||
isByKey bool
|
isByKey bool
|
||||||
byKeyCache map[any]*Entry
|
duration time.Duration
|
||||||
byKeyExpiration map[any]time.Time
|
cache cache.Store[any, string]
|
||||||
mutex sync.Mutex
|
|
||||||
duration time.Duration
|
|
||||||
expiration time.Time
|
|
||||||
html string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
|
|
@ -35,33 +33,45 @@ 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 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)
|
type GetElementFuncT4WithKey[K comparable, T any, T2 any, T3 any, T4 any] func(T, T2, T3, T4) (K, GetElementFunc)
|
||||||
|
|
||||||
func startExpiredCacheCleaner(node *CachedNode) {
|
// CacheOption defines a function that configures a CachedNode.
|
||||||
isTests := flag.Lookup("test.v") != nil
|
type CacheOption func(*CachedNode)
|
||||||
go func() {
|
|
||||||
for {
|
// WithCacheStore allows providing a custom cache implementation for a cached component.
|
||||||
if isTests {
|
func WithCacheStore(store cache.Store[any, string]) CacheOption {
|
||||||
time.Sleep(time.Second)
|
return func(c *CachedNode) {
|
||||||
} else {
|
c.cache = store
|
||||||
time.Sleep(time.Minute)
|
}
|
||||||
}
|
}
|
||||||
node.ClearExpired()
|
|
||||||
}
|
// 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]()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cached caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
|
// 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.
|
// Please note this element is globally cached, and not per unique identifier / user.
|
||||||
// Use CachedPerKey to cache elements per unqiue identifier.
|
// Use CachedPerKey to cache elements per unique identifier.
|
||||||
func Cached(duration time.Duration, cb GetElementFunc) func() *Element {
|
func Cached(duration time.Duration, cb GetElementFunc, opts ...CacheOption) func() *Element {
|
||||||
element := &Element{
|
node := &CachedNode{
|
||||||
tag: CachedNodeTag,
|
cb: cb,
|
||||||
meta: &CachedNode{
|
duration: duration,
|
||||||
cb: cb,
|
|
||||||
html: "",
|
|
||||||
duration: duration,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
startExpiredCacheCleaner(element.meta.(*CachedNode))
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.cache == nil {
|
||||||
|
node.cache = DefaultCacheProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
element := &Element{
|
||||||
|
tag: CachedNodeTag,
|
||||||
|
meta: node,
|
||||||
|
}
|
||||||
|
|
||||||
return func() *Element {
|
return func() *Element {
|
||||||
return element
|
return element
|
||||||
}
|
}
|
||||||
|
|
@ -69,17 +79,25 @@ func Cached(duration time.Duration, cb GetElementFunc) func() *Element {
|
||||||
|
|
||||||
// CachedPerKey caches the given element for the given duration. The element is only rendered once per key, and then cached for the given duration.
|
// 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.
|
// 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]) func() *Element {
|
func CachedPerKey[K comparable](duration time.Duration, cb GetElementFuncWithKey[K], opts ...CacheOption) func() *Element {
|
||||||
element := &Element{
|
node := &CachedNode{
|
||||||
tag: CachedNodeTag,
|
isByKey: true,
|
||||||
meta: &CachedNode{
|
duration: duration,
|
||||||
isByKey: true,
|
|
||||||
cb: nil,
|
|
||||||
html: "",
|
|
||||||
duration: duration,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
startExpiredCacheCleaner(element.meta.(*CachedNode))
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.cache == nil {
|
||||||
|
node.cache = DefaultCacheProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
element := &Element{
|
||||||
|
tag: CachedNodeTag,
|
||||||
|
meta: node,
|
||||||
|
}
|
||||||
|
|
||||||
return func() *Element {
|
return func() *Element {
|
||||||
key, componentFunc := cb()
|
key, componentFunc := cb()
|
||||||
return &Element{
|
return &Element{
|
||||||
|
|
@ -101,17 +119,25 @@ 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.
|
// 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.
|
// 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]) func(T) *Element {
|
func CachedPerKeyT[K comparable, T any](duration time.Duration, cb GetElementFuncTWithKey[K, T], opts ...CacheOption) func(T) *Element {
|
||||||
element := &Element{
|
node := &CachedNode{
|
||||||
tag: CachedNodeTag,
|
isByKey: true,
|
||||||
meta: &CachedNode{
|
duration: duration,
|
||||||
isByKey: true,
|
|
||||||
cb: nil,
|
|
||||||
html: "",
|
|
||||||
duration: duration,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
startExpiredCacheCleaner(element.meta.(*CachedNode))
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.cache == nil {
|
||||||
|
node.cache = DefaultCacheProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
element := &Element{
|
||||||
|
tag: CachedNodeTag,
|
||||||
|
meta: node,
|
||||||
|
}
|
||||||
|
|
||||||
return func(data T) *Element {
|
return func(data T) *Element {
|
||||||
key, componentFunc := cb(data)
|
key, componentFunc := cb(data)
|
||||||
return &Element{
|
return &Element{
|
||||||
|
|
@ -127,17 +153,25 @@ 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.
|
// 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.
|
// 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]) func(T, T2) *Element {
|
func CachedPerKeyT2[K comparable, T any, T2 any](duration time.Duration, cb GetElementFuncT2WithKey[K, T, T2], opts ...CacheOption) func(T, T2) *Element {
|
||||||
element := &Element{
|
node := &CachedNode{
|
||||||
tag: CachedNodeTag,
|
isByKey: true,
|
||||||
meta: &CachedNode{
|
duration: duration,
|
||||||
isByKey: true,
|
|
||||||
cb: nil,
|
|
||||||
html: "",
|
|
||||||
duration: duration,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
startExpiredCacheCleaner(element.meta.(*CachedNode))
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.cache == nil {
|
||||||
|
node.cache = DefaultCacheProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
element := &Element{
|
||||||
|
tag: CachedNodeTag,
|
||||||
|
meta: node,
|
||||||
|
}
|
||||||
|
|
||||||
return func(data T, data2 T2) *Element {
|
return func(data T, data2 T2) *Element {
|
||||||
key, componentFunc := cb(data, data2)
|
key, componentFunc := cb(data, data2)
|
||||||
return &Element{
|
return &Element{
|
||||||
|
|
@ -153,17 +187,25 @@ 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.
|
// 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.
|
// 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]) func(T, T2, T3) *Element {
|
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 {
|
||||||
element := &Element{
|
node := &CachedNode{
|
||||||
tag: CachedNodeTag,
|
isByKey: true,
|
||||||
meta: &CachedNode{
|
duration: duration,
|
||||||
isByKey: true,
|
|
||||||
cb: nil,
|
|
||||||
html: "",
|
|
||||||
duration: duration,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
startExpiredCacheCleaner(element.meta.(*CachedNode))
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.cache == nil {
|
||||||
|
node.cache = DefaultCacheProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
element := &Element{
|
||||||
|
tag: CachedNodeTag,
|
||||||
|
meta: node,
|
||||||
|
}
|
||||||
|
|
||||||
return func(data T, data2 T2, data3 T3) *Element {
|
return func(data T, data2 T2, data3 T3) *Element {
|
||||||
key, componentFunc := cb(data, data2, data3)
|
key, componentFunc := cb(data, data2, data3)
|
||||||
return &Element{
|
return &Element{
|
||||||
|
|
@ -179,17 +221,25 @@ 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.
|
// 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.
|
// 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]) func(T, T2, T3, T4) *Element {
|
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 {
|
||||||
element := &Element{
|
node := &CachedNode{
|
||||||
tag: CachedNodeTag,
|
isByKey: true,
|
||||||
meta: &CachedNode{
|
duration: duration,
|
||||||
isByKey: true,
|
|
||||||
cb: nil,
|
|
||||||
html: "",
|
|
||||||
duration: duration,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
startExpiredCacheCleaner(element.meta.(*CachedNode))
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.cache == nil {
|
||||||
|
node.cache = DefaultCacheProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
element := &Element{
|
||||||
|
tag: CachedNodeTag,
|
||||||
|
meta: node,
|
||||||
|
}
|
||||||
|
|
||||||
return func(data T, data2 T2, data3 T3, data4 T4) *Element {
|
return func(data T, data2 T2, data3 T3, data4 T4) *Element {
|
||||||
key, componentFunc := cb(data, data2, data3, data4)
|
key, componentFunc := cb(data, data2, data3, data4)
|
||||||
return &Element{
|
return &Element{
|
||||||
|
|
@ -205,19 +255,27 @@ 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.
|
// 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.
|
// Please note this element is globally cached, and not per unique identifier / user.
|
||||||
// Use CachedPerKey to cache elements per unqiue identifier.
|
// Use CachedPerKey to cache elements per unique identifier.
|
||||||
func CachedT[T any](duration time.Duration, cb GetElementFuncT[T]) func(T) *Element {
|
func CachedT[T any](duration time.Duration, cb GetElementFuncT[T], opts ...CacheOption) func(T) *Element {
|
||||||
element := &Element{
|
node := &CachedNode{
|
||||||
tag: CachedNodeTag,
|
duration: duration,
|
||||||
meta: &CachedNode{
|
|
||||||
html: "",
|
|
||||||
duration: duration,
|
|
||||||
mutex: sync.Mutex{},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
startExpiredCacheCleaner(element.meta.(*CachedNode))
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.cache == nil {
|
||||||
|
node.cache = DefaultCacheProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
element := &Element{
|
||||||
|
tag: CachedNodeTag,
|
||||||
|
meta: node,
|
||||||
|
}
|
||||||
|
|
||||||
return func(data T) *Element {
|
return func(data T) *Element {
|
||||||
element.meta.(*CachedNode).cb = func() *Element {
|
node.cb = func() *Element {
|
||||||
return cb(data)
|
return cb(data)
|
||||||
}
|
}
|
||||||
return element
|
return element
|
||||||
|
|
@ -226,18 +284,27 @@ func CachedT[T any](duration time.Duration, cb GetElementFuncT[T]) func(T) *Elem
|
||||||
|
|
||||||
// CachedT2 caches the given element for the given duration. The element is only rendered once, and then cached for the given duration.
|
// 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.
|
// Please note this element is globally cached, and not per unique identifier / user.
|
||||||
// Use CachedPerKey to cache elements per unqiue identifier.
|
// Use CachedPerKey to cache elements per unique identifier.
|
||||||
func CachedT2[T any, T2 any](duration time.Duration, cb GetElementFuncT2[T, T2]) func(T, T2) *Element {
|
func CachedT2[T any, T2 any](duration time.Duration, cb GetElementFuncT2[T, T2], opts ...CacheOption) func(T, T2) *Element {
|
||||||
element := &Element{
|
node := &CachedNode{
|
||||||
tag: CachedNodeTag,
|
duration: duration,
|
||||||
meta: &CachedNode{
|
|
||||||
html: "",
|
|
||||||
duration: duration,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
startExpiredCacheCleaner(element.meta.(*CachedNode))
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.cache == nil {
|
||||||
|
node.cache = DefaultCacheProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
element := &Element{
|
||||||
|
tag: CachedNodeTag,
|
||||||
|
meta: node,
|
||||||
|
}
|
||||||
|
|
||||||
return func(data T, data2 T2) *Element {
|
return func(data T, data2 T2) *Element {
|
||||||
element.meta.(*CachedNode).cb = func() *Element {
|
node.cb = func() *Element {
|
||||||
return cb(data, data2)
|
return cb(data, data2)
|
||||||
}
|
}
|
||||||
return element
|
return element
|
||||||
|
|
@ -246,18 +313,27 @@ 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.
|
// 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.
|
// Please note this element is globally cached, and not per unique identifier / user.
|
||||||
// Use CachedPerKey to cache elements per unqiue identifier.
|
// Use CachedPerKey to cache elements per unique identifier.
|
||||||
func CachedT3[T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3[T, T2, T3]) func(T, T2, T3) *Element {
|
func CachedT3[T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3[T, T2, T3], opts ...CacheOption) func(T, T2, T3) *Element {
|
||||||
element := &Element{
|
node := &CachedNode{
|
||||||
tag: CachedNodeTag,
|
duration: duration,
|
||||||
meta: &CachedNode{
|
|
||||||
html: "",
|
|
||||||
duration: duration,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
startExpiredCacheCleaner(element.meta.(*CachedNode))
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.cache == nil {
|
||||||
|
node.cache = DefaultCacheProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
element := &Element{
|
||||||
|
tag: CachedNodeTag,
|
||||||
|
meta: node,
|
||||||
|
}
|
||||||
|
|
||||||
return func(data T, data2 T2, data3 T3) *Element {
|
return func(data T, data2 T2, data3 T3) *Element {
|
||||||
element.meta.(*CachedNode).cb = func() *Element {
|
node.cb = func() *Element {
|
||||||
return cb(data, data2, data3)
|
return cb(data, data2, data3)
|
||||||
}
|
}
|
||||||
return element
|
return element
|
||||||
|
|
@ -266,18 +342,27 @@ 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.
|
// 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.
|
// Please note this element is globally cached, and not per unique identifier / user.
|
||||||
// Use CachedPerKey to cache elements per unqiue identifier.
|
// 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]) func(T, T2, T3, T4) *Element {
|
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 {
|
||||||
element := &Element{
|
node := &CachedNode{
|
||||||
tag: CachedNodeTag,
|
duration: duration,
|
||||||
meta: &CachedNode{
|
|
||||||
html: "",
|
|
||||||
duration: duration,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
startExpiredCacheCleaner(element.meta.(*CachedNode))
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.cache == nil {
|
||||||
|
node.cache = DefaultCacheProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
element := &Element{
|
||||||
|
tag: CachedNodeTag,
|
||||||
|
meta: node,
|
||||||
|
}
|
||||||
|
|
||||||
return func(data T, data2 T2, data3 T3, data4 T4) *Element {
|
return func(data T, data2 T2, data3 T3, data4 T4) *Element {
|
||||||
element.meta.(*CachedNode).cb = func() *Element {
|
node.cb = func() *Element {
|
||||||
return cb(data, data2, data3, data4)
|
return cb(data, data2, data3, data4)
|
||||||
}
|
}
|
||||||
return element
|
return element
|
||||||
|
|
@ -286,70 +371,24 @@ 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.
|
// ClearCache clears the cached HTML of the element. This is called automatically by the framework.
|
||||||
func (c *CachedNode) ClearCache() {
|
func (c *CachedNode) ClearCache() {
|
||||||
c.html = ""
|
c.cache.Purge()
|
||||||
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 clears all expired cached HTML of the element. This is called automatically by the framework.
|
// ClearExpired is deprecated and does nothing. Cache expiration is now handled by the Store implementation.
|
||||||
func (c *CachedNode) ClearExpired() {
|
func (c *CachedNode) ClearExpired() {
|
||||||
c.mutex.Lock()
|
// No-op for backward compatibility
|
||||||
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) {
|
func (c *CachedNode) Render(ctx *RenderContext) {
|
||||||
if c.isByKey {
|
if c.isByKey {
|
||||||
panic("CachedPerKey should not be rendered directly")
|
panic("CachedPerKey should not be rendered directly")
|
||||||
} else {
|
} else {
|
||||||
c.mutex.Lock()
|
// For simple cached components, we use a single key
|
||||||
defer c.mutex.Unlock()
|
// Use GetOrCompute for atomic check-and-set
|
||||||
|
html := c.cache.GetOrCompute(_singleCacheKey, func() string {
|
||||||
now := time.Now()
|
return Render(c.cb())
|
||||||
expiration := c.expiration
|
}, c.duration)
|
||||||
|
ctx.builder.WriteString(html)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,47 +396,9 @@ func (c *ByKeyEntry) Render(ctx *RenderContext) {
|
||||||
key := c.key
|
key := c.key
|
||||||
parentMeta := c.parent.meta.(*CachedNode)
|
parentMeta := c.parent.meta.(*CachedNode)
|
||||||
|
|
||||||
parentMeta.mutex.Lock()
|
// Use GetOrCompute for atomic check-and-set
|
||||||
defer parentMeta.mutex.Unlock()
|
html := parentMeta.cache.GetOrCompute(key, func() string {
|
||||||
|
return Render(c.cb())
|
||||||
if parentMeta.byKeyCache == nil {
|
}, parentMeta.duration)
|
||||||
parentMeta.byKeyCache = make(map[any]*Entry)
|
ctx.builder.WriteString(html)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
292
framework/h/cache/README.md
vendored
Normal file
292
framework/h/cache/README.md
vendored
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
# 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
|
||||||
318
framework/h/cache/example_test.go
vendored
Normal file
318
framework/h/cache/example_test.go
vendored
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
186
framework/h/cache/examples/atomic_example.go
vendored
Normal file
186
framework/h/cache/examples/atomic_example.go
vendored
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
28
framework/h/cache/interface.go
vendored
Normal file
28
framework/h/cache/interface.go
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
200
framework/h/cache/lru_store_example.go
vendored
Normal file
200
framework/h/cache/lru_store_example.go
vendored
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
676
framework/h/cache/lru_store_test.go
vendored
Normal file
676
framework/h/cache/lru_store_test.go
vendored
Normal file
|
|
@ -0,0 +1,676 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
133
framework/h/cache/ttl_store.go
vendored
Normal file
133
framework/h/cache/ttl_store.go
vendored
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue