pgbus 0.9.0 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/pgbus/streams.rb CHANGED
@@ -15,6 +15,12 @@ module Pgbus
15
15
  # this specifically can rescue Pgbus::Streams::StreamNameTooLong.
16
16
  class StreamNameTooLong < ArgumentError; end
17
17
 
18
+ # The default SSE `event:` name for a broadcast frame. Turbo's
19
+ # StreamObserver consumes frames the client re-dispatches as the
20
+ # `message` DOM event; the client maps this SSE event name to
21
+ # `message`. Broadcasts may override it with a typed name (issue #170).
22
+ DEFAULT_SSE_EVENT = "turbo-stream"
23
+
18
24
  # Process-wide registry of server-side audience filter predicates.
19
25
  # Register filters at boot time via:
20
26
  # Pgbus::Streams.filters.register(:admin_only) { |user| user.admin? }
@@ -28,6 +34,25 @@ module Pgbus
28
34
  @filters = nil
29
35
  end
30
36
 
37
+ # Process-wide publish-side coalescer for high-frequency broadcasts
38
+ # (issue #171). Lazily built; the flush re-enters the normal broadcast
39
+ # path so a coalesced frame is just a deferred ordinary broadcast.
40
+ def self.coalescer
41
+ # `target:` is part of the flush signature but unused here — it's only
42
+ # a coalescing key; the target is already encoded in the payload's
43
+ # turbo-stream tag. Absorbed via ** so it isn't a broadcast argument.
44
+ @coalescer ||= Coalescer.new(
45
+ flush: lambda do |stream_name:, payload:, opts:, **|
46
+ Pgbus.stream(stream_name).broadcast(payload, **opts)
47
+ end
48
+ )
49
+ end
50
+
51
+ # Clears the coalescer. Used by tests; not intended for runtime.
52
+ def self.reset_coalescer!
53
+ @coalescer = nil
54
+ end
55
+
31
56
  # A handle on a single logical stream. The name can be any string, an
32
57
  # object responding to `to_gid_param`, or an array of streamables (which
33
58
  # are joined with colons — turbo-rails-compatible).
@@ -76,9 +101,48 @@ module Pgbus
76
101
  # Per-broadcast `durable:` overrides the stream-level default for a
77
102
  # single broadcast. `nil` (the default) defers to the stream's own
78
103
  # `durable?` setting; `true`/`false` flip the mode for this call only.
79
- def broadcast(payload, visible_to: nil, durable: nil)
104
+ #
105
+ # Actor-echo suppression: pass `exclude:` with the broadcaster's own
106
+ # SSE connection id (surfaced to the page as
107
+ # `<meta name="pgbus-connection-id">` / the element's `connection-id`
108
+ # attribute, sent back on the action request as the
109
+ # `X-Pgbus-Connection` header). The dispatcher skips delivery to that
110
+ # one connection, so the actor doesn't receive the echo of its own
111
+ # broadcast — it already applied the change via the action's HTTP
112
+ # response. Everyone else gets the broadcast. A nil/blank `exclude`
113
+ # is a no-op (the common path).
114
+ # Typed SSE event name: pass `event:` to set the SSE `event:` field
115
+ # on the delivered frame (e.g. `event: "presence"`, `event:
116
+ # "reactive"`) while keeping the payload a Turbo Stream. Clients that
117
+ # care can route on the typed event without sniffing the HTML;
118
+ # default consumers still receive the standard turbo-stream/`message`
119
+ # path. The default (`nil` or `"turbo-stream"`) is not carried on the
120
+ # wire — it's the implicit default the dispatcher applies.
121
+ # High-frequency coalescing: pass `coalesce:` (a window in milliseconds,
122
+ # or `true` for the default window) together with `target:` to batch
123
+ # rapid broadcasts to the same (stream, target) and publish only the
124
+ # latest within the window. Superseded frames never reach the bus.
125
+ # Last-write-wins, so this is only safe for idempotent replace/update
126
+ # of a stable target (the high-frequency case: cursors, typing,
127
+ # progress). Returns nil — the actual broadcast is deferred to the
128
+ # coalescer's flush. See issue #171 and Pgbus::Streams::Coalescer.
129
+ def broadcast(payload, visible_to: nil, durable: nil, exclude: nil, event: nil, coalesce: nil, target: nil)
130
+ if coalesce
131
+ return coalesce_broadcast(
132
+ payload,
133
+ coalesce: coalesce,
134
+ target: target,
135
+ visible_to: visible_to,
136
+ durable: durable,
137
+ exclude: exclude,
138
+ event: event
139
+ )
140
+ end
141
+
80
142
  wrapped = { "html" => payload.to_s }
