spin up chat app, setup sockets, fix trigger children to work

This commit is contained in:
maddalax 2024-09-30 16:05:06 -05:00
parent d2072fe777
commit c7f4781137
30 changed files with 1081 additions and 58 deletions

View file

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

View file

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

View file

@ -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:]...)

View file

@ -0,0 +1,11 @@
# Project exclude paths
/tmp/
node_modules/
dist/
js/dist
js/node_modules
go.work
go.work.sum
.idea
!framework/assets/dist
__htmgo

6
examples/chat/.gitignore vendored Normal file
View file

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

38
examples/chat/Dockerfile Normal file
View file

@ -0,0 +1,38 @@
# Stage 1: Build the Go binary
FROM golang:1.23-alpine AS builder
RUN apk update
RUN apk add git
RUN apk add curl
# Set the working directory inside the container
WORKDIR /app
# Copy go.mod and go.sum files
COPY go.mod go.sum ./
# Download and cache the Go modules
RUN go mod download
# Copy the source code into the container
COPY . .
# Build the Go binary for Linux
RUN GOPRIVATE=github.com/maddalax GOPROXY=direct go run github.com/maddalax/htmgo/cli/htmgo@latest build
# Stage 2: Create the smallest possible image
FROM gcr.io/distroless/base-debian11
# Set the working directory inside the container
WORKDIR /app
# Copy the Go binary from the builder stage
COPY --from=builder /app/dist .
# Expose the necessary port (replace with your server port)
EXPOSE 3000
# Command to run the binary
CMD ["./starter-template"]

View file

@ -0,0 +1,20 @@
version: '3'
tasks:
run:
cmds:
- go run github.com/maddalax/htmgo/cli/htmgo@latest run
silent: true
build:
cmds:
- go run github.com/maddalax/htmgo/cli/htmgo@latest build
docker:
cmds:
- docker build .
watch:
cmds:
- go run github.com/maddalax/htmgo/cli/htmgo@latest watch
silent: true

View file

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

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

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

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

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

View file

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

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

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

@ -1,19 +1,57 @@
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);
});
if(evt.detail.meta === 'trigger-children') {
return false;
}
const triggered = new Set<HTMLElement>();
const target = evt.target as HTMLElement || evt.detail.target as HTMLElement;
triggerChildren(target, name, evt, triggered);
return true;
},
init: function (api: any): void {},
init: function (api: any): void {
},
transformResponse: function (
text: string,
xhr: XMLHttpRequest,
@ -36,7 +74,8 @@ htmx.defineExtension("trigger-children", {
xhr: XMLHttpRequest,
parameters: FormData,
elt: Node,
) {},
) {
},
getSelectors: function (): string[] | null {
return null;
},

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

View file

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

View file

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

View file

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

View file

@ -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:]...)

View file

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

View file

@ -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:]...)