2026-08-24 11:53:36 +01:00
|
|
|
package messaging
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"sync"
|
|
|
|
|
|
|
|
|
|
"github.com/logos-messaging/logos-delivery-go-bindings/internal/ffi"
|
2026-08-26 00:07:24 +01:00
|
|
|
"github.com/logos-messaging/logos-delivery-go-bindings/pkg/kernel"
|
2026-08-24 11:53:36 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// eventBufferSize bounds the buffered Events channel. Events are dropped rather
|
|
|
|
|
// than blocked when a consumer falls behind, so the library's event thread is
|
|
|
|
|
// never stalled by a slow reader.
|
|
|
|
|
const eventBufferSize = 1024
|
|
|
|
|
|
2026-08-26 00:07:24 +01:00
|
|
|
// ErrClosed is returned by operations on a MessagingClient whose node has been
|
2026-08-24 11:53:36 +01:00
|
|
|
// closed.
|
2026-08-26 00:07:24 +01:00
|
|
|
var ErrClosed = kernel.ErrClosed
|
2026-08-24 11:53:36 +01:00
|
|
|
|
|
|
|
|
// MessagingClient is a logos-delivery Messaging API client, mirroring the Nim
|
2026-08-26 00:07:24 +01:00
|
|
|
// MessagingClient: it drives a node and exposes the messaging surface over it —
|
2026-08-24 11:53:36 +01:00
|
|
|
// subscribe, unsubscribe, send, and a stream of delivery events.
|
|
|
|
|
//
|
2026-08-26 00:07:24 +01:00
|
|
|
// The node is reached through Node, so the kernel protocols run against the
|
|
|
|
|
// same node the client sends on.
|
|
|
|
|
//
|
2026-08-24 11:53:36 +01:00
|
|
|
// The lifecycle is New -> Start -> ... -> Stop -> Close. Consume Events()
|
|
|
|
|
// concurrently for the whole lifetime; it is closed by Close.
|
|
|
|
|
//
|
|
|
|
|
// A MessagingClient is safe for concurrent use.
|
|
|
|
|
type MessagingClient struct {
|
2026-08-26 00:07:24 +01:00
|
|
|
node *kernel.Node
|
|
|
|
|
h ffi.Handle
|
2026-08-24 11:53:36 +01:00
|
|
|
|
|
|
|
|
events chan Event
|
|
|
|
|
|
|
|
|
|
// mu guards closed and serialises it against the event callbacks, which
|
|
|
|
|
// run on the library's event thread. It is only ever held briefly.
|
2026-08-26 00:07:24 +01:00
|
|
|
mu sync.RWMutex
|
|
|
|
|
closed bool
|
2026-08-24 11:53:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// New creates a node from cfg and wires up its event stream. The node is not
|
|
|
|
|
// started yet: call Start. Release it with Close, started or not.
|
|
|
|
|
func New(cfg Config) (*MessagingClient, error) {
|
2026-08-26 00:07:24 +01:00
|
|
|
node, err := kernel.New(cfg)
|
2026-08-24 11:53:36 +01:00
|
|
|
if err != nil {
|
2026-08-26 00:07:24 +01:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return Attach(node)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Attach adds the Messaging API event stream to an existing node, for a caller
|
|
|
|
|
// that built the node itself. The client takes over the node's lifetime:
|
|
|
|
|
// closing either one closes both.
|
|
|
|
|
func Attach(node *kernel.Node) (*MessagingClient, error) {
|
|
|
|
|
c := &MessagingClient{
|
|
|
|
|
node: node,
|
|
|
|
|
h: kernel.Handle(node),
|
|
|
|
|
events: make(chan Event, eventBufferSize),
|
2026-08-24 11:53:36 +01:00
|
|
|
}
|
|
|
|
|
|
2026-08-26 00:07:24 +01:00
|
|
|
// Runs after the node drops its listeners, so nothing sends afterwards.
|
|
|
|
|
node.OnClose(func() {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
c.closed = true
|
|
|
|
|
close(c.events)
|
|
|
|
|
})
|
2026-08-24 11:53:36 +01:00
|
|
|
|
|
|
|
|
// Register before Start so no event emitted during startup is missed.
|
|
|
|
|
for _, name := range messagingEvents() {
|
2026-08-26 00:07:24 +01:00
|
|
|
if _, err := node.AddEventListener(name, c.onEvent); err != nil {
|
|
|
|
|
_ = node.Close()
|
|
|
|
|
return nil, err
|
2026-08-24 11:53:36 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return c, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 00:07:24 +01:00
|
|
|
// Node returns the node this client drives, so the kernel protocols can be
|
|
|
|
|
// used against it. Its lifetime is the client's: Close either one and both are
|
|
|
|
|
// done.
|
|
|
|
|
func (c *MessagingClient) Node() *kernel.Node { return c.node }
|
|
|
|
|
|
2026-08-24 11:53:36 +01:00
|
|
|
// onEvent runs on the library's event thread. It must not block, so a decoded
|
|
|
|
|
// event is dropped when the Events channel is full.
|
2026-08-26 00:07:24 +01:00
|
|
|
func (c *MessagingClient) onEvent(msg string) {
|
2026-08-24 11:53:36 +01:00
|
|
|
ev, err := decodeEvent(msg)
|
|
|
|
|
if err != nil || ev == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.mu.RLock()
|
|
|
|
|
defer c.mu.RUnlock()
|
|
|
|
|
if c.closed {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
select {
|
|
|
|
|
case c.events <- ev:
|
|
|
|
|
default:
|
|
|
|
|
// Consumer is not keeping up. Dropping is the contract: blocking here
|
|
|
|
|
// would stall the library's event thread.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Events returns the stream of Messaging API events. Type-switch over the
|
|
|
|
|
// concrete Event types. The channel is closed by Close.
|
|
|
|
|
func (c *MessagingClient) Events() <-chan Event { return c.events }
|
|
|
|
|
|
|
|
|
|
// Start starts the node's protocols and Messaging API services.
|
2026-08-26 00:07:24 +01:00
|
|
|
func (c *MessagingClient) Start() error { return c.node.Start() }
|
2026-08-24 11:53:36 +01:00
|
|
|
|
|
|
|
|
// Stop stops the node. A stopped client can be started again.
|
2026-08-26 00:07:24 +01:00
|
|
|
func (c *MessagingClient) Stop() error { return c.node.Stop() }
|
2026-08-24 11:53:36 +01:00
|
|
|
|
|
|
|
|
// Close releases the node and closes the Events channel. It is idempotent, and
|
|
|
|
|
// tears a running node down, so Stop beforehand is optional. The client must
|
|
|
|
|
// not be used afterwards, and no other method may be in flight when it is
|
|
|
|
|
// called.
|
2026-08-26 00:07:24 +01:00
|
|
|
func (c *MessagingClient) Close() error { return c.node.Close() }
|
2026-08-24 11:53:36 +01:00
|
|
|
|
|
|
|
|
// Subscribe starts receiving messages published on a content topic. They arrive
|
|
|
|
|
// as MessageReceivedEvent on Events().
|
|
|
|
|
func (c *MessagingClient) Subscribe(topic ContentTopic) error {
|
|
|
|
|
if err := c.check(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := ffi.Subscribe(c.h, topic); err != nil {
|
|
|
|
|
return fmt.Errorf("messaging: subscribe %q: %w", topic, err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unsubscribe stops receiving messages published on a content topic.
|
|
|
|
|
func (c *MessagingClient) Unsubscribe(topic ContentTopic) error {
|
|
|
|
|
if err := c.check(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := ffi.Unsubscribe(c.h, topic); err != nil {
|
|
|
|
|
return fmt.Errorf("messaging: unsubscribe %q: %w", topic, err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// wireEnvelope is the send call's JSON shape: the C surface reads exactly these
|
|
|
|
|
// three fields, with the payload base64-encoded.
|
|
|
|
|
type wireEnvelope struct {
|
|
|
|
|
ContentTopic string `json:"contentTopic"`
|
|
|
|
|
Payload string `json:"payload"`
|
|
|
|
|
Ephemeral bool `json:"ephemeral"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send publishes payload on contentTopic and returns the RequestID that
|
|
|
|
|
// correlates it with the MessageSentEvent, MessagePropagatedEvent or
|
|
|
|
|
// MessageErrorEvent it produces. An ephemeral message is transient, so stores
|
|
|
|
|
// do not retain it.
|
|
|
|
|
//
|
|
|
|
|
// Returning marks the message accepted by the send service, not delivered:
|
|
|
|
|
// delivery is reported on Events(). If ctx is cancelled while the library is
|
|
|
|
|
// still working, Send returns ctx.Err() and the message may still go out.
|
|
|
|
|
func (c *MessagingClient) Send(
|
|
|
|
|
ctx context.Context, contentTopic ContentTopic, payload []byte, ephemeral bool,
|
|
|
|
|
) (RequestID, error) {
|
|
|
|
|
if err := c.check(); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msg, err := json.Marshal(wireEnvelope{
|
|
|
|
|
ContentTopic: contentTopic,
|
|
|
|
|
Payload: base64.StdEncoding.EncodeToString(payload),
|
|
|
|
|
Ephemeral: ephemeral,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("messaging: marshal message: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type result struct {
|
|
|
|
|
id string
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
// Buffered: the call outlives a cancelled ctx, and must not block on exit.
|
|
|
|
|
done := make(chan result, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
id, err := ffi.Send(c.h, string(msg))
|
|
|
|
|
done <- result{id, err}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return "", ctx.Err()
|
|
|
|
|
case r := <-done:
|
|
|
|
|
if r.err != nil {
|
|
|
|
|
return "", fmt.Errorf("messaging: send: %w", r.err)
|
|
|
|
|
}
|
|
|
|
|
return RequestID(r.id), nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// check reports whether the client is still usable.
|
|
|
|
|
func (c *MessagingClient) check() error {
|
|
|
|
|
c.mu.RLock()
|
|
|
|
|
defer c.mu.RUnlock()
|
|
|
|
|
if c.closed {
|
|
|
|
|
return ErrClosed
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|