81
143
  wrapped["visible_to"] = visible_to.to_s if visible_to
144
+ wrapped["exclude"] = exclude.to_s if exclude && !exclude.to_s.empty?
145
+ wrapped["event"] = event.to_s if event && event.to_s != DEFAULT_SSE_EVENT
82
146
 
83
147
  use_durable = durable.nil? ? @durable : durable
84
148
  return broadcast_ephemeral(wrapped) unless use_durable
@@ -101,6 +165,36 @@ module Pgbus
101
165
  end
102
166
  end
103
167
 
168
+ # Renders a renderable (a Phlex component, a ViewComponent, or a
169
+ # pre-rendered HTML string) into a complete `<turbo-stream>` action
170
+ # tag and broadcasts it atomically — the render and the broadcast in
171
+ # one call. This removes the #1 footgun in server-driven UI: building
172
+ # the off-request render context and the turbo-stream wrapper by hand
173
+ # at every call site.
174
+ #
175
+ # Pgbus.stream("chat", room).broadcast_render(
176
+ # renderable: Chat::Message.new(chat_message: msg),
177
+ # action: :append, target: "chat-messages-#{room}",
178
+ # exclude: connection_id # composes with #165
179
+ # )
180
+ #
181
+ # `action` defaults to :replace. `target` is required. `exclude:`,
182
+ # `visible_to:`, and `durable:` are forwarded to #broadcast unchanged,
183
+ # so actor-echo suppression and audience filtering compose. Returns
184
+ # whatever #broadcast returns (msg_id, or nil when deferred to
185
+ # after_commit inside a transaction).
186
+ #
187
+ # See Pgbus::Streams::Renderer for the renderable-resolution order.
188
+ # Components that need URL helpers or a full view context should be
189
+ # rendered by the app (which has the request context) and the
190
+ # resulting string passed as `renderable:`.
191
+ def broadcast_render(target:, action: :replace, renderable: nil, visible_to: nil, durable: nil, exclude: nil,
192
+ event: nil, coalesce: nil)
193
+ html = Renderer.turbo_stream_tag(action: action, target: target, renderable: renderable)
194
+ broadcast(html, visible_to: visible_to, durable: durable, exclude: exclude, event: event,
195
+ coalesce: coalesce, target: target)
196
+ end
197
+
104
198
  def current_msg_id
105
199
  @client.stream_current_msg_id(@name)
106
200
  end
@@ -172,6 +266,61 @@ module Pgbus
172
266
  nil
173
267
  end
174
268
 
