Added an events bus, and pushed OpportunitiesUpdated to WebSocket clients when the fetcher stores new opportunities
internal/events decouples the parts of JobSearch that cause events from
the parts that react to them. bus.go has Bus: Subscribe, and a synchronous
Publish that calls each handler in turn on the publisher's goroutine, so
handlers mustn't block (the hub doesn't: it queues per client). events.go
has the event types and their payloads, starting with OpportunitiesUpdated.
With fetcher.WithEvents, a save that stores new opportunities, or new
releases of stored ones, publishes OpportunitiesUpdated with their UUIDs and
counts. An opportunity whose row is only rewritten, as when it is fetched
again from the newest stored release, isn't counted as updated, and nothing
is published when nothing changed. store.SaveResult now reports
NewOpportunity for each opportunity saved.
job-search serve subscribes its WebSocket hub to the bus, so every client
gets {"type":"event","eventType":"OpportunitiesUpdated","payload":{"new":
[...],"updated":[...],"summary":{"new":n,"updated":n}}}. The Envelope gains
an eventType field, and Hub.PushEvent and Server.PushEvent send events.
This commit is contained in:
1 parent
284a589627
commit
6e101d2b4d
13 files changed
+448
-26
No files matched your search
@@ -143,6 +143,38 @@ func TestPushReachesBothTransports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// PushEvent sends a TypeEvent message naming the event, to both transports.
|
||||
func TestPushEvent(t *testing.T) {
|
||||
h, addr := tcpHub(t, nil)
|
||||
url := wsHub(t, h)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
conn := dialWS(t, ctx, url)
|
||||
c := dialTCP(t, addr)
|
||||
c.read() // welcome
|
||||
waitCount(t, h, 2)
|
||||
|
||||
h.PushEvent("OpportunitiesUpdated", map[string]int{"new": 1})
|
||||
var e Envelope
|
||||
if err := wsjson.Read(ctx, conn, &e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
received := map[string]Envelope{
|
||||
"websocket": e,
|
||||
"tcp": c.read(),
|
||||
}
|
||||
for transport, got := range received {
|
||||
if got.Type != TypeEvent || got.EventType != "OpportunitiesUpdated" ||
|
||||
string(got.Payload) != `{"new":1}` {
|
||||
t.Errorf(
|
||||
"%s: got %+v; want the OpportunitiesUpdated event",
|
||||
transport, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A payload that can't be marshalled is not sent.
|
||||
func TestPushBadPayload(t *testing.T) {
|
||||
h, addr := tcpHub(t, nil)
|
||||
|
||||
@@ -78,21 +78,46 @@ func (h *Hub) logger() *slog.Logger {
|
||||
// whose queue is full is closed as a slow consumer. A payload that can't be
|
||||
// marshalled is logged and nothing is sent.
|
||||
func (h *Hub) Push(msgType string, payload any) {
|
||||
var rawPayload json.RawMessage
|
||||
h.push(
|
||||
Envelope{
|
||||
Type: msgType,
|
||||
},
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
// PushEvent sends a TypeEvent message to every client, as Push does, with
|
||||
// eventType as the envelope's EventType.
|
||||
func (h *Hub) PushEvent(eventType string, payload any) {
|
||||
h.push(
|
||||
Envelope{
|
||||
Type: TypeEvent,
|
||||
EventType: eventType,
|
||||
},
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
// push sends envelope, with payload marshalled into it, to every client (see
|
||||
// Push).
|
||||
func (h *Hub) push(envelope Envelope, payload any) {
|
||||
if payload != nil {
|
||||
var err error
|
||||
if rawPayload, err = json.Marshal(payload); err != nil {
|
||||
h.logger().Error("marshal push payload", "type", msgType, "error", err)
|
||||
envelope.Payload, err = json.Marshal(payload)
|
||||
if err != nil {
|
||||
h.logger().Error(
|
||||
"marshal push payload",
|
||||
"type", envelope.Type,
|
||||
"event_type", envelope.EventType,
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := json.Marshal(Envelope{
|
||||
Type: msgType,
|
||||
Payload: rawPayload,
|
||||
})
|
||||
msg, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
h.logger().Error("marshal push", "type", msgType, "error", err)
|
||||
h.logger().Error("marshal push", "type", envelope.Type, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -19,23 +19,27 @@
|
||||
// {"type":"echo","id":"2","payload":{...}}
|
||||
// {"type":"error","id":"3","error":"..."}
|
||||
// {"type":"item.created","payload":{...}}
|
||||
// {"type":"event","eventType":"OpportunitiesUpdated","payload":{...}}
|
||||
//
|
||||
// Any other type from a client gets an "unknown message type" error. The last
|
||||
// message is one sent by Hub.Push: its type is the pushed message type.
|
||||
// Any other type from a client gets an "unknown message type" error. The
|
||||
// last two are sent by Hub.Push, with the pushed message type, and by
|
||||
// Hub.PushEvent, with TypeEvent and the event's type (see package events).
|
||||
package websocket
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Envelope is the wire format for every message in both directions.
|
||||
// EventType is set only on a TypeEvent message, naming the event.
|
||||
type Envelope struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Type string `json:"type"`
|
||||
EventType string `json:"eventType,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Envelope types. TypeEvent, TypeRequest and TypeAck are reserved: nothing
|
||||
// sends or handles them yet.
|
||||
// Envelope types. TypeRequest and TypeAck are reserved: nothing sends or
|
||||
// handles them yet.
|
||||
const (
|
||||
// TypeWelcome is sent to a client when it connects, with its client ID.
|
||||
TypeWelcome = "welcome"
|
||||
@@ -45,7 +49,8 @@ const (
|
||||
TypePong = "pong"
|
||||
// TypeEcho is sent back to the client with the same ID and payload.
|
||||
TypeEcho = "echo"
|
||||
// TypeEvent is reserved for events pushed to clients.
|
||||
// TypeEvent is an event pushed to clients by Hub.PushEvent, named by the
|
||||
// envelope's EventType.
|
||||
TypeEvent = "event"
|
||||
// TypeRequest is reserved for requests from clients.
|
||||
TypeRequest = "request"
|
||||
|
||||
Reference in new issue
Block a user