chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:40:33 +08:00
commit e071084ebe
982 changed files with 160368 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
package web
import (
"context"
"crypto/tls"
"net/http"
"time"
"github.com/urfave/cli/v2"
"go-micro.dev/v6"
"go-micro.dev/v6/logger"
"go-micro.dev/v6/registry"
)
// Options for web.
type Options struct {
Handler http.Handler
Logger logger.Logger
Service micro.Service
Registry registry.Registry
// Alternative Options
Context context.Context
Action func(*cli.Context)
Metadata map[string]string
TLSConfig *tls.Config
Server *http.Server
// RegisterCheck runs a check function before registering the service
RegisterCheck func(context.Context) error
Version string
// Static directory
StaticDir string
Advertise string
Address string
Name string
Id string
Flags []cli.Flag
BeforeStart []func() error
BeforeStop []func() error
AfterStart []func() error
AfterStop []func() error
RegisterInterval time.Duration
RegisterTTL time.Duration
Secure bool
Signal bool
}
func newOptions(opts ...Option) Options {
opt := Options{
Name: DefaultName,
Version: DefaultVersion,
Id: DefaultId,
Address: DefaultAddress,
RegisterTTL: DefaultRegisterTTL,
RegisterInterval: DefaultRegisterInterval,
StaticDir: DefaultStaticDir,
Service: micro.NewService(DefaultName),
Context: context.TODO(),
Signal: true,
Logger: logger.DefaultLogger,
}
for _, o := range opts {
o(&opt)
}
if opt.RegisterCheck == nil {
opt.RegisterCheck = DefaultRegisterCheck
}
return opt
}
// Name of Web.
func Name(n string) Option {
return func(o *Options) {
o.Name = n
}
}
// Icon specifies an icon url to load in the UI.
func Icon(ico string) Option {
return func(o *Options) {
if o.Metadata == nil {
o.Metadata = make(map[string]string)
}
o.Metadata["icon"] = ico
}
}
// Id for Unique server id.
func Id(id string) Option {
return func(o *Options) {
o.Id = id
}
}
// Version of the service.
func Version(v string) Option {
return func(o *Options) {
o.Version = v
}
}
// Metadata associated with the service.
func Metadata(md map[string]string) Option {
return func(o *Options) {
o.Metadata = md
}
}
// Address to bind to - host:port.
func Address(a string) Option {
return func(o *Options) {
o.Address = a
}
}
// Advertise The address to advertise for discovery - host:port.
func Advertise(a string) Option {
return func(o *Options) {
o.Advertise = a
}
}
// Context specifies a context for the service.
// Can be used to signal shutdown of the service.
// Can be used for extra option values.
func Context(ctx context.Context) Option {
return func(o *Options) {
o.Context = ctx
}
}
// Registry used for discovery.
func Registry(r registry.Registry) Option {
return func(o *Options) {
o.Registry = r
}
}
// RegisterTTL Register the service with a TTL.
func RegisterTTL(t time.Duration) Option {
return func(o *Options) {
o.RegisterTTL = t
}
}
// RegisterInterval Register the service with at interval.
func RegisterInterval(t time.Duration) Option {
return func(o *Options) {
o.RegisterInterval = t
}
}
// Handler for custom handler.
func Handler(h http.Handler) Option {
return func(o *Options) {
o.Handler = h
}
}
// Server for custom Server.
func Server(srv *http.Server) Option {
return func(o *Options) {
o.Server = srv
}
}
// MicroService sets the micro.Service used internally.
func MicroService(s micro.Service) Option {
return func(o *Options) {
o.Service = s
}
}
// Flags sets the command flags.
func Flags(flags ...cli.Flag) Option {
return func(o *Options) {
o.Flags = append(o.Flags, flags...)
}
}
// Action sets the command action.
func Action(a func(*cli.Context)) Option {
return func(o *Options) {
o.Action = a
}
}
// BeforeStart is executed before the server starts.
func BeforeStart(fn func() error) Option {
return func(o *Options) {
o.BeforeStart = append(o.BeforeStart, fn)
}
}
// BeforeStop is executed before the server stops.
func BeforeStop(fn func() error) Option {
return func(o *Options) {
o.BeforeStop = append(o.BeforeStop, fn)
}
}
// AfterStart is executed after server start.
func AfterStart(fn func() error) Option {
return func(o *Options) {
o.AfterStart = append(o.AfterStart, fn)
}
}
// AfterStop is executed after server stop.
func AfterStop(fn func() error) Option {
return func(o *Options) {
o.AfterStop = append(o.AfterStop, fn)
}
}
// Secure Use secure communication.
// If TLSConfig is not specified we use InsecureSkipVerify and generate a self signed cert.
func Secure(b bool) Option {
return func(o *Options) {
o.Secure = b
}
}
// TLSConfig to be used for the transport.
func TLSConfig(t *tls.Config) Option {
return func(o *Options) {
o.TLSConfig = t
}
}
// StaticDir sets the static file directory. This defaults to ./html.
func StaticDir(d string) Option {
return func(o *Options) {
o.StaticDir = d
}
}
// RegisterCheck run func before registry service.
func RegisterCheck(fn func(context.Context) error) Option {
return func(o *Options) {
o.RegisterCheck = fn
}
}
// HandleSignal toggles automatic installation of the signal handler that
// traps TERM, INT, and QUIT. Users of this feature to disable the signal
// handler, should control liveness of the service through the context.
func HandleSignal(b bool) Option {
return func(o *Options) {
o.Signal = b
}
}
// Logger sets the underline logger.
func Logger(l logger.Logger) Option {
return func(o *Options) {
o.Logger = l
}
}
+588
View File
@@ -0,0 +1,588 @@
package web
import (
"crypto/tls"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/urfave/cli/v2"
"go-micro.dev/v6"
maddr "go-micro.dev/v6/internal/util/addr"
"go-micro.dev/v6/internal/util/backoff"
mhttp "go-micro.dev/v6/internal/util/http"
mnet "go-micro.dev/v6/internal/util/net"
signalutil "go-micro.dev/v6/internal/util/signal"
mls "go-micro.dev/v6/internal/util/tls"
log "go-micro.dev/v6/logger"
"go-micro.dev/v6/registry"
)
type service struct {
mux *http.ServeMux
srv *registry.Service
exit chan chan error
ex chan bool
opts Options
sync.RWMutex
running bool
static bool
}
func newService(opts ...Option) Service {
options := newOptions(opts...)
s := &service{
opts: options,
mux: http.NewServeMux(),
static: true,
ex: make(chan bool),
}
s.srv = s.genSrv()
return s
}
func (s *service) genSrv() *registry.Service {
var (
host string
port string
err error
)
logger := s.opts.Logger
// default host:port
if len(s.opts.Address) > 0 {
host, port, err = net.SplitHostPort(s.opts.Address)
if err != nil {
logger.Log(log.FatalLevel, err)
}
}
// check the advertise address first
// if it exists then use it, otherwise
// use the address
if len(s.opts.Advertise) > 0 {
host, port, err = net.SplitHostPort(s.opts.Advertise)
if err != nil {
logger.Log(log.FatalLevel, err)
}
}
addr, err := maddr.Extract(host)
if err != nil {
logger.Log(log.FatalLevel, err)
}
if strings.Count(addr, ":") > 0 {
addr = "[" + addr + "]"
}
return &registry.Service{
Name: s.opts.Name,
Version: s.opts.Version,
Nodes: []*registry.Node{{
Id: s.opts.Id,
Address: net.JoinHostPort(addr, port),
Metadata: s.opts.Metadata,
}},
}
}
func (s *service) run() {
s.RLock()
if s.opts.RegisterInterval <= time.Duration(0) {
s.RUnlock()
return
}
t := time.NewTicker(s.opts.RegisterInterval)
s.RUnlock()
for {
select {
case <-t.C:
_ = s.register()
case <-s.ex:
t.Stop()
return
}
}
}
func (s *service) register() error {
s.Lock()
defer s.Unlock()
if s.srv == nil {
return nil
}
logger := s.opts.Logger
// default to service registry
r := s.opts.Service.Client().Options().Registry
// switch to option if specified
if s.opts.Registry != nil {
r = s.opts.Registry
}
// service node need modify, node address maybe changed
srv := s.genSrv()
srv.Endpoints = s.srv.Endpoints
s.srv = srv
// use RegisterCheck func before register
if err := s.opts.RegisterCheck(s.opts.Context); err != nil {
logger.Logf(log.ErrorLevel, "Server %s-%s register check error: %s", s.opts.Name, s.opts.Id, err)
return err
}
var regErr error
// try three times if necessary
for i := 0; i < 3; i++ {
// attempt to register
if err := r.Register(s.srv, registry.RegisterTTL(s.opts.RegisterTTL)); err != nil {
// set the error
regErr = err
// backoff then retry
time.Sleep(backoff.Do(i + 1))
continue
}
// success so nil error
regErr = nil
break
}
return regErr
}
func (s *service) deregister() error {
s.Lock()
defer s.Unlock()
if s.srv == nil {
return nil
}
// default to service registry
r := s.opts.Service.Client().Options().Registry
// switch to option if specified
if s.opts.Registry != nil {
r = s.opts.Registry
}
return r.Deregister(s.srv)
}
func (s *service) start() error {
s.Lock()
defer s.Unlock()
if s.running {
return nil
}
for _, fn := range s.opts.BeforeStart {
if err := fn(); err != nil {
return err
}
}
listener, err := s.listen("tcp", s.opts.Address)
if err != nil {
return err
}
logger := s.opts.Logger
s.opts.Address = listener.Addr().String()
srv := s.genSrv()
srv.Endpoints = s.srv.Endpoints
s.srv = srv
var handler http.Handler
if s.opts.Handler != nil {
handler = s.opts.Handler
} else {
handler = s.mux
var r sync.Once
// register the html dir
r.Do(func() {
// static dir
static := s.opts.StaticDir
if s.opts.StaticDir[0] != '/' {
dir, _ := os.Getwd()
static = filepath.Join(dir, static)
}
// set static if no / handler is registered
if s.static {
_, err := os.Stat(static)
if err == nil {
logger.Logf(log.InfoLevel, "Enabling static file serving from %s", static)
s.mux.Handle("/", http.FileServer(http.Dir(static)))
}
}
})
}
var httpSrv *http.Server
if s.opts.Server != nil {
httpSrv = s.opts.Server
} else {
httpSrv = &http.Server{}
}
httpSrv.Handler = handler
go func() { _ = httpSrv.Serve(listener) }()
for _, fn := range s.opts.AfterStart {
if err := fn(); err != nil {
return err
}
}
s.exit = make(chan chan error, 1)
s.running = true
go func() {
ch := <-s.exit
ch <- listener.Close()
}()
logger.Logf(log.InfoLevel, "Listening on %v", listener.Addr().String())
return nil
}
func (s *service) stop() error {
s.Lock()
defer s.Unlock()
if !s.running {
return nil
}
for _, fn := range s.opts.BeforeStop {
if err := fn(); err != nil {
return err
}
}
ch := make(chan error, 1)
s.exit <- ch
s.running = false
s.opts.Logger.Log(log.InfoLevel, "Stopping")
for _, fn := range s.opts.AfterStop {
if err := fn(); err != nil {
if chErr := <-ch; chErr != nil {
return chErr
}
return err
}
}
return <-ch
}
func (s *service) Client() *http.Client {
rt := mhttp.NewRoundTripper(
mhttp.WithRegistry(s.opts.Registry),
)
return &http.Client{
Transport: rt,
}
}
func (s *service) Handle(pattern string, handler http.Handler) {
var seen bool
s.RLock()
for _, ep := range s.srv.Endpoints {
if ep.Name == pattern {
seen = true
break
}
}
s.RUnlock()
// if its unseen then add an endpoint
if !seen {
s.Lock()
s.srv.Endpoints = append(s.srv.Endpoints, &registry.Endpoint{
Name: pattern,
})
s.Unlock()
}
// disable static serving
if pattern == "/" {
s.Lock()
s.static = false
s.Unlock()
}
// register the handler
s.mux.Handle(pattern, handler)
}
func (s *service) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
var seen bool
s.RLock()
for _, ep := range s.srv.Endpoints {
if ep.Name == pattern {
seen = true
break
}
}
s.RUnlock()
if !seen {
s.Lock()
s.srv.Endpoints = append(s.srv.Endpoints, &registry.Endpoint{
Name: pattern,
})
s.Unlock()
}
// disable static serving
if pattern == "/" {
s.Lock()
s.static = false
s.Unlock()
}
s.mux.HandleFunc(pattern, handler)
}
func (s *service) Init(opts ...Option) error {
s.Lock()
for _, o := range opts {
o(&s.opts)
}
serviceOpts := []micro.Option{}
if len(s.opts.Flags) > 0 {
serviceOpts = append(serviceOpts, micro.Flags(s.opts.Flags...))
}
if s.opts.Registry != nil {
serviceOpts = append(serviceOpts, micro.Registry(s.opts.Registry))
}
s.Unlock()
serviceOpts = append(serviceOpts, micro.Action(func(ctx *cli.Context) error {
s.Lock()
defer s.Unlock()
if ttl := ctx.Int("register_ttl"); ttl > 0 {
s.opts.RegisterTTL = time.Duration(ttl) * time.Second
}
if interval := ctx.Int("register_interval"); interval > 0 {
s.opts.RegisterInterval = time.Duration(interval) * time.Second
}
if name := ctx.String("server_name"); len(name) > 0 {
s.opts.Name = name
}
if ver := ctx.String("server_version"); len(ver) > 0 {
s.opts.Version = ver
}
if id := ctx.String("server_id"); len(id) > 0 {
s.opts.Id = id
}
if addr := ctx.String("server_address"); len(addr) > 0 {
s.opts.Address = addr
}
if adv := ctx.String("server_advertise"); len(adv) > 0 {
s.opts.Advertise = adv
}
if s.opts.Action != nil {
s.opts.Action(ctx)
}
return nil
}))
s.RLock()
// pass in own name and version
if s.opts.Service.Name() == "" {
serviceOpts = append(serviceOpts, micro.Name(s.opts.Name))
}
serviceOpts = append(serviceOpts, micro.Version(s.opts.Version))
s.RUnlock()
s.opts.Service.Init(serviceOpts...)
s.Lock()
srv := s.genSrv()
srv.Endpoints = s.srv.Endpoints
s.srv = srv
s.Unlock()
return nil
}
func (s *service) Start() error {
if err := s.start(); err != nil {
return err
}
if err := s.register(); err != nil {
return err
}
// start reg loop
go s.run()
return nil
}
func (s *service) Stop() error {
// exit reg loop
close(s.ex)
if err := s.deregister(); err != nil {
return err
}
return s.stop()
}
func (s *service) Run() error {
if err := s.start(); err != nil {
return err
}
logger := s.opts.Logger
// start the profiler
if s.opts.Service.Options().Profile != nil {
// to view mutex contention
runtime.SetMutexProfileFraction(5)
// to view blocking profile
runtime.SetBlockProfileRate(1)
if err := s.opts.Service.Options().Profile.Start(); err != nil {
return err
}
defer func() {
if err := s.opts.Service.Options().Profile.Stop(); err != nil {
logger.Log(log.ErrorLevel, err)
}
}()
}
if err := s.register(); err != nil {
return err
}
// start reg loop
go s.run()
ch := make(chan os.Signal, 1)
if s.opts.Signal {
signal.Notify(ch, signalutil.Shutdown()...)
}
select {
// wait on kill signal
case sig := <-ch:
logger.Logf(log.InfoLevel, "Received signal %s", sig)
// wait on context cancel
case <-s.opts.Context.Done():
logger.Log(log.InfoLevel, "Received context shutdown")
}
// exit reg loop
close(s.ex)
if err := s.deregister(); err != nil {
return err
}
return s.stop()
}
// Options returns the options for the given service.
func (s *service) Options() Options {
return s.opts
}
func (s *service) listen(network, addr string) (net.Listener, error) {
var (
listener net.Listener
err error
)
// TODO: support use of listen options
if s.opts.Secure || s.opts.TLSConfig != nil {
config := s.opts.TLSConfig
fn := func(addr string) (net.Listener, error) {
if config == nil {
hosts := []string{addr}
// check if its a valid host:port
if host, _, err := net.SplitHostPort(addr); err == nil {
if len(host) == 0 {
hosts = maddr.IPs()
} else {
hosts = []string{host}
}
}
// generate a certificate
cert, err := mls.Certificate(hosts...)
if err != nil {
return nil, err
}
config = &tls.Config{Certificates: []tls.Certificate{cert}}
}
return tls.Listen(network, addr, config)
}
listener, err = mnet.Listen(addr, fn)
} else {
fn := func(addr string) (net.Listener, error) {
return net.Listen(network, addr)
}
listener, err = mnet.Listen(addr, fn)
}
if err != nil {
return nil, err
}
return listener, nil
}
+297
View File
@@ -0,0 +1,297 @@
package web
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"syscall"
"testing"
"time"
"go-micro.dev/v6/registry"
)
func TestService(t *testing.T) {
var (
beforeStartCalled bool
afterStartCalled bool
beforeStopCalled bool
afterStopCalled bool
str = `<html><body><h1>Hello World</h1></body></html>`
fn = func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, str) }
reg = registry.NewMemoryRegistry()
)
beforeStart := func() error {
beforeStartCalled = true
return nil
}
afterStart := func() error {
afterStartCalled = true
return nil
}
beforeStop := func() error {
beforeStopCalled = true
return nil
}
afterStop := func() error {
afterStopCalled = true
return nil
}
service := NewService(
Name("go.micro.web.test"),
Registry(reg),
BeforeStart(beforeStart),
AfterStart(afterStart),
BeforeStop(beforeStop),
AfterStop(afterStop),
)
service.HandleFunc("/", fn)
errCh := make(chan error, 1)
go func() {
errCh <- service.Run()
close(errCh)
}()
var s []*registry.Service
eventually(func() bool {
var err error
s, err = reg.GetService("go.micro.web.test")
return err == nil
}, t.Fatal)
if have, want := len(s), 1; have != want {
t.Fatalf("Expected %d but got %d services", want, have)
}
rsp, err := http.Get(fmt.Sprintf("http://%s", s[0].Nodes[0].Address))
if err != nil {
t.Fatal(err)
}
defer rsp.Body.Close()
b, err := io.ReadAll(rsp.Body)
if err != nil {
t.Fatal(err)
}
if string(b) != str {
t.Errorf("Expected %s got %s", str, string(b))
}
callbackTests := []struct {
subject string
have interface{}
}{
{"beforeStartCalled", beforeStartCalled},
{"afterStartCalled", afterStartCalled},
}
for _, tt := range callbackTests {
if tt.have != true {
t.Errorf("unexpected %s: want true, have false", tt.subject)
}
}
select {
case err := <-errCh:
if err != nil {
t.Fatalf("service.Run():%v", err)
}
case <-time.After(time.Second):
if len(os.Getenv("IN_TRAVIS_CI")) == 0 {
t.Logf("service.Run() survived a client request without an error")
}
}
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTERM)
p, _ := os.FindProcess(os.Getpid())
p.Signal(syscall.SIGTERM)
<-ch
select {
case err := <-errCh:
if err != nil {
t.Fatalf("service.Run():%v", err)
} else {
if len(os.Getenv("IN_TRAVIS_CI")) == 0 {
t.Log("service.Run() nil return on syscall.SIGTERM")
}
}
case <-time.After(time.Second):
if len(os.Getenv("IN_TRAVIS_CI")) == 0 {
t.Logf("service.Run() survived a client request without an error")
}
}
eventually(func() bool {
_, err := reg.GetService("go.micro.web.test")
return err == registry.ErrNotFound
}, t.Error)
callbackTests = []struct {
subject string
have interface{}
}{
{"beforeStopCalled", beforeStopCalled},
{"afterStopCalled", afterStopCalled},
}
for _, tt := range callbackTests {
if tt.have != true {
t.Errorf("unexpected %s: want true, have false", tt.subject)
}
}
}
func TestOptions(t *testing.T) {
var (
name = "service-name"
id = "service-id"
version = "service-version"
address = "service-addr:8080"
advertise = "service-adv:8080"
reg = registry.NewMemoryRegistry()
registerTTL = 123 * time.Second
registerInterval = 456 * time.Second
handler = http.NewServeMux()
metadata = map[string]string{"key": "val"}
secure = true
)
service := NewService(
Name(name),
Id(id),
Version(version),
Address(address),
Advertise(advertise),
Registry(reg),
RegisterTTL(registerTTL),
RegisterInterval(registerInterval),
Handler(handler),
Metadata(metadata),
Secure(secure),
)
opts := service.Options()
tests := []struct {
subject string
want interface{}
have interface{}
}{
{"name", name, opts.Name},
{"version", version, opts.Version},
{"id", id, opts.Id},
{"address", address, opts.Address},
{"advertise", advertise, opts.Advertise},
{"registry", reg, opts.Registry},
{"registerTTL", registerTTL, opts.RegisterTTL},
{"registerInterval", registerInterval, opts.RegisterInterval},
{"handler", handler, opts.Handler},
{"metadata", metadata["key"], opts.Metadata["key"]},
{"secure", secure, opts.Secure},
}
for _, tc := range tests {
if tc.want != tc.have {
t.Errorf("unexpected %s: want %v, have %v", tc.subject, tc.want, tc.have)
}
}
}
func eventually(pass func() bool, fail func(...interface{})) {
tick := time.NewTicker(10 * time.Millisecond)
defer tick.Stop()
timeout := time.After(time.Second)
for {
select {
case <-timeout:
fail("timed out")
return
case <-tick.C:
if pass() {
return
}
}
}
}
func TestTLS(t *testing.T) {
var (
str = `<html><body><h1>Hello World</h1></body></html>`
fn = func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, str) }
secure = true
reg = registry.NewMemoryRegistry()
)
service := NewService(
Name("go.micro.web.test"),
Secure(secure),
Registry(reg),
)
service.HandleFunc("/", fn)
errCh := make(chan error, 1)
go func() {
errCh <- service.Run()
close(errCh)
}()
var s []*registry.Service
eventually(func() bool {
var err error
s, err = reg.GetService("go.micro.web.test")
return err == nil
}, t.Fatal)
if have, want := len(s), 1; have != want {
t.Fatalf("Expected %d but got %d services", want, have)
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
rsp, err := client.Get(fmt.Sprintf("https://%s", s[0].Nodes[0].Address))
if err != nil {
t.Fatal(err)
}
defer rsp.Body.Close()
b, err := io.ReadAll(rsp.Body)
if err != nil {
t.Fatal(err)
}
if string(b) != str {
t.Errorf("Expected %s got %s", str, string(b))
}
select {
case err := <-errCh:
if err != nil {
t.Fatalf("service.Run():%v", err)
}
case <-time.After(time.Second):
if len(os.Getenv("IN_TRAVIS_CI")) == 0 {
t.Logf("service.Run() survived a client request without an error")
}
}
}
+294
View File
@@ -0,0 +1,294 @@
// Package web provides a web service for go-micro
package web
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"go-micro.dev/v6/events"
log "go-micro.dev/v6/logger"
)
// SSEClient represents a connected SSE client
type SSEClient struct {
id string
send chan []byte
done chan struct{}
}
// SSEBroadcaster manages SSE connections and broadcasts events to connected clients
type SSEBroadcaster struct {
clients map[*SSEClient]struct{}
register chan *SSEClient
unregister chan *SSEClient
broadcast chan []byte
stream events.Stream
topics []string
logger log.Logger
mu sync.RWMutex
running bool
stopCh chan struct{}
}
// SSEEvent represents an event to be sent to clients
type SSEEvent struct {
ID string `json:"id,omitempty"`
Event string `json:"event,omitempty"`
Data interface{} `json:"data"`
}
// SSEOption is a function that configures the SSEBroadcaster
type SSEOption func(*SSEBroadcaster)
// WithStream sets the events stream for the broadcaster
func WithStream(stream events.Stream) SSEOption {
return func(b *SSEBroadcaster) {
b.stream = stream
}
}
// WithTopics sets the topics to subscribe to
func WithTopics(topics ...string) SSEOption {
return func(b *SSEBroadcaster) {
b.topics = topics
}
}
// WithSSELogger sets the logger for the broadcaster
func WithSSELogger(logger log.Logger) SSEOption {
return func(b *SSEBroadcaster) {
b.logger = logger
}
}
// NewSSEBroadcaster creates a new SSE broadcaster
func NewSSEBroadcaster(opts ...SSEOption) *SSEBroadcaster {
b := &SSEBroadcaster{
clients: make(map[*SSEClient]struct{}),
register: make(chan *SSEClient),
unregister: make(chan *SSEClient),
broadcast: make(chan []byte, 256),
logger: log.DefaultLogger,
stopCh: make(chan struct{}),
}
for _, opt := range opts {
opt(b)
}
return b
}
// Start begins the broadcaster's event loop and subscribes to configured topics
func (b *SSEBroadcaster) Start() error {
b.mu.Lock()
if b.running {
b.mu.Unlock()
return nil
}
b.running = true
b.mu.Unlock()
// Start the main event loop
go b.run()
// Subscribe to topics if stream is configured
if b.stream != nil && len(b.topics) > 0 {
for _, topic := range b.topics {
if err := b.subscribeToTopic(topic); err != nil {
b.logger.Logf(log.ErrorLevel, "Failed to subscribe to topic %s: %v", topic, err)
}
}
}
return nil
}
// Stop gracefully shuts down the broadcaster
func (b *SSEBroadcaster) Stop() {
b.mu.Lock()
if !b.running {
b.mu.Unlock()
return
}
b.running = false
b.mu.Unlock()
close(b.stopCh)
}
func (b *SSEBroadcaster) run() {
for {
select {
case client := <-b.register:
b.mu.Lock()
b.clients[client] = struct{}{}
b.mu.Unlock()
b.logger.Logf(log.DebugLevel, "SSE client connected: %s", client.id)
case client := <-b.unregister:
b.mu.Lock()
if _, ok := b.clients[client]; ok {
delete(b.clients, client)
close(client.send)
}
b.mu.Unlock()
b.logger.Logf(log.DebugLevel, "SSE client disconnected: %s", client.id)
case message := <-b.broadcast:
b.mu.RLock()
for client := range b.clients {
select {
case client.send <- message:
default:
// Client buffer full, skip
}
}
b.mu.RUnlock()
case <-b.stopCh:
b.mu.Lock()
for client := range b.clients {
close(client.send)
delete(b.clients, client)
}
b.mu.Unlock()
return
}
}
}
func (b *SSEBroadcaster) subscribeToTopic(topic string) error {
eventChan, err := b.stream.Consume(topic)
if err != nil {
return err
}
go func() {
for {
select {
case event := <-eventChan:
b.Broadcast(event.Payload)
case <-b.stopCh:
return
}
}
}()
b.logger.Logf(log.InfoLevel, "SSE broadcaster subscribed to topic: %s", topic)
return nil
}
// Broadcast sends a message to all connected clients
func (b *SSEBroadcaster) Broadcast(data []byte) {
select {
case b.broadcast <- data:
default:
b.logger.Log(log.WarnLevel, "SSE broadcast channel full, dropping message")
}
}
// BroadcastEvent sends a structured event to all connected clients
func (b *SSEBroadcaster) BroadcastEvent(eventType string, data interface{}) error {
event := SSEEvent{
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
Event: eventType,
Data: data,
}
jsonData, err := json.Marshal(event)
if err != nil {
return err
}
b.Broadcast(jsonData)
return nil
}
// BroadcastHTML sends raw HTML to clients (for htmx/datastar integration)
func (b *SSEBroadcaster) BroadcastHTML(eventType string, html string) {
// Format as SSE with event type for htmx sse-swap
message := fmt.Sprintf("event: %s\ndata: %s\n\n", eventType, html)
b.Broadcast([]byte(message))
}
// ClientCount returns the number of connected clients
func (b *SSEBroadcaster) ClientCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.clients)
}
// Handler returns an http.HandlerFunc for SSE connections
func (b *SSEBroadcaster) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check if the client supports SSE
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "SSE not supported", http.StatusInternalServerError)
return
}
// Set SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
// Create client
client := &SSEClient{
id: fmt.Sprintf("%d", time.Now().UnixNano()),
send: make(chan []byte, 64),
done: make(chan struct{}),
}
// Register client
b.register <- client
// Ensure cleanup on disconnect
defer func() {
b.unregister <- client
}()
// Send initial connection event
fmt.Fprintf(w, "event: connected\ndata: {\"id\":\"%s\"}\n\n", client.id)
flusher.Flush()
// Keep-alive ticker
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case message, ok := <-client.send:
if !ok {
return
}
// Check if message is already SSE formatted (contains "event:" or "data:")
if len(message) > 0 && (message[0] == 'e' || message[0] == 'd') {
w.Write(message)
} else {
fmt.Fprintf(w, "data: %s\n\n", message)
}
flusher.Flush()
case <-ticker.C:
// Send keep-alive comment
fmt.Fprintf(w, ": keepalive\n\n")
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
}
// GinHandler returns a handler compatible with Gin framework
func (b *SSEBroadcaster) GinHandler() interface{} {
return b.Handler()
}
+137
View File
@@ -0,0 +1,137 @@
package web
import (
"bufio"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestSSEBroadcaster_Basic(t *testing.T) {
// Create broadcaster
b := NewSSEBroadcaster()
if err := b.Start(); err != nil {
t.Fatalf("Failed to start broadcaster: %v", err)
}
defer b.Stop()
// Create test server
server := httptest.NewServer(b.Handler())
defer server.Close()
// Connect client
resp, err := http.Get(server.URL)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer resp.Body.Close()
// Check headers
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
t.Errorf("Expected Content-Type text/event-stream, got %s", ct)
}
// Read initial connection event
reader := bufio.NewReader(resp.Body)
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("Failed to read: %v", err)
}
if !strings.HasPrefix(line, "event: connected") {
t.Errorf("Expected connected event, got: %s", line)
}
}
func TestSSEBroadcaster_BroadcastEvent(t *testing.T) {
b := NewSSEBroadcaster()
if err := b.Start(); err != nil {
t.Fatalf("Failed to start broadcaster: %v", err)
}
defer b.Stop()
server := httptest.NewServer(b.Handler())
defer server.Close()
// Connect client
resp, err := http.Get(server.URL)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer resp.Body.Close()
// Wait for client to register
time.Sleep(50 * time.Millisecond)
// Broadcast an event
testData := map[string]string{"message": "hello"}
if err := b.BroadcastEvent("test", testData); err != nil {
t.Fatalf("Failed to broadcast: %v", err)
}
// Read and verify
reader := bufio.NewReader(resp.Body)
// Skip connection event
for i := 0; i < 3; i++ {
reader.ReadString('\n')
}
// Read broadcast event
line, _ := reader.ReadString('\n')
if !strings.HasPrefix(line, "data:") {
t.Errorf("Expected data line, got: %s", line)
}
// Parse the data
dataStr := strings.TrimPrefix(line, "data: ")
dataStr = strings.TrimSpace(dataStr)
var event SSEEvent
if err := json.Unmarshal([]byte(dataStr), &event); err != nil {
t.Fatalf("Failed to parse event: %v", err)
}
if event.Event != "test" {
t.Errorf("Expected event type 'test', got '%s'", event.Event)
}
}
func TestSSEBroadcaster_ClientCount(t *testing.T) {
b := NewSSEBroadcaster()
if err := b.Start(); err != nil {
t.Fatalf("Failed to start broadcaster: %v", err)
}
defer b.Stop()
server := httptest.NewServer(b.Handler())
defer server.Close()
if count := b.ClientCount(); count != 0 {
t.Errorf("Expected 0 clients, got %d", count)
}
// Connect a client
resp, err := http.Get(server.URL)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
// Wait for registration
time.Sleep(50 * time.Millisecond)
if count := b.ClientCount(); count != 1 {
t.Errorf("Expected 1 client, got %d", count)
}
resp.Body.Close()
// Wait for unregistration
time.Sleep(50 * time.Millisecond)
if count := b.ClientCount(); count != 0 {
t.Errorf("Expected 0 clients after disconnect, got %d", count)
}
}
+47
View File
@@ -0,0 +1,47 @@
// Package web provides web based micro services
package web
import (
"context"
"net/http"
"time"
"github.com/google/uuid"
)
// Service is a web service with service discovery built in.
type Service interface {
Client() *http.Client
Init(opts ...Option) error
Options() Options
Handle(pattern string, handler http.Handler)
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
Start() error
Stop() error
Run() error
}
// Option for web.
type Option func(o *Options)
// Web basic Defaults.
var (
// For serving.
DefaultName = "go-web"
DefaultVersion = "latest"
DefaultId = uuid.New().String()
DefaultAddress = ":0"
// for registration.
DefaultRegisterTTL = time.Second * 90
DefaultRegisterInterval = time.Second * 30
// static directory.
DefaultStaticDir = "html"
DefaultRegisterCheck = func(context.Context) error { return nil }
)
// NewService returns a new web.Service.
func NewService(opts ...Option) Service {
return newService(opts...)
}
+68
View File
@@ -0,0 +1,68 @@
package web_test
import (
"context"
"sync"
"testing"
"time"
"github.com/urfave/cli/v2"
"go-micro.dev/v6"
"go-micro.dev/v6/logger"
"go-micro.dev/v6/web"
)
func TestWeb(t *testing.T) {
for i := 0; i < 10; i++ {
testFunc()
}
}
func testFunc() {
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*250)
defer cancel()
service := micro.NewService("test", micro.Context(ctx),
micro.HandleSignal(false),
micro.Flags(
&cli.StringFlag{
Name: "test.timeout",
},
&cli.BoolFlag{
Name: "test.v",
},
&cli.StringFlag{
Name: "test.run",
},
&cli.StringFlag{
Name: "test.testlogfile",
},
),
)
w := web.NewService(
web.MicroService(service),
web.Context(ctx),
web.HandleSignal(false),
)
// s.Init()
// w.Init()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
err := service.Run()
if err != nil {
logger.Logf(logger.ErrorLevel, "micro run error: %v", err)
}
}()
go func() {
defer wg.Done()
err := w.Run()
if err != nil {
logger.Logf(logger.ErrorLevel, "web run error: %v", err)
}
}()
wg.Wait()
}