269
+ # Submits a frame to the process-wide coalescer instead of
270
+ # broadcasting now. Requires a target (the dedupe key — there's no
271
+ # way to last-write-win without one). The window is `coalesce` in ms
272
+ # when numeric, else the coalescer's default. Returns nil; the
273
+ # coalescer flushes the latest frame as an ordinary broadcast.
274
+ def coalesce_broadcast(payload, coalesce:, target:, visible_to:, durable:, exclude:, event:)
275
+ if target.nil? || target.to_s.empty?
276
+ raise ArgumentError,
277
+ "broadcast(coalesce:) requires target: — coalescing keys on (stream, target), " \
278
+ "so the latest frame per target can win. Use broadcast_render(coalesce:) which " \
279
+ "already has a target, or pass target: explicitly."
280
+ end
281
+
282
+ window_ms = coalesce_window_ms(coalesce)
283
+ # Resolve durability against THIS stream instance now, not nil. The
284
+ # coalescer's flush re-enters Pgbus.stream(@name), whose durability
285
+ # could resolve differently (config patterns) than this instance —
286
+ # passing the resolved value keeps the coalesced frame's mode stable.
287
+ resolved_durable = durable.nil? ? @durable : durable
288
+
289
+ submit = lambda do
290
+ Streams.coalescer.submit(
291
+ stream_name: @name,
292
+ target: target.to_s,
293
+ payload: payload.to_s,
294
+ window_ms: window_ms,
295
+ opts: { visible_to: visible_to, durable: resolved_durable, exclude: exclude, event: event }
296
+ )
297
+ end
298
+
299
+ # Transaction gating: a coalesced DURABLE broadcast made inside an
300
+ # open transaction must not flush if the transaction rolls back, so
301
+ # defer the submission to after_commit (mirrors the non-coalesced
302
+ # path). Ephemeral coalescing is fire-and-forget, so it submits now.
303
+ transaction = resolved_durable ? current_open_transaction : nil
304
+ if transaction
305
+ transaction.after_commit { submit.call }
306
+ else
307
+ submit.call
308
+ end
309
+ nil
310
+ end
311
+
312
+ # Resolves the coalescing window in ms. `true` → the default window;
313
+ # a positive Numeric is used as-is. Anything else is a misuse and is
314
+ # rejected at the API boundary with an actionable error rather than a
315
+ # cryptic NoMethodError deep inside the coalescer's timer math.
316
+ def coalesce_window_ms(coalesce)
317
+ return Coalescer::DEFAULT_WINDOW_MS if coalesce == true
318
+ return coalesce if coalesce.is_a?(Numeric) && coalesce.positive?
319
+
320
+ raise ArgumentError,
321
+ "coalesce: must be true or a positive Numeric window in milliseconds, got #{coalesce.inspect}"
322
+ end
323
+
175
324
  def ensure_queue!
176
325
  return if @ensured
177
326
 
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.9.0"
4
+ VERSION = "0.9.3"
5
5
  end
@@ -117,10 +117,11 @@ module Pgbus
117
117
  hijack_result = env["rack.hijack"].call
118
118
  io = hijack_result || env["rack.hijack_io"]
119
119
 
120
- write_headers(io, stream_name: stream_name, since_id: since_id)
120
+ connection_id = SecureRandom.hex(8)
121
+ write_headers(io, stream_name: stream_name, since_id: since_id, connection_id: connection_id)
121
122
 
122
123
  connection = Pgbus::Web::Streamer::Connection.new(
123
- id: SecureRandom.hex(8),
124
+ id: connection_id,
124
125
  stream_name: stream_name,
125
126
  io: io,
126
127
  since_id: since_id,
@@ -138,14 +139,16 @@ module Pgbus
138
139
  require_relative "streamer/falcon_connection" unless defined?(Pgbus::Web::Streamer::FalconConnection)
139
140
 
140
141
  body = ::Protocol::HTTP::Body::Writable.new
142
+ connection_id = SecureRandom.hex(8)
141
143
 
142
144
  body.write(Pgbus::Streams::Envelope.retry_directive(2_000))
143
145
  body.write(Pgbus::Streams::Envelope.comment(
144
146
  "pgbus stream open since_id=#{since_id} stream=#{stream_name}"
145
147
  ))
148
+ body.write(Pgbus::Streams::Envelope.connected(id: connection_id))
146
149
 
147
150
  connection = Pgbus::Web::Streamer::FalconConnection.new(
148
- id: SecureRandom.hex(8),
151
+ id: connection_id,
149
152
  stream_name: stream_name,
150
153
  body: body,
151
154
  since_id: since_id,
@@ -165,10 +168,11 @@ module Pgbus
165
168
  }
166
169
  end
167
170
 
168
- def write_headers(io, stream_name:, since_id:)
171
+ def write_headers(io, stream_name:, since_id:, connection_id:)
169
172
  io.write(Pgbus::Streams::Envelope.http_response_headers)
170
173
  io.write(Pgbus::Streams::Envelope.retry_directive(2_000))
171
174
  io.write(Pgbus::Streams::Envelope.comment("pgbus stream open since_id=#{since_id} stream=#{stream_name}"))
175
+ io.write(Pgbus::Streams::Envelope.connected(id: connection_id))
172
176
  end
173
177
 
174
178
  def streamer
@@ -14,6 +14,10 @@ module Pgbus
14
14
  # the client-side leg of the replay-race fix (§6.5 of the design doc).
15
15
  class Connection
16
16
  attr_reader :id, :stream_name, :io, :mutex, :last_msg_id_sent, :context
17
+ # The presence member id this connection auto-joined as, or nil for
18
+ # non-presence streams / anonymous connections. Set by the
19
+ # Dispatcher on connect; read on disconnect and heartbeat touch.
20
+ attr_accessor :presence_member
17
21
 
18
22
  def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil)
