Compare commits

...

115 commits

Author SHA1 Message Date
maddalax
72c171709e wrap example in div 2025-08-16 09:47:51 -05:00
maddalax
5dba9d0167 param -> urlParam 2025-08-15 09:05:46 -05:00
github-actions[bot]
6f29b307ec Auto-update HTMGO framework version 2025-07-03 19:08:02 +00:00
Eliah Rusin
06f01b3d7c
Refactor caching system to use pluggable stores (#98)
* Refactor caching system to use pluggable stores

The commit modernizes the caching implementation by introducing a pluggable store interface that allows different cache backends. Key changes:

- Add Store interface for custom cache implementations
- Create default TTL-based store for backwards compatibility
- Add example LRU store for memory-bounded caching
- Support cache store configuration via options pattern
- Make cache cleanup logic implementation-specific
- Add comprehensive tests and documentation

The main goals were to:

1. Prevent unbounded memory growth through pluggable stores
2. Enable distributed caching support
3. Maintain backwards compatibility
4. Improve testability and maintainability

Signed-off-by: franchb <hello@franchb.com>

* Add custom cache stores docs and navigation

Signed-off-by: franchb <hello@franchb.com>

* Use GetOrCompute for atomic cache access

The commit introduces an atomic GetOrCompute method to the cache interface and refactors all cache implementations to use it. This prevents race conditions and duplicate computations when multiple goroutines request the same uncached key simultaneously.

The changes eliminate a time-of-check to time-of-use race condition in the original caching implementation, where separate Get/Set operations could lead to duplicate renders under high concurrency.

With GetOrCompute, the entire check-compute-store operation happens atomically while holding the lock, ensuring only one goroutine computes a value for any given key.

The API change is backwards compatible as the framework handles the GetOrCompute logic internally. Existing applications will automatically benefit from the

* rename to WithCacheStore

---------

Signed-off-by: franchb <hello@franchb.com>
Co-authored-by: maddalax <jm@madev.me>
2025-07-03 14:07:16 -05:00
maddalax
d555e5337f make run server build the binary instead of outputting each run to a tmp file
ensure tailwind cli is v3 for now
2025-01-24 11:51:01 -06:00
maddalax
c406b5f068 Revert "Revert "make run server build the binary instead of outputting each run to a tmp file""
This reverts commit c52f10f92d.
2025-01-24 11:50:44 -06:00
maddalax
c52f10f92d Revert "make run server build the binary instead of outputting each run to a tmp file"
This reverts commit d9c7fb3936.
2025-01-24 11:39:22 -06:00
maddalax
d9c7fb3936 make run server build the binary instead of outputting each run to a tmp file 2025-01-24 11:36:53 -06:00
github-actions[bot]
66b6dfffd3 Auto-update HTMGO framework version 2025-01-06 16:27:11 +00:00
maddalax
24b41a7604 Merge remote-tracking branch 'origin/master' 2025-01-06 10:26:19 -06:00
maddalax
0c84e42160 add info on how to change it 2025-01-06 10:26:15 -06:00
github-actions[bot]
ca4faf103e Auto-update HTMGO framework version 2025-01-06 16:25:41 +00:00
maddalax
4f537567ad allow port to be configured 2025-01-06 10:24:49 -06:00
maddalax
0d61b12561 fix snippet 2024-11-30 10:57:42 -06:00
maddalax
3f719d7011 remove new lines 2024-11-28 10:13:23 -06:00
maddalax
331f4cde82 test auto deploy 2024-11-25 11:03:40 -06:00
maddalax
ab50eaecf4 mobile fixes css 2024-11-25 10:34:22 -06:00
maddalax
baf10419f7 fix examples link 2024-11-25 10:23:12 -06:00
maddalax
c924f63ffb test 2024-11-24 10:11:41 -06:00
maddalax
ab342535d3 test 2024-11-24 10:08:41 -06:00
maddalax
15655d5c02 Merge remote-tracking branch 'origin/master' 2024-11-23 02:08:15 -06:00
maddalax
14272d6507 test 2024-11-23 02:08:11 -06:00
maddalax
d325677a1f
Update README.md 2024-11-21 07:54:23 -06:00
maddalax
e0bb30b976 remove assets dir 2024-11-21 07:51:49 -06:00
maddalax
baf5292212 add js 2024-11-21 07:51:08 -06:00
maddalax
9b69b25d0b minimal htmgo doc 2024-11-21 07:45:34 -06:00
maddalax
495e759689 remove from pages as its not needed 2024-11-21 07:29:51 -06:00
maddalax
ba8c0106d9 Merge remote-tracking branch 'origin/master' 2024-11-21 07:29:10 -06:00
maddalax
4c942a0a16
Minimal htmgo (#84)
* add RunAfterTimeout & RunOnInterval

* minimal htmgo
2024-11-21 07:27:22 -06:00
maddalax
407cc12079 Merge remote-tracking branch 'origin/master' 2024-11-21 07:04:32 -06:00
maddalax
01e4568c48
Update README.md 2024-11-18 18:14:51 -06:00
maddalax
158a6264a9 Merge remote-tracking branch 'origin/master' 2024-11-18 11:28:40 -06:00
github-actions[bot]
909d38c7f4 Auto-update HTMGO framework version 2024-11-16 14:52:58 +00:00
maddalax
825c4dd7ec
add RunAfterTimeout & RunOnInterval (#75) 2024-11-16 08:52:00 -06:00
maddalax
ef83e34b1e add RunAfterTimeout & RunOnInterval 2024-11-16 08:45:03 -06:00
Rafael de Mattos
a1af01a480
Check project directories (#70)
* Check project directories

* Remove partials directory

---------

Co-authored-by: maddalax <jm@madev.me>
2024-11-14 09:54:53 -06:00
maddalax
971f05c005 Revert "move socket manager"
This reverts commit 423fd3f429.
2024-11-12 18:16:20 -06:00
maddalax
b06d1b14bd log http requests 2024-11-12 18:15:59 -06:00
maddalax
423fd3f429 move socket manager 2024-11-12 13:04:20 -06:00
maddalax
257def3b53 up cli version 2024-11-12 08:55:35 -06:00
maddalax
97a5687f2e astgen only 2024-11-11 10:12:29 -06:00
maddalax
d2d8e449ae does this work 2024-11-11 10:10:40 -06:00
maddalax
a2d3a367d1 move codecov 2024-11-11 10:01:11 -06:00
maddalax
dc8a62313c only generate routes for partials or pages that have *h.RequestContext as a param 2024-11-11 09:55:09 -06:00
maddalax
6ec582a834
allow partials throughout the project, not just partials folder (#72)
* allow partials throughout the project, not jsut partials file

* route directly to partial

* generate correctly even if there is no partials

* run cli tests

* tidy

* only run tests on master if push

* add codecov
2024-11-11 09:17:57 -06:00
github-actions[bot]
b3834bf559 Auto-update HTMGO framework version 2024-11-09 18:33:21 +00:00
maddalax
b234ead964 fix loading livereload extension 2024-11-09 12:32:30 -06:00
github-actions[bot]
a756a0484f Auto-update HTMGO framework version 2024-11-09 18:06:38 +00:00
maddalax
34e816ff7c
Websocket Extension - Alpha (#22)
* wip

* merge

* working again

* refactor/make it a bit cleaner

* fix to only call cb for session id who initiated the event

* support broadcasting events to all clients

* refactor

* refactor into ws extension

* add go mod

* rename module

* fix naming

* refactor

* rename

* merge

* fix manager ws delete, add manager tests

* add metric page

* fixes, add k6 script

* fixes, add k6 script

* deploy docker image

* cleanup

* cleanup

* cleanup
2024-11-09 12:05:53 -06:00
maddalax
841262341a test 2024-11-05 15:17:12 -06:00
maddalax
142411c0e5 test 2024-11-05 15:13:05 -06:00
maddalax
e424dac826 test 2024-11-05 15:09:22 -06:00
maddalax
6acfc74a65 version 2024-11-01 07:57:30 -05:00
maddalax
aeb3a7be64 fixes 2024-11-01 07:53:48 -05:00
maddalax
ea997b41de debug 2024-11-01 07:44:17 -05:00
maddalax
7d04d8861f add windows instructions 2024-11-01 07:29:15 -05:00
maddalax
bf9cf2bf96 add version 2024-11-01 07:23:18 -05:00
maddalax
2346708ab1 windows fix 2024-11-01 07:09:58 -05:00
maddalax
25c216e2b6 mod tidy 2024-11-01 06:16:29 -05:00
github-actions[bot]
af0091c370 Auto-update HTMGO framework version 2024-11-01 11:11:25 +00:00
maddalax
2c4ac8b286
gen code for assets (#68)
* gen code for assets

* fix

* test
2024-11-01 06:10:35 -05:00
maddalax
4c6187e18d add docs for eval commands 2024-11-01 05:08:36 -05:00
maddalax
b75dadf00e add prefix 2024-10-31 18:19:32 -05:00
maddalax
e067a17f53 add more urls to sitemap 2024-10-31 18:10:11 -05:00
maddalax
9643e08232 Merge remote-tracking branch 'origin/master' 2024-10-31 13:55:31 -05:00
maddalax
06792019f8 add discord link 2024-10-31 13:55:27 -05:00
maddalax
f952d6ed3e
Update README.md 2024-10-31 13:41:13 -05:00
maddalax
f0b8118e00 Merge remote-tracking branch 'origin/master' 2024-10-31 13:40:47 -05:00
maddalax
e27cda5779 add discord route 2024-10-31 13:40:43 -05:00
maddalax
f25c6bd8f5
Update README.md 2024-10-31 13:36:09 -05:00
github-actions[bot]
64f201f4a3 Auto-update HTMGO framework version 2024-10-31 17:00:12 +00:00
maddalax
032159149c Merge remote-tracking branch 'origin/master' 2024-10-31 11:59:23 -05:00
maddalax
2d6ab078be swap tests 2024-10-31 11:59:19 -05:00
github-actions[bot]
479df08d63 Auto-update HTMGO framework version 2024-10-31 16:44:28 +00:00
maddalax
0a4bcfa7a3 Merge remote-tracking branch 'origin/master' 2024-10-31 11:43:28 -05:00
maddalax
248e485ff0 ordered map tests, extensions test 2024-10-31 11:43:24 -05:00
maddalax
51995681e3
Update README.md 2024-10-31 11:39:21 -05:00
github-actions[bot]
92f33c8cff Auto-update HTMGO framework version 2024-10-31 16:37:49 +00:00
maddalax
e268a581ce Merge remote-tracking branch 'origin/master' 2024-10-31 11:37:01 -05:00
maddalax
f42351e94f more tests 2024-10-31 11:36:57 -05:00
github-actions[bot]
9ed353ebe3 Auto-update HTMGO framework version 2024-10-31 16:12:09 +00:00
maddalax
8a00828232 conditional tests 2024-10-31 11:11:09 -05:00
maddalax
01cde545ce Merge remote-tracking branch 'origin/master' 2024-10-31 11:01:44 -05:00
maddalax
6db280907a upload to codecov 2024-10-31 11:01:40 -05:00
maddalax
ca946f2cba
Update README.md 2024-10-31 10:55:17 -05:00
maddalax
7da172cfad
Update README.md 2024-10-31 10:53:32 -05:00
maddalax
de0e06155b text input example 2024-10-31 10:16:03 -05:00
maddalax
44461b1ec7 add helper 2024-10-31 09:58:16 -05:00
maddalax
833708e38a wrap lines on examples 2024-10-31 09:56:37 -05:00
github-actions[bot]
7f5274b34d Auto-update HTMGO framework version 2024-10-31 14:45:06 +00:00
maddalax
f6556b579f css form on blur validation 2024-10-31 09:44:16 -05:00
maddalax
3cd7577b06 css fixes 2024-10-30 14:44:02 -05:00
maddalax
e6223a36b7 remove myapp 2024-10-30 14:32:27 -05:00
maddalax
60a37b65d6 change version 2024-10-30 14:17:35 -05:00
maddalax
5feb271aed fix 2024-10-30 14:14:12 -05:00
maddalax
cb6fcdd676 new favicon 2024-10-30 14:07:38 -05:00
maddalax
129c230c72 fix link 2024-10-30 13:50:52 -05:00
maddalax
965a8487b9 add title tag to starter template 2024-10-30 13:40:49 -05:00
maddalax
1ce5f37fc4 set title 2024-10-30 13:39:07 -05:00
maddalax
2e60b84c83 Merge remote-tracking branch 'origin/master' 2024-10-30 13:29:24 -05:00
maddalax
61b215436b cleanup 2024-10-30 13:29:20 -05:00
github-actions[bot]
101bb022c9 Auto-update HTMGO framework version 2024-10-30 18:28:28 +00:00
maddalax
35877a1b2e
New Docs (#63)
* scripting enhancements

* tests

* cleanup / tests

* new docs wip

* add more docs

* more updates

* add caching docs

* add sse docs

* more docs

* sidebar, and fix navigation blocks

* remove old docs

* set proper meta

* fixes
2024-10-30 13:27:42 -05:00
github-actions[bot]
df9c7f9cf7 Auto-update HTMGO framework version 2024-10-29 13:45:48 +00:00
maddalax
d85737bfb8
JS Eval Enhancements (#62)
* scripting enhancements

* tests

* cleanup / tests
2024-10-29 08:44:52 -05:00
maddalax
10f48af304 Merge remote-tracking branch 'origin/master' 2024-10-29 05:54:38 -05:00
maddalax
2ec9fd14c0 remove border for iframe examples 2024-10-29 05:54:34 -05:00
github-actions[bot]
0c22fc137b Auto-update HTMGO framework version 2024-10-29 10:49:14 +00:00
maddalax
cb012a4d82 cleanup usage of orderedmap
add tests
add groupby
add groupbyordered
2024-10-29 05:48:13 -05:00
maddalax
d44cd0b2ed fix urls 2024-10-29 05:11:35 -05:00
maddalax
60e1a161ca simplify since we arent allowing editing on load 2024-10-28 18:55:42 -05:00
maddalax
1fd773f726 Merge remote-tracking branch 'origin/master' 2024-10-28 18:50:35 -05:00
maddalax
2e110dcafd title case 2024-10-28 18:50:31 -05:00
github-actions[bot]
59a75aac9d Auto-update HTMGO framework version 2024-10-28 23:47:56 +00:00
maddalax
7666186f83 add inline edit / fix copy button 2024-10-28 18:47:00 -05:00
230 changed files with 10803 additions and 1990 deletions

48
.github/workflows/release-ws-test.yml vendored Normal file
View 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
View 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 }}

View file

@ -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 }}

View file

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

View file

@ -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
)

View file

@ -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=

View file

@ -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
wg.Add(1)
go func() {
defer wg.Done()
astgen.GenAst(process.ExitOnError) astgen.GenAst(process.ExitOnError)
}()
wg.Add(1)
go func() {
defer wg.Done()
run.EntGenerate() 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()

View file

@ -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(`
router.Handle("%s", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext) cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
return %s(cc) 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)
} }
`, moduleName)
handlerMethods := make([]string, 0)
for _, f := range partials {
caller := fmt.Sprintf("%s.%s", f.Package, f.FuncName)
path := fmt.Sprintf("/%s/%s.%s", moduleName, f.Import, f.FuncName)
handlerMethods = append(handlerMethods, routerHandlerMethod(path, caller))
}
registerFunction := fmt.Sprintf(`
func RegisterPartials(router *chi.Mux) {
%s
}
`, strings.Join(handlerMethods, "\n"))
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,9 +313,12 @@ func writePartialsFile() {
builder := NewCodeBuilder(nil) builder := NewCodeBuilder(nil)
builder.AppendLine(GeneratedFileLine) builder.AppendLine(GeneratedFileLine)
builder.AppendLine(PackageName) builder.AppendLine(PackageName)
builder.AddImport(ChiModuleName)
if len(partials) > 0 {
builder.AddImport(ModuleName) builder.AddImport(ModuleName)
builder.AddImport(HttpModuleName) builder.AddImport(HttpModuleName)
builder.AddImport(ChiModuleName) }
moduleName := GetModuleName() moduleName := GetModuleName()
for _, partial := range partials { for _, partial := range partials {
@ -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 {

View file

@ -1,82 +0,0 @@
package astgen
// OrderedMap is a generic data structure that maintains the order of keys.
type OrderedMap[K comparable, V any] struct {
keys []K
values map[K]V
}
// Entries returns the key-value pairs in the order they were added.
func (om *OrderedMap[K, V]) Entries() []struct {
Key K
Value V
} {
entries := make([]struct {
Key K
Value V
}, len(om.keys))
for i, key := range om.keys {
entries[i] = struct {
Key K
Value V
}{
Key: key,
Value: om.values[key],
}
}
return entries
}
// NewOrderedMap creates a new OrderedMap.
func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] {
return &OrderedMap[K, V]{
keys: []K{},
values: make(map[K]V),
}
}
// Set adds or updates a key-value pair in the OrderedMap.
func (om *OrderedMap[K, V]) Set(key K, value V) {
// Check if the key already exists
if _, exists := om.values[key]; !exists {
om.keys = append(om.keys, key) // Append key to the keys slice if it's a new key
}
om.values[key] = value
}
// Get retrieves a value by key.
func (om *OrderedMap[K, V]) Get(key K) (V, bool) {
value, exists := om.values[key]
return value, exists
}
// Keys returns the keys in the order they were added.
func (om *OrderedMap[K, V]) Keys() []K {
return om.keys
}
// Values returns the values in the order of their keys.
func (om *OrderedMap[K, V]) Values() []V {
values := make([]V, len(om.keys))
for i, key := range om.keys {
values[i] = om.values[key]
}
return values
}
// Delete removes a key-value pair from the OrderedMap.
func (om *OrderedMap[K, V]) Delete(key K) {
if _, exists := om.values[key]; exists {
// Remove the key from the map
delete(om.values, key)
// Remove the key from the keys slice
for i, k := range om.keys {
if k == key {
om.keys = append(om.keys[:i], om.keys[i+1:]...)
break
}
}
}
}

View file

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

View 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()
}

View 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
}

View 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
)

View 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=

View file

@ -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"

View 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{}
}

View 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)
},
})
}

