snapshot of https://github.com/ThreeDotsLabs/watermill.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
master / ci (push) Failing after 0s

This commit is contained in:
upstream-mirror
2026-09-15 21:27:46 +05:00
commit ce933d1277
538 changed files with 46504 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
validation_cmd: "docker compose up"
teardown_cmd: "docker compose down"
timeout: 180
expected_output: "Starting handler"

View File

@@ -0,0 +1,146 @@
# HTTP Server push using SSE (Server-Sent Events)
This example is a Twitter-like web application using [Server-Sent Events](https://en.wikipedia.org/wiki/Server-sent_events) to support real-time refreshing.
![](./screen.gif)
## Running
```
docker-compose up
```
Then, open http://localhost:8080
You can add your own post or click the button to get randomly generated posts.
Either way, the feeds list and posts in a feed should be always up-to-date. Try using a second browser window to see the update.
## How it works
* Posts can be created and updated.
* Posts can contain tags.
* Each tag has its own feed that contains all posts from that tag.
* All posts are stored in MySQL. This is the Write Model.
* All feeds are updated asynchronously and stored in MongoDB. This is the Read Model.
### Why use separate write and read models?
For this example application, using polyglot persistence (two database engines) is, of course, an overkill.
We did it to showcase this technique and how easy it is to apply it with Watermill.
A dedicated read model is a useful pattern for applications with high read/write ratio. All writes are applied atomically
to the write model (MySQL in our case). Event handlers asynchronously update the read model (we use Mongo).
The data in the read model is ready to serve as it is. It can also be scaled independently of the write model.
Keep in mind that eventual consistency has to be acceptable in your application to use this pattern.
Also, you probably won't need to use it for most use cases. Be pragmatic!
![](./diagram.jpg)
### SSE Router
The `SSERouter` comes from [watermill-http](https://github.com/ThreeDotsLabs/watermill-http).
When creating a new router, you pass an upstream subscriber. Messages coming from that subscriber will trigger pushing updates over HTTP.
In this example, we use [NATS](https://nats.io/) as Pub/Sub, but this can be any Pub/Sub supported by Watermill.
```go
sseRouter, err := watermillHTTP.NewSSERouter(
watermillHTTP.SSERouterConfig{
UpstreamSubscriber: router.Subscriber,
ErrorHandler: watermillHTTP.DefaultErrorHandler,
},
router.Logger,
)
```
### Stream Adapters
To work with `SSERouter` you need to prepare a `StreamAdapter` with two methods.
`GetResponse` is similar to a standard HTTP handler. It should be super easy to modify an existing handler to match this signature.
`Validate` is an extra method that tells whether an update should be pushed for a particular `Message`.
```go
type StreamAdapter interface {
// GetResponse returns the response to be sent back to client.
// Any errors that occur should be handled and written to `w`, returning false as `ok`.
GetResponse(w http.ResponseWriter, r *http.Request) (response interface{}, ok bool)
// Validate validates if the incoming message should be handled by this handler.
// Typically this involves checking some kind of model ID.
Validate(r *http.Request, msg *message.Message) (ok bool)
}
```
An example `Validate` can look like this. It checks whether the message came for the same post ID that the user sent over the HTTP request.
```go
func (p postStreamAdapter) Validate(r *http.Request, msg *message.Message) (ok bool) {
postUpdated := PostUpdated{}
err := json.Unmarshal(msg.Payload, &postUpdated)
if err != nil {
return false
}
postID := chi.URLParam(r, "id")
return postUpdated.OriginalPost.ID == postID
}
```
If you'd like to trigger an update for every message, you can simply return `true`.
```go
func (f allFeedsStreamAdapter) Validate(r *http.Request, msg *message.Message) (ok bool) {
return true
}
```
Before starting the `SSERouter`, you need to add the handler with particular topic.
`AddHandler` returns a standard HTTP handler that can be used in any routing library.
```go
postHandler := sseRouter.AddHandler(PostUpdatedTopic, postStream)
// ...
r.Get("/posts/{id}", postHandler)
```
## Event handlers
The example uses Watermill for all asynchronous communication, including SSE.
There are several events published:
* `PostCreated`
* Adds the post to all feeds with tags present in the post.
* `FeedUpdated`
* Pushes update to all clients currently visiting the feed page.
* `PostUpdated`
* Pushes update to all clients currently visiting the post page.
* Updates post in all feeds with tags present in the post
* a) For existing tags, the post content will be updated in the tag.
* b) If a new tag has been added, the post will be added to the tag's feed.
* c) If a tag has been deleted, the post will be removed from the tag's feed.
## Frontend app
The frontend application is built using Vue.js and Bootstrap.
The most interesting part is the use of `EventSource`.
```js
this.es = new EventSource('/api/feeds/' + this.feed)
this.es.addEventListener('data', event => {
let data = JSON.parse(event.data);
this.posts_stream = data.posts;
}, false);
```
Please note the author is not a frontend developer and the code in `index.html` is probably not idiomatic. PRs are welcome. :)