19
23
  @id = id
@@ -25,6 +29,7 @@ module Pgbus
25
29
  @mutex = Mutex.new
26
30
  @dead = false
27
31
  @closed = false
32
+ @presence_member = nil
28
33
  @created_at = monotonic
29
34
  @last_write_at = @created_at
30
35
  # Context is whatever the StreamApp's authorize hook returned
@@ -43,7 +48,7 @@ module Pgbus
43
48
 
44
49
  bytes = Pgbus::Streams::Envelope.message(
45
50
  id: envelope.msg_id,
46
- event: "turbo-stream",
51
+ event: sse_event_for(envelope),
47
52
  data: envelope.payload
48
53
  )
49
54
 
@@ -115,6 +120,15 @@ module Pgbus
115
120
 
116
121
  private
117
122
 
123
+ # The SSE event name for a frame: the envelope's typed event when
124
+ # present, else the default turbo-stream. Plain ReadAfter::Envelopes
125
+ # (no event field) and StreamEnvelopes with a nil event both fall
126
+ # back to the default. (issue #170)
127
+ def sse_event_for(envelope)
128
+ event = envelope.respond_to?(:event) ? envelope.event : nil
129
+ event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
130
+ end
131
+
118
132
  def monotonic
119
133
  # Qualify ::Process because Pgbus::Process already exists as a
120
134
  # namespace for worker/supervisor/consumer and would otherwise
@@ -13,6 +13,7 @@ module Pgbus
13
13
  # is fiber-safe under Falcon's scheduler.
14
14
  class FalconConnection
15
15
  attr_reader :id, :stream_name, :io, :mutex, :last_msg_id_sent, :context
16
+ attr_accessor :presence_member
16
17
 
17
18
  def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil)
18
19
  @id = id
@@ -24,6 +25,7 @@ module Pgbus
24
25
  @mutex = Mutex.new
25
26
  @dead = false
26
27
  @closed = false
28
+ @presence_member = nil
27
29
  @created_at = monotonic
28
30
  @last_write_at = @created_at
29
31
  @context = context
@@ -36,7 +38,7 @@ module Pgbus
36
38
 
37
39
  bytes = Pgbus::Streams::Envelope.message(
38
40
  id: envelope.msg_id,
39
- event: "turbo-stream",
41
+ event: sse_event_for(envelope),
40
42
  data: envelope.payload
41
43
  )
42
44
 
@@ -94,6 +96,12 @@ module Pgbus
94
96
 
95
97
  private
96
98
 
99
+ # See Connection#sse_event_for. (issue #170)
100
+ def sse_event_for(envelope)
101
+ event = envelope.respond_to?(:event) ? envelope.event : nil
102
+ event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
103
+ end
104
+
97
105
  def write_to_body(bytes)