View 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"),
),
)
}

View 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...),
),
),
),
)
}

View 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(),
)
}

View 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()
}

View file

@ -7,16 +7,3 @@ import (
func PanicF(format string, args ...interface{}) { func PanicF(format string, args ...interface{}) {
panic(fmt.Sprintf(format, args...)) panic(fmt.Sprintf(format, args...))
} }
func Unique[T any](slice []T, key func(item T) string) []T {
var result []T
seen := make(map[string]bool)
for _, v := range slice {
k := key(v)
if _, ok := seen[k]; !ok {
seen[k] = true
result = append(result, v)
}
}
return result
}

View file

@ -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))

View file

@ -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")))

View file

@ -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...))
} }

View file

@ -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))
} }

View file

@ -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.2-0.20241028155603-b65a913d4ea3 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
) )

View file

@ -4,8 +4,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/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.2-0.20241028155603-b65a913d4ea3 h1:Syc9EVHsnHR9Q1FSzSlJD0rLD8sE7Jem/9ptB4ZnN9g= 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.2-0.20241028155603-b65a913d4ea3/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=

View file

@ -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
} }

View file

@ -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)
} }
} }
}() }()

View file

@ -3,7 +3,7 @@ module hackernews
go 1.23.0 go 1.23.0
require ( require (
github.com/maddalax/htmgo/framework v1.0.2-0.20241028155603-b65a913d4ea3 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
) )