View File

@@ -0,0 +1,33 @@
services:
server:
image: golang:1.25
restart: unless-stopped
ports:
- 8080:8080
volumes:
- ./server:/app
- $GOPATH/pkg/mod:/go/pkg/mod
working_dir: /app
command: 'go run .'
mysql:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_DATABASE: example
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
volumes:
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql
mongo:
image: mongo:3.6
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: password
nats-streaming:
image: nats-streaming:0.11.2
restart: unless-stopped
logging:
driver: none

View File

@@ -0,0 +1,6 @@
CREATE TABLE example.posts (
id VARCHAR(36) NOT NULL PRIMARY KEY,
title VARCHAR(255) NOT NULL DEFAULT '',
content TEXT NOT NULL,
author VARCHAR(255) NOT NULL DEFAULT ''
);

View File

@@ -0,0 +1,180 @@
package main
import (
"context"
"encoding/json"
"time"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill-nats/pkg/nats"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/message/router/middleware"
"github.com/nats-io/stan.go"
)
const (
PostCreatedTopic = "post-created"
PostUpdatedTopic = "post-updated"
FeedUpdatedTopic = "feed-updated"
)
func SetupMessageRouter(
feedsStorage FeedsStorage,
logger watermill.LoggerAdapter,
) (message.Publisher, message.Subscriber, error) {
router, err := message.NewRouter(message.RouterConfig{}, logger)
if err != nil {
return nil, nil, err
}
router.AddMiddleware(middleware.Recoverer)
natsURL := stan.NatsURL("nats://nats-streaming:4222")
pub, err := nats.NewStreamingPublisher(nats.StreamingPublisherConfig{
ClusterID: "test-cluster",
ClientID: "publisher",
StanOptions: []stan.Option{natsURL},
Marshaler: nats.GobMarshaler{},
}, logger)
if err != nil {
return nil, nil, err
}
sub, err := nats.NewStreamingSubscriber(nats.StreamingSubscriberConfig{
ClusterID: "test-cluster",
ClientID: "subscriber",
StanOptions: []stan.Option{natsURL},
Unmarshaler: nats.GobMarshaler{},
}, logger)
if err != nil {
return nil, nil, err
}
router.AddHandler(
"update-feeds-on-post-created",
PostCreatedTopic,
sub,
FeedUpdatedTopic,
pub,
func(msg *message.Message) (messages []*message.Message, err error) {
defer func() {
if err == nil {
logger.Info("Successfully updated feeds on new post created", nil)
} else {
logger.Error("Error while updating feeds on new post created", err, nil)
}
}()
event := PostCreated{}
err = json.Unmarshal(msg.Payload, &event)
if err != nil {
return nil, err
}
logger.Info("Adding post", watermill.LogFields{"post": event.Post})
if len(event.Post.Tags) > 0 {
for _, tag := range event.Post.Tags {
logger.Info("Adding tag", watermill.LogFields{"tag": tag})
err = feedsStorage.Add(msg.Context(), tag)
if err != nil {
return nil, err
}
}
err = feedsStorage.AppendPost(msg.Context(), event.Post)
if err != nil {
return nil, err
}
}
return createFeedUpdatedEvents(event.Post.Tags)
},
)
router.AddHandler(
"update-feeds-on-post-updated",
PostUpdatedTopic,
sub,
FeedUpdatedTopic,
pub,
func(msg *message.Message) (messages []*message.Message, err error) {
defer func() {
if err == nil {
logger.Info("Successfully updated feeds on post updated", nil)
} else {
logger.Error("Error while updating feeds on post updated", err, nil)
}
}()
event := PostUpdated{}
err = json.Unmarshal(msg.Payload, &event)
if err != nil {
return nil, err
}
for _, tag := range event.NewPost.Tags {
logger.Info("Adding tag", watermill.LogFields{"tag": tag})
err = feedsStorage.Add(msg.Context(), tag)
if err != nil {
return nil, err
}
}
err = feedsStorage.UpdatePost(msg.Context(), event.NewPost)
if err != nil {
return nil, err
}
return createFeedUpdatedEvents(append(event.NewPost.Tags, event.OriginalPost.Tags...))
},
)
go func() {
err = router.Run(context.Background())
if err != nil {
panic(err)
}
}()
<-router.Running()
return pub, sub, nil
}
func createFeedUpdatedEvents(tags []string) ([]*message.Message, error) {
var messages []*message.Message
for _, tag := range tags {
event := FeedUpdated{
Name: tag,
OccurredAt: time.Now().UTC(),
}
payload, err := json.Marshal(event)
if err != nil {
return nil, err
}
msg := message.NewMessage(watermill.NewUUID(), payload)
messages = append(messages, msg)
}
return messages, nil
}
type Publisher struct {
publisher message.Publisher
}
func (p Publisher) Publish(topic string, event interface{}) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
msg := message.NewMessage(watermill.NewUUID(), payload)
return p.publisher.Publish(topic, msg)
}