98
106
  @mutex.synchronize do
99
107
  return :closed if @dead || @body.closed?
@@ -61,6 +61,7 @@ module Pgbus
61
61
  # code goes through the background thread.
62
62
  def tick
63
63
  now = @clock.call
64
+ presence_conns = []
64
65
  @registry.each_connection do |connection|
65
66
  if connection.dead?
66
67
  # Already dead (e.g. IoWriter returned :closed on a previous
@@ -76,8 +77,15 @@ module Pgbus
76
77
  end
77
78
 
78
79
  result = connection.write_comment("heartbeat #{now.to_i}")
79
- enqueue_disconnect(connection) if connection.dead? || result != :ok
80
+ if connection.dead? || result != :ok
81
+ enqueue_disconnect(connection)
82
+ next
83
+ end
84
+
85
+ presence_conns << connection if presence_member?(connection)
80
86
  end
87
+
88
+ enqueue_presence_touch(presence_conns)
81
89
  end
82
90
 
83
91
  private
@@ -99,6 +107,20 @@ module Pgbus
99
107
  def enqueue_disconnect(connection)
100
108
  @queue << StreamEventDispatcher::DisconnectMessage.new(connection: connection)
101
109
  end
110
+
111
+ def presence_member?(connection)
112
+ connection.respond_to?(:presence_member) && !connection.presence_member.nil?
113
+ end
114
+
115
+ # Posts one batched touch per tick so the dispatcher (which owns AR
116
+ # connection release) refreshes last_seen_at for live presence
117
+ # members, keeping them out of the sweeper. No message when no
118
+ # connection has presence — avoids waking the dispatcher for nothing.
119
+ def enqueue_presence_touch(connections)
120
+ return if connections.empty?
121
+
122
+ @queue << StreamEventDispatcher::PresenceTouchMessage.new(connections: connections)
123
+ end
102
124
  end
103
125
  end
104
126
  end
@@ -32,18 +32,34 @@ module Pgbus
32
32
  # disambiguate from Pgbus::Process::Dispatcher, which is an
33
33
  # unrelated worker-side pool coordinator. See issue #98 item 8.
34
34
  class StreamEventDispatcher
35
- WakeMessage = Listener::WakeMessage
36
- ConnectMessage = Data.define(:connection)
37
- DisconnectMessage = Data.define(:connection)
35
+ WakeMessage = Listener::WakeMessage
36
+ ConnectMessage = Data.define(:connection)
37
+ DisconnectMessage = Data.define(:connection)
38
+ # Posted by the Heartbeat once per tick with the current presence
39
+ # connections, so the touch (a last_seen_at refresh) runs on the
40
+ # dispatcher thread where AR connections are released each pass.
41
+ PresenceTouchMessage = Data.define(:connections)
38
42
 
39
43
  # An unwrapped stream broadcast. Similar shape to
40
44
  # Pgbus::Client::ReadAfter::Envelope (msg_id + payload) so
41
45
  # Connection#enqueue can consume either type via duck typing,
42
- # but adds the `visible_to` label carried through from
43
- # Pgbus::Streams::Stream#broadcast. The Dispatcher uses
44
- # visible_to to decide per-connection delivery; Connection
45
- # never sees the field.
46
- StreamEnvelope = Data.define(:msg_id, :enqueued_at, :payload, :source, :visible_to)
46
+ # but adds two delivery-control fields carried through from
47
+ # Pgbus::Streams::Stream#broadcast:
48
+ # - `visible_to` audience filter label (evaluated per-connection)
49
+ # - `exclude` — a connection id to skip (actor-echo suppression:
50
+ # the broadcaster's own SSE connection does not
51
+ # receive the echo of its own broadcast)
52
+ # The Dispatcher uses both to decide per-connection delivery;
53
+ # Connection never sees either field.
54
+ # - `event` — the SSE `event:` name for the delivered frame.
55
+ # nil means the default (turbo-stream); a typed
56
+ # name (e.g. "presence", "reactive") lets clients
57
+ # route without sniffing the HTML (issue #170).
58
+ StreamEnvelope = Data.define(:msg_id, :enqueued_at, :payload, :source, :visible_to, :exclude, :event) do
59
+ def initialize(msg_id:, enqueued_at:, payload:, source:, visible_to: nil, exclude: nil, event: nil)
60
+ super
61
+ end
62
+ end
47
63
 