View file

@ -8,8 +8,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/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.2-0.20241028155603-b65a913d4ea3 h1:Syc9EVHsnHR9Q1FSzSlJD0rLD8sE7Jem/9ptB4ZnN9g= 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.2-0.20241028155603-b65a913d4ea3/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
View file

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

View 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

View 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

View 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=

View 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()),
)
}

View 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)
}

File diff suppressed because one or more lines are too long

View 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))
}

View file

@ -3,7 +3,7 @@ module simpleauth
go 1.23.0 go 1.23.0
require ( require (
github.com/maddalax/htmgo/framework v1.0.2-0.20241028155603-b65a913d4ea3 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
) )

View file

@ -4,8 +4,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/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.2-0.20241028155603-b65a913d4ea3 h1:Syc9EVHsnHR9Q1FSzSlJD0rLD8sE7Jem/9ptB4ZnN9g= 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.2-0.20241028155603-b65a913d4ea3/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=

View file

@ -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.2-0.20241028155603-b65a913d4ea3 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
) )

View file

@ -33,8 +33,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/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.2-0.20241028155603-b65a913d4ea3 h1:Syc9EVHsnHR9Q1FSzSlJD0rLD8sE7Jem/9ptB4ZnN9g= 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.2-0.20241028155603-b65a913d4ea3/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=