View File

@@ -0,0 +1,213 @@
package main
import (
"context"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.mongodb.org/mongo-driver/mongo/readpref"
)
const collectionName = "feeds"
type FeedsStorage struct {
collection *mongo.Collection
}
func NewFeedsStorage() FeedsStorage {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://root:password@mongo:27017"))
if err != nil {
panic(err)
}
err = client.Ping(ctx, readpref.Primary())
if err != nil {
panic(err)
}
db := client.Database("example")
names, err := db.ListCollectionNames(ctx, bson.M{})
if err != nil {
panic(err)
}
found := false
for _, n := range names {
if n == collectionName {
found = true
break
}
}
if !found {
err := db.CreateCollection(ctx, collectionName)
if err != nil {
panic(err)
}
}
return FeedsStorage{
collection: db.Collection(collectionName),
}
}
func (s FeedsStorage) Add(ctx context.Context, name string) error {
feed := Feed{
Name: name,
Posts: []Post{},
}
_, err := s.collection.InsertOne(ctx, feed)
if err != nil {
if !isDuplicateError(err) {
return err
}
}
return nil
}
func (s FeedsStorage) All(ctx context.Context) ([]Feed, error) {
cursor, err := s.collection.Find(ctx, bson.M{})
if err != nil {
return nil, err
}
var feeds []Feed
err = cursor.All(ctx, &feeds)
if err != nil {
return nil, err
}
return feeds, nil
}
func (s FeedsStorage) ByName(ctx context.Context, name string) (Feed, error) {
filter := bson.M{
"_id": name,
}
var feed Feed
err := s.collection.FindOne(ctx, filter).Decode(&feed)
if err != nil {
return Feed{}, err
}
return feed, nil
}
func (s FeedsStorage) AppendPost(ctx context.Context, post Post) error {
return s.appendPostIfNotPresent(ctx, post)
}
func (s FeedsStorage) UpdatePost(ctx context.Context, post Post) error {
err := s.updatePostIfPresent(ctx, post)
if err != nil {
return err
}
err = s.appendPostIfNotPresent(ctx, post)
if err != nil {
return err
}
err = s.removePostIfNotInFeed(ctx, post)
if err != nil {
return err
}
return nil
}
func (s FeedsStorage) updatePostIfPresent(ctx context.Context, post Post) error {
if len(post.Tags) == 0 {
return nil
}
filter := bson.M{
"_id": bson.M{
"$in": post.Tags,
},
"posts.id": post.ID,
}
update := bson.M{
"$set": bson.M{
"posts.$": post,
},
}
_, err := s.collection.UpdateMany(ctx, filter, update)
return err
}
func (s FeedsStorage) appendPostIfNotPresent(ctx context.Context, post Post) error {
if len(post.Tags) == 0 {
return nil
}
filter := bson.M{
"_id": bson.M{
"$in": post.Tags,
},
"posts.id": bson.M{
"$ne": post.ID,
},
}
update := bson.M{
"$push": bson.M{
"posts": bson.M{
"$each": bson.A{post},
"$position": 0,
},
},
}
_, err := s.collection.UpdateMany(ctx, filter, update)
return err
}
func (s FeedsStorage) removePostIfNotInFeed(ctx context.Context, post Post) error {
tags := post.Tags
if tags == nil {
tags = []string{}
}
filter := bson.M{
"_id": bson.M{
"$nin": tags,
},
"posts.id": post.ID,
}
update := bson.M{
"$pull": bson.M{
"posts": bson.M{
"id": post.ID,
},
},
}
_, err := s.collection.UpdateMany(ctx, filter, update)
if err != nil {
return err
}
return nil
}
func isDuplicateError(err error) bool {
mErr, ok := err.(mongo.WriteException)
if !ok {
return false
}
return mErr.WriteErrors[0].Code == 11000
}

View File

