spin up chat app, setup sockets, fix trigger children to work
This commit is contained in:
parent
d2072fe777
commit
c7f4781137
30 changed files with 1081 additions and 58 deletions
|
|
@ -71,7 +71,7 @@ func MoveFile(src, dst string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to copy file: %v", err)
|
return fmt.Errorf("failed to copy file: %v", err)
|
||||||
}
|
}
|
||||||
// Remove the source file.
|
// Disconnect the source file.
|
||||||
err = os.Remove(src)
|
err = os.Remove(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove source file: %v", err)
|
return fmt.Errorf("failed to remove source file: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ func sliceCommonPrefix(dir1, dir2 string) string {
|
||||||
slicedDir1 := strings.TrimPrefix(dir1, commonPrefix)
|
slicedDir1 := strings.TrimPrefix(dir1, commonPrefix)
|
||||||
slicedDir2 := strings.TrimPrefix(dir2, commonPrefix)
|
slicedDir2 := strings.TrimPrefix(dir2, commonPrefix)
|
||||||
|
|
||||||
// Remove leading slashes
|
// Disconnect leading slashes
|
||||||
slicedDir1 = strings.TrimPrefix(slicedDir1, string(filepath.Separator))
|
slicedDir1 = strings.TrimPrefix(slicedDir1, string(filepath.Separator))
|
||||||
slicedDir2 = strings.TrimPrefix(slicedDir2, string(filepath.Separator))
|
slicedDir2 = strings.TrimPrefix(slicedDir2, string(filepath.Separator))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,10 @@ func (om *OrderedMap[K, V]) Values() []V {
|
||||||
// Delete removes a key-value pair from the OrderedMap.
|
// Delete removes a key-value pair from the OrderedMap.
|
||||||
func (om *OrderedMap[K, V]) Delete(key K) {
|
func (om *OrderedMap[K, V]) Delete(key K) {
|
||||||
if _, exists := om.values[key]; exists {
|
if _, exists := om.values[key]; exists {
|
||||||
// Remove the key from the map
|
// Disconnect the key from the map
|
||||||
delete(om.values, key)
|
delete(om.values, key)
|
||||||
|
|
||||||
// Remove the key from the keys slice
|
// Disconnect the key from the keys slice
|
||||||
for i, k := range om.keys {
|
for i, k := range om.keys {
|
||||||
if k == key {
|
if k == key {
|
||||||
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
||||||
|
|
|
||||||
11
examples/chat/.dockerignore
Normal file
11
examples/chat/.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Project exclude paths
|
||||||
|
/tmp/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
js/dist
|
||||||
|
js/node_modules
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
.idea
|
||||||
|
!framework/assets/dist
|
||||||
|
__htmgo
|
||||||
6
examples/chat/.gitignore
vendored
Normal file
6
examples/chat/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/assets/dist
|
||||||
|
tmp
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
__htmgo
|
||||||
|
dist
|
||||||
38
examples/chat/Dockerfile
Normal file
38
examples/chat/Dockerfile
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Stage 1: Build the Go binary
|
||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk update
|
||||||
|
RUN apk add git
|
||||||
|
RUN apk add curl
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go.mod and go.sum files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download and cache the Go modules
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy the source code into the container
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the Go binary for Linux
|
||||||
|
RUN GOPRIVATE=github.com/maddalax GOPROXY=direct go run github.com/maddalax/htmgo/cli/htmgo@latest build
|
||||||
|
|
||||||
|
|
||||||
|
# Stage 2: Create the smallest possible image
|
||||||
|
FROM gcr.io/distroless/base-debian11
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the Go binary from the builder stage
|
||||||
|
COPY --from=builder /app/dist .
|
||||||
|
|
||||||
|
# Expose the necessary port (replace with your server port)
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|
||||||
|
# Command to run the binary
|
||||||
|
CMD ["./starter-template"]
|
||||||
20
examples/chat/Taskfile.yml
Normal file
20
examples/chat/Taskfile.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
run:
|
||||||
|
cmds:
|
||||||
|
- go run github.com/maddalax/htmgo/cli/htmgo@latest run
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
build:
|
||||||
|
cmds:
|
||||||
|
- go run github.com/maddalax/htmgo/cli/htmgo@latest build
|
||||||
|
|
||||||
|
docker:
|
||||||
|
cmds:
|
||||||
|
- docker build .
|
||||||
|
|
||||||
|
watch:
|
||||||
|
cmds:
|
||||||
|
- go run github.com/maddalax/htmgo/cli/htmgo@latest watch
|
||||||
|
silent: true
|
||||||
3
examples/chat/assets/css/input.css
Normal file
3
examples/chat/assets/css/input.css
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
40
examples/chat/chat/broadcast.go
Normal file
40
examples/chat/chat/broadcast.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/ws"
|
||||||
|
"fmt"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartListener(loader *service.Locator) {
|
||||||
|
manager := service.Get[ws.SocketManager](loader)
|
||||||
|
|
||||||
|
c := make(chan ws.MessageEvent)
|
||||||
|
manager.Listen(c)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event := <-c:
|
||||||
|
fmt.Printf("Received message from %s: %v\n", event.Id, event.Message)
|
||||||
|
message := event.Message["message"].(string)
|
||||||
|
if message == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
messageEle := h.Div(
|
||||||
|
h.Attribute("hx-swap-oob", "beforeend"),
|
||||||
|
h.Class("flex flex-col gap-2 w-full"),
|
||||||
|
h.Id("messages"),
|
||||||
|
h.Pf(message),
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.BroadcastText(
|
||||||
|
h.Render(
|
||||||
|
h.Fragment(
|
||||||
|
messageEle,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
examples/chat/go.mod
Normal file
12
examples/chat/go.mod
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
module chat
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
require github.com/maddalax/htmgo/framework v0.0.0-20240930141756-0fa096ea2f12
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/coder/websocket v1.8.12 // indirect
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
|
||||||
|
)
|
||||||
20
examples/chat/go.sum
Normal file
20
examples/chat/go.sum
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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-20240930141756-0fa096ea2f12 h1:UKmSB4aTk7+FS8j2pz7ytFQQI0ihqZznG9PLqUM+2QM=
|
||||||
|
github.com/maddalax/htmgo/framework v0.0.0-20240930141756-0fa096ea2f12/go.mod h1:HYKI49Pb6oyY2opSJdTt145B1vWgfWIDohvlolynv80=
|
||||||
|
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=
|
||||||
44
examples/chat/main.go
Normal file
44
examples/chat/main.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"chat/__htmgo"
|
||||||
|
"chat/chat"
|
||||||
|
"chat/ws"
|
||||||
|
"embed"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/dist/*
|
||||||
|
var StaticAssets embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
locator := service.NewLocator()
|
||||||
|
|
||||||
|
service.Set[ws.SocketManager](locator, service.Singleton, func() *ws.SocketManager {
|
||||||
|
return ws.NewSocketManager()
|
||||||
|
})
|
||||||
|
|
||||||
|
go chat.StartListener(locator)
|
||||||
|
|
||||||
|
h.Start(h.AppOpts{
|
||||||
|
ServiceLocator: locator,
|
||||||
|
LiveReload: true,
|
||||||
|
Register: func(app *h.App) {
|
||||||
|
sub, err := fs.Sub(StaticAssets, "assets/dist")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.FileServerFS(sub)
|
||||||
|
|
||||||
|
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
|
||||||
|
app.Router.Handle("/chat", ws.Handle())
|
||||||
|
|
||||||
|
__htmgo.Register(app.Router)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
93
examples/chat/pages/index.go
Normal file
93
examples/chat/pages/index.go
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/hx"
|
||||||
|
"github.com/maddalax/htmgo/framework/js"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IndexPage(ctx *h.RequestContext) *h.Page {
|
||||||
|
return h.NewPage(
|
||||||
|
RootPage(
|
||||||
|
h.Div(
|
||||||
|
h.JoinAttributes(
|
||||||
|
", ",
|
||||||
|
h.TriggerChildren(),
|
||||||
|
h.HxExtension("ws"),
|
||||||
|
),
|
||||||
|
h.Attribute("ws-connect", "/chat"),
|
||||||
|
h.Class("flex flex-col gap-4 items-center pt-24 min-h-screen bg-neutral-100"),
|
||||||
|
Form(ctx),
|
||||||
|
h.Div(
|
||||||
|
h.Div(
|
||||||
|
h.Id("messages"),
|
||||||
|
h.Class("flex flex-col gap-2 w-full"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MessageInput() *h.Element {
|
||||||
|
return h.Input("text",
|
||||||
|
h.Id("message-input"),
|
||||||
|
h.Required(),
|
||||||
|
h.Class("p-4 rounded-md border border-slate-200"),
|
||||||
|
h.Name("message"),
|
||||||
|
h.Placeholder("Message"),
|
||||||
|
h.OnEvent("htmx:wsBeforeMessage", js.EvalJs("console.log('got message input')")),
|
||||||
|
h.HxBeforeWsSend(
|
||||||
|
js.SetValue(""),
|
||||||
|
),
|
||||||
|
h.OnEvent(hx.KeyDownEvent, js.SubmitFormOnEnter()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Form(ctx *h.RequestContext) *h.Element {
|
||||||
|
return h.Div(
|
||||||
|
h.Class("flex flex-col items-center justify-center p-4 gap-6"),
|
||||||
|
h.H2F("Form submission with ws example", h.Class("text-2xl font-bold")),
|
||||||
|
h.Form(
|
||||||
|
h.Attribute("ws-send", ""),
|
||||||
|
h.Class("flex flex-col gap-2"),
|
||||||
|
h.LabelFor("name", "Your Message"),
|
||||||
|
MessageInput(),
|
||||||
|
SubmitButton(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SubmitButton() *h.Element {
|
||||||
|
buttonClasses := "rounded items-center px-3 py-2 bg-slate-800 text-white w-full text-center"
|
||||||
|
return h.Div(
|
||||||
|
h.HxBeforeRequest(
|
||||||
|
js.RemoveClassOnChildren(".loading", "hidden"),
|
||||||
|
js.SetClassOnChildren(".submit", "hidden"),
|
||||||
|
),
|
||||||
|
h.HxAfterRequest(
|
||||||
|
js.SetClassOnChildren(".loading", "hidden"),
|
||||||
|
js.RemoveClassOnChildren(".submit", "hidden"),
|
||||||
|
),
|
||||||
|
h.Class("flex gap-2 justify-center"),
|
||||||
|
h.Button(
|
||||||
|
h.Class("loading hidden relative text-center", buttonClasses),
|
||||||
|
Spinner(),
|
||||||
|
h.Disabled(),
|
||||||
|
h.Text("Submitting..."),
|
||||||
|
),
|
||||||
|
h.Button(
|
||||||
|
h.Type("submit"),
|
||||||
|
h.Class("submit", buttonClasses),
|
||||||
|
h.Text("Submit"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Spinner(children ...h.Ren) *h.Element {
|
||||||
|
return h.Div(
|
||||||
|
h.Children(children...),
|
||||||
|
h.Class("absolute left-1 spinner spinner-border animate-spin inline-block w-6 h-6 border-4 rounded-full border-slate-200 border-t-transparent"),
|
||||||
|
h.Attribute("role", "status"),
|
||||||
|
)
|
||||||
|
}
|
||||||
22
examples/chat/pages/root.go
Normal file
22
examples/chat/pages/root.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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.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...),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
54
examples/chat/partials/index.go
Normal file
54
examples/chat/partials/index.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package partials
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CounterPartial(ctx *h.RequestContext) *h.Partial {
|
||||||
|
count, err := strconv.ParseInt(ctx.FormValue("count"), 10, 64)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
count++
|
||||||
|
|
||||||
|
return h.SwapManyPartial(
|
||||||
|
ctx,
|
||||||
|
CounterForm(int(count)),
|
||||||
|
h.ElementIf(count > 10, SubmitButton("New record!")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CounterForm(count int) *h.Element {
|
||||||
|
return h.Form(
|
||||||
|
h.Class("flex flex-col gap-3 items-center"),
|
||||||
|
h.Id("counter-form"),
|
||||||
|
h.PostPartial(CounterPartial),
|
||||||
|
h.Input("text",
|
||||||
|
h.Class("hidden"),
|
||||||
|
h.Value(count),
|
||||||
|
h.Name("count"),
|
||||||
|
),
|
||||||
|
h.P(
|
||||||
|
h.AttributePairs(
|
||||||
|
"id", "counter",
|
||||||
|
"class", "text-xl",
|
||||||
|
"name", "count",
|
||||||
|
"text", "count",
|
||||||
|
),
|
||||||
|
h.TextF("Count: %d", count),
|
||||||
|
),
|
||||||
|
SubmitButton("Increment"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SubmitButton(text string) *h.Element {
|
||||||
|
return h.Button(
|
||||||
|
h.Class("bg-rose-400 hover:bg-rose-500 text-white font-bold py-2 px-4 rounded"),
|
||||||
|
h.Id("swap-text"),
|
||||||
|
h.Type("submit"),
|
||||||
|
h.Text(text),
|
||||||
|
)
|
||||||
|
}
|
||||||
5
examples/chat/tailwind.config.js
Normal file
5
examples/chat/tailwind.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["**/*.go"],
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
44
examples/chat/ws/handler.go
Normal file
44
examples/chat/ws/handler.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Handle() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := websocket.Accept(w, r, nil)
|
||||||
|
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
|
||||||
|
locator := cc.ServiceLocator()
|
||||||
|
manager := service.Get[SocketManager](locator)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
manager.Add(id, c)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
manager.Disconnect(id)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
var v map[string]any
|
||||||
|
err = wsjson.Read(context.Background(), c, &v)
|
||||||
|
if err != nil {
|
||||||
|
manager.CloseWithError(id, "failed to read message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v != nil {
|
||||||
|
manager.OnMessage(id, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
examples/chat/ws/manager.go
Normal file
80
examples/chat/ws/manager.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/puzpuzpuz/xsync/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageEvent struct {
|
||||||
|
Id string
|
||||||
|
Message map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type SocketManager struct {
|
||||||
|
sockets *xsync.MapOf[string, *websocket.Conn]
|
||||||
|
listeners []chan MessageEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSocketManager() *SocketManager {
|
||||||
|
return &SocketManager{
|
||||||
|
sockets: xsync.NewMapOf[string, *websocket.Conn](),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) Listen(listener chan MessageEvent) {
|
||||||
|
if manager.listeners == nil {
|
||||||
|
manager.listeners = make([]chan MessageEvent, 0)
|
||||||
|
}
|
||||||
|
manager.listeners = append(manager.listeners, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) OnMessage(id string, message map[string]any) {
|
||||||
|
for _, listener := range manager.listeners {
|
||||||
|
listener <- MessageEvent{
|
||||||
|
Id: id,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) Add(id string, conn *websocket.Conn) {
|
||||||
|
manager.sockets.Store(id, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) CloseWithError(id string, message string) {
|
||||||
|
conn := manager.Get(id)
|
||||||
|
if conn != nil {
|
||||||
|
conn.Close(websocket.StatusInternalError, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) Disconnect(id string) {
|
||||||
|
conn := manager.Get(id)
|
||||||
|
if conn != nil {
|
||||||
|
_ = conn.CloseNow()
|
||||||
|
}
|
||||||
|
manager.sockets.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) Get(id string) *websocket.Conn {
|
||||||
|
conn, _ := manager.sockets.Load(id)
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) Broadcast(message []byte, messageType websocket.MessageType) {
|
||||||
|
ctx := context.Background()
|
||||||
|
manager.sockets.Range(func(id string, conn *websocket.Conn) bool {
|
||||||
|
err := conn.Write(ctx, messageType, message)
|
||||||
|
if err != nil {
|
||||||
|
manager.Disconnect(id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *SocketManager) BroadcastText(message string) {
|
||||||
|
fmt.Printf("Broadcasting message: \n%s\n", message)
|
||||||
|
manager.Broadcast([]byte(message), websocket.MessageText)
|
||||||
|
}
|
||||||
4
framework/assets/dist/htmgo.js
vendored
4
framework/assets/dist/htmgo.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -6,6 +6,7 @@ import "./htmxextensions/response-targets";
|
||||||
import "./htmxextensions/mutation-error";
|
import "./htmxextensions/mutation-error";
|
||||||
import "./htmxextensions/livereload"
|
import "./htmxextensions/livereload"
|
||||||
import "./htmxextensions/htmgo";
|
import "./htmxextensions/htmgo";
|
||||||
|
import "./htmxextensions/ws"
|
||||||
|
|
||||||
function watchUrl(callback: (oldUrl: string, newUrl: string) => void) {
|
function watchUrl(callback: (oldUrl: string, newUrl: string) => void) {
|
||||||
let lastUrl = window.location.href;
|
let lastUrl = window.location.href;
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ import htmx from "htmx.org";
|
||||||
htmx.defineExtension("debug", {
|
htmx.defineExtension("debug", {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
onEvent: function (name, evt) {
|
onEvent: function (name, evt) {
|
||||||
|
if(name != 'htmx:wsBeforeMessage') {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (console.debug) {
|
if (console.debug) {
|
||||||
console.debug(name);
|
console.debug(name, evt);
|
||||||
} else if (console) {
|
} else if (console) {
|
||||||
console.log("DEBUG:", name);
|
console.log("DEBUG:", name, evt);
|
||||||
} else {
|
} else {
|
||||||
// noop
|
// noop
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import htmx from "htmx.org";
|
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", {
|
htmx.defineExtension("htmgo", {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
@ -11,16 +11,17 @@ htmx.defineExtension("htmgo", {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function removeAssociatedScripts(element: HTMLElement) {
|
export function removeAssociatedScripts(element: HTMLElement) {
|
||||||
const attributes = Array.from(element.attributes)
|
const attributes = Array.from(element.attributes)
|
||||||
for (let attribute of attributes) {
|
for (let attribute of attributes) {
|
||||||
const matches = attribute.value.match(evalFuncRegex) || []
|
const matches = attribute.value.match(evalFuncRegex) || []
|
||||||
for (let match of matches) {
|
for (let match of matches) {
|
||||||
const id = match.replace("()", "")
|
const id = match.replace("()", "").replace("(this)", "").replace(";", "")
|
||||||
const ele = document.getElementById(id)
|
const ele = document.getElementById(id)
|
||||||
if(ele && ele.tagName === "SCRIPT") {
|
if(ele && ele.tagName === "SCRIPT") {
|
||||||
|
console.debug("removing associated script with id", id)
|
||||||
ele.remove()
|
ele.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,82 @@
|
||||||
import htmx, { HtmxSettleInfo, HtmxSwapStyle } from "htmx.org";
|
import htmx, {HtmxSettleInfo, HtmxSwapStyle} from "htmx.org";
|
||||||
|
|
||||||
|
function kebabEventName(str: string) {
|
||||||
|
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
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>) {
|
||||||
|
event.detail.meta = 'trigger-children';
|
||||||
|
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)
|
||||||
|
e.dispatchEvent(newEvent)
|
||||||
|
triggered.add(e as HTMLElement);
|
||||||
|
}
|
||||||
|
if (e.children) {
|
||||||
|
triggerChildren(e as HTMLElement, name, event, triggered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
htmx.defineExtension("trigger-children", {
|
htmx.defineExtension("trigger-children", {
|
||||||
onEvent: (name, evt: Event | CustomEvent) => {
|
onEvent: (name, evt: Event | CustomEvent) => {
|
||||||
if (!(evt instanceof CustomEvent)) {
|
if (!(evt instanceof CustomEvent)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const target = evt.detail.target as HTMLElement;
|
if(evt.detail.meta === 'trigger-children') {
|
||||||
if (target && target.children) {
|
return false;
|
||||||
Array.from(target.children).forEach((e) => {
|
}
|
||||||
htmx.trigger(e, name, null);
|
const triggered = new Set<HTMLElement>();
|
||||||
});
|
const target = evt.target as HTMLElement || evt.detail.target as HTMLElement;
|
||||||
}
|
triggerChildren(target, name, evt, triggered);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
init: function (api: any): void {},
|
init: function (api: any): void {
|
||||||
transformResponse: function (
|
},
|
||||||
text: string,
|
transformResponse: function (
|
||||||
xhr: XMLHttpRequest,
|
text: string,
|
||||||
elt: Element,
|
xhr: XMLHttpRequest,
|
||||||
): string {
|
elt: Element,
|
||||||
return text;
|
): string {
|
||||||
},
|
return text;
|
||||||
isInlineSwap: function (swapStyle: HtmxSwapStyle): boolean {
|
},
|
||||||
return false;
|
isInlineSwap: function (swapStyle: HtmxSwapStyle): boolean {
|
||||||
},
|
return false;
|
||||||
handleSwap: function (
|
},
|
||||||
swapStyle: HtmxSwapStyle,
|
handleSwap: function (
|
||||||
target: Node,
|
swapStyle: HtmxSwapStyle,
|
||||||
fragment: Node,
|
target: Node,
|
||||||
settleInfo: HtmxSettleInfo,
|
fragment: Node,
|
||||||
): boolean | Node[] {
|
settleInfo: HtmxSettleInfo,
|
||||||
return false;
|
): boolean | Node[] {
|
||||||
},
|
return false;
|
||||||
encodeParameters: function (
|
},
|
||||||
xhr: XMLHttpRequest,
|
encodeParameters: function (
|
||||||
parameters: FormData,
|
xhr: XMLHttpRequest,
|
||||||
elt: Node,
|
parameters: FormData,
|
||||||
) {},
|
elt: Node,
|
||||||
getSelectors: function (): string[] | null {
|
) {
|
||||||
return null;
|
},
|
||||||
},
|
getSelectors: function (): string[] | null {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
449
framework/assets/js/htmxextensions/ws.ts
Normal file
449
framework/assets/js/htmxextensions/ws.ts
Normal file
|
|
@ -0,0 +1,449 @@
|
||||||
|
import htmx from 'htmx.org'
|
||||||
|
import {removeAssociatedScripts} from "./htmgo";
|
||||||
|
|
||||||
|
|
||||||
|
declare module 'htmx.org' {
|
||||||
|
interface Htmx {
|
||||||
|
defineExtension(name: string, extension: HtmxExtension): void;
|
||||||
|
createWebSocket?: (url: string) => WebSocket;
|
||||||
|
config: {
|
||||||
|
wsReconnectDelay?: 'full-jitter' | ((retryCount: number) => number);
|
||||||
|
wsBinaryType?: string;
|
||||||
|
[key: string]: any
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HtmxExtension {
|
||||||
|
init: (apiRef: HtmxInternalApi) => void;
|
||||||
|
onEvent: (name: string, evt: Event) => void;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HtmxInternalApi {
|
||||||
|
getInternalData(elt: Element): any;
|
||||||
|
bodyContains(elt: Element): boolean;
|
||||||
|
getAttributeValue(elt: Element, name: string): string | null;
|
||||||
|
triggerEvent(elt: Element, name: string, detail?: any): boolean;
|
||||||
|
withExtensions(elt: Element, callback: (extension: any) => void): void;
|
||||||
|
makeSettleInfo(elt: Element): any;
|
||||||
|
makeFragment(html: string): DocumentFragment;
|
||||||
|
oobSwap(swapStyle: string, fragment: Element, settleInfo: any): void;
|
||||||
|
settleImmediately(tasks: any): void;
|
||||||
|
getClosestMatch(elt: Element, condition: (node: Element) => boolean): Element | null;
|
||||||
|
getTriggerSpecs(elt: Element): any[];
|
||||||
|
addTriggerHandler(elt: Element, triggerSpec: any, nodeData: any, handler: (elt: Element, evt: Event) => void): void;
|
||||||
|
getHeaders(elt: Element, target: Element): any;
|
||||||
|
getTarget(elt: Element): Element;
|
||||||
|
getInputValues(elt: Element, verb: string): { errors: any[]; values: any };
|
||||||
|
getExpressionVars(elt: Element): any;
|
||||||
|
mergeObjects(obj1: any, obj2: any): any;
|
||||||
|
filterValues(values: any, elt: Element): any;
|
||||||
|
triggerErrorEvent(elt?: Element, name?: string, detail?: any): void;
|
||||||
|
hasAttribute(elt: Element, name: string): boolean;
|
||||||
|
shouldCancel(evt: Event, elt: Element): boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebSocketWrapper {
|
||||||
|
socket: WebSocket;
|
||||||
|
events : { [key: string]: ((event: Event) => void)[] };
|
||||||
|
messageQueue: { message: string; sendElt: Element | null }[];
|
||||||
|
retryCount: number;
|
||||||
|
sendImmediately(message: string, sendElt: Element | null): void;
|
||||||
|
send(message: string, sendElt: Element | null): void;
|
||||||
|
addEventListener(event: string, handler: (event: Event) => void): void;
|
||||||
|
handleQueuedMessages(): void;
|
||||||
|
init(): void;
|
||||||
|
close(): void;
|
||||||
|
publicInterface: {
|
||||||
|
send: (message: string, sendElt: Element | null) => void;
|
||||||
|
sendImmediately: (message: string, sendElt: Element | null) => void;
|
||||||
|
queue: { message: string; sendElt: Element | null }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let api: HtmxInternalApi;
|
||||||
|
|
||||||
|
function splitOnWhitespace(trigger: string): string[] {
|
||||||
|
return trigger.trim().split(/\s+/);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyWebsocketURL(elt: Element): string | undefined {
|
||||||
|
const legacySSEValue = api.getAttributeValue(elt, 'hx-ws');
|
||||||
|
if (legacySSEValue) {
|
||||||
|
const values = splitOnWhitespace(legacySSEValue);
|
||||||
|
for (let i = 0; i < values.length; i++) {
|
||||||
|
const value = values[i].split(/:(.+)/);
|
||||||
|
if (value[0] === 'connect') {
|
||||||
|
return value[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWebSocket(socketElt: HTMLElement): void {
|
||||||
|
// If the element containing the WebSocket connection no longer exists, then
|
||||||
|
// do not connect/reconnect the WebSocket.
|
||||||
|
if (!api.bodyContains(socketElt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the source straight from the element's value
|
||||||
|
let wssSource = api.getAttributeValue(socketElt, 'ws-connect');
|
||||||
|
|
||||||
|
if (wssSource == null || wssSource === '') {
|
||||||
|
const legacySource = getLegacyWebsocketURL(socketElt);
|
||||||
|
if (legacySource == null) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
wssSource = legacySource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee that the wssSource value is a fully qualified URL
|
||||||
|
if (wssSource.indexOf('/') === 0) {
|
||||||
|
const base_part = location.hostname + (location.port ? ':' + location.port : '');
|
||||||
|
if (location.protocol === 'https:') {
|
||||||
|
wssSource = 'wss://' + base_part + wssSource;
|
||||||
|
} else if (location.protocol === 'http:') {
|
||||||
|
wssSource = 'ws://' + base_part + wssSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketWrapper = createWebsocketWrapper(socketElt, () => htmx.createWebSocket!(wssSource));
|
||||||
|
|
||||||
|
socketWrapper.addEventListener('message', (event) => {
|
||||||
|
if (maybeCloseWebSocketSource(socketElt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = (event as MessageEvent).data;
|
||||||
|
if (
|
||||||
|
!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', {
|
||||||
|
message: response,
|
||||||
|
socketWrapper: socketWrapper.publicInterface,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.withExtensions(socketElt, (extension) => {
|
||||||
|
response = extension.transformResponse(response, null, socketElt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const settleInfo = api.makeSettleInfo(socketElt);
|
||||||
|
const fragment = api.makeFragment(response);
|
||||||
|
|
||||||
|
if (fragment.children.length) {
|
||||||
|
const children = Array.from(fragment.children);
|
||||||
|
for (let i = 0; i < children.length; i++) {
|
||||||
|
const child = children[i]
|
||||||
|
api.oobSwap(api.getAttributeValue(child, 'hx-swap-oob') || 'true', children[i], settleInfo);
|
||||||
|
// support htmgo eval__ scripts
|
||||||
|
if(child.tagName === 'SCRIPT' && child.id.startsWith("__eval")) {
|
||||||
|
document.body.appendChild(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.settleImmediately(settleInfo.tasks);
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', {
|
||||||
|
message: response,
|
||||||
|
socketWrapper: socketWrapper.publicInterface,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Put the WebSocket into the HTML Element's custom data.
|
||||||
|
api.getInternalData(socketElt).webSocket = socketWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWebsocketWrapper(socketElt: HTMLElement, socketFunc: () => WebSocket): WebSocketWrapper {
|
||||||
|
const wrapper: WebSocketWrapper = {
|
||||||
|
socket: null as unknown as WebSocket,
|
||||||
|
messageQueue: [],
|
||||||
|
retryCount: 0,
|
||||||
|
events: {} as { [key: string]: ((event: Event) => void)[] },
|
||||||
|
addEventListener(event: string, handler: (event: Event) => void) {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.addEventListener(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.events[event]) {
|
||||||
|
this.events[event] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events[event].push(handler);
|
||||||
|
},
|
||||||
|
sendImmediately(message: string, sendElt: Element | null) {
|
||||||
|
if (!this.socket) {
|
||||||
|
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: 'No socket available' });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!sendElt ||
|
||||||
|
api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||||
|
message,
|
||||||
|
socketWrapper: this.publicInterface,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
this.socket.send(message);
|
||||||
|
if (sendElt) {
|
||||||
|
api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||||
|
message,
|
||||||
|
socketWrapper: this.publicInterface,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
send(message: string, sendElt: Element | null) {
|
||||||
|
if (this.socket.readyState !== this.socket.OPEN) {
|
||||||
|
this.messageQueue.push({ message, sendElt });
|
||||||
|
} else {
|
||||||
|
this.sendImmediately(message, sendElt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleQueuedMessages() {
|
||||||
|
while (this.messageQueue.length > 0) {
|
||||||
|
const queuedItem = this.messageQueue[0];
|
||||||
|
if (this.socket.readyState === this.socket.OPEN) {
|
||||||
|
this.sendImmediately(queuedItem.message, queuedItem.sendElt);
|
||||||
|
this.messageQueue.shift();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||||
|
// Close discarded socket
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new WebSocket and event handlers
|
||||||
|
const socket = socketFunc();
|
||||||
|
|
||||||
|
// The event.type detail is added for interface conformance with the
|
||||||
|
// other two lifecycle events (open and close) so a single handler method
|
||||||
|
// can handle them polymorphically, if required.
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } });
|
||||||
|
|
||||||
|
this.socket = socket;
|
||||||
|
|
||||||
|
socket.onopen = (e) => {
|
||||||
|
this.retryCount = 0;
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: this.publicInterface });
|
||||||
|
this.handleQueuedMessages();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = (e) => {
|
||||||
|
// If socket should not be connected, stop further attempts to establish connection
|
||||||
|
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||||
|
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||||
|
const delay = getWebSocketReconnectDelay(this.retryCount);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.retryCount += 1;
|
||||||
|
this.init();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||||
|
// to determine whether closure has been valid or abnormal
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: this.publicInterface });
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = (e) => {
|
||||||
|
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: this });
|
||||||
|
maybeCloseWebSocketSource(socketElt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = this.events;
|
||||||
|
Object.keys(events).forEach((k) => {
|
||||||
|
events[k].forEach((e) => {
|
||||||
|
socket.addEventListener(k, e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.socket.close();
|
||||||
|
},
|
||||||
|
publicInterface: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
wrapper.init();
|
||||||
|
|
||||||
|
wrapper.publicInterface = {
|
||||||
|
send: wrapper.send.bind(wrapper),
|
||||||
|
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||||
|
queue: wrapper.messageQueue,
|
||||||
|
};
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWebSocketSend(elt: HTMLElement): void {
|
||||||
|
const legacyAttribute = api.getAttributeValue(elt, 'hx-ws');
|
||||||
|
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const webSocketParent = api.getClosestMatch(elt, hasWebSocket);
|
||||||
|
if (webSocketParent) {
|
||||||
|
processWebSocketSend(webSocketParent as HTMLElement, elt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWebSocket(node: HTMLElement): boolean {
|
||||||
|
return api.getInternalData(node).webSocket != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processWebSocketSend(socketElt: HTMLElement, sendElt: HTMLElement): void {
|
||||||
|
const nodeData = api.getInternalData(sendElt);
|
||||||
|
const triggerSpecs = api.getTriggerSpecs(sendElt);
|
||||||
|
triggerSpecs.forEach((ts) => {
|
||||||
|
api.addTriggerHandler(sendElt, ts, nodeData, (elt: Element, evt: Event) => {
|
||||||
|
if (maybeCloseWebSocketSource(socketElt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketWrapper: WebSocketWrapper = api.getInternalData(socketElt).webSocket;
|
||||||
|
const headers = api.getHeaders(sendElt, api.getTarget(sendElt));
|
||||||
|
const results = api.getInputValues(sendElt, 'post');
|
||||||
|
const errors = results.errors;
|
||||||
|
const rawParameters = Object.assign({}, results.values);
|
||||||
|
const expressionVars = api.getExpressionVars(sendElt);
|
||||||
|
const allParameters = api.mergeObjects(rawParameters, expressionVars);
|
||||||
|
const filteredParameters = api.filterValues(allParameters, sendElt);
|
||||||
|
|
||||||
|
const sendConfig = {
|
||||||
|
parameters: filteredParameters,
|
||||||
|
unfilteredParameters: allParameters,
|
||||||
|
headers,
|
||||||
|
errors,
|
||||||
|
|
||||||
|
triggeringEvent: evt,
|
||||||
|
messageBody: undefined as string | undefined,
|
||||||
|
socketWrapper: socketWrapper.publicInterface,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors && errors.length > 0) {
|
||||||
|
api.triggerEvent(elt, 'htmx:validation:halted', errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = sendConfig.messageBody;
|
||||||
|
if (body === undefined) {
|
||||||
|
const toSend = Object.assign({}, sendConfig.parameters);
|
||||||
|
if (sendConfig.headers) {
|
||||||
|
toSend.HEADERS = headers;
|
||||||
|
}
|
||||||
|
body = JSON.stringify(toSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
socketWrapper.send(body, elt as Element);
|
||||||
|
|
||||||
|
if (evt && api.shouldCancel(evt, elt as Element)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebSocketReconnectDelay(retryCount: number): number {
|
||||||
|
const delay = htmx.config.wsReconnectDelay;
|
||||||
|
if (typeof delay === 'function') {
|
||||||
|
return delay(retryCount);
|
||||||
|
}
|
||||||
|
if (delay === 'full-jitter') {
|
||||||
|
const exp = Math.min(retryCount, 6);
|
||||||
|
const maxDelay = 1000 * Math.pow(2, exp);
|
||||||
|
return maxDelay * Math.random();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeCloseWebSocketSource(elt: HTMLElement): boolean {
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
api.getInternalData(elt).webSocket.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWebSocket(url: string): WebSocket {
|
||||||
|
const sock = new WebSocket(url, []);
|
||||||
|
sock.binaryType = (htmx.config.wsBinaryType || 'blob') as unknown as BinaryType;
|
||||||
|
return sock;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryAttributeOnThisOrChildren(elt: HTMLElement, attributeName: string): HTMLElement[] {
|
||||||
|
const result: HTMLElement[] = [];
|
||||||
|
|
||||||
|
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||||
|
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
|
||||||
|
result.push(elt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search all child nodes that match the requested attribute
|
||||||
|
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach((node) => {
|
||||||
|
result.push(node as HTMLElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forEach<T>(arr: T[], func: (item: T) => void): void {
|
||||||
|
if (arr) {
|
||||||
|
arr.forEach(func);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
htmx.defineExtension('ws', {
|
||||||
|
init: (apiRef: HtmxInternalApi) => {
|
||||||
|
// Store reference to internal API
|
||||||
|
api = apiRef;
|
||||||
|
|
||||||
|
// Default function for creating new WebSocket objects
|
||||||
|
if (!htmx.createWebSocket) {
|
||||||
|
htmx.createWebSocket = createWebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default setting for reconnect delay
|
||||||
|
if (!htmx.config.wsReconnectDelay) {
|
||||||
|
htmx.config.wsReconnectDelay = 'full-jitter';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onEvent: (name: string, evt: Event) => {
|
||||||
|
const parent: Element = evt.target as Element || (evt as CustomEvent).detail.elt;
|
||||||
|
|
||||||
|
if(!(parent instanceof HTMLElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
// Try to close the socket when elements are removed
|
||||||
|
case 'htmx:beforeCleanupElement':
|
||||||
|
removeAssociatedScripts(parent);
|
||||||
|
const internalData = api.getInternalData(parent);
|
||||||
|
if (internalData.webSocket) {
|
||||||
|
internalData.webSocket.close();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Try to create websockets when elements are processed
|
||||||
|
case 'htmx:beforeProcessNode':
|
||||||
|
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), (child) => {
|
||||||
|
ensureWebSocket(child);
|
||||||
|
});
|
||||||
|
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), (child) => {
|
||||||
|
ensureWebSocketSend(child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -116,7 +116,7 @@ func HxIndicator(tag string) *AttributeR {
|
||||||
return Attribute(hx.IndicatorAttr, tag)
|
return Attribute(hx.IndicatorAttr, tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TriggerChildren() Ren {
|
func TriggerChildren() *AttributeR {
|
||||||
return HxExtension("trigger-children")
|
return HxExtension("trigger-children")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,10 +133,22 @@ func HxTriggerClick(opts ...hx.Modifier) *AttributeR {
|
||||||
return HxTrigger(hx.OnClick(opts...))
|
return HxTrigger(hx.OnClick(opts...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func HxExtension(value string) Ren {
|
func HxExtension(value string) *AttributeR {
|
||||||
return Attribute(hx.ExtAttr, value)
|
return Attribute(hx.ExtAttr, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HxExtensions(value ...string) Ren {
|
||||||
|
return Attribute(hx.ExtAttr, strings.Join(value, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
func Href(path string) Ren {
|
||||||
return Attribute("href", path)
|
return Attribute("href", path)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,16 @@ func (l *LifeCycle) HxBeforeRequest(cmd ...Command) *LifeCycle {
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *LifeCycle) HxBeforeWsSend(cmd ...Command) *LifeCycle {
|
||||||
|
l.OnEvent(hx.BeforeWsSendEvent, cmd...)
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LifeCycle) HxAfterWsSend(cmd ...Command) *LifeCycle {
|
||||||
|
l.OnEvent(hx.AfterWsSendEvent, cmd...)
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
func HxOnLoad(cmd ...Command) *LifeCycle {
|
func HxOnLoad(cmd ...Command) *LifeCycle {
|
||||||
return NewLifeCycle().OnEvent(hx.LoadEvent, cmd...)
|
return NewLifeCycle().OnEvent(hx.LoadEvent, cmd...)
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +86,14 @@ func OnEvent(event hx.Event, cmd ...Command) *LifeCycle {
|
||||||
return NewLifeCycle().OnEvent(event, cmd...)
|
return NewLifeCycle().OnEvent(event, cmd...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HxBeforeWsSend(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().HxBeforeWsSend(cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HxAfterWsSend(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().HxAfterWsSend(cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
func HxBeforeRequest(cmd ...Command) *LifeCycle {
|
func HxBeforeRequest(cmd ...Command) *LifeCycle {
|
||||||
return NewLifeCycle().HxBeforeRequest(cmd...)
|
return NewLifeCycle().HxBeforeRequest(cmd...)
|
||||||
}
|
}
|
||||||
|
|
@ -261,6 +279,11 @@ func EvalJs(js string) ComplexJsCommand {
|
||||||
return NewComplexJsCommand(js)
|
return NewComplexJsCommand(js)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetValue(value string) SimpleJsCommand {
|
||||||
|
// language=JavaScript
|
||||||
|
return SimpleJsCommand{Command: fmt.Sprintf("this.value = '%s'", value)}
|
||||||
|
}
|
||||||
|
|
||||||
func SubmitFormOnEnter() ComplexJsCommand {
|
func SubmitFormOnEnter() ComplexJsCommand {
|
||||||
// language=JavaScript
|
// language=JavaScript
|
||||||
return EvalJs(`
|
return EvalJs(`
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,8 @@ const (
|
||||||
XhrLoadEndEvent Event = "htmx:xhr:loadend"
|
XhrLoadEndEvent Event = "htmx:xhr:loadend"
|
||||||
XhrLoadStartEvent Event = "htmx:xhr:loadstart"
|
XhrLoadStartEvent Event = "htmx:xhr:loadstart"
|
||||||
XhrProgressEvent Event = "htmx:xhr:progress"
|
XhrProgressEvent Event = "htmx:xhr:progress"
|
||||||
|
BeforeWsSendEvent Event = "htmx:wsBeforeSend"
|
||||||
|
AfterWsSendEvent Event = "htmx:wsAfterSend"
|
||||||
|
|
||||||
// RevealedEvent Misc Events
|
// RevealedEvent Misc Events
|
||||||
RevealedEvent Event = "revealed"
|
RevealedEvent Event = "revealed"
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,10 @@ func (om *OrderedMap[K, V]) Values() []V {
|
||||||
// Delete removes a key-value pair from the OrderedMap.
|
// Delete removes a key-value pair from the OrderedMap.
|
||||||
func (om *OrderedMap[K, V]) Delete(key K) {
|
func (om *OrderedMap[K, V]) Delete(key K) {
|
||||||
if _, exists := om.values[key]; exists {
|
if _, exists := om.values[key]; exists {
|
||||||
// Remove the key from the map
|
// Disconnect the key from the map
|
||||||
delete(om.values, key)
|
delete(om.values, key)
|
||||||
|
|
||||||
// Remove the key from the keys slice
|
// Disconnect the key from the keys slice
|
||||||
for i, k := range om.keys {
|
for i, k := range om.keys {
|
||||||
if k == key {
|
if k == key {
|
||||||
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ var SetClassOnSibling = h.SetClassOnSibling
|
||||||
var RemoveClassOnSibling = h.RemoveClassOnSibling
|
var RemoveClassOnSibling = h.RemoveClassOnSibling
|
||||||
var Remove = h.Remove
|
var Remove = h.Remove
|
||||||
var EvalJs = h.EvalJs
|
var EvalJs = h.EvalJs
|
||||||
|
var SetValue = h.SetValue
|
||||||
var SubmitFormOnEnter = h.SubmitFormOnEnter
|
var SubmitFormOnEnter = h.SubmitFormOnEnter
|
||||||
var InjectScript = h.InjectScript
|
var InjectScript = h.InjectScript
|
||||||
var InjectScriptIfNotExist = h.InjectScriptIfNotExist
|
var InjectScriptIfNotExist = h.InjectScriptIfNotExist
|
||||||
|
|
|
||||||
|
|
@ -64,10 +64,10 @@ func (om *OrderedMap[K, V]) Values() []V {
|
||||||
// Delete removes a key-value pair from the OrderedMap.
|
// Delete removes a key-value pair from the OrderedMap.
|
||||||
func (om *OrderedMap[K, V]) Delete(key K) {
|
func (om *OrderedMap[K, V]) Delete(key K) {
|
||||||
if _, exists := om.values[key]; exists {
|
if _, exists := om.values[key]; exists {
|
||||||
// Remove the key from the map
|
// Disconnect the key from the map
|
||||||
delete(om.values, key)
|
delete(om.values, key)
|
||||||
|
|
||||||
// Remove the key from the keys slice
|
// Disconnect the key from the keys slice
|
||||||
for i, k := range om.keys {
|
for i, k := range om.keys {
|
||||||
if k == key {
|
if k == key {
|
||||||
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue