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 {
|
||||
return fmt.Errorf("failed to copy file: %v", err)
|
||||
}
|
||||
// Remove the source file.
|
||||
// Disconnect the source file.
|
||||
err = os.Remove(src)
|
||||
if err != nil {
|
||||
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)
|
||||
slicedDir2 := strings.TrimPrefix(dir2, commonPrefix)
|
||||
|
||||
// Remove leading slashes
|
||||
// Disconnect leading slashes
|
||||
slicedDir1 = strings.TrimPrefix(slicedDir1, 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.
|
||||
func (om *OrderedMap[K, V]) Delete(key K) {
|
||||
if _, exists := om.values[key]; exists {
|
||||
// Remove the key from the map
|
||||
// Disconnect the key from the map
|
||||
delete(om.values, key)
|
||||
|
||||
// Remove the key from the keys slice
|
||||
// Disconnect the key from the keys slice
|
||||
for i, k := range om.keys {
|
||||
if k == key {
|
||||
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
||||
|
|
|
|||
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/livereload"
|
||||
import "./htmxextensions/htmgo";
|
||||
import "./htmxextensions/ws"
|
||||
|
||||
function watchUrl(callback: (oldUrl: string, newUrl: string) => void) {
|
||||
let lastUrl = window.location.href;
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ import htmx from "htmx.org";
|
|||
htmx.defineExtension("debug", {
|
||||
// @ts-ignore
|
||||
onEvent: function (name, evt) {
|
||||
if(name != 'htmx:wsBeforeMessage') {
|
||||
return
|
||||
}
|
||||
if (console.debug) {
|
||||
console.debug(name);
|
||||
console.debug(name, evt);
|
||||
} else if (console) {
|
||||
console.log("DEBUG:", name);
|
||||
console.log("DEBUG:", name, evt);
|
||||
} else {
|
||||
// noop
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,16 +11,17 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
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);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
init: function (api: any): void {},
|
||||
transformResponse: function (
|
||||
text: string,
|
||||
xhr: XMLHttpRequest,
|
||||
elt: Element,
|
||||
): string {
|
||||
return text;
|
||||
},
|
||||
isInlineSwap: function (swapStyle: HtmxSwapStyle): boolean {
|
||||
return false;
|
||||
},
|
||||
handleSwap: function (
|
||||
swapStyle: HtmxSwapStyle,
|
||||
target: Node,
|
||||
fragment: Node,
|
||||
settleInfo: HtmxSettleInfo,
|
||||
): boolean | Node[] {
|
||||
return false;
|
||||
},
|
||||
encodeParameters: function (
|
||||
xhr: XMLHttpRequest,
|
||||
parameters: FormData,
|
||||
elt: Node,
|
||||
) {},
|
||||
getSelectors: function (): string[] | null {
|
||||
return null;
|
||||
},
|
||||
onEvent: (name, evt: Event | CustomEvent) => {
|
||||
if (!(evt instanceof CustomEvent)) {
|
||||
return false;
|
||||
}
|
||||
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 {
|
||||
},
|
||||
transformResponse: function (
|
||||
text: string,
|
||||
xhr: XMLHttpRequest,
|
||||
elt: Element,
|
||||
): string {
|
||||
return text;
|
||||
},
|
||||
isInlineSwap: function (swapStyle: HtmxSwapStyle): boolean {
|
||||
return false;
|
||||
},
|
||||
handleSwap: function (
|
||||
swapStyle: HtmxSwapStyle,
|
||||
target: Node,
|
||||
fragment: Node,
|
||||
settleInfo: HtmxSettleInfo,
|
||||
): boolean | Node[] {
|
||||
return false;
|
||||
},
|
||||
encodeParameters: function (
|
||||
xhr: XMLHttpRequest,
|
||||
parameters: FormData,
|
||||
elt: Node,
|
||||
) {
|
||||
},
|
||||
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)
|
||||
}
|
||||
|
||||
func TriggerChildren() Ren {
|
||||
func TriggerChildren() *AttributeR {
|
||||
return HxExtension("trigger-children")
|
||||
}
|
||||
|
||||
|
|
@ -133,10 +133,22 @@ 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 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,16 @@ func (l *LifeCycle) HxBeforeRequest(cmd ...Command) *LifeCycle {
|
|||
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 {
|
||||
return NewLifeCycle().OnEvent(hx.LoadEvent, cmd...)
|
||||
}
|
||||
|
|
@ -76,6 +86,14 @@ func OnEvent(event hx.Event, cmd ...Command) *LifeCycle {
|
|||
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 {
|
||||
return NewLifeCycle().HxBeforeRequest(cmd...)
|
||||
}
|
||||
|
|
@ -261,6 +279,11 @@ func EvalJs(js string) ComplexJsCommand {
|
|||
return NewComplexJsCommand(js)
|
||||
}
|
||||
|
||||
func SetValue(value string) SimpleJsCommand {
|
||||
// language=JavaScript
|
||||
return SimpleJsCommand{Command: fmt.Sprintf("this.value = '%s'", value)}
|
||||
}
|
||||
|
||||
func SubmitFormOnEnter() ComplexJsCommand {
|
||||
// language=JavaScript
|
||||
return EvalJs(`
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ const (
|
|||
XhrLoadEndEvent Event = "htmx:xhr:loadend"
|
||||
XhrLoadStartEvent Event = "htmx:xhr:loadstart"
|
||||
XhrProgressEvent Event = "htmx:xhr:progress"
|
||||
BeforeWsSendEvent Event = "htmx:wsBeforeSend"
|
||||
AfterWsSendEvent Event = "htmx:wsAfterSend"
|
||||
|
||||
// RevealedEvent Misc Events
|
||||
RevealedEvent Event = "revealed"
|
||||
|
|
|
|||
|
|
@ -70,10 +70,10 @@ func (om *OrderedMap[K, V]) Values() []V {
|
|||
// Delete removes a key-value pair from the OrderedMap.
|
||||
func (om *OrderedMap[K, V]) Delete(key K) {
|
||||
if _, exists := om.values[key]; exists {
|
||||
// Remove the key from the map
|
||||
// Disconnect the key from the map
|
||||
delete(om.values, key)
|
||||
|
||||
// Remove the key from the keys slice
|
||||
// Disconnect the key from the keys slice
|
||||
for i, k := range om.keys {
|
||||
if k == key {
|
||||
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ var SetClassOnSibling = h.SetClassOnSibling
|
|||
var RemoveClassOnSibling = h.RemoveClassOnSibling
|
||||
var Remove = h.Remove
|
||||
var EvalJs = h.EvalJs
|
||||
var SetValue = h.SetValue
|
||||
var SubmitFormOnEnter = h.SubmitFormOnEnter
|
||||
var InjectScript = h.InjectScript
|
||||
var InjectScriptIfNotExist = h.InjectScriptIfNotExist
|
||||
|
|
|
|||
|
|
@ -64,10 +64,10 @@ func (om *OrderedMap[K, V]) Values() []V {
|
|||
// Delete removes a key-value pair from the OrderedMap.
|
||||
func (om *OrderedMap[K, V]) Delete(key K) {
|
||||
if _, exists := om.values[key]; exists {
|
||||
// Remove the key from the map
|
||||
// Disconnect the key from the map
|
||||
delete(om.values, key)
|
||||
|
||||
// Remove the key from the keys slice
|
||||
// Disconnect the key from the keys slice
|
||||
for i, k := range om.keys {
|
||||
if k == key {
|
||||
om.keys = append(om.keys[:i], om.keys[i+1:]...)
|
||||
|
|
|
|||
Loading…
Reference in a new issue