48
64
  DEFAULT_READ_LIMIT = 500
49
65
 
@@ -51,13 +67,18 @@ module Pgbus
51
67
 
52
68
  def initialize(client:, registry:, listener:, dispatch_queue:,
53
69
  logger: Pgbus.logger, read_limit: DEFAULT_READ_LIMIT,
54
- filters: nil, config: nil, stream_counter: nil)
70
+ filters: nil, config: nil, stream_counter: nil,
71
+ presence_provider: nil)
55
72
  @client = client
56
73
  @registry = registry
57
74
  @listener = listener
58
75
  @queue = dispatch_queue
59
76
  @logger = logger
60
77
  @read_limit = read_limit
78
+ # Vends a presence handle for a logical stream name. Injected so
79
+ # tests can record join/leave/touch without a DB. Production
80
+ # defaults to the real per-stream Presence via Pgbus.stream.
81
+ @presence_provider = presence_provider || ->(name) { Pgbus.stream(name).presence }
61
82
  # Filters default to the process-wide registry so production
62
83
  # code picks up whatever was registered at boot. Tests inject
63
84
  # a fresh Filters instance to avoid cross-test pollution.
@@ -175,9 +196,10 @@ module Pgbus
175
196
 
176
197
  def handle(msg)
177
198
  case msg
178
- when WakeMessage then handle_wake(msg)
179
- when ConnectMessage then handle_connect(msg)
180
- when DisconnectMessage then handle_disconnect(msg)
199
+ when WakeMessage then handle_wake(msg)
200
+ when ConnectMessage then handle_connect(msg)
201
+ when DisconnectMessage then handle_disconnect(msg)
202
+ when PresenceTouchMessage then handle_presence_touch(msg)
181
203
  else
182
204
  @logger.warn { "[Pgbus::Streamer::StreamEventDispatcher] unknown message: #{msg.class}" }
183
205
  end
@@ -239,6 +261,7 @@ module Pgbus
239
261
 
240
262
  visible_to = parsed["visible_to"]
241
263
  visible_to = visible_to.to_sym if visible_to.is_a?(String)
264
+ exclude = parsed["exclude"]
242
265
 
243
266
  @ephemeral_seq += 1
244
267
  envelope = StreamEnvelope.new(
@@ -246,7 +269,9 @@ module Pgbus
246
269
  enqueued_at: Time.now.utc.iso8601(6),
247
270
  payload: html,
248
271
  source: "ephemeral",
249
- visible_to: visible_to
272
+ visible_to: visible_to,
273
+ exclude: exclude,
274
+ event: normalize_sse_event(parsed["event"])
250
275
  )
251
276
 
252
277
  registered.each do |conn|
@@ -320,6 +345,7 @@ module Pgbus
320
345
  else
321
346
  @stream_counter.increment_connections(stream)
322
347
  @registry.register(connection)
348
+ presence_join(connection, stream)
323
349
  end
324
350
 
