sse support

This commit is contained in:
2026-01-20 20:48:19 -07:00
parent 88eb917381
commit 1130366bf5
15 changed files with 886 additions and 29 deletions
+8 -9
View File
@@ -23,22 +23,21 @@ func Routes(
r *gin.RouterGroup, r *gin.RouterGroup,
logger *logging.Logger, logger *logging.Logger,
accts *accounts.Store, accts *accounts.Store,
authMiddleware *middleware.Auth, pub *middleware.UpdateNotificationPublisher,
) { ) {
as := &accountSubrouter{ as := &accountSubrouter{
log: logger, log: logger,
accts: accts, accts: accts,
} }
withAuth := func(fn response.HandlerFunc) gin.HandlerFunc { syncGroups := r.Group("/:acctID/inventory/sync-groups")
return response.Handler(authMiddleware.AuthenticateAndAddIdentity(fn)) syncGroups.POST("", response.Handler(as.saveNewSyncGroup))
}
r.POST("/:acctID/inventory/sync-groups/draft/listings", withAuth(as.createSyncGroupListingDraft)) draftListings := syncGroups.Group("/draft/listings", pub.Publish("/:acctID/inventory/sync-groups/draft/listings"))
r.PUT("/:acctID/inventory/sync-groups/draft/listings/:orderIndex/shop", withAuth(as.setShopInSyncGroupListingDraft)) draftListings.POST("", response.Handler(as.createSyncGroupListingDraft))
r.PUT("/:acctID/inventory/sync-groups/draft/listings/:orderIndex/listing", withAuth(as.setListingInSyncGroupListingDraft)) draftListings.PUT("/:orderIndex/shop", response.Handler(as.setShopInSyncGroupListingDraft))
r.DELETE("/:acctID/inventory/sync-groups/draft/listings/:orderIndex", withAuth(as.deleteSyncGroupListingDraft)) draftListings.PUT("/:orderIndex/listing", response.Handler(as.setListingInSyncGroupListingDraft))
r.POST("/:acctID/inventory/sync-groups", withAuth(as.saveNewSyncGroup)) draftListings.DELETE("/:orderIndex", response.Handler(as.deleteSyncGroupListingDraft))
} }
// POST /api/accounts // POST /api/accounts
+97
View File
@@ -0,0 +1,97 @@
package sse
import (
"context"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/middleware"
"ruben/inventory2/internal/server/response"
"ruben/inventory2/internal/server/sse"
"sync"
"github.com/gin-gonic/gin"
)
type (
sseRouter struct {
log *logging.Logger
sse *sse.Queue
users map[string]context.CancelFunc
lock sync.Mutex
}
)
func Routes(
r gin.IRouter,
logger *logging.Logger,
sq *sse.Queue,
auth *middleware.Auth,
) {
s := &sseRouter{
log: logger,
sse: sq,
users: make(map[string]context.CancelFunc),
}
r.GET("/", auth.AuthenticateAndAddIdentityGin(), response.Handler(s.serveEvents))
}
func (r *sseRouter) serveEvents(c *gin.Context) (response.Response, error) {
acct := middleware.GetIdentity(c).Account
acctID := acct.AccountID
userID := acct.UserID
email := acct.Email
log := r.log.WithGroup("serveEvents").With(
"accountID", acctID,
"userID", userID,
"email", email,
)
log.Info("user connected to sse queue")
ctx := r.closeExistingConnectionsForUserAndStoreCancelFuncForUser(c, userID)
w := c.Writer
hdr := w.Header()
hdr.Set("Access-Control-Allow-Origin", "*")
hdr.Set("Access-Control-Expose-Headers", "Content-Type")
hdr.Set("Content-Type", "text/event-stream")
hdr.Set("Connection", "keep-alive")
hdr.Set("Cache-Control", "no-cache")
w.Flush()
err := r.sse.Listen(ctx, func(ctx context.Context, e *sse.Event) error {
log.Debugf("sending event of type %s", e.Type)
e.Write(w)
return nil
})
if err != nil {
log.Errorf("no longer connected to sse queue due to error: %v", err)
return nil, err
}
// send a 'close' message so the front end doesn't try to reconnect
(&sse.Event{Type: "close"}).Write(w)
log.Info("user disconnecting sse queue")
return response.Status(200), nil
}
func (r *sseRouter) closeExistingConnectionsForUserAndStoreCancelFuncForUser(ctx context.Context, userID string) context.Context {
r.lock.Lock()
defer r.lock.Unlock()
// close existing connection
if closeConn, ok := r.users[userID]; ok {
closeConn()
delete(r.users, userID)
}
// store reference to cancel func
ctx, r.users[userID] = context.WithCancel(ctx)
return ctx
}
+122 -4
View File
@@ -111,13 +111,17 @@ func (a *Auth) AuthenticateAndAddIdentity(f response.HandlerFunc, assertions ...
return func(c *gin.Context) (response.Response, error) { return func(c *gin.Context) (response.Response, error) {
c, res, err := a.AuthenticateAndAddIdentityToRequest(c, assertions...) c, res, err := a.AuthenticateAndAddIdentityToRequest(c, assertions...)
if res != nil || err != nil { if res != nil || err != nil {
return res, nil return res, err
} }
return f(c) return f(c)
} }
} }
func (a *Auth) AuthenticateAndAddIdentityGin(assertions ...AuthorizationAssertions) gin.HandlerFunc {
return a.AuthenticateAndAddIdentityToRequestGin(assertions...)
}
func (a *Auth) AuthenticateAndAddIdentityToRequest(c *gin.Context, assertions ...AuthorizationAssertions) (*gin.Context, response.Response, error) { func (a *Auth) AuthenticateAndAddIdentityToRequest(c *gin.Context, assertions ...AuthorizationAssertions) (*gin.Context, response.Response, error) {
r := c.Request r := c.Request
ck, err := r.Cookie("access_token") ck, err := r.Cookie("access_token")
@@ -188,15 +192,129 @@ func (a *Auth) AuthenticateAndAddIdentityToRequest(c *gin.Context, assertions ..
return c, nil, nil return c, nil, nil
} }
func (a *Auth) AuthenticateAndAddIdentityToRequestGin(assertions ...AuthorizationAssertions) gin.HandlerFunc {
return func(c *gin.Context) {
r := c.Request
ck, err := r.Cookie("access_token")
if err != nil {
response.Write(c, response.TemporaryRedirect("/").
JSON("no access_token cookie provided"))
c.Abort()
return
}
ctx := r.Context()
accessToken := ck.Value
claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
if err != nil {
if errors.Is(err, consts.ErrNotFound) {
u, err := a.newLoginURL(ctx, a.auth, r.URL.String())
if err != nil {
response.WriteError(c, response.Errorf("failed to generate login url: %w", err))
c.Abort()
return
}
response.Write(c, response.TemporaryRedirect(u))
c.Abort()
return
}
response.WriteError(c, response.Errorf("failed to authenticate: %w", err))
c.Abort()
return
}
now := time.Now()
// refresh tokens, when the access token is "old enough"
// id token lifetime is 48 hours, allowing a person to use the app everyday comfortably, with wiggle room, without having to log in.
const idTokenLifetime = 48 * time.Hour
if refreshFloor := expiration.Add(-(idTokenLifetime / 4)); refreshFloor.Before(now) {
accessToken, expiration, err = a.auth.RefreshAccessToken(ctx, accessToken)
if err != nil {
a.log.Warn("failed to refresh access token", "error", err)
response.Write(c, response.TemporaryRedirect("/").
Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))).
Cookie(cookies.Expired("access_token")))
c.Abort()
return
}
// 'redirect' to same url, to set the new access_token cookie
response.Write(c, response.TemporaryRedirect(r.URL.String()).
Cookie(cookies.AccessToken(accessToken, expiration)))
c.Abort()
return
}
// add identity info to request context
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
if err != nil {
response.WriteError(c, response.Errorf("failed to authorize: %w", err))
c.Abort()
return
}
for _, as := range assertions {
res, err := as(c)
if err != nil {
response.WriteError(c, err)
c.Abort()
return
}
if res != nil {
response.Write(c, res)
c.Abort()
return
}
}
id := Identity{
AccessToken: accessToken,
Claims: claims,
User: user,
Account: acct,
}
c.Set(identityKeyString, id)
c.Request = r.WithContext(SetIdentity(ctx, id))
c.Next()
}
}
type identityKey struct{} type identityKey struct{}
// stores identity in request context const identityKeyString = "identity"
// stores identity in context
func SetIdentity(ctx context.Context, id Identity) context.Context { func SetIdentity(ctx context.Context, id Identity) context.Context {
return context.WithValue(ctx, identityKey{}, id) return context.WithValue(ctx, identityKey{}, id)
} }
// get identity from request context // get identity from context
func GetIdentity(ctx context.Context) Identity { func GetIdentity(ctx context.Context) Identity {
id, _ := ctx.Value(identityKey{}).(Identity) id, ok := ctx.Value(identityKey{}).(Identity)
if ok {
return id
}
c, ok := ctx.(*gin.Context)
if !ok {
return Identity{}
}
v, ok := c.Get(identityKeyString)
if !ok {
return Identity{}
}
id, _ = v.(Identity)
return id return id
} }
+16
View File
@@ -0,0 +1,16 @@
package middleware
import (
"ruben/inventory2/internal/server/response"
)
func Chain(ms ...response.Middleware) response.Middleware {
return func(fn response.HandlerFunc) response.HandlerFunc {
res := fn
for _, m := range ms {
prev := res
res = m(prev)
}
return res
}
}
+108
View File
@@ -0,0 +1,108 @@
package middleware
import (
"context"
"path"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/sse"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type UpdateNotificationPublisher struct {
log *logging.Logger
sse *sse.Queue
trimBasePath string
basePathPattern string
}
func NewUpdateNotificationPublisher(
log *logging.Logger,
sse *sse.Queue,
) *UpdateNotificationPublisher {
return &UpdateNotificationPublisher{
log: log,
sse: sse,
}
}
func (p *UpdateNotificationPublisher) Trim(pathPattern string) *UpdateNotificationPublisher {
p2 := *p
p2.trimBasePath = path.Join(p2.trimBasePath, pathPattern)
return &p2
}
func (p *UpdateNotificationPublisher) Group(pathPattern string) *UpdateNotificationPublisher {
p2 := *p
p2.basePathPattern = path.Join(p2.basePathPattern, pathPattern)
return &p2
}
func (p *UpdateNotificationPublisher) Publish(pathPattern string) gin.HandlerFunc {
trimBasePathSegs := getPathSegments(p.trimBasePath)
trimmedBasePathPattern := path.Join(p.basePathPattern, pathPattern)
trimmedBasePathPatternSegs := getPathSegments(trimmedBasePathPattern)
fullBasePathPatternSegs := append(trimBasePathSegs, trimmedBasePathPatternSegs...)
return func(c *gin.Context) {
reqPathSegs := getPathSegments(c.Request.URL.Path)
c.Next()
if len(c.Errors) > 0 {
return
}
if len(reqPathSegs) < len(fullBasePathPatternSegs) {
p.log.Errorf("req path is less that the full path: %v, %v", c.Request.URL.Path, path.Join(p.trimBasePath, p.basePathPattern, pathPattern))
return
}
// if the path is a subpath, then emit events along the subpath.
for i, s := range trimmedBasePathPatternSegs {
if isWildcard := s[0] == ':'; isWildcard {
continue
}
rs := reqPathSegs[i]
if isSubpath := s != rs; isSubpath {
continue
}
return
}
topEventParts := reqPathSegs[len(trimBasePathSegs):len(fullBasePathPatternSegs)]
topEvent := strings.Join(topEventParts, "_")
events := make([]string, len(reqPathSegs)-len(fullBasePathPatternSegs)+1)
events[0] = topEvent
parentEvent := topEvent
for i, s := range reqPathSegs[len(fullBasePathPatternSegs):] {
e := parentEvent + "_" + s
events[i+1] = e
parentEvent = e
}
for _, e := range events {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := p.sse.Send(ctx, e, nil); err != nil {
p.log.Errorf("failed to send sse event to listener: %v", err)
}
}()
}
}
}
func getPathSegments(p string) []string {
p = path.Clean(p)
if p == "" || p == "." || p == "/" {
return nil
}
return strings.Split(strings.Trim(p, "/"), "/")
}
+38 -9
View File
@@ -15,23 +15,29 @@ import (
"ruben/inventory2/internal/logging" "ruben/inventory2/internal/logging"
accounts_api "ruben/inventory2/internal/server/api/accounts" accounts_api "ruben/inventory2/internal/server/api/accounts"
auth_api "ruben/inventory2/internal/server/api/auth" auth_api "ruben/inventory2/internal/server/api/auth"
sse_api "ruben/inventory2/internal/server/api/sse"
templates_api "ruben/inventory2/internal/server/api/templates" templates_api "ruben/inventory2/internal/server/api/templates"
"ruben/inventory2/internal/server/api/webhooks" "ruben/inventory2/internal/server/api/webhooks"
etsy_webhooks "ruben/inventory2/internal/server/api/webhooks/etsy" etsy_webhooks "ruben/inventory2/internal/server/api/webhooks/etsy"
"ruben/inventory2/internal/server/middleware" "ruben/inventory2/internal/server/middleware"
"ruben/inventory2/internal/server/sse"
) )
// r gin.IRouter, // TODO: use this everywhere // r gin.IRouter, // TODO: use this everywhere
func Router( type Router struct {
ctx context.Context, *gin.Engine
sse *sse.Queue
}
func NewRouter(
logger *logging.Logger, logger *logging.Logger,
contentDir string, contentDir string,
rawEvents *raw_events.Store, rawEvents *raw_events.Store,
accts *accounts.Store, accts *accounts.Store,
etsy *etsy_platform.Platform, etsy *etsy_platform.Platform,
auth *authentication.Authenticator, auth *authentication.Authenticator,
) *gin.Engine { ) *Router {
r := gin.Default() r := gin.Default()
// TODO: shouldn't this ACTUALLY be a middleware? // TODO: shouldn't this ACTUALLY be a middleware?
@@ -74,21 +80,37 @@ func Router(
authMiddleware, authMiddleware,
) )
// sse setup
sq := sse.NewQueue()
unp := middleware.NewUpdateNotificationPublisher(
logger.WithGroup("update.notification.publisher"),
sq,
)
// api endpoints // api endpoints
api := r.Group("/api") api := r.Group("/api")
apiLogger := logger.WithGroup("/api")
unp = unp.Trim("/api")
auth_api.Routes( auth_api.Routes(
api.Group("/auth"), api.Group("/auth"),
logger.WithGroup("/auth"), apiLogger.WithGroup("/auth"),
auth, auth,
) )
accounts_api.Routes( sse_api.Routes(
api.Group("/accounts"), api.Group("/events"),
logger.WithGroup("/accounts"), apiLogger.WithGroup("/events"),
accts, sq,
authMiddleware, authMiddleware,
) )
accounts_api.Routes(
api.Group("/accounts", authMiddleware.AuthenticateAndAddIdentityGin()),
apiLogger.WithGroup("/accounts"),
accts,
unp.Group("/accounts"),
)
// api webhooks (TODO: make a router for these) // api webhooks (TODO: make a router for these)
@@ -104,7 +126,14 @@ func Router(
}, },
) )
return r return &Router{
Engine: r,
sse: sq,
}
}
func (r *Router) RunSSE(ctx context.Context) error {
return r.sse.Start(ctx)
} }
func fileServer(urlPrefix, dir string, beforeServe func(c *gin.Context)) gin.HandlerFunc { func fileServer(urlPrefix, dir string, beforeServe func(c *gin.Context)) gin.HandlerFunc {
+120
View File
@@ -0,0 +1,120 @@
package sse
import (
"bytes"
"context"
"fmt"
"net/http"
"sync"
)
type (
Queue struct {
in chan Event
out map[int]chan Event
ctx context.Context
cancel context.CancelFunc
lock sync.Mutex
prevID int
}
Event struct {
Type string
Data []byte
}
)
func NewQueue() *Queue {
ctx, cancel := context.WithCancel(context.Background())
return &Queue{
in: make(chan Event),
out: make(map[int]chan Event),
ctx: ctx,
cancel: cancel,
}
}
func (q *Queue) Start(ctx context.Context) error {
defer q.cancel()
for {
select {
case <-ctx.Done():
// we're done piping events
return nil
case e := <-q.in:
// share event will all subscribers
q.lock.Lock()
for _, out := range q.out {
out <- e
}
q.lock.Unlock()
}
}
}
func (q *Queue) Listen(ctx context.Context, fn func(context.Context, *Event) error) error {
// create new out pipe and append it to the queue
out := make(chan Event, 1)
q.lock.Lock()
q.prevID += 1
id := q.prevID
q.out[id] = out
q.lock.Unlock()
// delete the pipe when done listening
defer func() {
q.lock.Lock()
delete(q.out, id)
q.lock.Unlock()
}()
for {
select {
case <-q.ctx.Done():
// queue is shut down
return nil
case <-ctx.Done():
// done listening to events
return nil
case e := <-out:
// pass event to the caller
if err := fn(ctx, &e); err != nil {
return err
}
}
}
}
func (q *Queue) Send(ctx context.Context, eventType string, data []byte) error {
select {
case <-q.ctx.Done():
// the queue has closed
return fmt.Errorf("event queue closed: %w", q.ctx.Err())
case <-ctx.Done():
// sender ran out of time
return fmt.Errorf("provided context canceled: %w", ctx.Err())
// push the event onto the queue
case q.in <- Event{
Type: eventType,
Data: data,
}:
}
return nil
}
func (e *Event) Write(w http.ResponseWriter) {
fmt.Fprintf(
w,
"event: %s\ndata: %s\n\n",
e.Type,
bytes.ReplaceAll(e.Data, []byte("\n"), []byte(" ")),
)
w.(http.Flusher).Flush()
}
+16 -4
View File
@@ -150,8 +150,7 @@ func runAuthProcesses(ctx context.Context, auth *authentication.Authenticator) <
} }
func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Pool, auth *authentication.Authenticator) <-chan error { func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Pool, auth *authentication.Authenticator) <-chan error {
r := server.Router( r := server.NewRouter(
ctx,
logger.WithGroup("server"), logger.WithGroup("server"),
"./", "./",
raw_events.NewStore(logger.WithGroup("raw-event-store"), connPool), raw_events.NewStore(logger.WithGroup("raw-event-store"), connPool),
@@ -190,6 +189,18 @@ func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Po
} }
}() }()
sseErrCh := make(chan error, 1)
go func() {
defer close(sseErrCh)
defer cancel()
defer logger.Info("server side events stopped")
logger.Info("server side events streaming...")
if err := r.RunSSE(ctx); err != nil {
sseErrCh <- err
}
}()
shutdownErrCh := make(chan error, 1) shutdownErrCh := make(chan error, 1)
go func() { go func() {
defer close(shutdownErrCh) defer close(shutdownErrCh)
@@ -212,8 +223,9 @@ func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Po
defer close(errCh) defer close(errCh)
err1 := <-runningErrCh err1 := <-runningErrCh
err2 := <-shutdownErrCh err2 := <-sseErrCh
if err := errors.Join(err1, err2); err != nil { err3 := <-shutdownErrCh
if err := errors.Join(err1, err2, err3); err != nil {
errCh <- err errCh <- err
} }
}() }()
+13
View File
@@ -0,0 +1,13 @@
(function() {
htmx.defineExtension('path-params', {
onEvent: function(name, evt) {
if (name === 'htmx:configRequest') {
evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function(_, param) {
var val = evt.detail.parameters[param]
delete evt.detail.parameters[param]
return val === undefined ? '{' + param + '}' : encodeURIComponent(val)
})
}
}
})
})()
+290
View File
@@ -0,0 +1,290 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec, { contextElement: elt })
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()
+1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+39
View File
@@ -21,6 +21,8 @@
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-weight-bold: 700; --font-weight-bold: 700;
--radius-lg: var(--radius); --radius-lg: var(--radius);
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans); --default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono); --default-mono-font-family: var(--font-mono);
--font-display: var(--display-family); --font-display: var(--display-family);
@@ -242,15 +244,24 @@
.min-w-fit { .min-w-fit {
min-width: fit-content; min-width: fit-content;
} }
.flex-grow {
flex-grow: 1;
}
.grow { .grow {
flex-grow: 1; flex-grow: 1;
} }
.basis-full { .basis-full {
flex-basis: 100%; flex-basis: 100%;
} }
.border-collapse {
border-collapse: collapse;
}
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
.resize {
resize: both;
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@@ -281,6 +292,10 @@
.rounded-sm { .rounded-sm {
border-radius: calc(var(--radius) - 4px); border-radius: calc(var(--radius) - 4px);
} }
.border {
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-\[1px\] { .border-\[1px\] {
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 1px; border-width: 1px;
@@ -312,6 +327,12 @@
.text-center { .text-center {
text-align: center; text-align: center;
} }
.font-display {
font-family: var(--display-family);
}
.font-text {
font-family: var(--text-family);
}
.text-lg { .text-lg {
font-size: var(--text-lg); font-size: var(--text-lg);
line-height: var(--tw-leading, var(--text-lg--line-height)); line-height: var(--tw-leading, var(--text-lg--line-height));
@@ -335,15 +356,27 @@
.text-nowrap { .text-nowrap {
text-wrap: nowrap; text-wrap: nowrap;
} }
.text-wrap {
text-wrap: wrap;
}
.italic { .italic {
font-style: italic; font-style: italic;
} }
.underline { .underline {
text-decoration-line: underline; text-decoration-line: underline;
} }
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.filter { .filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
} }
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.hover\:bg-accent { .hover\:bg-accent {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -694,6 +727,11 @@
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
} }
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-blur { @property --tw-blur {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@@ -752,6 +790,7 @@
*, ::before, ::after, ::backdrop { *, ::before, ::after, ::backdrop {
--tw-border-style: solid; --tw-border-style: solid;
--tw-font-weight: initial; --tw-font-weight: initial;
--tw-outline-style: solid;
--tw-blur: initial; --tw-blur: initial;
--tw-brightness: initial; --tw-brightness: initial;
--tw-contrast: initial; --tw-contrast: initial;
@@ -5,7 +5,12 @@
{{- $stores := .Accounts.GetShops $acctID -}} {{- $stores := .Accounts.GetShops $acctID -}}
<table id="accounts-acct-id-inventory-sync-groups-draft-table" class="max-w-full overflow-x-auto"> <table
id="accounts-acct-id-inventory-sync-groups-draft-table"
class="max-w-full overflow-x-auto"
hx-get="{{ printf "/ui/accounts/%d/inventory/sync-groups/draft/table" $acctID }}"
hx-trigger="sse:{{ printf "accounts_%d_inventory_sync-groups_draft" $acctID }}"
>
<thead> <thead>
<tr> <tr>
<th> <th>
+12 -2
View File
@@ -11,7 +11,8 @@
<script src="/scripts/htmx.min.js.gz"></script> <script src="/scripts/htmx.min.js.gz"></script>
<script src="/scripts/_hyperscript.min.js.gz"></script> <script src="/scripts/_hyperscript.min.js.gz"></script>
<script src="https://unpkg.com/htmx-ext-path-params@2.0.0/path-params.js"></script> <script src="/scripts/htmx-ext-sse.js"></script>
<script src="/scripts/htmx-ext-path-params.js"></script>
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png">
@@ -22,7 +23,16 @@
{{- block "head" . }}{{ end }} {{- block "head" . }}{{ end }}
</head> </head>
<body class="basis-full grow bg-background flex flex-col items-stretch" hx-ext="path-params"> <body
class="basis-full grow bg-background flex flex-col items-stretch"
hx-ext="path-params,sse"
{{/* subscribe to sse (if logged in) */}}
{{- if and .Identity .Identity.AccessToken }}
sse-connect="/api/events/"
sse-close="close"
{{- end }}
>
<header> <header>
<nav class="flex flex-col items-center text-xl overflow-x-scroll overflow-y-hidden "> <nav class="flex flex-col items-center text-xl overflow-x-scroll overflow-y-hidden ">
{{- $path := or .Request.URL.Path "/" }} {{- $path := or .Request.URL.Path "/" }}