@@ -0,0 +1,52 @@
module main.go
go 1.25.0
require (
github.com/ThreeDotsLabs/watermill v1.5.1
github.com/ThreeDotsLabs/watermill-nats v1.0.7
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/render v1.0.3
github.com/go-sql-driver/mysql v1.10.0
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/nats-io/stan.go v0.10.4
go.mongodb.org/mongo-driver v1.17.9
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
require (
github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1
github.com/brianvoe/gofakeit/v6 v6.28.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/ajg/form v1.7.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/hashicorp/go-hclog v1.4.0 // indirect
github.com/hashicorp/go-msgpack v0.5.5 // indirect
github.com/hashicorp/raft v1.3.11 // indirect
github.com/lithammer/shortuuid/v3 v3.0.7 // indirect
github.com/minio/highwayhash v1.0.2 // indirect
github.com/montanaflynn/stats v0.9.0 // indirect
github.com/nats-io/jwt/v2 v2.3.0 // indirect
github.com/nats-io/nats.go v1.52.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/time v0.3.0 // indirect
)

View File

@@ -0,0 +1,196 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY=
github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4=
github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1 h1:M0iYM5HsGcoxtiQqprRlYZNZnGk3w5LsE9RbC2R8myQ=
github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1/go.mod h1:RwGHEzGsEEXC/rQNLWQqR83+WPlABgOgnv2kTB56Y4Y=
github.com/ThreeDotsLabs/watermill-nats v1.0.7 h1:hOquWq0GAwm5jaIc3wGaDoVCPYL+If4NZPb+RUaHni4=
github.com/ThreeDotsLabs/watermill-nats v1.0.7/go.mod h1:t5A8XbO/v8CPM+AIljgoO9NR1jBk3ixYBGAtvn1N4lA=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/ajg/form v1.7.1 h1:OsnBDzTkrWdrxvEnO68I72ZVGJGNaMwPhoAm0V+llgc=
github.com/ajg/form v1.7.1/go.mod h1:HL757PzLyNkj5AIfptT6L+iGNeXTlnrr/oDePGc/y7Q=
github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878 h1:EFSB7Zo9Eg91v7MJPVsifUysc/wPdN+NOnVe6bWbdBM=
github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-hclog v0.9.1/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v1.4.0 h1:ctuWFGrhFha8BnnzxqeRGidlEcQkDyL5u8J8t5eA11I=
github.com/hashicorp/go-hclog v1.4.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI=
github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/raft v1.3.11 h1:p3v6gf6l3S797NnK5av3HcczOC1T5CLoaRvg0g9ys4A=
github.com/hashicorp/raft v1.3.11/go.mod h1:J8naEwc6XaaCfts7+28whSeRvCqTd6e20BlCU3LtEO4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g=
github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/nats-io/jwt/v2 v2.3.0 h1:z2mA1a7tIf5ShggOFlR1oBPgd6hGqcDYsISxZByUzdI=
github.com/nats-io/jwt/v2 v2.3.0/go.mod h1:0tqz9Hlu6bCBFLWAASKhE5vUA4c24L9KPUUgvwumE/k=
github.com/nats-io/nats-server/v2 v2.6.1 h1:cJy+ia7/4EaJL+ZYDmIy2rD1mDWTfckhtPBU0GYo8xM=
github.com/nats-io/nats-server/v2 v2.6.1/go.mod h1:Az91TbZiV7K4a6k/4v6YYdOKEoxCXj+iqhHVf/MlrKo=
github.com/nats-io/nats-streaming-server v0.22.1 h1:YKDdLAWZud3UnEBvUPaYppMxSDuh+9czTCDriq19tJY=
github.com/nats-io/nats-streaming-server v0.22.1/go.mod h1:1WpVkVV5NyZbHuGGxkaPWopLFnxNthO/TK/BkzFdnPE=
github.com/nats-io/nats.go v1.22.1/go.mod h1:tLqubohF7t4z3du1QDPYJIQQyhb4wl6DhjxEajSI7UA=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/nats-io/stan.go v0.10.4 h1:19GS/eD1SeQJaVkeM9EkvEYattnvnWrZ3wkSWSw4uXw=
github.com/nats-io/stan.go v0.10.4/go.mod h1:3XJXH8GagrGqajoO/9+HgPyKV5MWsv7S5ccdda+pc6k=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,370 @@
package main
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"strings"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/ThreeDotsLabs/watermill"
watermillHTTP "github.com/ThreeDotsLabs/watermill-http/v2/pkg/http"
"github.com/ThreeDotsLabs/watermill/message"
)
var generatedTags = []string{"watermill", "golang", "pubsub", "unicorn", "HelloWorld", "example", "ThreeDotsLabs"}
type Router struct {
Subscriber message.Subscriber
Publisher Publisher
PostsStorage PostsStorage
FeedsStorage FeedsStorage
Logger watermill.LoggerAdapter
}
func (router Router) Mux() *chi.Mux {
r := chi.NewRouter()
root := http.Dir("./public")
FileServer(r, "/", root)
sseRouter, err := watermillHTTP.NewSSERouter(
watermillHTTP.SSERouterConfig{
UpstreamSubscriber: router.Subscriber,
ErrorHandler: watermillHTTP.DefaultErrorHandler,
},
router.Logger,
)
if err != nil {
panic(err)
}
postStream := postStreamAdapter{storage: router.PostsStorage, logger: router.Logger}
feedStream := feedStreamAdapter{storage: router.FeedsStorage, logger: router.Logger}
allFeedsStream := allFeedsStreamAdapter{storage: router.FeedsStorage, logger: router.Logger}
postHandler := sseRouter.AddHandler(PostUpdatedTopic, postStream)
feedHandler := sseRouter.AddHandler(FeedUpdatedTopic, feedStream)
allFeedsHandler := sseRouter.AddHandler(FeedUpdatedTopic, allFeedsStream)
r.Route("/api", func(r chi.Router) {
r.Get("/posts/{id}", postHandler)
r.Post("/posts", router.CreatePost)
r.Post("/generate/post", router.GeneratePost)
r.Patch("/posts/{id}", router.UpdatePost)
r.Get("/feeds/{name}", feedHandler)
r.Get("/feeds", allFeedsHandler)
})
go func() {
err = sseRouter.Run(context.Background())
if err != nil {
panic(err)
}
}()
<-sseRouter.Running()
return r
}
type feedSummary struct {
Name string `json:"name"`
Posts int `json:"posts"`
}
type AllFeedsResponse struct {
Feeds []feedSummary `json:"feeds"`
}
type allFeedsStreamAdapter struct {
storage FeedsStorage
logger watermill.LoggerAdapter
}
func (f allFeedsStreamAdapter) InitialStreamResponse(w http.ResponseWriter, r *http.Request) (response interface{}, ok bool) {
resp, err := f.getResponse(r)
if err != nil {
logAndWriteError(f.logger, w, err)
return resp, false
}
return resp, true
}
func (f allFeedsStreamAdapter) NextStreamResponse(r *http.Request, msg *message.Message) (response interface{}, ok bool) {
resp, err := f.getResponse(r)
if err != nil {
return resp, false
}
return resp, true
}
func (f allFeedsStreamAdapter) getResponse(r *http.Request) (interface{}, error) {
feeds, err := f.storage.All(r.Context())
if err != nil {
return nil, err
}
response := AllFeedsResponse{
Feeds: []feedSummary{},
}
for _, f := range feeds {
response.Feeds = append(response.Feeds, feedSummary{
Name: f.Name,
Posts: len(f.Posts),
})
}
return response, nil
}
type CreatePostRequest struct {
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
}
func (router Router) CreatePost(w http.ResponseWriter, r *http.Request) {
req := CreatePostRequest{}
err := render.Decode(r, &req)
if err != nil {
logAndWriteError(router.Logger, w, err)
return
}
post := NewPost(
watermill.NewUUID(),
req.Title,
req.Content,
req.Author,
)
err = router.addPost(r.Context(), post)
if err != nil {
logAndWriteError(router.Logger, w, err)
return
}
w.WriteHeader(204)
}
func (router Router) GeneratePost(w http.ResponseWriter, r *http.Request) {
title := gofakeit.Sentence(5)
content := gofakeit.Sentence(20)
author := gofakeit.Name()
tagsCount := rand.Intn(3) + 1
for i := 0; i < tagsCount; i++ {
content += fmt.Sprintf(" #%v", generatedTags[rand.Intn(len(generatedTags))])
}
post := NewPost(
watermill.NewUUID(),
title,
content,
author,
)
err := router.addPost(r.Context(), post)
if err != nil {
logAndWriteError(router.Logger, w, err)
return
}
w.WriteHeader(204)
}
func (router Router) addPost(ctx context.Context, post Post) error {
err := router.PostsStorage.Add(ctx, post)
if err != nil {
return err
}
event := PostCreated{
Post: post,
OccurredAt: time.Now().UTC(),
}
err = router.Publisher.Publish(PostCreatedTopic, event)
if err != nil {
return err
}
return nil
}
type UpdatePostRequest struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
}
func (router Router) UpdatePost(w http.ResponseWriter, r *http.Request) {
req := UpdatePostRequest{}
err := render.Decode(r, &req)
if err != nil {
logAndWriteError(router.Logger, w, err)
return
}
post, err := router.PostsStorage.ByID(r.Context(), req.ID)
if err != nil {
logAndWriteError(router.Logger, w, err)
return
}
newPost := NewPost(
post.ID,
req.Title,
req.Content,
post.Author,
)
err = router.PostsStorage.Update(r.Context(), newPost)
if err != nil {
logAndWriteError(router.Logger, w, err)
return
}
event := PostUpdated{
OriginalPost: post,
NewPost: newPost,
OccurredAt: time.Now().UTC(),
}
err = router.Publisher.Publish(PostUpdatedTopic, event)
if err != nil {
logAndWriteError(router.Logger, w, err)
return
}
w.WriteHeader(204)
}
type feedStreamAdapter struct {
storage FeedsStorage
logger watermill.LoggerAdapter
}
func (f feedStreamAdapter) InitialStreamResponse(w http.ResponseWriter, r *http.Request) (response interface{}, ok bool) {
resp, err := f.getResponse(r)
if err != nil {
logAndWriteError(f.logger, w, err)
return nil, false
}
return resp, true
}
func (f feedStreamAdapter) NextStreamResponse(r *http.Request, msg *message.Message) (response interface{}, ok bool) {
feedUpdated := FeedUpdated{}
err := json.Unmarshal(msg.Payload, &feedUpdated)
if err != nil {
return nil, false
}
feedName := chi.URLParam(r, "name")
if feedUpdated.Name != feedName {
return nil, false
}
resp, err := f.getResponse(r)
if err != nil {
return nil, false
}
return resp, true
}
func (f feedStreamAdapter) getResponse(r *http.Request) (response interface{}, err error) {
feedName := chi.URLParam(r, "name")
feed, err := f.storage.ByName(r.Context(), feedName)
if err != nil {
return nil, err
}
return feed, nil
}
type postStreamAdapter struct {
storage PostsStorage
logger watermill.LoggerAdapter
}
func (p postStreamAdapter) InitialStreamResponse(w http.ResponseWriter, r *http.Request) (response interface{}, ok bool) {
resp, err := p.getResponse(r)
if err != nil {
logAndWriteError(p.logger, w, err)
return nil, false
}
return resp, true
}
func (p postStreamAdapter) NextStreamResponse(r *http.Request, msg *message.Message) (response interface{}, ok bool) {
postUpdated := PostUpdated{}
err := json.Unmarshal(msg.Payload, &postUpdated)
if err != nil {
return nil, false
}
postID := chi.URLParam(r, "id")
if postUpdated.OriginalPost.ID != postID {
return nil, false
}
resp, err := p.getResponse(r)
if err != nil {
return nil, false
}
return resp, true
}
func (p postStreamAdapter) getResponse(r *http.Request) (response interface{}, err error) {
postID := chi.URLParam(r, "id")
post, err := p.storage.ByID(r.Context(), postID)
if err != nil {
return nil, err
}
return post, nil
}
func FileServer(r chi.Router, path string, root http.FileSystem) {
if strings.ContainsAny(path, "{}*") {
panic("FileServer does not permit URL parameters.")
}
fs := http.StripPrefix(path, http.FileServer(root))
if path != "/" && path[len(path)-1] != '/' {
r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP)
path += "/"
}
path += "*"
r.Get(path, func(w http.ResponseWriter, r *http.Request) {
fs.ServeHTTP(w, r)
})
}
func logAndWriteError(logger watermill.LoggerAdapter, w http.ResponseWriter, err error) {
logger.Error("Error", err, nil)
w.WriteHeader(500)
}