325
351
  record_stat(
@@ -346,6 +372,7 @@ module Pgbus
346
372
  removed = @registry.unregister(connection)
347
373
  @scanned_cursor.delete(connection)
348
374
  @stream_counter.decrement_connections(stream) if removed
375
+ presence_leave(connection, stream)
349
376
  cleanup_stream_if_unused(stream)
350
377
 
351
378
  record_stat(
@@ -355,6 +382,21 @@ module Pgbus
355
382
  )
356
383
  end
357
384
 
385
+ # Touches (refreshes last_seen_at for) the presence members on the
386
+ # given connections. Posted by the Heartbeat each tick so idle but
387
+ # still-connected members don't get swept. Connections without a
388
+ # presence member (non-presence streams, anonymous) are skipped.
389
+ def handle_presence_touch(msg)
390
+ msg.connections.each do |connection|
391
+ member_id = presence_member_of(connection)
392
+ next unless member_id
393
+
394
+ @presence_provider.call(connection.stream_name).touch(member_id: member_id)
395
+ rescue StandardError => e
396
+ @logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence touch failed: #{e.class}: #{e.message}" }
397
+ end
398
+ end
399
+
358
400
  # If this stream has no remaining subscribers (registered or
359
401
  # in-flight), release all per-stream state so long-running
360
402
  # processes don't leak memory proportional to unique stream
@@ -426,6 +468,22 @@ module Pgbus
426
468
  @client.config.queue_name(stream_name)
427
469
  end
428
470
 
471
+ # Sanitizes a typed SSE event name from an untrusted broadcast
472
+ # payload before it reaches the SSE `event:` line. Returns nil
473
+ # (→ the default turbo-stream event) for non-strings, blanks, or
474
+ # any value containing CR/LF — a crafted event with a newline could
475
+ # otherwise inject extra SSE fields (a forged id:/data:) into the
476
+ # frame and corrupt cursor/event routing. Defense in depth with
477
+ # Envelope.message, which also strips newlines.
478
+ def normalize_sse_event(value)
479
+ return nil unless value.is_a?(String)
480
+
481
+ event = value.strip
482
+ return nil if event.empty? || event.match?(/[\r\n]/)
483
+
484
+ event
485
+ end
486
+
429
487
  # Pgbus::Streams::Stream#broadcast wraps HTML payloads as
430
488
  # {"html": "..."} so PGMQ's JSONB column accepts them. Here we
431
489
  # unwrap the html field and return a new envelope whose payload
@@ -442,13 +500,16 @@ module Pgbus
442
500
 
443
501
  visible_to = parsed["visible_to"]
444
502
  visible_to = visible_to.to_sym if visible_to.is_a?(String)
503
+ exclude = parsed["exclude"]
445
504
 
446
505
  StreamEnvelope.new(
447
506
  msg_id: envelope.msg_id,
448
507
  enqueued_at: envelope.enqueued_at,
449
508
  payload: html,
450
509
  source: envelope.source,
451
- visible_to: visible_to
510
+ visible_to: visible_to,
511
+ exclude: exclude,
512
+ event: normalize_sse_event(parsed["event"])
452
513
  )
453
514
  rescue JSON::ParserError
454
515
  envelope
@@ -460,13 +521,67 @@ module Pgbus
460
521
  # Filters registry. Envelopes that predate the StreamEnvelope
461
522
  # refactor (plain ReadAfter::Envelope with no visible_to) also
462
523
  # pass through.
524
+ #
525
+ # Actor-echo suppression: an envelope carrying `exclude:` (a
526
+ # connection id) is dropped for the connection whose id matches.
527
+ # This lets the broadcaster's own SSE connection skip the echo of
528
+ # its own broadcast — the actor already applied the change via its
529
+ # action's HTTP response, so re-applying the SSE echo would
530
+ # double-apply (re-run animations, clobber optimistic edits). The
531
+ # exclude check runs *before* the audience filter so an excluded
532
+ # actor is skipped even when it would otherwise match visible_to.
463
533
  def visible_envelopes_for(envelopes, connection)
464
534
  envelopes.select do |envelope|
535
+ next false if excluded?(envelope, connection)
536
+
465
537
  label = envelope.respond_to?(:visible_to) ? envelope.visible_to : nil
466
538
  @filters.visible?(label, connection.context)
467
539
  end
468
540
  end
469
541
 
542
+ # True when the envelope names this connection in its `exclude`
543
+ # field. Guarded by respond_to? so plain ReadAfter::Envelopes
544
+ # (no exclude field) and connections without an id never match.
545
+ def excluded?(envelope, connection)
546
+ return false unless envelope.respond_to?(:exclude)
547
+
548
+ exclude = envelope.exclude
549
+ return false if exclude.nil? || exclude.to_s.empty?
550
+
551
+ connection.respond_to?(:id) && connection.id.to_s == exclude.to_s
552
+ end
553
+
554
+ # Connection-driven presence (issue #169). Auto-joins a member when
555
+ # the stream is configured for presence and the connection's
556
+ # authorize-context yields a member id. Stores the member id on the
557
+ # connection so handle_disconnect/handle_presence_touch can act on
558
+ # it. Failures are logged and swallowed: a presence DB hiccup must
559
+ # not knock a live SSE connection out of the registry.
560
+ def presence_join(connection, stream)
561
+ return unless @config&.stream_presence?(stream)
562
+
563
+ member = @config.presence_member_for(connection.context)
564
+ return unless member
565
+
566
+ @presence_provider.call(stream).join(member_id: member[:id], metadata: member[:metadata] || {})
567
+ connection.presence_member = member[:id] if connection.respond_to?(:presence_member=)
568
+ rescue StandardError => e
569
+ @logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence join failed: #{e.class}: #{e.message}" }
570
+ end
571
+
572
+ def presence_leave(connection, stream)
573
+ member_id = presence_member_of(connection)
574
+ return unless member_id
575
+
576
+ @presence_provider.call(stream).leave(member_id: member_id)
577
+ rescue StandardError => e
578
+ @logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence leave failed: #{e.class}: #{e.message}" }
579
+ end
580
+
581
+ def presence_member_of(connection)
582
+ connection.respond_to?(:presence_member) ? connection.presence_member : nil
583
+ end
584
+
470
585
  def monotonic_ms
471
586
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) * 1000.0
472
587
  end
