Updates from my site
This commit is contained in:
@ -19,19 +19,4 @@ type CardLink struct {
|
||||
Internal bool
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
Content template.HTML
|
||||
Name string
|
||||
Date string
|
||||
Tags []string
|
||||
}
|
||||
type FrontMatter struct {
|
||||
Name string
|
||||
Date string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
type PubSubMessage struct {
|
||||
Channel string `json:"channel"`
|
||||
Data string `json:"data"`
|
||||
}
|
86
lib/localpubsub.go
Normal file
86
lib/localpubsub.go
Normal file
@ -0,0 +1,86 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LocalPubSub struct {
|
||||
subscribers map[string][]chan Message
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
type LocalPubSubMessage struct {
|
||||
messages <-chan Message
|
||||
}
|
||||
|
||||
func (ps *LocalPubSub) SubscribeToChannel(channel string) (PubSubMessage, error) {
|
||||
ps.lock.Lock()
|
||||
defer ps.lock.Unlock()
|
||||
|
||||
if ps.subscribers == nil {
|
||||
ps.subscribers = make(map[string][]chan Message)
|
||||
}
|
||||
|
||||
ch := make(chan Message, 100)
|
||||
ps.subscribers[channel] = append(ps.subscribers[channel], ch)
|
||||
|
||||
log.Printf("Subscribed to channel %s", channel)
|
||||
|
||||
return &LocalPubSubMessage{messages: ch}, nil
|
||||
}
|
||||
|
||||
func (ps *LocalPubSub) PublishToChannel(channel string, message string) error {
|
||||
ps.lock.RLock()
|
||||
defer ps.lock.RUnlock()
|
||||
|
||||
if subscribers, ok := ps.subscribers[channel]; ok {
|
||||
log.Printf("Publishing message to channel %s: %s", channel, message)
|
||||
for _, ch := range subscribers {
|
||||
ch <- Message{Payload: message}
|
||||
}
|
||||
} else {
|
||||
log.Printf("No subscribers for channel %s", channel)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *LocalPubSubMessage) ReceiveMessage(ctx context.Context) (*Message, error) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// The client has disconnected. Stop trying to send messages.
|
||||
return nil, ctx.Err()
|
||||
case msg := <-m.messages:
|
||||
// A message has been received. Send it to the client.
|
||||
log.Printf("Received message: %s", msg.Payload)
|
||||
return &msg, nil
|
||||
case <-time.After(30 * time.Second):
|
||||
// No message has been received for 30 seconds. Send a keep-alive message.
|
||||
return &Message{Payload: "keep-alive"}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ps *LocalPubSub) UnsubscribeFromChannel(channel string, ch <-chan Message) {
|
||||
ps.lock.Lock()
|
||||
defer ps.lock.Unlock()
|
||||
|
||||
subscribers := ps.subscribers[channel]
|
||||
for i, subscriber := range subscribers {
|
||||
if subscriber == ch {
|
||||
// Remove the subscriber from the slice
|
||||
subscribers = append(subscribers[:i], subscribers[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(subscribers) == 0 {
|
||||
delete(ps.subscribers, channel)
|
||||
} else {
|
||||
ps.subscribers[channel] = subscribers
|
||||
}
|
||||
}
|
@ -12,6 +12,12 @@ import (
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type FrontMatter struct {
|
||||
Name string
|
||||
Date string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
func ExtractFrontMatter(file os.DirEntry, dir string) (CardLink, error) {
|
||||
f, err := os.Open(dir + file.Name())
|
||||
if err != nil {
|
||||
|
@ -1,57 +0,0 @@
|
||||
package lib_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/alecthomas/assert/v2"
|
||||
"goth.stack/lib"
|
||||
)
|
||||
|
||||
func TestExtractFrontMatter(t *testing.T) {
|
||||
// Create a temporary file with some front matter
|
||||
tmpfile, err := os.CreateTemp("../content", "example.*.md")
|
||||
println(tmpfile.Name())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer os.Remove(tmpfile.Name()) // clean up
|
||||
|
||||
text := `---
|
||||
name: "Test Title"
|
||||
description: "Test Description"
|
||||
---
|
||||
|
||||
# Test Content
|
||||
|
||||
`
|
||||
if _, err := tmpfile.Write([]byte(text)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := tmpfile.Close(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Get the directory entry for the temporary file
|
||||
dirEntry, err := os.ReadDir(filepath.Dir(tmpfile.Name()))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var tmpFileEntry fs.DirEntry
|
||||
for _, entry := range dirEntry {
|
||||
if entry.Name() == filepath.Base(tmpfile.Name()) {
|
||||
tmpFileEntry = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Now we can test ExtractFrontMatter
|
||||
frontMatter, err := lib.ExtractFrontMatter(tmpFileEntry, "../content/")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Test Title", frontMatter.Name)
|
||||
assert.Equal(t, "Test Description", frontMatter.Description)
|
||||
}
|
16
lib/pubsub.go
Normal file
16
lib/pubsub.go
Normal file
@ -0,0 +1,16 @@
|
||||
package lib
|
||||
|
||||
import "context"
|
||||
|
||||
type Message struct {
|
||||
Payload string
|
||||
}
|
||||
|
||||
type PubSubMessage interface {
|
||||
ReceiveMessage(ctx context.Context) (*Message, error)
|
||||
}
|
||||
|
||||
type PubSub interface {
|
||||
SubscribeToChannel(channel string) (PubSubMessage, error)
|
||||
PublishToChannel(channel string, message string) error
|
||||
}
|
53
lib/redis.go
53
lib/redis.go
@ -9,11 +9,18 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
var RedisClient *redis.Client
|
||||
|
||||
func NewClient() *redis.Client {
|
||||
type RedisPubSubMessage struct {
|
||||
pubsub *redis.PubSub
|
||||
}
|
||||
|
||||
// RedisPubSub is a Redis implementation of the PubSub interface.
|
||||
type RedisPubSub struct {
|
||||
Client *redis.Client
|
||||
}
|
||||
|
||||
func NewRedisClient() *redis.Client {
|
||||
if RedisClient != nil {
|
||||
return RedisClient
|
||||
}
|
||||
@ -32,23 +39,29 @@ func NewClient() *redis.Client {
|
||||
return RedisClient
|
||||
}
|
||||
|
||||
func Publish(client *redis.Client, channel string, message string) error {
|
||||
if client == nil {
|
||||
client = NewClient()
|
||||
}
|
||||
|
||||
return client.Publish(ctx, channel, message).Err()
|
||||
}
|
||||
|
||||
func Subscribe(client *redis.Client, channel string) (*redis.PubSub, string) {
|
||||
if client == nil {
|
||||
client = NewClient()
|
||||
}
|
||||
|
||||
pubsub := client.Subscribe(ctx, channel)
|
||||
_, err := pubsub.Receive(ctx)
|
||||
func (m *RedisPubSubMessage) ReceiveMessage(ctx context.Context) (*Message, error) {
|
||||
msg, err := m.pubsub.ReceiveMessage(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Error receiving subscription: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return pubsub, channel
|
||||
|
||||
return &Message{Payload: msg.Payload}, nil
|
||||
}
|
||||
|
||||
func (ps *RedisPubSub) SubscribeToChannel(channel string) (PubSubMessage, error) {
|
||||
pubsub := ps.Client.Subscribe(context.Background(), channel)
|
||||
_, err := pubsub.Receive(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &RedisPubSubMessage{pubsub: pubsub}, nil
|
||||
}
|
||||
|
||||
func (r *RedisPubSub) PublishToChannel(channel string, message string) error {
|
||||
err := r.Client.Publish(context.Background(), channel, message).Err()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -1,27 +0,0 @@
|
||||
package lib_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-redis/redismock/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"goth.stack/lib"
|
||||
)
|
||||
|
||||
func TestPublish(t *testing.T) {
|
||||
db, mock := redismock.NewClientMock()
|
||||
mock.ExpectPublish("mychannel", "mymessage").SetVal(1)
|
||||
|
||||
err := lib.Publish(db, "mychannel", "mymessage")
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
// Then you can check the channel name in your test
|
||||
func TestSubscribe(t *testing.T) {
|
||||
db, _ := redismock.NewClientMock()
|
||||
|
||||
pubsub, channel := lib.Subscribe(db, "mychannel")
|
||||
assert.NotNil(t, pubsub)
|
||||
assert.Equal(t, "mychannel", channel)
|
||||
}
|
84
lib/sse.go
84
lib/sse.go
@ -1,6 +1,15 @@
|
||||
package lib
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type SSEServerType struct {
|
||||
clients map[string]map[chan string]bool
|
||||
@ -8,6 +17,7 @@ type SSEServerType struct {
|
||||
}
|
||||
|
||||
var SSEServer *SSEServerType
|
||||
var mutex = &sync.Mutex{}
|
||||
|
||||
func init() {
|
||||
SSEServer = &SSEServerType{
|
||||
@ -48,14 +58,20 @@ func (s *SSEServerType) ClientCount(channel string) int {
|
||||
return len(s.clients[channel])
|
||||
}
|
||||
|
||||
func SendSSE(channel string, message string) error {
|
||||
func SendSSE(ctx context.Context, messageBroker PubSub, channel string, message string) error {
|
||||
// Create a channel to receive an error from the goroutine
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
// Use a goroutine to send the message asynchronously
|
||||
go func() {
|
||||
err := Publish(RedisClient, channel, message)
|
||||
errCh <- err // Send the error to the channel
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// The client has disconnected, so return an error
|
||||
errCh <- ctx.Err()
|
||||
default:
|
||||
err := messageBroker.PublishToChannel(channel, message)
|
||||
errCh <- err // Send the error to the channel
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for the goroutine to finish and check for errors
|
||||
@ -66,3 +82,63 @@ func SendSSE(channel string, message string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetSSEHeaders(c echo.Context) {
|
||||
c.Response().Header().Set(echo.HeaderContentType, "text/event-stream")
|
||||
c.Response().Header().Set(echo.HeaderConnection, "keep-alive")
|
||||
c.Response().Header().Set(echo.HeaderCacheControl, "no-cache")
|
||||
}
|
||||
|
||||
func CreateTickerAndKeepAlive(c echo.Context, duration time.Duration) *time.Ticker {
|
||||
ticker := time.NewTicker(duration)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
if _, err := c.Response().Write([]byte(": keep-alive\n\n")); err != nil {
|
||||
log.Printf("Failed to write keep-alive: %v", err)
|
||||
}
|
||||
c.Response().Flush()
|
||||
}
|
||||
}()
|
||||
return ticker
|
||||
}
|
||||
|
||||
func HandleIncomingMessages(c echo.Context, pubsub PubSubMessage, client chan string) {
|
||||
for {
|
||||
select {
|
||||
case <-c.Request().Context().Done():
|
||||
// The client has disconnected. Stop trying to send messages.
|
||||
return
|
||||
default:
|
||||
// The client is still connected. Continue processing messages.
|
||||
msg, err := pubsub.ReceiveMessage(c.Request().Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to receive message: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
data := fmt.Sprintf("data: %s\n\n", msg.Payload)
|
||||
|
||||
mutex.Lock()
|
||||
_, err = c.Response().Write([]byte(data))
|
||||
mutex.Unlock()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to write message: %v", err)
|
||||
return // Stop processing if an error occurs
|
||||
}
|
||||
|
||||
// Check if the ResponseWriter is nil before trying to flush it
|
||||
if c.Response().Writer != nil {
|
||||
// Check if the ResponseWriter implements http.Flusher before calling Flush
|
||||
flusher, ok := c.Response().Writer.(http.Flusher)
|
||||
if ok {
|
||||
flusher.Flush()
|
||||
} else {
|
||||
log.Println("Failed to flush: ResponseWriter does not implement http.Flusher")
|
||||
}
|
||||
} else {
|
||||
log.Println("Failed to flush: ResponseWriter is nil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user