View 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
View file

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

View 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"]

View 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

View 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()
}

View file

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

View 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
}

View 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
)

View 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=

View 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{}
}

View file

View 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)
},
})
}

View 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)),
)
},
}),
),
),
)
}

View 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...),
),
),
)
}

View 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),
)
}

View 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))
}),
),
)
}

View 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++
}),
),
)
}

View file

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

View 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
)

View 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=

View 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))
}

View 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()
}
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}
}

View 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)
}
}
}
}

View 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(),
}
}

View 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)
}

View file

@ -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.2-0.20241028155603-b65a913d4ea3 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

View file

@ -4,8 +4,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/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.2-0.20241028155603-b65a913d4ea3 h1:Syc9EVHsnHR9Q1FSzSlJD0rLD8sE7Jem/9ptB4ZnN9g= 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.2-0.20241028155603-b65a913d4ea3/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=

File diff suppressed because one or more lines are too long

View file

@ -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;
} }

View 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;
}

View file

@ -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

View 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([])
}

View 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
}

View file

@ -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"}

View file

@ -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)

View file

@ -1,18 +1,24 @@
package datastructures package orderedmap
// OrderedMap is a generic data structure that maintains the order of keys.
type OrderedMap[K comparable, V any] struct {
keys []K
values map[K]V
}
type Entry[K comparable, V any] struct { type Entry[K comparable, V any] struct {
Key K Key K
Value V Value V
} }
// Map is a generic data structure that maintains the order of keys.
type Map[K comparable, V any] struct {
keys []K
values map[K]V
}
func (om *Map[K, V]) Each(cb func(key K, value V)) {
for _, key := range om.keys {
cb(key, om.values[key])
}
}
// Entries returns the key-value pairs in the order they were added. // Entries returns the key-value pairs in the order they were added.
func (om *OrderedMap[K, V]) Entries() []Entry[K, V] { func (om *Map[K, V]) Entries() []Entry[K, V] {
entries := make([]Entry[K, V], len(om.keys)) entries := make([]Entry[K, V], len(om.keys))
for i, key := range om.keys { for i, key := range om.keys {
entries[i] = Entry[K, V]{ entries[i] = Entry[K, V]{
@ -23,16 +29,16 @@ func (om *OrderedMap[K, V]) Entries() []Entry[K, V] {
return entries return entries
} }
// NewOrderedMap creates a new OrderedMap. // New creates a new Map.
func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] { func New[K comparable, V any]() *Map[K, V] {
return &OrderedMap[K, V]{ return &Map[K, V]{
keys: []K{}, keys: []K{},
values: make(map[K]V), values: make(map[K]V),
} }
} }
// Set adds or updates a key-value pair in the OrderedMap. // Set adds or updates a key-value pair in the Map.
func (om *OrderedMap[K, V]) Set(key K, value V) { func (om *Map[K, V]) Set(key K, value V) {
// Check if the key already exists // Check if the key already exists
if _, exists := om.values[key]; !exists { if _, exists := om.values[key]; !exists {
om.keys = append(om.keys, key) // Append key to the keys slice if it's a new key om.keys = append(om.keys, key) // Append key to the keys slice if it's a new key
@ -41,18 +47,18 @@ func (om *OrderedMap[K, V]) Set(key K, value V) {
} }
// Get retrieves a value by key. // Get retrieves a value by key.
func (om *OrderedMap[K, V]) Get(key K) (V, bool) { func (om *Map[K, V]) Get(key K) (V, bool) {
value, exists := om.values[key] value, exists := om.values[key]
return value, exists return value, exists
} }
// Keys returns the keys in the order they were added. // Keys returns the keys in the order they were added.
func (om *OrderedMap[K, V]) Keys() []K { func (om *Map[K, V]) Keys() []K {
return om.keys return om.keys
} }
// Values returns the values in the order of their keys. // Values returns the values in the order of their keys.
func (om *OrderedMap[K, V]) Values() []V { func (om *Map[K, V]) Values() []V {
values := make([]V, len(om.keys)) values := make([]V, len(om.keys))
for i, key := range om.keys { for i, key := range om.keys {
values[i] = om.values[key] values[i] = om.values[key]
@ -61,8 +67,8 @@ func (om *OrderedMap[K, V]) Values() []V {
return values return values
} }
// Delete removes a key-value pair from the OrderedMap. // Delete removes a key-value pair from the Map.
func (om *OrderedMap[K, V]) Delete(key K) { func (om *Map[K, V]) Delete(key K) {
if _, exists := om.values[key]; exists { if _, exists := om.values[key]; exists {
// Remove the key from the map // Remove the key from the map
delete(om.values, key) delete(om.values, key)

View file

@ -0,0 +1,63 @@
package orderedmap
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestOrderedMap(t *testing.T) {
t.Parallel()
om := New[string, int]()
alphabet := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"}
for index, letter := range alphabet {
om.Set(letter, index)
}
assert.Equal(t, alphabet, om.Keys())
c, ok := om.Get("c")
assert.True(t, ok)
assert.Equal(t, 2, c)
for i, entry := range om.Entries() {
if i == 5 {
assert.Equal(t, "f", entry.Key)
}
}
om.Delete("c")
value, ok := om.Get("c")
assert.False(t, ok)
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)
}

View file

@ -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 {

View file

@ -1,5 +1,9 @@
package h package h
import (
"github.com/maddalax/htmgo/framework/datastructure/orderedmap"
)
// Unique returns a new slice with only unique items. // Unique returns a new slice with only unique items.
func Unique[T any](slice []T, key func(item T) string) []T { func Unique[T any](slice []T, key func(item T) string) []T {
var result []T var result []T
@ -14,6 +18,44 @@ func Unique[T any](slice []T, key func(item T) string) []T {
return result return result
} }
// Find returns the first item in the slice that matches the predicate.
func Find[T any](slice []T, predicate func(item *T) bool) *T {
for _, v := range slice {
if predicate(&v) {
return &v
}
}
return nil
}
// GroupBy groups the items in the slice by the key returned by the key function.
func GroupBy[T any, K comparable](slice []T, key func(item T) K) map[K][]T {
grouped := make(map[K][]T)
for _, item := range slice {
k := key(item)
items, ok := grouped[k]
if !ok {
items = []T{}
}
grouped[k] = append(items, item)
}
return grouped
}
// GroupByOrdered groups the items in the slice by the key returned by the key function, and returns an Map.
func GroupByOrdered[T any, K comparable](slice []T, key func(item T) K) *orderedmap.Map[K, []T] {
grouped := orderedmap.New[K, []T]()
for _, item := range slice {
k := key(item)
items, ok := grouped.Get(k)
if !ok {
items = []T{}
}
grouped.Set(k, append(items, item))
}
return grouped
}
// Filter returns a new slice with only items that match the predicate. // Filter returns a new slice with only items that match the predicate.
func Filter[T any](slice []T, predicate func(item T) bool) []T { func Filter[T any](slice []T, predicate func(item T) bool) []T {
var result []T var result []T

102
framework/h/array_test.go Normal file
View file

@ -0,0 +1,102 @@
package h
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
)
func TestUnique(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "b", "c", "d", "d", "x"}
unique := Unique(slice, func(item string) string {
return item
})
assert.Equal(t, []string{"a", "b", "c", "d", "x"}, unique)
}
func TestFilter(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "b", "c", "d", "d", "x"}
filtered := Filter(slice, func(item string) bool {
return item == "b"
})
assert.Equal(t, []string{"b", "b"}, filtered)
}
func TestMap(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "c"}
mapped := Map(slice, func(item string) string {
return strings.ToUpper(item)
})
assert.Equal(t, []string{"A", "B", "C"}, mapped)
}
func TestGroupBy(t *testing.T) {
t.Parallel()
type Item struct {
Name string
Job string
}
items := []Item{
{Name: "Alice", Job: "Developer"},
{Name: "Bob", Job: "Designer"},
{Name: "Charlie", Job: "Developer"},
{Name: "David", Job: "Designer"},
{Name: "Eve", Job: "Developer"},
{Name: "Frank", Job: "Product Manager"},
}
grouped := GroupBy(items, func(item Item) string {
return item.Job
})
assert.Equal(t, 3, len(grouped))
assert.Equal(t, 3, len(grouped["Developer"]))
assert.Equal(t, 2, len(grouped["Designer"]))
assert.Equal(t, 1, len(grouped["Product Manager"]))
}
func TestGroupByOrdered(t *testing.T) {
t.Parallel()
type Item struct {
Name string
Job string
}
items := []Item{
{Name: "Alice", Job: "Developer"},
{Name: "Bob", Job: "Designer"},
{Name: "Charlie", Job: "Developer"},
{Name: "David", Job: "Designer"},
{Name: "Eve", Job: "Developer"},
{Name: "Frank", Job: "Product Manager"},
}
grouped := GroupByOrdered(items, func(item Item) string {
return item.Job
})
keys := []string{"Developer", "Designer", "Product Manager"}
assert.Equal(t, keys, grouped.Keys())
devs, ok := grouped.Get("Developer")
assert.True(t, ok)
assert.Equal(t, 3, len(devs))
assert.Equal(t, "Alice", devs[0].Name)
assert.Equal(t, "Charlie", devs[1].Name)
assert.Equal(t, "Eve", devs[2].Name)
}
func TestFind(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "c"}
found := Find(slice, func(item *string) bool {
return *item == "b"
})
assert.Equal(t, "b", *found)
}

View file

@ -2,16 +2,16 @@ package h
import ( import (
"fmt" "fmt"
"strings" "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/datastructure" "github.com/maddalax/htmgo/framework/internal/util"
"strings"
) )
type AttributeMap = map[string]any type AttributeMap = map[string]any
type AttributeMapOrdered struct { type AttributeMapOrdered struct {
data *datastructure.OrderedMap[string, string] data *orderedmap.Map[string, string]
} }
func (m *AttributeMapOrdered) Set(key string, value any) { func (m *AttributeMapOrdered) Set(key string, value any) {
@ -39,12 +39,12 @@ func (m *AttributeMapOrdered) Each(cb func(key string, value string)) {
}) })
} }
func (m *AttributeMapOrdered) Entries() []datastructure.MapEntry[string, string] { func (m *AttributeMapOrdered) Entries() []orderedmap.Entry[string, string] {
return m.data.Entries() return m.data.Entries()
} }
func NewAttributeMap(pairs ...string) *AttributeMapOrdered { func NewAttributeMap(pairs ...string) *AttributeMapOrdered {
m := datastructure.NewOrderedMap[string, string]() m := orderedmap.New[string, string]()
if len(pairs)%2 == 0 { if len(pairs)%2 == 0 {
for i := 0; i < len(pairs); i++ { for i := 0; i < len(pairs); i++ {
m.Set(pairs[i], pairs[i+1]) m.Set(pairs[i], pairs[i+1])
@ -197,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...))
} }
@ -355,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)
}

View 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)
}

View file

@ -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
View 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&param2=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))
}

View file

@ -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
byKeyExpiration map[any]time.Time
mutex sync.Mutex
duration time.Duration duration time.Duration
expiration time.Time cache cache.Store[any, string]
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 {
node := &CachedNode{
cb: cb,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
meta: &CachedNode{ meta: node,
cb: cb,
html: "",
duration: duration,
},
} }
startExpiredCacheCleaner(element.meta.(*CachedNode))
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 {
node := &CachedNode{
isByKey: true,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
meta: &CachedNode{ meta: node,
isByKey: true,
cb: nil,
html: "",
duration: duration,
},
} }
startExpiredCacheCleaner(element.meta.(*CachedNode))
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 {
node := &CachedNode{
isByKey: true,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
meta: &CachedNode{ meta: node,
isByKey: true,
cb: nil,
html: "",
duration: duration,
},
} }
startExpiredCacheCleaner(element.meta.(*CachedNode))
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 {
node := &CachedNode{
isByKey: true,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
meta: &CachedNode{ meta: node,
isByKey: true,
cb: nil,
html: "",
duration: duration,
},
} }
startExpiredCacheCleaner(element.meta.(*CachedNode))
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 {
node := &CachedNode{
isByKey: true,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
meta: &CachedNode{ meta: node,
isByKey: true,
cb: nil,
html: "",
duration: duration,
},
} }
startExpiredCacheCleaner(element.meta.(*CachedNode))
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 {
node := &CachedNode{
isByKey: true,
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
meta: &CachedNode{ meta: node,
isByKey: true,
cb: nil,
html: "",
duration: duration,
},
} }
startExpiredCacheCleaner(element.meta.(*CachedNode))
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 {
node := &CachedNode{
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
meta: &CachedNode{ meta: node,
html: "",
duration: duration,
mutex: sync.Mutex{},
},
} }
startExpiredCacheCleaner(element.meta.(*CachedNode))
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 {
node := &CachedNode{
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
meta: &CachedNode{ meta: node,
html: "",
duration: duration,
},
} }
startExpiredCacheCleaner(element.meta.(*CachedNode))
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 {
node := &CachedNode{
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
meta: &CachedNode{ meta: node,
html: "",
duration: duration,
},
} }
startExpiredCacheCleaner(element.meta.(*CachedNode))
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 {
node := &CachedNode{
duration: duration,
}
for _, opt := range opts {
opt(node)
}
if node.cache == nil {
node.cache = DefaultCacheProvider()
}
element := &Element{ element := &Element{
tag: CachedNodeTag, tag: CachedNodeTag,
meta: &CachedNode{ meta: node,
html: "",
duration: duration,
},
} }
startExpiredCacheCleaner(element.meta.(*CachedNode))
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)
}
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) 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
View 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
View 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
}

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