View File

@@ -0,0 +1,34 @@
package main
import (
"net/http"
"github.com/ThreeDotsLabs/watermill"
)
func main() {
logger := watermill.NewStdLogger(false, false)
postsStorage := NewPostsStorage()
feedsStorage := NewFeedsStorage()
pub, sub, err := SetupMessageRouter(feedsStorage, logger)
if err != nil {
panic(err)
}
httpRouter := Router{
Subscriber: sub,
Publisher: Publisher{publisher: pub},
PostsStorage: postsStorage,
FeedsStorage: feedsStorage,
Logger: logger,
}
mux := httpRouter.Mux()
err = http.ListenAndServe(":8080", mux)
if err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,71 @@
package main
import (
"regexp"
"strings"
"time"
)
// Note that in a real application using both "json" and "bson" tags in the same structure is strongly discouraged.
// We use common models for database storage and HTTP API just to make this example simple and easy to grasp.
// See our article about the idea behind this: https://threedots.tech/post/things-to-know-about-dry/
type Post struct {
ID string `json:"id" bson:"id"`
Title string `json:"title" bson:"title"`
Content string `json:"content" bson:"content"`
Author string `json:"author" bson:"author"`
Tags []string `json:"tags" bson:"tags"`
}
func NewPost(id, title, content, author string) Post {
pattern := regexp.MustCompile("#([a-zA-Z0-9]+)")
matches := pattern.FindAllStringSubmatch(content, -1)
var tags []string
tagsMap := map[string]struct{}{}
for _, tag := range matches {
tagSlug := strings.ToLower(tag[1])
_, ok := tagsMap[tagSlug]
if ok {
continue
}
tagsMap[tagSlug] = struct{}{}
tags = append(tags, tagSlug)
}
return Post{
ID: id,
Title: title,
Content: content,
Author: author,
Tags: tags,
}
}
type Feed struct {
Name string `json:"name" bson:"_id"`
Posts []Post `json:"posts" bson:"posts"`
}
type PostCreated struct {
Post Post `json:"post"`
OccurredAt time.Time `json:"occurred_at"`
}
type PostUpdated struct {
OriginalPost Post `json:"original_post"`
NewPost Post `json:"new_post"`
OccurredAt time.Time `json:"occurred_at"`
}
type FeedUpdated struct {
Name string `json:"name"`
OccurredAt time.Time `json:"occurred_at"`
}

View File

@@ -0,0 +1,66 @@
package main
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/go-sql-driver/mysql"
)
type PostsStorage struct {
db *sql.DB
}
func NewPostsStorage() PostsStorage {
conf := mysql.NewConfig()
conf.Net = "tcp"
conf.User = "root"
conf.Addr = "mysql"
conf.DBName = "example"
db, err := sql.Open("mysql", conf.FormatDSN())
if err != nil {
panic(err)
}
for {
err = db.Ping()
if err == nil {
break
} else {
fmt.Println("Could not connect to MySQL, retrying...")
time.Sleep(time.Second * 3)
}
}
return PostsStorage{
db: db,
}
}
func (s PostsStorage) ByID(ctx context.Context, id string) (Post, error) {
query := "SELECT title, content, author FROM posts WHERE id=?"
row := s.db.QueryRowContext(ctx, query, id)
var title, content, author string
err := row.Scan(&title, &content, &author)
if err != nil {
return Post{}, err
}
return NewPost(id, title, content, author), nil
}
func (s PostsStorage) Add(ctx context.Context, post Post) error {
query := "INSERT INTO posts (id, title, content, author) VALUES (?, ?, ?, ?)"
_, err := s.db.ExecContext(ctx, query, post.ID, post.Title, post.Content, post.Author)
return err
}
func (s PostsStorage) Update(ctx context.Context, post Post) error {
query := "UPDATE posts SET title=?, content=?, author=? WHERE id=?"
_, err := s.db.ExecContext(ctx, query, post.Title, post.Content, post.Author, post.ID)
return err
}

