Merge pull request #13 from maddalax/chat-app

Chat App Example
This commit is contained in:
maddalax 2024-10-04 10:33:14 -05:00 committed by GitHub
commit caad5633d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 2135 additions and 74 deletions

View file

@ -0,0 +1,49 @@
name: Build and Deploy htmgo.dev chat example
on:
pull_request:
branches:
- master
workflow_dispatch: # Trigger on manual workflow_dispatch
push:
branches:
- master # Trigger on pushes to master
paths:
- 'examples/chat/**' # Trigger only if files in this directory change
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/chat && docker build -t ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:${{ steps.vars.outputs.short_sha }} .
- name: Tag as latest Docker image
run: |
docker tag ghcr.io/${{ github.repository_owner }}/htmgo-chat-example:${{ steps.vars.outputs.short_sha }} ghcr.io/${{ github.repository_owner }}/htmgo-chat-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 }}/htmgo-chat-example:latest

View file

@ -4,6 +4,7 @@ import (
"fmt"
"github.com/fsnotify/fsnotify"
"github.com/maddalax/htmgo/cli/htmgo/tasks/astgen"
"github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets"
"github.com/maddalax/htmgo/cli/htmgo/tasks/css"
"github.com/maddalax/htmgo/cli/htmgo/tasks/run"
"github.com/maddalax/htmgo/cli/htmgo/tasks/util"
@ -101,6 +102,12 @@ func OnFileChange(version string, events []*fsnotify.Event) {
hasTask = true
}
// framework assets changed
if c.HasAnySuffix("assets/dist/htmgo.js") {
copyassets.CopyAssets()
//tasks.Run = true
}
if hasTask {
slog.Info("file changed", slog.String("version", version), slog.String("file", c.Name()))
}

View file

@ -4,10 +4,12 @@ import (
"github.com/fsnotify/fsnotify"
"github.com/google/uuid"
"github.com/maddalax/htmgo/cli/htmgo/internal"
"github.com/maddalax/htmgo/cli/htmgo/tasks/module"
"log"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
)
@ -36,6 +38,7 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
if !ok {
return
}
slog.Debug("event:", slog.String("name", event.Name), slog.String("op", event.Op.String()))
if event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
events = append(events, &event)
debouncer.Do(func() {
@ -61,6 +64,15 @@ func startWatcher(cb func(version string, file []*fsnotify.Event)) {
}()
rootDir := "."
frameworkPath := module.GetDependencyPath("github.com/maddalax/htmgo/framework")
if !strings.HasPrefix(frameworkPath, "github.com/") {
assetPath := filepath.Join(frameworkPath, "assets", "dist")
slog.Debug("Watching directory:", slog.String("path", assetPath))
watcher.Add(assetPath)
}
// Walk through the root directory and add all subdirectories to the watcher
err = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {

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/chat/.gitignore vendored Normal file
View file

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

36
examples/chat/Dockerfile Normal file
View file

@ -0,0 +1,36 @@
# Stage 1: Build the Go binary
FROM golang:1.23 AS builder
# 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 CGO_ENABLED=0 GOPRIVATE=github.com/maddalax LOG_LEVEL=debug go run github.com/maddalax/htmgo/cli/htmgo@8b816e956692683337d9fff6416ccc31f5047b59 build
RUN CGO_ENABLED=1 GOOS=linux go build -tags prod -o ./dist -a -ldflags '-linkmode external -extldflags "-static"' .
# 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 ["./chat"]

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

13
examples/chat/assets.go Normal file
View file

@ -0,0 +1,13 @@
//go:build !prod
// +build !prod
package main
import (
"chat/internal/embedded"
"io/fs"
)
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,155 @@
package chat
import (
"chat/internal/db"
"chat/ws"
"context"
"fmt"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"time"
)
type Manager struct {
socketManager *ws.SocketManager
queries *db.Queries
service *Service
}
func NewManager(locator *service.Locator) *Manager {
return &Manager{
socketManager: service.Get[ws.SocketManager](locator),
queries: service.Get[db.Queries](locator),
service: NewService(locator),
}
}
func (m *Manager) StartListener() {
c := make(chan ws.SocketEvent, 1)
m.socketManager.Listen(c)
for {
select {
case event := <-c:
switch event.Type {
case ws.ConnectedEvent:
m.OnConnected(event)
case ws.DisconnectedEvent:
m.OnDisconnected(event)
case ws.MessageEvent:
m.onMessage(event)
default:
fmt.Printf("Unknown event type: %s\n", event.Type)
}
}
}
}
func (m *Manager) dispatchConnectedUsers(roomId string, predicate func(conn ws.SocketConnection) bool) {
connectedUsers := make([]db.User, 0)
// backfill all existing clients to the connected client
m.socketManager.ForEachSocket(roomId, func(conn ws.SocketConnection) {
if !predicate(conn) {
return
}
user, err := m.queries.GetUserBySessionId(context.Background(), conn.Id)
if err != nil {
return
}
connectedUsers = append(connectedUsers, user)
})
m.socketManager.ForEachSocket(roomId, func(conn ws.SocketConnection) {
m.socketManager.SendText(conn.Id, h.Render(ConnectedUsers(connectedUsers, conn.Id)))
})
}
func (m *Manager) OnConnected(e ws.SocketEvent) {
room, _ := m.service.GetRoom(e.RoomId)
if room == nil {
m.socketManager.CloseWithMessage(e.Id, "invalid room")
return
}
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
if err != nil {
m.socketManager.CloseWithMessage(e.Id, "invalid user")
return
}
fmt.Printf("User %s connected to %s\n", user.Name, e.RoomId)
m.dispatchConnectedUsers(e.RoomId, func(conn ws.SocketConnection) bool {
return true
})
m.backFill(e.Id, e.RoomId)
}
func (m *Manager) OnDisconnected(e ws.SocketEvent) {
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
if err != nil {
return
}
room, err := m.service.GetRoom(e.RoomId)
if err != nil {
return
}
fmt.Printf("User %s disconnected from %s\n", user.Name, room.ID)
m.dispatchConnectedUsers(e.RoomId, func(conn ws.SocketConnection) bool {
return conn.Id != e.Id
})
}
func (m *Manager) backFill(socketId string, roomId string) {
messages, _ := m.queries.GetLastMessages(context.Background(), db.GetLastMessagesParams{
ChatRoomID: roomId,
Limit: 200,
})
for _, message := range messages {
parsed, _ := time.Parse("2006-01-02 15:04:05", message.CreatedAt)
m.socketManager.SendText(socketId,
h.Render(MessageRow(&Message{
UserId: message.UserID,
UserName: message.UserName,
Message: message.Message,
CreatedAt: parsed,
})),
)
}
}
func (m *Manager) onMessage(e ws.SocketEvent) {
message := e.Payload["message"].(string)
if message == "" {
return
}
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
if err != nil {
fmt.Printf("Error getting user: %v\n", err)
return
}
saved := m.service.InsertMessage(
&user,
e.RoomId,
message,
)
if saved != nil {
m.socketManager.BroadcastText(
e.RoomId,
h.Render(MessageRow(saved)),
func(conn ws.SocketConnection) bool {
return true
},
)
}
}

View file

@ -0,0 +1,51 @@
package chat
import (
"chat/internal/db"
"fmt"
"github.com/maddalax/htmgo/framework/h"
"strings"
"time"
)
func MessageRow(message *Message) *h.Element {
return h.Div(
h.Attribute("hx-swap-oob", "beforeend"),
h.Class("flex flex-col gap-4 w-full break-words whitespace-normal"), // Ensure container breaks long words
h.Id("messages"),
h.Div(
h.Class("flex flex-col gap-1"),
h.Div(
h.Class("flex gap-2 items-center"),
h.Pf(message.UserName, h.Class("font-bold")),
h.Pf(message.CreatedAt.In(time.Local).Format("01/02 03:04 PM")),
),
h.Article(
h.Class("break-words whitespace-normal"), // Ensure message text wraps correctly
h.P(h.Text(message.Message)),
),
),
)
}
func ConnectedUsers(users []db.User, myId string) *h.Element {
return h.Ul(
h.Attribute("hx-swap-oob", "outerHTML"),
h.Id("connected-users"),
h.Class("flex flex-col"),
h.List(users, func(user db.User, index int) *h.Element {
return connectedUser(user.Name, user.SessionID == myId)
}),
)
}
func connectedUser(username string, isMe bool) *h.Element {
id := fmt.Sprintf("connected-user-%s", strings.ReplaceAll(username, "#", "-"))
return h.Li(
h.Id(id),
h.ClassX("truncate text-slate-700", h.ClassMap{
"font-bold": isMe,
}),
h.Text(username),
)
}

View file

@ -0,0 +1,84 @@
package chat
import (
"chat/internal/db"
"context"
"fmt"
"github.com/google/uuid"
"github.com/maddalax/htmgo/framework/service"
"log"
"time"
)
type Message struct {
UserId int64 `json:"userId"`
UserName string `json:"userName"`
Message string `json:"message"`
CreatedAt time.Time `json:"createdAt"`
}
type Service struct {
queries *db.Queries
}
func NewService(locator *service.Locator) *Service {
return &Service{
queries: service.Get[db.Queries](locator),
}
}
func (s *Service) InsertMessage(user *db.User, roomId string, message string) *Message {
err := s.queries.InsertMessage(context.Background(), db.InsertMessageParams{
UserID: user.ID,
Username: user.Name,
ChatRoomID: roomId,
Message: message,
})
if err != nil {
log.Printf("Failed to insert message: %v\n", err)
return nil
}
return &Message{
UserId: user.ID,
UserName: user.Name,
Message: message,
CreatedAt: time.Now(),
}
}
func (s *Service) GetUserBySession(sessionId string) (*db.User, error) {
user, err := s.queries.GetUserBySessionId(context.Background(), sessionId)
return &user, err
}
func (s *Service) CreateUser(name string) (*db.CreateUserRow, error) {
nameWithHash := fmt.Sprintf("%s#%s", name, uuid.NewString()[0:4])
sessionId := fmt.Sprintf("session-%s-%s", uuid.NewString(), uuid.NewString())
user, err := s.queries.CreateUser(context.Background(), db.CreateUserParams{
Name: nameWithHash,
SessionID: sessionId,
})
if err != nil {
return nil, err
}
return &user, nil
}
func (s *Service) CreateRoom(name string) (*db.CreateChatRoomRow, error) {
room, err := s.queries.CreateChatRoom(context.Background(), db.CreateChatRoomParams{
ID: fmt.Sprintf("room-%s-%s", uuid.NewString()[0:8], name),
Name: name,
})
if err != nil {
return nil, err
}
return &room, nil
}
func (s *Service) GetRoom(id string) (*db.ChatRoom, error) {
room, err := s.queries.GetChatRoom(context.Background(), id)
if err != nil {
return nil, err
}
return &room, nil
}

View file

@ -0,0 +1,41 @@
package components
import "github.com/maddalax/htmgo/framework/h"
type ButtonProps struct {
Id string
Text string
Target string
Type string
Trigger string
Get string
Class string
Children []h.Ren
}
func PrimaryButton(props ButtonProps) h.Ren {
props.Class = h.MergeClasses(props.Class, "border-slate-800 bg-slate-900 hover:bg-slate-800 text-white")
return Button(props)
}
func SecondaryButton(props ButtonProps) h.Ren {
props.Class = h.MergeClasses(props.Class, "border-gray-700 bg-gray-700 text-white")
return Button(props)
}
func Button(props ButtonProps) h.Ren {
text := h.Text(props.Text)
button := h.Button(
h.If(props.Id != "", h.Id(props.Id)),
h.If(props.Children != nil, h.Children(props.Children...)),
h.Class("flex gap-1 items-center justify-center border p-4 rounded cursor-hover", props.Class),
h.If(props.Get != "", h.Get(props.Get)),
h.If(props.Target != "", h.HxTarget(props.Target)),
h.IfElse(props.Type != "", h.Type(props.Type), h.Type("button")),
text,
)
return button
}

View file

@ -0,0 +1,11 @@
package components
import "github.com/maddalax/htmgo/framework/h"
func FormError(error string) *h.Element {
return h.Div(
h.Id("form-error"),
h.Text(error),
h.If(error != "", h.Class("p-4 bg-rose-400 text-white rounded")),
)
}

View file

@ -0,0 +1,55 @@
package components
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/hx"
)
type InputProps struct {
Id string
Label string
Name string
Type string
DefaultValue string
Placeholder string
Required bool
ValidationPath string
Error string
Children []h.Ren
}
func Input(props InputProps) *h.Element {
validation := h.If(props.ValidationPath != "", h.Children(
h.Post(props.ValidationPath, hx.BlurEvent),
h.Attribute("hx-swap", "innerHTML transition:true"),
h.Attribute("hx-target", "next div"),
))
if props.Type == "" {
props.Type = "text"
}
input := h.Input(
props.Type,
h.Class("border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800"),
h.If(props.Name != "", h.Name(props.Name)),
h.If(props.Children != nil, h.Children(props.Children...)),
h.If(props.Required, h.Required()),
h.If(props.Placeholder != "", h.Placeholder(props.Placeholder)),
h.If(props.DefaultValue != "", h.Attribute("value", props.DefaultValue)),
validation,
)
wrapped := h.Div(
h.If(props.Id != "", h.Id(props.Id)),
h.Class("flex flex-col gap-1"),
h.If(props.Label != "", h.Label(h.Text(props.Label))),
input,
h.Div(
h.Id(props.Id+"-error"),
h.Class("text-red-500"),
),
)
return wrapped
}

12
examples/chat/go.mod Normal file
View file

@ -0,0 +1,12 @@
module chat
go 1.23.0
require (
github.com/coder/websocket v1.8.12
github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692
github.com/mattn/go-sqlite3 v1.14.23
github.com/puzpuzpuz/xsync/v3 v3.4.0
)

24
examples/chat/go.sum Normal file
View file

@ -0,0 +1,24 @@
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
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 v0.0.0-20241001184532-9a5b92987701 h1:0Zk282axc1kPiuspLNzK5BJV7cQ5h2kPZHe54dznhYY=
github.com/maddalax/htmgo/framework v0.0.0-20241001184532-9a5b92987701/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692 h1:NtLJ7GcD9hWvPYmombxC1SzVNgvnhLXWhZEQJZOstik=
github.com/maddalax/htmgo/framework v0.0.0-20241002032603-8b816e956692/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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=
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 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

View file

@ -0,0 +1,35 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
package db
import (
"database/sql"
)
type ChatRoom struct {
ID string
Name string
LastMessageSentAt sql.NullString
CreatedAt string
UpdatedAt string
}
type Message struct {
ID int64
ChatRoomID string
UserID int64
Username string
Message string
CreatedAt string
UpdatedAt string
}
type User struct {
ID int64
Name string
CreatedAt string
UpdatedAt string
SessionID string
}

View file

@ -0,0 +1,25 @@
package db
import (
"context"
"database/sql"
_ "embed"
_ "github.com/mattn/go-sqlite3"
)
//go:embed schema.sql
var ddl string
func Provide() *Queries {
db, err := sql.Open("sqlite3", "file:chat.db?cache=shared&_fk=1")
if err != nil {
panic(err)
}
if _, err := db.ExecContext(context.Background(), ddl); err != nil {
panic(err)
}
return New(db)
}

View file

@ -0,0 +1,47 @@
-- name: CreateChatRoom :one
INSERT INTO chat_rooms (id, name, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, name, created_at, updated_at, last_message_sent_at;
-- name: InsertMessage :exec
INSERT INTO messages (chat_room_id, user_id, username, message, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, chat_room_id, user_id, username, message, created_at, updated_at;
-- name: UpdateChatRoomLastMessageSentAt :exec
UPDATE chat_rooms
SET last_message_sent_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: GetChatRoom :one
SELECT
id,
name,
last_message_sent_at,
created_at,
updated_at
FROM chat_rooms
WHERE chat_rooms.id = ?;
-- name: CreateUser :one
INSERT INTO users (name, session_id, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, name, session_id, created_at, updated_at;
-- name: GetLastMessages :many
SELECT
messages.id,
messages.chat_room_id,
messages.user_id,
users.name AS user_name,
messages.message,
messages.created_at,
messages.updated_at
FROM messages
JOIN users ON messages.user_id = users.id
WHERE messages.chat_room_id = ?
ORDER BY messages.created_at
LIMIT ?;
-- name: GetUserBySessionId :one
SELECT * FROM users WHERE session_id = ?;

View file

@ -0,0 +1,212 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: queries.sql
package db
import (
"context"
"database/sql"
)
const createChatRoom = `-- name: CreateChatRoom :one
INSERT INTO chat_rooms (id, name, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, name, created_at, updated_at, last_message_sent_at
`
type CreateChatRoomParams struct {
ID string
Name string
}
type CreateChatRoomRow struct {
ID string
Name string
CreatedAt string
UpdatedAt string
LastMessageSentAt sql.NullString
}
func (q *Queries) CreateChatRoom(ctx context.Context, arg CreateChatRoomParams) (CreateChatRoomRow, error) {
row := q.db.QueryRowContext(ctx, createChatRoom, arg.ID, arg.Name)
var i CreateChatRoomRow
err := row.Scan(
&i.ID,
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastMessageSentAt,
)
return i, err
}
const createUser = `-- name: CreateUser :one
INSERT INTO users (name, session_id, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, name, session_id, created_at, updated_at
`
type CreateUserParams struct {
Name string
SessionID string
}
type CreateUserRow struct {
ID int64
Name string
SessionID string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
row := q.db.QueryRowContext(ctx, createUser, arg.Name, arg.SessionID)
var i CreateUserRow
err := row.Scan(
&i.ID,
&i.Name,
&i.SessionID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getChatRoom = `-- name: GetChatRoom :one
SELECT
id,
name,
last_message_sent_at,
created_at,
updated_at
FROM chat_rooms
WHERE chat_rooms.id = ?
`
func (q *Queries) GetChatRoom(ctx context.Context, id string) (ChatRoom, error) {
row := q.db.QueryRowContext(ctx, getChatRoom, id)
var i ChatRoom
err := row.Scan(
&i.ID,
&i.Name,
&i.LastMessageSentAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getLastMessages = `-- name: GetLastMessages :many
SELECT
messages.id,
messages.chat_room_id,
messages.user_id,
users.name AS user_name,
messages.message,
messages.created_at,
messages.updated_at
FROM messages
JOIN users ON messages.user_id = users.id
WHERE messages.chat_room_id = ?
ORDER BY messages.created_at
LIMIT ?
`
type GetLastMessagesParams struct {
ChatRoomID string
Limit int64
}
type GetLastMessagesRow struct {
ID int64
ChatRoomID string
UserID int64
UserName string
Message string
CreatedAt string
UpdatedAt string
}
func (q *Queries) GetLastMessages(ctx context.Context, arg GetLastMessagesParams) ([]GetLastMessagesRow, error) {
rows, err := q.db.QueryContext(ctx, getLastMessages, arg.ChatRoomID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetLastMessagesRow
for rows.Next() {
var i GetLastMessagesRow
if err := rows.Scan(
&i.ID,
&i.ChatRoomID,
&i.UserID,
&i.UserName,
&i.Message,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUserBySessionId = `-- name: GetUserBySessionId :one
SELECT id, name, created_at, updated_at, session_id FROM users WHERE session_id = ?
`
func (q *Queries) GetUserBySessionId(ctx context.Context, sessionID string) (User, error) {
row := q.db.QueryRowContext(ctx, getUserBySessionId, sessionID)
var i User
err := row.Scan(
&i.ID,
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
&i.SessionID,
)
return i, err
}
const insertMessage = `-- name: InsertMessage :exec
INSERT INTO messages (chat_room_id, user_id, username, message, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, chat_room_id, user_id, username, message, created_at, updated_at
`
type InsertMessageParams struct {
ChatRoomID string
UserID int64
Username string
Message string
}
func (q *Queries) InsertMessage(ctx context.Context, arg InsertMessageParams) error {
_, err := q.db.ExecContext(ctx, insertMessage,
arg.ChatRoomID,
arg.UserID,
arg.Username,
arg.Message,
)
return err
}
const updateChatRoomLastMessageSentAt = `-- name: UpdateChatRoomLastMessageSentAt :exec
UPDATE chat_rooms
SET last_message_sent_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
func (q *Queries) UpdateChatRoomLastMessageSentAt(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, updateChatRoomLastMessageSentAt, id)
return err
}

View file

@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS users
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
session_id TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS chat_rooms
(
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
last_message_sent_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE TABLE IF NOT EXISTS messages
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_room_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (chat_room_id) REFERENCES chat_rooms (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
) STRICT;
CREATE INDEX IF NOT EXISTS idx_messages_chat_room_id ON messages (chat_room_id);
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages (user_id);

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,25 @@
package routine
import (
"fmt"
"time"
)
func DebugLongRunning(name string, f func()) {
now := time.Now()
done := make(chan struct{}, 1)
go func() {
ticker := time.NewTicker(time.Second * 5)
for {
select {
case <-done:
return
case <-ticker.C:
elapsed := time.Since(now).Milliseconds()
fmt.Printf("function %s has not finished after %dms\n", name, elapsed)
}
}
}()
f()
done <- struct{}{}
}

54
examples/chat/main.go Normal file
View file

@ -0,0 +1,54 @@
package main
import (
"chat/__htmgo"
"chat/chat"
"chat/internal/db"
"chat/ws"
"fmt"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"io/fs"
"net/http"
"runtime"
"time"
)
func main() {
locator := service.NewLocator()
service.Set[db.Queries](locator, service.Singleton, db.Provide)
service.Set[ws.SocketManager](locator, service.Singleton, func() *ws.SocketManager {
return ws.NewSocketManager()
})
chatManager := chat.NewManager(locator)
go chatManager.StartListener()
go func() {
for {
count := runtime.NumGoroutine()
fmt.Printf("goroutines: %d\n", count)
time.Sleep(10 * time.Second)
}
}()
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)
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
app.Router.Handle("/ws/chat/{id}", ws.Handle())
__htmgo.Register(app.Router)
},
})
}

View file

@ -0,0 +1,167 @@
package pages
import (
"chat/chat"
"chat/internal/db"
"chat/partials"
"fmt"
"github.com/go-chi/chi/v5"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/js"
"time"
)
func ChatRoom(ctx *h.RequestContext) *h.Page {
roomId := chi.URLParam(ctx.Request, "id")
return h.NewPage(
RootPage(
h.Div(
h.JoinExtensions(
h.TriggerChildren(),
h.HxExtension("ws"),
),
h.Attribute("sse-connect", fmt.Sprintf("/ws/chat/%s", roomId)),
h.HxOnSseOpen(
js.ConsoleLog("Connected to chat room"),
),
h.HxOnSseError(
js.EvalJs(fmt.Sprintf(`
const reason = e.detail.event.data
if(['invalid room', 'no session', 'invalid user'].includes(reason)) {
window.location.href = '/?roomId=%s';
} else if(e.detail.event.code === 1011) {
window.location.reload()
} else if (e.detail.event.code === 1008 || e.detail.event.code === 1006) {
window.location.href = '/?roomId=%s';
} else {
console.error('Connection closed:', e.detail.event)
}
`, roomId, roomId)),
),
// Adjusted flex properties for responsive layout
h.Class("flex flex-row h-screen bg-neutral-100"),
// Collapse Button for mobile
CollapseButton(),
// Sidebar for connected users
UserSidebar(),
h.Div(
// Adjusted to fill height and width
h.Class("flex flex-col h-full w-full bg-white p-4 overflow-hidden"),
// Room name at the top, fixed
CachedRoomHeader(ctx),
h.HxAfterSseMessage(
js.EvalJsOnSibling("#messages",
`element.scrollTop = element.scrollHeight;`),
),
// Chat Messages
h.Div(
h.Id("messages"),
// Adjusted flex properties and removed max-width
h.Class("flex flex-col gap-4 mb-4 overflow-auto flex-grow w-full pt-[50px]"),
),
// Chat Input at the bottom
Form(),
),
),
),
)
}
var CachedRoomHeader = h.CachedPerKeyT(time.Hour, func(ctx *h.RequestContext) (string, h.GetElementFunc) {
roomId := chi.URLParam(ctx.Request, "id")
return roomId, func() *h.Element {
return roomNameHeader(ctx)
}
})
func roomNameHeader(ctx *h.RequestContext) *h.Element {
roomId := chi.URLParam(ctx.Request, "id")
service := chat.NewService(ctx.ServiceLocator())
room, err := service.GetRoom(roomId)
if err != nil {
return h.Div()
}
return h.Div(
h.Class("bg-neutral-700 text-white p-3 shadow-sm w-full fixed top-0 left-0 flex justify-center z-10"),
h.H2F(room.Name, h.Class("text-lg font-bold")),
h.Div(
h.Class("absolute right-5 top-3 cursor-pointer"),
h.Text("Share"),
h.OnClick(
js.EvalJs(`
alert("Share this url with your friends:\n " + window.location.href)
`),
),
),
)
}
func UserSidebar() *h.Element {
return h.Div(
h.Class("sidebar h-full pt-[67px] min-w-48 w-48 bg-neutral-200 p-4 flex-col justify-between gap-3 rounded-l-lg hidden md:flex"),
h.Div(
h.H3F("Connected Users", h.Class("text-lg font-bold")),
chat.ConnectedUsers(make([]db.User, 0), ""),
),
h.A(
h.Class("cursor-pointer"),
h.Href("/"),
h.Text("Leave Room"),
),
)
}
func CollapseButton() *h.Element {
return h.Div(
h.Class("fixed top-0 left-4 md:hidden z-50"), // Always visible on mobile
h.Button(
h.Class("p-2 text-2xl bg-neutral-700 text-white rounded-md"), // Styling the button
h.OnClick(
js.EvalJs(`
const sidebar = document.querySelector('.sidebar');
sidebar.classList.toggle('hidden');
sidebar.classList.toggle('flex');
`),
),
h.UnsafeRaw("&#9776;"), // The icon for collapsing the sidebar
),
)
}
func MessageInput() *h.Element {
return h.Input("text",
h.Id("message-input"),
h.Required(),
h.Class("p-4 rounded-md border border-slate-200 w-full focus:outline-none focus:ring focus:ring-slate-200"),
h.Name("message"),
h.MaxLength(1000),
h.Placeholder("Type a message..."),
h.HxAfterSseMessage(
js.SetValue(""),
),
)
}
func Form() *h.Element {
return h.Div(
h.Class("flex gap-4 items-center"),
h.Form(
h.NoSwap(),
h.PostPartial(partials.SendMessage),
h.Attribute("ws-send", ""),
h.Class("flex flex-grow"),
MessageInput(),
),
)
}

View file

@ -0,0 +1,84 @@
package pages
import (
"chat/components"
"chat/partials"
"github.com/maddalax/htmgo/framework/h"
)
func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page {
return h.NewPage(
RootPage(
h.Div(
h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"),
h.Div(
h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"),
h.H2F("htmgo chat", h.Class("text-3xl font-bold text-center mb-6")),
h.Form(
h.Attribute("hx-swap", "none"),
h.PostPartial(partials.CreateOrJoinRoom),
h.Class("flex flex-col gap-6"),
// Username input at the top
components.Input(components.InputProps{
Id: "username",
Name: "username",
Label: "Username",
Required: true,
Children: []h.Ren{
h.Attribute("autocomplete", "off"),
h.MaxLength(15),
},
}),
// Single box for Create or Join a Chat Room
h.Div(
h.Class("p-4 border border-gray-300 rounded-md flex flex-col gap-6"),
// Create New Chat Room input
components.Input(components.InputProps{
Name: "new-chat-room",
Label: "Create a new chat room",
Placeholder: "Enter chat room name",
Children: []h.Ren{
h.Attribute("autocomplete", "off"),
h.MaxLength(20),
},
}),
// OR divider
h.Div(
h.Class("flex items-center justify-center gap-4"),
h.Div(h.Class("border-t border-gray-300 flex-grow")),
h.P(h.Text("OR"), h.Class("text-gray-500")),
h.Div(h.Class("border-t border-gray-300 flex-grow")),
),
// Join Chat Room input
components.Input(components.InputProps{
Id: "join-chat-room",
Name: "join-chat-room",
Label: "Join an existing chat room",
Placeholder: "Enter chat room ID",
DefaultValue: ctx.QueryParam("roomId"),
Children: []h.Ren{
h.Attribute("autocomplete", "off"),
h.MaxLength(100),
},
}),
),
// Error message
components.FormError(""),
// Submit button at the bottom
components.PrimaryButton(components.ButtonProps{
Type: "submit",
Text: "Submit",
}),
),
),
),
),
)
}

View file

@ -0,0 +1,26 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func RootPage(children ...h.Ren) h.Ren {
extensions := h.BaseExtensions()
return h.Html(
h.HxExtension(extensions),
h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Meta("title", "htmgo chat example"),
h.Meta("charset", "utf-8"),
h.Meta("author", "htmgo"),
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,35 @@
package partials
import (
"chat/components"
"chat/ws"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
)
func SendMessage(ctx *h.RequestContext) *h.Partial {
locator := ctx.ServiceLocator()
socketManager := service.Get[ws.SocketManager](locator)
sessionCookie, err := ctx.Request.Cookie("session_id")
if err != nil {
return h.SwapPartial(ctx, components.FormError("Session not found"))
}
message := ctx.Request.FormValue("message")
if message == "" {
return h.SwapPartial(ctx, components.FormError("Message is required"))
}
if len(message) > 200 {
return h.SwapPartial(ctx, components.FormError("Message is too long"))
}
socketManager.OnMessage(sessionCookie.Value, map[string]any{
"message": message,
})
return h.EmptyPartial()
}

View file

@ -0,0 +1,73 @@
package partials
import (
"chat/chat"
"chat/components"
"github.com/maddalax/htmgo/framework/h"
"net/http"
"time"
)
func CreateOrJoinRoom(ctx *h.RequestContext) *h.Partial {
locator := ctx.ServiceLocator()
service := chat.NewService(locator)
chatRoomId := ctx.Request.FormValue("join-chat-room")
username := ctx.Request.FormValue("username")
if username == "" {
return h.SwapPartial(ctx, components.FormError("Username is required"))
}
if len(username) > 15 {
return h.SwapPartial(ctx, components.FormError("Username is too long"))
}
user, err := service.CreateUser(username)
if err != nil {
return h.SwapPartial(ctx, components.FormError("Failed to create user"))
}
var redirect = func(path string) *h.Partial {
cookie := &http.Cookie{
Name: "session_id",
Value: user.SessionID,
Path: "/",
Expires: time.Now().Add(24 * 30 * time.Hour),
}
return h.RedirectPartialWithHeaders(
path,
h.NewHeaders(
"Set-Cookie", cookie.String(),
),
)
}
if chatRoomId != "" {
room, _ := service.GetRoom(chatRoomId)
if room == nil {
return h.SwapPartial(ctx, components.FormError("Room not found"))
} else {
return redirect("/chat/" + chatRoomId)
}
}
chatRoomName := ctx.Request.FormValue("new-chat-room")
if len(chatRoomName) > 20 {
return h.SwapPartial(ctx, components.FormError("Chat room name is too long"))
}
if chatRoomName != "" {
room, _ := service.CreateRoom(chatRoomName)
if room == nil {
return h.SwapPartial(ctx, components.FormError("Failed to create room"))
} else {
return redirect("/chat/" + room.ID)
}
}
return h.SwapPartial(ctx, components.FormError("Create a new room or join an existing one"))
}

9
examples/chat/sqlc.yaml Normal file
View file

@ -0,0 +1,9 @@
version: "2"
sql:
- schema: "internal/db/schema.sql"
queries: "internal/db/queries.sql"
engine: "sqlite"
gen:
go:
package: "db"
out: "internal/db"

View file

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

112
examples/chat/ws/handler.go Normal file
View file

@ -0,0 +1,112 @@
package ws
import (
"fmt"
"github.com/go-chi/chi/v5"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"log/slog"
"net/http"
"sync"
"time"
)
func Handle() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Set the necessary headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*") // Optional for CORS
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
locator := cc.ServiceLocator()
manager := service.Get[SocketManager](locator)
sessionCookie, _ := r.Cookie("session_id")
sessionId := ""
if sessionCookie != nil {
sessionId = sessionCookie.Value
}
ctx := r.Context()
/*
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{}
wg.Add(1)
/*
* This goroutine is responsible for writing messages to the client
*/
go func() {
defer wg.Done()
defer manager.Disconnect(sessionId)
defer func() {
fmt.Printf("empting channels\n")
for len(writer) > 0 {
<-writer
}
for len(done) > 0 {
<-done
}
}()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-done:
fmt.Printf("closing connection: \n")
return
case <-ticker.C:
manager.Ping(sessionId)
case message := <-writer:
_, err := fmt.Fprintf(w, message)
if err != nil {
done <- true
} else {
flusher, ok := w.(http.Flusher)
if ok {
flusher.Flush()
}
}
}
}
}()
/**
* This goroutine is responsible for adding the client to the room
*/
wg.Add(1)
go func() {
defer wg.Done()
if sessionId == "" {
manager.writeCloseRaw(writer, "no session")
return
}
roomId := chi.URLParam(r, "id")
if roomId == "" {
slog.Error("invalid room", slog.String("room_id", roomId))
manager.writeCloseRaw(writer, "invalid room")
return
}
manager.Add(roomId, sessionId, writer, done)
}()
wg.Wait()
}
}

233
examples/chat/ws/manager.go Normal file
View file

@ -0,0 +1,233 @@
package ws
import (
"chat/internal/routine"
"fmt"
"github.com/puzpuzpuz/xsync/v3"
"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 {
Id 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 SocketManager struct {
sockets *xsync.MapOf[string, *xsync.MapOf[string, SocketConnection]]
idToRoom *xsync.MapOf[string, string]
listeners []chan SocketEvent
}
func NewSocketManager() *SocketManager {
return &SocketManager{
sockets: xsync.NewMapOf[string, *xsync.MapOf[string, SocketConnection]](),
idToRoom: xsync.NewMapOf[string, string](),
}
}
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) 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) {
fmt.Printf("dispatching event: %s\n", event.Type)
done := make(chan struct{}, 1)
go func() {
for {
select {
case <-done:
fmt.Printf("dispatched event: %s\n", event.Type)
return
case <-time.After(5 * time.Second):
fmt.Printf("havent dispatched 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.dispatch(SocketEvent{
Id: 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{
Id: s.Id,
Type: ConnectedEvent,
RoomId: s.RoomId,
Payload: map[string]any{},
})
fmt.Printf("User %s connected to %s\n", id, roomId)
}
func (manager *SocketManager) OnClose(id string) {
socket := manager.Get(id)
if socket == nil {
return
}
manager.dispatch(SocketEvent{
Id: id,
Type: DisconnectedEvent,
RoomId: socket.RoomId,
Payload: map[string]any{},
})
manager.sockets.Delete(id)
}
func (manager *SocketManager) CloseWithMessage(id string, message string) {
conn := manager.Get(id)
if conn != nil {
defer manager.OnClose(id)
manager.writeText(*conn, "error", 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) {
conn := manager.Get(id)
if conn != nil {
manager.writeText(*conn, "ping", "")
}
}
func (manager *SocketManager) writeCloseRaw(writer WriterChan, message string) {
manager.writeTextRaw(writer, "close", message)
}
func (manager *SocketManager) writeTextRaw(writer WriterChan, event string, message string) {
routine.DebugLongRunning("writeTextRaw", func() {
timeout := 3 * time.Second
data := ""
if event != "" {
data = fmt.Sprintf("event: %s\ndata: %s\n\n", event, message)
} else {
data = fmt.Sprintf("data: %s\n\n", message)
}
select {
case writer <- data:
case <-time.After(timeout):
fmt.Printf("could not send %s to channel after %s\n", data, timeout)
}
})
}
func (manager *SocketManager) writeText(socket SocketConnection, event string, message string) {
if socket.Writer == nil {
return
}
manager.writeTextRaw(socket.Writer, event, message)
}
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) SendText(id string, message string) {
conn := manager.Get(id)
if conn != nil {
manager.writeText(*conn, "", message)
}
}

View file

@ -11,6 +11,8 @@ type InputProps struct {
Name string
Type string
DefaultValue string
Placeholder string
Required bool
ValidationPath string
Children []h.Ren
}
@ -22,19 +24,24 @@ func Input(props InputProps) h.Ren {
h.Attribute("hx-target", "next div"),
))
if props.Type == "" {
props.Type = "text"
}
input := h.Input(
props.Type,
h.Class("border p-2 rounded"),
h.If(props.Id != "", h.Id(props.Id)),
h.If(props.Name != "", h.Name(props.Name)),
h.If(props.Children != nil, h.Children(props.Children...)),
h.If(props.Required, h.Required()),
h.If(props.DefaultValue != "", h.Attribute("value", props.DefaultValue)),
validation,
)
wrapped := h.Div(
h.Class("flex flex-col gap-1"),
h.If(props.Label != "", h.Label(props.Label)),
h.If(props.Label != "", h.Label(h.Text(props.Label))),
input,
h.Div(h.Class("text-red-500")),
)

File diff suppressed because one or more lines are too long

View file

@ -6,6 +6,7 @@ import "./htmxextensions/response-targets";
import "./htmxextensions/mutation-error";
import "./htmxextensions/livereload"
import "./htmxextensions/htmgo";
import "./htmxextensions/sse"
function watchUrl(callback: (oldUrl: string, newUrl: string) => void) {
let lastUrl = window.location.href;

View file

@ -4,9 +4,9 @@ htmx.defineExtension("debug", {
// @ts-ignore
onEvent: function (name, evt) {
if (console.debug) {
console.debug(name);
console.debug(name, evt);
} else if (console) {
console.log("DEBUG:", name);
console.log("DEBUG:", name, evt);
} else {
// noop
}

View file

@ -1,6 +1,6 @@
import htmx from "htmx.org";
const evalFuncRegex = /__eval_[A-Za-z0-9]+\(\)/gm
const evalFuncRegex =/__eval_[A-Za-z0-9]+\([a-z]+\)/gm
htmx.defineExtension("htmgo", {
// @ts-ignore
@ -11,14 +11,15 @@ htmx.defineExtension("htmgo", {
},
});
function removeAssociatedScripts(element: HTMLElement) {
export function removeAssociatedScripts(element: HTMLElement) {
const attributes = Array.from(element.attributes)
for (let attribute of attributes) {
const matches = attribute.value.match(evalFuncRegex) || []
for (let match of matches) {
const id = match.replace("()", "")
const id = match.replace("()", "").replace("(this)", "").replace(";", "")
const ele = document.getElementById(id)
if(ele && ele.tagName === "SCRIPT") {
console.debug("removing associated script with id", id)
ele.remove()
}
}

View file

@ -0,0 +1,72 @@
import htmx from 'htmx.org'
import {removeAssociatedScripts} from "./htmgo";
let api : any = null;
let processed = new Set<string>()
htmx.defineExtension("sse", {
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('[sse-connect]');
for (let element of Array.from(elements)) {
const url = element.getAttribute("sse-connect")!;
if(url && !processed.has(url)) {
connectEventSource(element, url)
processed.add(url)
}
}
}
}
})
function connectEventSource(ele: Element, url: string) {
if(!url) {
return
}
console.info('Connecting to EventSource', url)
const eventSource = new EventSource(url);
eventSource.addEventListener("close", function(event) {
htmx.trigger(ele, "htmx:sseClose", {event: event});
})
eventSource.onopen = function(event) {
htmx.trigger(ele, "htmx:sseOpen", {event: event});
}
eventSource.onerror = function(event) {
htmx.trigger(ele, "htmx:sseError", {event: event});
if (eventSource.readyState == EventSource.CLOSED) {
htmx.trigger(ele, "htmx:sseClose", {event: event});
}
}
eventSource.onmessage = function(event) {
const settleInfo = api.makeSettleInfo(ele);
htmx.trigger(ele, "htmx:sseBeforeMessage", {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:sseAfterMessage", {event: event});
}
}

View file

@ -1,19 +1,62 @@
import htmx, {HtmxSettleInfo, HtmxSwapStyle} from "htmx.org";
function kebabEventName(str: string) {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
}
const ignoredEvents = ['htmx:beforeProcessNode', 'htmx:afterProcessNode', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:beforeOnLoad', 'htmx:afterOnLoad', 'htmx:configRequest', 'htmx:configResponse', 'htmx:responseError'];
function makeEvent(eventName: string, detail: any) {
let evt
if (window.CustomEvent && typeof window.CustomEvent === 'function') {
// TODO: `composed: true` here is a hack to make global event handlers work with events in shadow DOM
evt = new CustomEvent(eventName, { bubbles: false, cancelable: true, composed: true, detail })
} else {
evt = document.createEvent('CustomEvent')
evt.initCustomEvent(eventName, true, true, detail)
}
return evt
}
function triggerChildren(target: HTMLElement, name: string, event: CustomEvent, triggered: Set<HTMLElement>) {
if(ignoredEvents.includes(name)) {
return
}
if (target && target.children) {
Array.from(target.children).forEach((e) => {
const kehab = kebabEventName(name);
const eventName = kehab.replace("htmx:", "hx-on::")
if (!triggered.has(e as HTMLElement)) {
if(e.hasAttribute(eventName)) {
const newEvent = makeEvent(eventName.replace("hx-on::", "htmx:"), event.detail)
newEvent.detail.meta = 'trigger-children'
e.dispatchEvent(newEvent)
triggered.add(e as HTMLElement);
}
if (e.children) {
triggerChildren(e as HTMLElement, name, event, triggered);
}
}
});
}
}
htmx.defineExtension("trigger-children", {
onEvent: (name, evt: Event | CustomEvent) => {
if (!(evt instanceof CustomEvent)) {
return false;
}
const target = evt.detail.target as HTMLElement;
if (target && target.children) {
Array.from(target.children).forEach((e) => {
htmx.trigger(e, name, null);
});
if(evt.detail.meta === 'trigger-children') {
return false;
}
const triggered = new Set<HTMLElement>();
const target = evt.target as HTMLElement || evt.detail.target as HTMLElement;
triggerChildren(target, name, evt, triggered);
return true;
},
init: function (api: any): void {},
init: function (api: any): void {
},
transformResponse: function (
text: string,
xhr: XMLHttpRequest,
@ -36,7 +79,8 @@ htmx.defineExtension("trigger-children", {
xhr: XMLHttpRequest,
parameters: FormData,
elt: Node,
) {},
) {
},
getSelectors: function (): string[] | null {
return null;
},

View file

@ -16,7 +16,8 @@ import (
)
type RequestContext struct {
*http.Request
Request *http.Request
Response http.ResponseWriter
locator *service.Locator
isBoosted bool
currentBrowserUrl string
@ -120,6 +121,7 @@ func (app *App) start() {
cc := &RequestContext{
locator: app.Opts.ServiceLocator,
Request: r,
Response: w,
kv: make(map[string]interface{}),
}
populateHxFields(cc)

View file

@ -53,6 +53,10 @@ func NewAttributeMap(pairs ...string) *AttributeMapOrdered {
return &AttributeMapOrdered{data: m}
}
func NoSwap() *AttributeR {
return Attribute("hx-swap", "none")
}
func Attribute(key string, value string) *AttributeR {
return &AttributeR{
Name: key,
@ -116,7 +120,7 @@ func HxIndicator(tag string) *AttributeR {
return Attribute(hx.IndicatorAttr, tag)
}
func TriggerChildren() Ren {
func TriggerChildren() *AttributeR {
return HxExtension("trigger-children")
}
@ -133,10 +137,26 @@ func HxTriggerClick(opts ...hx.Modifier) *AttributeR {
return HxTrigger(hx.OnClick(opts...))
}
func HxExtension(value string) Ren {
func HxExtension(value string) *AttributeR {
return Attribute(hx.ExtAttr, value)
}
func HxExtensions(value ...string) Ren {
return Attribute(hx.ExtAttr, strings.Join(value, ","))
}
func JoinExtensions(attrs ...*AttributeR) Ren {
return JoinAttributes(", ", attrs...)
}
func JoinAttributes(sep string, attrs ...*AttributeR) *AttributeR {
values := make([]string, 0, len(attrs))
for _, a := range attrs {
values = append(values, a.Value)
}
return Attribute(attrs[0].Name, strings.Join(values, sep))
}
func Href(path string) Ren {
return Attribute("href", path)
}

View file

@ -51,11 +51,27 @@ func SwapManyPartialWithHeaders(ctx *RequestContext, headers *Headers, swaps ...
)
}
func RedirectPartial(path string) *Partial {
return RedirectPartialWithHeaders(path, NewHeaders())
}
func RedirectPartialWithHeaders(path string, headers *Headers) *Partial {
h := *NewHeaders("HX-Redirect", path)
for k, v := range *headers {
h[k] = v
}
return NewPartialWithHeaders(&h, Fragment())
}
func SwapPartial(ctx *RequestContext, swap *Element) *Partial {
return NewPartial(
SwapMany(ctx, swap))
}
func EmptyPartial() *Partial {
return NewPartial(Fragment())
}
func SwapManyPartial(ctx *RequestContext, swaps ...*Element) *Partial {
return NewPartial(
SwapMany(ctx, swaps...),

View file

@ -32,17 +32,20 @@ func renderJs(t *testing.T, command Command) string {
value := parsed.FirstChild.FirstChild.NextSibling.LastChild.Attr[0].Val
isComplex := strings.HasPrefix(value, "__eval_")
if !isComplex {
return value
value = strings.ReplaceAll(value, "var e=event;", "")
return strings.ReplaceAll(value, "var self=this;", "")
} else {
id := strings.TrimSuffix(value, "(this);")
id := strings.TrimSuffix(value, "(this, event);")
script := findScriptById(parsed, id)
assert.NotNil(t, script)
funcCall := script.LastChild.Data
funcCall = strings.ReplaceAll(funcCall, "\n", "")
funcCall = strings.ReplaceAll(funcCall, "\t", "")
start := fmt.Sprintf("function %s(self) {", id)
start := fmt.Sprintf("function %s(self, event) {", id)
funcCall = strings.TrimPrefix(funcCall, start)
funcCall = strings.TrimSuffix(funcCall, "}")
funcCall = strings.ReplaceAll(funcCall, "let e = event;", "")
funcCall = strings.ReplaceAll(funcCall, "var self=this;", "")
return funcCall
}
}

View file

@ -3,7 +3,7 @@ package h
import "strings"
func BaseExtensions() string {
extensions := []string{"path-deps", "response-targets", "mutation-error", "htmgo"}
extensions := []string{"path-deps", "response-targets", "mutation-error", "htmgo", "sse"}
if IsDevelopment() {
extensions = append(extensions, "livereload")
}

View file

@ -34,7 +34,7 @@ func CombineHeaders(headers ...*Headers) *Headers {
}
func CurrentPath(ctx *RequestContext) string {
current := ctx.Header.Get(hx.CurrentUrlHeader)
current := ctx.Request.Header.Get(hx.CurrentUrlHeader)
parsed, err := url.Parse(current)
if err != nil {
return ""

View file

@ -76,6 +76,34 @@ func OnEvent(event hx.Event, cmd ...Command) *LifeCycle {
return NewLifeCycle().OnEvent(event, cmd...)
}
func HxBeforeSseMessage(cmd ...Command) *LifeCycle {
return NewLifeCycle().OnEvent(hx.SseBeforeMessageEvent, cmd...)
}
func HxAfterSseMessage(cmd ...Command) *LifeCycle {
return NewLifeCycle().OnEvent(hx.SseAfterMessageEvent, cmd...)
}
func OnSubmit(cmd ...Command) *LifeCycle {
return NewLifeCycle().OnEvent(hx.SubmitEvent, cmd...)
}
func HxOnSseError(cmd ...Command) *LifeCycle {
return NewLifeCycle().OnEvent(hx.SseErrorEvent, cmd...)
}
func HxOnSseClose(cmd ...Command) *LifeCycle {
return NewLifeCycle().OnEvent(hx.SseClosedEvent, cmd...)
}
func HxOnSseConnecting(cmd ...Command) *LifeCycle {
return NewLifeCycle().OnEvent(hx.SseConnectingEvent, cmd...)
}
func HxOnSseOpen(cmd ...Command) *LifeCycle {
return NewLifeCycle().OnEvent(hx.SseConnectedEvent, cmd...)
}
func HxBeforeRequest(cmd ...Command) *LifeCycle {
return NewLifeCycle().HxBeforeRequest(cmd...)
}
@ -261,12 +289,25 @@ func EvalJs(js string) ComplexJsCommand {
return NewComplexJsCommand(js)
}
func PreventDefault() SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: "event.preventDefault()"}
}
func ConsoleLog(text string) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("console.log('%s')", text)}
}
func SetValue(value string) SimpleJsCommand {
// language=JavaScript
return SimpleJsCommand{Command: fmt.Sprintf("this.value = '%s'", value)}
}
func SubmitFormOnEnter() ComplexJsCommand {
// language=JavaScript
return EvalJs(`
if (event.code === 'Enter') {
self.form.dispatchEvent(new Event('submit', { cancelable: true }));
}
if (event.code === 'Enter') { self.form.dispatchEvent(new Event('submit', { cancelable: true })); }
`)
}

View file

@ -49,7 +49,7 @@ func (q *Qs) ToString() string {
}
func GetQueryParam(ctx *RequestContext, key string) string {
value, ok := ctx.URL.Query()[key]
value, ok := ctx.Request.URL.Query()[key]
if value == nil || !ok {
current := ctx.currentBrowserUrl
if current != "" {

View file

@ -25,7 +25,6 @@ func TestRender(t *testing.T) {
Attribute("data-attr-2", "value"),
Attributes(&AttributeMap{
"data-attr-3": "value",
"data-attr-4": "value",
}),
HxBeforeRequest(
SetText("before request"),
@ -41,12 +40,12 @@ func TestRender(t *testing.T) {
div.attributes.Set("data-attr-1", "value")
expected := `<div data-attr-1="value" id="my-div" data-attr-2="value" data-attr-3="value" data-attr-4="value" hx-on::before-request="this.innerText = &#39;before request&#39;;" hx-on::after-request="this.innerText = &#39;after request&#39;;"><div>hello, world</div>hello, child</div>`
expected := `<div data-attr-1="value" id="my-div" data-attr-2="value" data-attr-3="value" hx-on::before-request="this.innerText = &#39;before request&#39;;" hx-on::after-request="this.innerText = &#39;after request&#39;;"><div>hello, world</div>hello, child</div>`
result := Render(div)
assert.Equal(t,
expected,
result)
strings.ReplaceAll(result, "var self=this;var e=event;", ""))
}
func TestRenderAttributes_1(t *testing.T) {

View file

@ -43,7 +43,8 @@ type RenderContext struct {
func (ctx *RenderContext) AddScript(funcName string, body string) {
script := fmt.Sprintf(`
<script id="%s">
function %s(self) {
function %s(self, event) {
let e = event;
%s
}
</script>`, funcName, funcName, body)
@ -51,7 +52,9 @@ func (ctx *RenderContext) AddScript(funcName string, body string) {
}
func (node *Element) Render(context *RenderContext) {
// some elements may not have a tag, such as a Fragment
if node == nil {
return
}
if node.tag == CachedNodeTag {
meta := node.meta.(*CachedNode)
@ -65,6 +68,7 @@ func (node *Element) Render(context *RenderContext) {
return
}
// some elements may not have a tag, such as a Fragment
if node.tag != "" {
context.builder.WriteString("<")
context.builder.WriteString(node.tag)
@ -220,10 +224,10 @@ func (l *LifeCycle) Render(context *RenderContext) {
for _, command := range commands {
switch c := command.(type) {
case SimpleJsCommand:
m[event] += fmt.Sprintf("%s;", c.Command)
m[event] += fmt.Sprintf("var self=this;var e=event;%s;", c.Command)
case ComplexJsCommand:
context.AddScript(c.TempFuncName, c.Command)
m[event] += fmt.Sprintf("%s(this);", c.TempFuncName)
m[event] += fmt.Sprintf("%s(this, event);", c.TempFuncName)
case *AttributeMapOrdered:
c.Each(func(key string, value string) {
l.fromAttributeMap(event, key, value, context)

View file

@ -65,7 +65,7 @@ func SwapMany(ctx *RequestContext, elements ...*Element) *Element {
for _, element := range elements {
element.AppendChild(outOfBandSwap(""))
}
return Template(Map(elements, func(arg *Element) Ren {
return Fragment(Map(elements, func(arg *Element) Ren {
return arg
})...)
}

View file

@ -1,6 +1,9 @@
package h
import "github.com/maddalax/htmgo/framework/hx"
import (
"github.com/maddalax/htmgo/framework/hx"
"strings"
)
func Get(path string, trigger ...string) *AttributeMapOrdered {
return AttributeList(Attribute(hx.GetAttr, path), HxTriggerString(trigger...))
@ -19,10 +22,18 @@ func GetWithQs(path string, qs *Qs, trigger string) *AttributeMapOrdered {
}
func PostPartial(partial PartialFunc, triggers ...string) *AttributeMapOrdered {
return Post(GetPartialPath(partial), triggers...)
path := GetPartialPath(partial)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return Post(path, triggers...)
}
func PostPartialWithQs(partial PartialFunc, qs *Qs, trigger ...string) *AttributeMapOrdered {
path := GetPartialPathWithQs(partial, qs)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return Post(GetPartialPathWithQs(partial, qs), trigger...)
}

View file

@ -108,6 +108,12 @@ const (
XhrLoadEndEvent Event = "htmx:xhr:loadend"
XhrLoadStartEvent Event = "htmx:xhr:loadstart"
XhrProgressEvent Event = "htmx:xhr:progress"
SseConnectedEvent Event = "htmx:sseOpen"
SseConnectingEvent Event = "htmx:sseConnecting"
SseClosedEvent Event = "htmx:sseClose"
SseErrorEvent Event = "htmx:sseError"
SseBeforeMessageEvent Event = "htmx:sseBeforeMessage"
SseAfterMessageEvent Event = "htmx:sseAfterMessage"
// RevealedEvent Misc Events
RevealedEvent Event = "revealed"

View file

@ -21,7 +21,10 @@ var EvalJsOnParent = h.EvalJsOnParent
var SetClassOnSibling = h.SetClassOnSibling
var RemoveClassOnSibling = h.RemoveClassOnSibling
var Remove = h.Remove
var PreventDefault = h.PreventDefault
var EvalJs = h.EvalJs
var ConsoleLog = h.ConsoleLog
var SetValue = h.SetValue
var SubmitFormOnEnter = h.SubmitFormOnEnter
var InjectScript = h.InjectScript
var InjectScriptIfNotExist = h.InjectScriptIfNotExist

View file

@ -42,6 +42,8 @@ OnClick(cmd ...Command) *LifeCycle
HxOnAfterSwap(cmd ...Command) *LifeCycle
HxOnLoad(cmd ...Command) *LifeCycle
```
**Note:** Each command you attach to the event handler will be passed 'self' and 'event' (if applicable) as arguments.
'self' is the current element, and 'event' is the event object.
If you use the OnEvent directly, event names may be any [HTML DOM](https://www.w3schools.com/jsref/dom_obj_event.asp) events, or any [HTMX events](https://htmx.org/events/).