data/lib/pgbus.rb CHANGED
@@ -130,6 +130,17 @@ module Pgbus
130
130
  Streams::Key.stream_key(*parts, **)
131
131
  end
132
132
 
133
+ # Accepts an already-built stream key verbatim (no re-keying), only
134
+ # enforcing the queue-name budget. Use when you hold a key string and
135
+ # want to pass it to both `turbo_stream_from` and a broadcaster
136
+ # without the colon-separator guard raising on the second call.
137
+ #
138
+ # key = Pgbus.stream_key(chat, :messages)
139
+ # Pgbus.stream(Pgbus.stream_key!(key)).broadcast(html)
140
+ def stream_key!(key)
141
+ Streams::Key.stream_key!(key)
142
+ end
143
+
133
144
  def reset!
134
145
  @client&.close
135
146
  @client = nil
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -294,6 +294,7 @@ files:
294
294
  - lib/pgbus/process/dispatcher.rb
295
295
  - lib/pgbus/process/heartbeat.rb
296
296
  - lib/pgbus/process/lifecycle.rb
297
+ - lib/pgbus/process/notify_listener.rb
297
298
  - lib/pgbus/process/queue_lock.rb
298
299
  - lib/pgbus/process/signal_handler.rb
299
300
  - lib/pgbus/process/supervisor.rb
@@ -313,11 +314,13 @@ files:
313
314
  - lib/pgbus/stat_buffer.rb
314
315
  - lib/pgbus/streams.rb
315
316
  - lib/pgbus/streams/broadcastable_override.rb
317
+ - lib/pgbus/streams/coalescer.rb
316
318
  - lib/pgbus/streams/cursor.rb
317
319
  - lib/pgbus/streams/envelope.rb
318
320
  - lib/pgbus/streams/filters.rb
319
321
  - lib/pgbus/streams/key.rb
320
322
  - lib/pgbus/streams/presence.rb
323
+ - lib/pgbus/streams/renderer.rb
321
324
  - lib/pgbus/streams/signed_name.rb
322
325
  - lib/pgbus/streams/streamable.rb
323
326
  - lib/pgbus/streams/turbo_broadcastable.rb