View File

@@ -0,0 +1,335 @@
<!doctype html>
<html lang="en">
<head>
<title>Watermill Server-Sent Events Example</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
</head>
<body>
<style>
body {
padding-top: 5rem;
}
.example {
padding: 3rem 1.5rem;
}
#feeds li {
display: inline;
}
.breadcrumb-item + .breadcrumb-item::before {
content: "|";
}
</style>
<div id="app">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<a class="navbar-brand" href="/">Home</a>
</nav>
<main role="main" class="container">
<div class="example">
<feeds-list></feeds-list>
<add-post></add-post>
<hr />
<router-view></router-view>
</div>
</main>
</div>
</body>
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>
<script src="https://unpkg.com/vue@2.7.16/dist/vue.js"></script>
<script src="https://unpkg.com/vue-router@3.6.5/dist/vue-router.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-resource@1.3.5"></script>
<script type="text/x-template" id="feeds-list">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item" v-bind:class="{ active: feed.active }" v-for="feed in feeds">
<router-link v-if="!feed.active" :to="{ name: 'show-feed', params: { feed_name: feed.name }}">
{{ feed.text }}
</router-link>
<span v-else>
{{ feed.text }}
</span>
</li>
</ol>
</nav>
</script>
<script type="text/x-template" id="add-post">
<div>
<p>
<button class="btn btn-primary" type="button" v-on:click="toggle">
Add new post
</button>
<button class="btn btn-info" type="button" v-on:click="generatePost">Add randomly generated post</button>
</p>
<div id="addPostForm" v-show="showForm">
<div class="card card-body">
<h2>Add new post</h2>
<form v-on:submit.prevent="submitNewPost">
<div class="form-group row">
<label for="inputTitle" class="col-sm-2 col-form-label">Title</label>
<div class="col-sm-10">
<input type="title" class="form-control" id="inputTitle" placeholder="Title" v-model="post.title">
</div>
</div>
<div class="form-group row">
<label for="inputAuthor" class="col-sm-2 col-form-label">Author</label>
<div class="col-sm-10">
<input type="author" class="form-control" id="inputAuthor" placeholder="Author" v-model="post.author">
</div>
</div>
<div class="form-group row">
<label for="inputContent" class="col-sm-2 col-form-label">Content</label>
<div class="col-sm-10">
<textarea class="form-control" id="inputContent" rows="3" v-model="post.content"></textarea>
</div>
</div>
<div class="form-group row">
<div class="col-sm-2"></div>
<div class="col-sm-10">
<input type="submit" class="btn btn-success" value="Add post" />
</div>
</div>
</form>
</div>
</div>
</div>
</script>
<script type="text/x-template" id="home">
<div>
<h3>1. Add some posts using the buttons above (use #hashtags).</h3>
<h3>2. Click one of feeds to see a live stream.</h3>
</div>
</script>
<script type="text/x-template" id="feed-template">
<div>
<h2>{{ feed }}</h2>
<div v-for="post in posts">
<div class="card">
<div class="card-body">
<h4 class="card-title">
{{post.title}}
<router-link :to="{ name: 'edit-post', params: { post_id: post.id }}"
class="btn btn-light class-right btn-sm">Edit
</router-link>
</h4>
<p class="card-text" v-html="post.content"></p>
<p class="card-text">
<small class="text-muted">Author: {{post.author}}</small>
</p>
</div>
</div>
<br>
</div>
</div>
</script>
<script type="text/x-template" id="post-edit-template">
<div>
<h2>Edit post</h2>
<div class="card">
<div class="card-body">
<form v-on:submit.prevent="submitData">
<div class="form-group row">
<label for="inputTitle" class="col-sm-2 col-form-label">Title</label>
<div class="col-sm-10">
<input type="title" class="form-control" id="inputTitle" placeholder="Title" v-model="post.title">
</div>
</div>
<div class="form-group row">
<label for="inputContent" class="col-sm-2 col-form-label">Content</label>
<div class="col-sm-10">
<textarea class="form-control" id="inputContent" rows="3" v-model="post.content"></textarea>
</div>
</div>
<div class="form-group row">
<div class="col-sm-2"></div>
<div class="col-sm-10">
<input type="submit" class="btn btn-success" value="Update post" />
</div>
</div>
</form>
</div>
</div>
</div>
</script>
<script>
Vue.component('feeds-list', {
template: '#feeds-list',
data: function () {
return {
feeds_stream: [],
current_feed: "",
}
},
created() {
this.es = new EventSource('/api/feeds')
this.es.addEventListener('data', event => {
let data = JSON.parse(event.data);
this.feeds_stream = data.feeds;
}, false);
},
watch: {
$route(to, from) {
if (to.name === "show-feed") {
this.current_feed = to.params.feed_name;
} else {
this.current_feed = "";
}
}
},
computed: {
feeds: function (){
return this.feeds_stream.map(f => ({text: `${f.name} (${f.posts})`, name: f.name, active: f.name === this.current_feed}));
},
},
});
const Home = {
template: '#home',
}
Vue.component('add-post', {
template: '#add-post',
data: function () {
return {
post: {},
showForm: false,
}
},
methods: {
submitNewPost: function() {
postData = this.post;
this.$http.post('/api/posts', JSON.stringify(postData))
this.post = {};
},
generatePost: function() {
this.$http.post('/api/generate/post')
},
toggle: function () {
this.showForm = !this.showForm;
},
},
created() {},
});
const ShowFeed = {
template: '#feed-template',
data: function () {
return {
feed: "",
posts_stream: [],
}
},
watch: {
$route(to, from) {
console.log(to)
this.setupFeed(to.params.feed_name);
}
},
created() {
this.setupFeed(this.$route.params.feed_name);
},
destroyed() {
if (this.es) {
this.es.close();
}
},
methods: {
setupFeed: function(feed_name) {
this.feed = feed_name;
if (this.es) {
this.es.close();
}
this.es = new EventSource('/api/feeds/' + this.feed)
this.es.addEventListener('data', event => {
let data = JSON.parse(event.data);
this.posts_stream = data.posts;
}, false);
},
},
computed: {
posts: function() {
var posts = this.posts_stream.slice();
for (var p of posts) {
p.content = p.content.replace(/#([\w]+)/g,'<a href="#/feed/$1">#$1</a>')
}
return posts;
},
},
};
const EditPost = {
template: "#post-edit-template",
data: function () {
return {
post_id: this.$route.params.post_id,
post: {},
}
},
created() {
this.setupPost();
},
destroyed() {
if (this.es) {
console.log("closing")
this.es.close();
}
},
methods: {
submitData: function () {
postData = this.post;
this.$http.patch('/api/posts/' + this.post_id, JSON.stringify(postData));
router.go(-1);
},
setupPost() {
this.es = new EventSource('/api/posts/' + this.post_id)
this.es.addEventListener('data', event => {
let data = JSON.parse(event.data);
this.post = data;
}, false);
this.es.addEventListener('error', function (e) {
if (e.readyState == EventSource.CLOSED) {
console.log("close", e)
} else {
console.log("other error", e)
}
}, false);
},
},
};
const routes = [
{path: '/', component: Home, name: 'home'},
{path: '/edit-post/:post_id', component: EditPost, name: 'edit-post'},
{path: '/feed/:feed_name', component: ShowFeed, name: 'show-feed'},
];
const router = new VueRouter({
routes
});
const app = new Vue({
router
}).$mount('#app')
</script>
</html>