pgbus 0.17.3 → 0.18.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +20 -9
- data/app/assets/javascripts/pgbus/stream_source_element.js +3 -2
- data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -1
- data/lib/pgbus/configuration/stream_authorization.rb +36 -0
- data/lib/pgbus/configuration.rb +12 -1
- data/lib/pgbus/streams/envelope.rb +3 -2
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/stream_app.rb +25 -8
- data/lib/pgbus/web/streamer/connection.rb +19 -8
- data/lib/pgbus/web/streamer/falcon_connection.rb +16 -8
- data/lib/pgbus/web/streamer/instance.rb +23 -7
- data/lib/pgbus/web/streamer/peer_close_watcher.rb +183 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +18 -2
- data/lib/pgbus.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ecd5c93d6e4cd514d0733f95f5f064d84d16393e104047e4ce4920c69926939
|
|
4
|
+
data.tar.gz: ef893f0ded94410bb8759ff30486c7fbd2ea0abe2ad156b47b96c4e600f31e18
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 04d7ddc5cfa15881f2911ed7a88f559ed8b037eaf734ae7cee41cdde4ebca917e537a84f758b439477827e5824d8eb311fe1d373c4c511a859cae01a0bd3faba
|
|
7
|
+
data.tar.gz: 339b3a25781d0342b0414022cabe303c9f5b45332ee1b4d4cb83cf55c3635bec0a80cd528c794c6425eb0b58db7ccbe5a5a49a95854ba72aa41576f1619695df
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
### Added
|
|
4
4
|
|
|
5
|
+
- **`config.streams_authorize`: the engine's stream endpoint can supply a connection context.** The engine mounts `Pgbus::Web::StreamApp.new` with no `authorize:` hook, so every connection was authorized with no context. Connection-driven presence (`streams_presence_patterns`) then joined nobody, and `visible_to:` predicates that need a context (they receive `nil`, so `user&.admin?` is falsy) hid every broadcast, unless the app mounted its own `StreamApp`. `config.streams_authorize = ->(env, stream_name) { … }` takes the same contract as `StreamApp.new(authorize:)`: a falsy return answers 403, `true` authorizes with no context, any other value (the current user, say) becomes the connection context. It is read per request, so the order of initializers and route loading doesn't matter. An explicit `StreamApp.new(authorize:)` wins; any `StreamApp` without one, the engine's included, uses the configured hook, and nil (the default) keeps today's behaviour. `validate!` rejects a non-callable. Closes #522.
|
|
6
|
+
|
|
5
7
|
- **Streams presence keeps one row per tab, with per-tab state, a change hook and automatic expiry (experimental).** Connection-driven presence (`streams_presence_patterns`) kept one row per member, so a user with two tabs open vanished from the room when either tab closed. On a migrated table it now keeps one row per SSE connection: `pgbus_presence_members` gains `connection_id`, `seat` (the client's per-tab id) and `state jsonb`. `<pgbus-stream-source presence>` (`pgbus_stream_from @room, presence: true`) mints one seat per document and sends it as `?seat=`; the stream app stores it on the connection and the dispatcher joins with `connection_id:` and `seat:`. New API: `Presence#join(member_id:, connection_id:, seat:, metadata:)`, `#leave(connection_id:)`, `#touch(connection_id:)` (one statement per stream, returning the ids it found), `#set_state(connection_id:, state:)` (merges into that row) and `.seats_supported?`; `#members` rows carry `connection_id`, `seat` and `state`. Member-keyed calls keep working on their own row. New settings: `streams_presence_on_change = ->(stream_name, change) { }`, called after each seat join, leave and expiry with `{ type:, connection_id:, seat:, member_id: }` (errors logged and swallowed), and `streams_presence_ttl` (seconds, or a pattern => seconds Hash with `:default`; 60 by default). The heartbeat touches live seats each tick, re-joins a live connection whose row was expired, and at most every ttl / 2 expires silent seats across all streams, including rows of crashed processes, reporting each as a `:leave`. On a seat table `sweep!` only expires member-keyed rows, and pgbus warns at boot when a ttl does not exceed `streams_heartbeat_interval`. **Upgrade:** `rails generate pgbus:add_presence_seats` (queued by `pgbus:update`; fresh `pgbus:add_presence` tables already have the columns). It replaces the `(stream_name, member_id)` unique index with partial unique indexes on `(stream_name, member_id) WHERE connection_id IS NULL` and `(stream_name, connection_id) WHERE connection_id IS NOT NULL`, and is reversible. Until it runs, presence behaves as before; a running process notices the migrated table within 30 seconds (at once on a member-keyed `join` outside a transaction). Closes #515.
|
|
6
8
|
|
|
7
9
|
- **The home page and Insights explain themselves.** The home page (`/pgbus`) showed numbers without words ("Failed / DLQ 5 / 3", a Throughput card that was always `0.0` on the web, Queue Health percentages coloured against thresholds it never stated). It now opens with a health verdict strip — the OK / DEGRADED / STALLED verdict and reasons `/readyz` and the MCP compute (`HealthAnalyzer`), with links to the page that explains them. Eight cards each say what their number means and link to the page that explains it: Queues (paused, dead-letter, holding jobs nothing drains), Enqueued (claimable now vs scheduled, running or retrying), Processes (healthy · stalled · stale), Recurring, Retrying (the Jobs page's Retrying count, when the next attempt runs, how many are due now, the newest error), Dead-lettered (the newest message's reason, read as the DLQ page reads it), Throughput (jobs/min over the last 5 minutes from job stats, or "needs job stats") and Parked. The queues table gains a Status column in words (the queue page's summary line: paused and why, nothing drains it, what is waiting). Queue Health states each threshold ("12% on pgmq.q_pgbus_events — vacuum due (warns above 10%, critical above 20%)"; the MVCC card names both its own 30 s / 60 s warnings and the verdict's 300 s) and its table always renders, with an empty state. Recent failures show the job and attempt and link to the failure. New `Web::DataSource#dashboard_signals` and `#queue_status_lines` (in `DataSource::DashboardSignals`) and the pure presenters `Web::HealthVerdict` and `Web::InsightsCharts`; `summary_stats`, `/pgbus/api/stats`, `/pgbus/api/metrics` and the AppSignal probe are unchanged. On Insights each chart has a caption sentence (also the chart's accessible name) and a `<details>` data table rendered on the server (at most 60 rows: long ranges roll minutes up), empty series show an empty state, and the page draws from series it embeds instead of fetching `/pgbus/api/insights` a second time (that endpoint is unchanged). Closes #492.
|
|
@@ -54,6 +56,10 @@
|
|
|
54
56
|
|
|
55
57
|
### Fixed
|
|
56
58
|
|
|
59
|
+
- **`pgbus:connected` now fires only once the connection is registered and its presence join has run.** `StreamApp` wrote the frame at hijack time, before the streamer's dispatcher had handled the connect, so a page that acted on its connection id as soon as it heard the event — `Pgbus.stream(x).presence.set_state(connection_id:)` from an action, for instance — found no row (`set_state` returned nil) and had to retry. The dispatcher now writes the frame at the end of the connect, after the archive replay, `registry.register` and the presence join attempt (a stream without presence, or a member resolver that returns nil, has no seat to join; a join that raises is logged and the frame still follows), through the connection's mutex-serialised writer (`Connection#write_connected` / `FalconConnection#write_connected`). The event name, its `connectionId` payload and the element's `connection-id` attribute are unchanged; listeners hear it after any replayed frames instead of before them, and a connection that dies during the replay never gets one. A repeated disconnect for an already-unregistered connection no longer records a second `disconnect` stream stat. Closes #521.
|
|
60
|
+
|
|
61
|
+
- **A closed tab leaves its presence seat at once, not on the next heartbeat.** The server never reads a hijacked SSE socket after the request, so a client that went away was noticed only when a heartbeat write failed: up to `streams_heartbeat_interval` later (15 s by default), and often a beat later still, because the first write after the client's FIN succeeds. Every viewer of a presence roster kept the closed tab for that long. A new `PeerCloseWatcher` thread per streamer waits on every live hijacked socket with `IO.select` and probes a readable one with a non-blocking read: EOF or a reset marks the connection dead, closes it and posts the same `DisconnectMessage` the heartbeat posts, so the seat leaves (and `streams_presence_on_change` fires once) within milliseconds on Puma and on Falcon's default hijack path; a connection closed within its first `streams_peer_close_interval` after opening is noticed within that interval. A TLS client that vanishes without close_notify (OpenSSL's or Puma MiniSSL's SSL error on read) counts as closed, the probe skips a socket while a write holds its mutex, and `#stop` wakes the select through a self-pipe so shutdown does not wait out the interval. The client sends nothing after its request, so a readable socket means a close; stray bytes are read and discarded. Falcon's experimental streaming-body path has no socket and keeps the heartbeat. New setting `streams_peer_close_interval` (1.0 s by default): how long a newly opened connection waits to join the watched set; `nil` turns the watcher off. Closes #519.
|
|
62
|
+
|
|
57
63
|
- **Insights charts are readable in dark mode.** The charts were themed only through `foreColor`: in dark mode the donut kept white slice separators, axis borders and the crosshair label stayed light, and the "No data" fallback was `text-gray-400 dark:text-gray-500` (below WCAG AA, and invisible to the contrast guard because it lived in JS). `charts.js` now themes every chart from one `palette(isDark)` (theme mode, axis and grid colours, donut separators in the card colour, legend and tooltip, animations off under `prefers-reduced-motion`) and redraws on the dark-mode toggle without a reload. The latency headers and chart titles no longer carry "(ms)" / "(jobs/min)"; the P95 latency card's yellow is now AA. The static contrast guard scans the dashboard's JS modules too. Refs #492.
|
|
58
64
|
|
|
59
65
|
- **The dashboard home page reads the process rows and the stream registry once per render.** `DataSource` memoizes the raw process rows, `stream_queue_names` and `queue_health_stats` per instance (cleared by `reset_cache!`, as `queues_with_metrics` already was), so the stats, the verdict and the panels share one query each. `HealthApp` now calls `reset_cache!` on an injected DataSource before every `/readyz` check, so a long-lived one never answers with the first check's facts (it already did that for queues). Refs #492.
|
data/README.md
CHANGED
|
@@ -1443,6 +1443,7 @@ Pgbus.configure do |c|
|
|
|
1443
1443
|
"presence_room" => 30 # 30 seconds for presence
|
|
1444
1444
|
}
|
|
1445
1445
|
c.streams_heartbeat_interval = 15 # seconds
|
|
1446
|
+
c.streams_peer_close_interval = 1.0 # notice a closed client at once; nil = heartbeat only
|
|
1446
1447
|
c.streams_max_connections = 2_000 # per web-server process (Puma worker or Falcon process)
|
|
1447
1448
|
c.streams_idle_timeout = 3_600 # close idle connections after 1h
|
|
1448
1449
|
c.streams_listen_health_check_ms = 250 # PG LISTEN keepalive + ensure_listening ack budget
|
|
@@ -1617,16 +1618,26 @@ Pgbus::Streams.filters.register(:workspace_member) do |user, stream_name|
|
|
|
1617
1618
|
end
|
|
1618
1619
|
```
|
|
1619
1620
|
|
|
1620
|
-
The authorize hook
|
|
1621
|
+
The authorize hook doubles as a context provider — return any non-boolean value (typically a `User` model) and pgbus will pass it to the filter predicate when evaluating broadcasts. The engine's stream endpoint reads it from `config.streams_authorize`, so no custom mount is needed:
|
|
1621
1622
|
|
|
1622
1623
|
```ruby
|
|
1623
|
-
Pgbus
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1624
|
+
Pgbus.configure do |c|
|
|
1625
|
+
c.streams_authorize = ->(env, stream_name) {
|
|
1626
|
+
user = User.find_by(id: env["rack.session"]&.dig("user_id"))
|
|
1627
|
+
next unless user
|
|
1628
|
+
|
|
1629
|
+
# Check THIS user against THIS stream: here "room:42" names a room.
|
|
1630
|
+
room_id = stream_name.delete_prefix("room:") if stream_name.start_with?("room:")
|
|
1631
|
+
room = Room.find_by(id: room_id) if room_id
|
|
1632
|
+
user if room&.member?(user) # nil → 403; the user becomes the context
|
|
1633
|
+
}
|
|
1634
|
+
end
|
|
1628
1635
|
```
|
|
1629
1636
|
|
|
1637
|
+
Authorize per stream here. The signed stream name proves pgbus minted it, not that this user may read it: a user removed from a room keeps a working signed name until the signing key rotates (`Turbo.signed_stream_verifier_key` when turbo-rails is loaded, `streams_signed_name_secret` otherwise), so a hook that only checks "logged in" lets them keep listening. Rotating the key only refuses new connections: an SSE connection already open stays open until it closes or its worker restarts.
|
|
1638
|
+
|
|
1639
|
+
The hook gets the Rack env, not a controller (the stream endpoint is a bare Rack app), so read the session (`env["rack.session"]`) or a signed cookie from it. A falsy return answers 403, `true` authorizes with no context. An explicit `Pgbus::Web::StreamApp.new(authorize: …)` wins; every `StreamApp` built without one, the engine's mount included, uses `streams_authorize`. `validate!` rejects a non-callable value at boot; with `eager_validation = false` it is not checked there, and every stream request then fails closed with a 500.
|
|
1640
|
+
|
|
1630
1641
|
Then label broadcasts with the filter you want to apply:
|
|
1631
1642
|
|
|
1632
1643
|
```ruby
|
|
@@ -1650,7 +1661,7 @@ An actor who just triggered a change already applied it via the HTTP response of
|
|
|
1650
1661
|
Pgbus.stream(room).broadcast(html, exclude: connection_id)
|
|
1651
1662
|
```
|
|
1652
1663
|
|
|
1653
|
-
Every server-minted SSE connection exposes its id to the page:
|
|
1664
|
+
Every server-minted SSE connection exposes its id to the page: once the streamer has registered the connection (after the connect replay, and after its presence seat has joined on a presence stream), pgbus sends a `pgbus:connected` frame carrying the connection id, and `<pgbus-stream-source>` captures it onto its `connection-id` attribute and re-dispatches it as a `pgbus:connected` event. A write keyed by the connection id right after the event, such as `presence.set_state(connection_id:)`, finds the connection's row. The page reads that id and sends it back as the `X-Pgbus-Connection` header on the action request that triggers the broadcast:
|
|
1654
1665
|
|
|
1655
1666
|
```js
|
|
1656
1667
|
document.addEventListener("pgbus:connected", (event) => {
|
|
@@ -1795,7 +1806,7 @@ Pgbus.configure do |c|
|
|
|
1795
1806
|
end
|
|
1796
1807
|
```
|
|
1797
1808
|
|
|
1798
|
-
Identity comes from the connection's authorize-hook context (the value your `StreamApp` `authorize:` callable returns). The built-in extractor handles the common shapes without any configuration: a `Hash` with `:member_id` (or `:id`) and optional `:metadata`, or any object responding to `#id` (e.g. a `User` model). For anything else, provide a custom extractor — a `->(context) { { id:, metadata: } }` callable returning `nil` for a context with no derivable identity (anonymous connections are simply skipped, not an error):
|
|
1809
|
+
Identity comes from the connection's authorize-hook context (the value `config.streams_authorize`, or your own `StreamApp` `authorize:` callable, returns). Without a hook every connection is anonymous and nobody joins. The built-in extractor handles the common shapes without any configuration: a `Hash` with `:member_id` (or `:id`) and optional `:metadata`, or any object responding to `#id` (e.g. a `User` model). For anything else, provide a custom extractor — a `->(context) { { id:, metadata: } }` callable returning `nil` for a context with no derivable identity (anonymous connections are simply skipped, not an error):
|
|
1799
1810
|
|
|
1800
1811
|
```ruby
|
|
1801
1812
|
Pgbus.configure do |c|
|
|
@@ -1806,7 +1817,7 @@ Pgbus.configure do |c|
|
|
|
1806
1817
|
end
|
|
1807
1818
|
```
|
|
1808
1819
|
|
|
1809
|
-
Membership work runs on the dispatcher thread (which already releases AR connections each pass); the heartbeat posts a batched touch per tick. Presence failures are logged and swallowed so a presence-table hiccup can't knock a live SSE connection out of the registry.
|
|
1820
|
+
Membership work runs on the dispatcher thread (which already releases AR connections each pass); the heartbeat posts a batched touch per tick. A closed tab leaves at once (within `streams_peer_close_interval` for a connection that just opened): a watcher thread notices the client's close on the socket (Puma and Falcon's hijack path) instead of waiting for the next heartbeat write to fail. Falcon's experimental streaming-body path still leaves on the heartbeat. Presence failures are logged and swallowed so a presence-table hiccup can't knock a live SSE connection out of the registry.
|
|
1810
1821
|
|
|
1811
1822
|
#### Seats: one row per tab (experimental)
|
|
1812
1823
|
|
|
@@ -37,8 +37,9 @@
|
|
|
37
37
|
// pgbus:close { code, reason }
|
|
38
38
|
//
|
|
39
39
|
// Connection id (issue #165 — actor-echo suppression): the server sends a
|
|
40
|
-
// `pgbus:connected` frame
|
|
41
|
-
//
|
|
40
|
+
// `pgbus:connected` frame carrying the server-minted connection id once the
|
|
41
|
+
// streamer has registered the connection and joined its presence seat
|
|
42
|
+
// (issue #521), after the connect replay. This element captures it, reflects it onto
|
|
42
43
|
// the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.
|
|
43
44
|
// A page reads it (from the element or a `<meta name="pgbus-connection-id">`
|
|
44
45
|
// the app mirrors it to) and sends it back as the `X-Pgbus-Connection`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["src/stream_source_element.js"],"sourcesContent":["// <pgbus-stream-source> — the browser-side counterpart to\n// Pgbus::StreamsHelper#pgbus_stream_from. Drop-in replacement for\n// turbo-rails' <turbo-cable-stream-source> that speaks SSE + pgbus\n// instead of WebSocket + ActionCable.\n//\n// Attributes (set by the view helper):\n// src — absolute path to the SSE endpoint, including\n// the URL-safe signed stream name\n// since-id — PGMQ msg_id watermark at render time; the\n// client sends this as ?since= on the FIRST\n// connect so the server can replay any\n// broadcasts from the render-to-connect gap\n// (rails/rails#52420 fix)\n// signed-stream-name — present for parity with turbo-rails; unused\n// by this element because the signed name is\n// already in the URL path\n// channel — compatibility shim; ignored\n// presence — boolean; when present the element sends this\n// document's presence seat as ?seat= on every\n// connect (issue #515)\n//\n// Events (dispatched on the element):\n// message (MessageEvent) data = turbo-stream HTML;\n// lastEventId = the frame's msg_id (revision)\n// pgbus:message { msgId, data } — same frame, for optimistic-UI\n// reconciliation (skip morph if a newer rev for the\n// target was already applied). See #168.\n// pgbus:event { event, data, msgId } — a typed broadcast (any SSE\n// event name other than turbo-stream). See #170.\n// pgbus:<event> { data, msgId } — the same typed broadcast, named\n// for ergonomic addEventListener(\"pgbus:presence\").\n// pgbus:open { lastEventId, connectionId }\n// pgbus:connected { connectionId }\n// pgbus:replay-start { fromId, toId }\n// pgbus:replay-end {}\n// pgbus:gap-detected { lastSeenId, archiveOldestId }\n// pgbus:close { code, reason }\n//\n// Connection id (issue #165 — actor-echo suppression): the server sends a\n// `pgbus:connected` frame right after the open handshake carrying the\n// server-minted connection id. This element captures it, reflects it onto\n// the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.\n// A page reads it (from the element or a `<meta name=\"pgbus-connection-id\">`\n// the app mirrors it to) and sends it back as the `X-Pgbus-Connection`\n// header on action requests. The broadcaster then passes\n// `exclude: request.headers[\"X-Pgbus-Connection\"]` so the actor does not\n// receive the echo of its own broadcast.\n//\n// The element integrates with Turbo via connectStreamSource /\n// disconnectStreamSource + dispatching MessageEvent(\"message\") so Turbo\n// Stream HTML is automatically consumed by the existing StreamObserver.\n//\n// Presence seat (issue #515): with the `presence` attribute, the element\n// appends `?seat=<id>` to the SSE request. The seat is minted once per\n// document (shared by every presence element on the page and kept across\n// Turbo Drive visits, so it names the browser tab) and stays the same across\n// reconnects. The server stores it on the connection's presence row, which\n// lets a page tell its own tab apart from the member's other tabs. Read it\n// from `element.pgbusSeat`.\n//\n// Transport strategy:\n// - FIRST connect: use fetch() + ReadableStream to include ?since=\n// in the URL. Native EventSource cannot send Last-Event-ID on the\n// initial request, so we need fetch to carry the watermark.\n// - RECONNECT: switch to native EventSource, which sends Last-Event-ID\n// automatically based on the last id: we observed. The native\n// client is more battle-tested for reconnection backoff.\n\nimport { Turbo } from \"@hotwired/turbo-rails\"\nconst { connectStreamSource, disconnectStreamSource } = Turbo\n\nlet documentSeat = null\n\n// One seat per document, minted on first use (see \"Presence seat\" above).\nfunction seatForDocument() {\n if (!documentSeat) {\n documentSeat = globalThis.crypto && crypto.randomUUID\n ? crypto.randomUUID()\n : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`\n }\n return documentSeat\n}\n\nclass PgbusStreamSourceElement extends HTMLElement {\n static get observedAttributes() {\n return [\"src\", \"since-id\"]\n }\n\n constructor() {\n super()\n this.abortController = null\n this.eventSource = null\n this.lastEventId = null\n this.connectionId = null\n this.closed = false\n }\n\n // The server-minted connection id for this SSE connection, or null\n // until the `pgbus:connected` frame arrives. Public read accessor so\n // a reactive runtime can grab it without poking at attributes.\n get pgbusConnectionId() {\n return this.connectionId\n }\n\n // This document's presence seat when the element has the `presence`\n // attribute, otherwise null.\n get pgbusSeat() {\n return this.hasAttribute(\"presence\") ? seatForDocument() : null\n }\n\n connectedCallback() {\n this.closed = false\n connectStreamSource(this)\n const sinceId = this.getAttribute(\"since-id\")\n this.lastEventId = sinceId && sinceId !== \"\" ? sinceId : null\n this.openFetchStream()\n }\n\n disconnectedCallback() {\n this.closed = true\n disconnectStreamSource(this)\n this.teardown()\n }\n\n teardown() {\n if (this.abortController) {\n this.abortController.abort()\n this.abortController = null\n }\n if (this.eventSource) {\n this.eventSource.close()\n this.eventSource = null\n }\n }\n\n // First connect: use fetch() so we can include ?since=<watermark> on\n // the URL. Parses the SSE event stream by hand because EventSource\n // doesn't expose custom query strings uniformly across browsers.\n async openFetchStream() {\n // Each transport open is a fresh connection: the server mints a new id\n // and sends a new pgbus:connected frame. Clear the previous id so\n // pgbus:open (which fires before that frame arrives) can't surface a\n // stale connection id — a stale id would produce a wrong X-Pgbus-Connection\n // exclude header during reconnect windows.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.abortController = new AbortController()\n\n try {\n const response = await fetch(url, {\n headers: { Accept: \"text/event-stream\" },\n credentials: \"same-origin\",\n signal: this.abortController.signal\n })\n\n if (!response.ok) {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: response.status, reason: response.statusText }\n }))\n return\n }\n\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n\n const reader = response.body.getReader()\n const decoder = new TextDecoder(\"utf-8\")\n let buffer = \"\"\n\n while (!this.closed) {\n const { value, done } = await reader.read()\n if (done) {\n this.removeAttribute(\"connected\")\n this.switchToEventSource()\n return\n }\n\n buffer += decoder.decode(value, { stream: true })\n const events = buffer.split(\"\\n\\n\")\n buffer = events.pop() // last chunk may be incomplete\n\n for (const block of events) {\n this.handleBlock(block)\n }\n }\n } catch (err) {\n if (err.name !== \"AbortError\") {\n this.removeAttribute(\"connected\")\n // Fall through to reconnect via native EventSource, which\n // will carry Last-Event-ID from this.lastEventId forward.\n this.switchToEventSource()\n }\n }\n }\n\n // Reconnect path: native EventSource with Last-Event-ID baked into\n // the initial handshake by the browser.\n switchToEventSource() {\n if (this.closed) return\n\n // Fresh connection on reconnect — drop the previous connection id so\n // pgbus:open can't emit a stale one before the new pgbus:connected\n // frame lands. See openFetchStream.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.eventSource = new EventSource(url, { withCredentials: true })\n\n this.eventSource.addEventListener(\"open\", () => {\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n })\n\n this.eventSource.addEventListener(\"error\", () => {\n this.removeAttribute(\"connected\")\n })\n\n this.eventSource.addEventListener(\"turbo-stream\", (event) => {\n this.lastEventId = event.lastEventId\n this.emitTurboStream(event.data, event.lastEventId)\n })\n\n this.eventSource.addEventListener(\"pgbus:connected\", (event) => {\n this.handleConnected(event.data)\n })\n\n this.eventSource.addEventListener(\"pgbus:gap-detected\", (event) => {\n const detail = this.safeJsonParse(event.data)\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", { detail }))\n })\n\n this.eventSource.addEventListener(\"pgbus:shutdown\", () => {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n })\n\n // Typed broadcasts (issue #170): EventSource only fires listeners\n // registered by name, so we register one per declared typed event.\n for (const name of this.declaredTypedEvents()) {\n this.eventSource.addEventListener(name, (event) => {\n if (event.lastEventId) this.lastEventId = event.lastEventId\n this.emitTypedEvent(name, event.data, event.lastEventId)\n })\n }\n }\n\n // Parses a single SSE event block (: comment | id: ... | event: ... | data: ...)\n handleBlock(block) {\n if (!block || block.startsWith(\":\")) return // comment\n\n let id = null\n let event = \"message\"\n const dataLines = []\n\n for (const line of block.split(\"\\n\")) {\n if (line.startsWith(\"id:\")) id = line.slice(3).trim()\n else if (line.startsWith(\"event:\")) event = line.slice(6).trim()\n // Per the SSE spec: strip only a single leading space after the colon\n // (never trim — payload whitespace is significant, issue #392) and\n // rejoin consecutive data: lines with \\n, matching native EventSource.\n else if (line.startsWith(\"data:\")) dataLines.push(line.slice(5).replace(/^ /, \"\"))\n }\n const data = dataLines.join(\"\\n\")\n\n if (id !== null) this.lastEventId = id\n\n if (event === \"turbo-stream\") {\n this.emitTurboStream(data, id)\n } else if (event === \"pgbus:connected\") {\n this.handleConnected(data)\n } else if (event === \"pgbus:gap-detected\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", {\n detail: this.safeJsonParse(data)\n }))\n } else if (event === \"pgbus:shutdown\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n } else {\n // A typed broadcast (issue #170): event: presence | reactive | ...\n this.emitTypedEvent(event, data, id)\n }\n }\n\n // Captures the server-minted connection id from a `pgbus:connected`\n // frame: stores it, reflects it onto the `connection-id` attribute (so\n // it's visible in the DOM / to MutationObservers), and re-dispatches it\n // as a `pgbus:connected` CustomEvent. Idempotent across reconnects — the\n // server mints a fresh id per connection, so a reconnect updates it.\n handleConnected(data) {\n const detail = this.safeJsonParse(data)\n const connectionId = detail && detail.connectionId\n if (!connectionId) return\n\n this.connectionId = connectionId\n this.setAttribute(\"connection-id\", connectionId)\n this.dispatchEvent(new CustomEvent(\"pgbus:connected\", {\n detail: { connectionId }\n }))\n }\n\n // Clears the cached connection id and its reflected attribute. Called at\n // the start of every transport open so a reconnect doesn't carry the\n // previous connection's id into pgbus:open before the new pgbus:connected\n // frame arrives.\n resetConnectionId() {\n this.connectionId = null\n this.removeAttribute(\"connection-id\")\n }\n\n // Delivers a turbo-stream frame to two audiences (issue #168):\n //\n // 1. Turbo's StreamObserver, via a `message` MessageEvent whose `data`\n // is the turbo-stream HTML. The MessageEvent's standard\n // `lastEventId` field carries the frame's msg_id, so a reactive\n // runtime that listens for `message` can read the revision without\n // any pgbus-specific API. (Turbo ignores lastEventId.)\n //\n // 2. A reactive runtime doing optimistic-UI reconciliation, via a\n // `pgbus:message` CustomEvent carrying { msgId, data }. msgId is the\n // monotonic per-stream revision (a negative value marks an ephemeral\n // frame that was never persisted). Pattern: track the highest msgId\n // you have applied per target; when a frame arrives, skip the morph\n // if you have already applied a newer revision for that target —\n // this stops a late echo from clobbering a newer optimistic edit.\n // Complements #165: exclude handles the actor; this handles\n // out-of-order delivery for everyone else.\n //\n // msgId is parsed to a Number when numeric so consumers can compare\n // revisions with `>` directly; left as-is otherwise.\n emitTurboStream(data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n const message = new MessageEvent(\"message\", { data, lastEventId: id == null ? \"\" : String(id) })\n this.dispatchEvent(message)\n\n this.dispatchEvent(new CustomEvent(\"pgbus:message\", {\n detail: { msgId, data }\n }))\n }\n\n // Delivers a typed broadcast (issue #170) — a frame whose SSE event name\n // is something other than turbo-stream (e.g. \"presence\", \"reactive\").\n // The payload is still whatever the broadcaster sent (usually a Turbo\n // Stream); the typed name lets a client route without sniffing the HTML.\n // Dispatched two ways for ergonomics:\n // - a generic `pgbus:event` { event, data, msgId } (one listener for all)\n // - a named `pgbus:<event>` { data, msgId } (addEventListener by name)\n emitTypedEvent(event, data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n this.dispatchEvent(new CustomEvent(\"pgbus:event\", {\n detail: { event, data, msgId }\n }))\n this.dispatchEvent(new CustomEvent(`pgbus:${event}`, {\n detail: { data, msgId }\n }))\n }\n\n // Typed event names the EventSource (reconnect) path should listen for,\n // declared by the app via the `listen-events` attribute (comma- or\n // space-separated). EventSource only invokes listeners registered by\n // name, so unlike the fetch path it cannot route unknown typed events\n // generically; declaring them here keeps typed delivery working across\n // reconnects. The fetch (initial) path always routes typed events.\n declaredTypedEvents() {\n const raw = this.getAttribute(\"listen-events\")\n if (!raw) return []\n return raw.split(/[\\s,]+/).filter((name) => name && name !== \"turbo-stream\")\n }\n\n buildUrl({ includeSince }) {\n const src = this.getAttribute(\"src\")\n const since = includeSince && this.lastEventId ? this.lastEventId : null\n const seat = this.pgbusSeat\n if (!since && !seat) return src\n\n const url = new URL(src, window.location.origin)\n if (since) url.searchParams.set(\"since\", since)\n if (seat) url.searchParams.set(\"seat\", seat)\n return url.toString()\n }\n\n safeJsonParse(str) {\n try { return JSON.parse(str) } catch { return null }\n }\n}\n\nif (!customElements.get(\"pgbus-stream-source\")) {\n customElements.define(\"pgbus-stream-source\", PgbusStreamSourceElement)\n}\n\nexport { PgbusStreamSourceElement }\n"],"mappings":"AAoEA,gBAAS,8BACT,IAAQ,sBAAqB,0BAA2B,EAEpD,EAAe,KAGnB,SAAS,CAAe,EAAG,CACzB,GAAI,CAAC,EACH,EAAe,WAAW,QAAU,OAAO,WACvC,OAAO,WAAW,EAClB,GAAG,KAAK,IAAI,EAAE,SAAS,EAAE,KAAK,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,EAAG,EAAE,IAE1E,OAAO,EAGT,MAAM,UAAiC,WAAY,WACtC,mBAAkB,EAAG,CAC9B,MAAO,CAAC,MAAO,UAAU,EAG3B,WAAW,EAAG,CACZ,MAAM,EACN,KAAK,gBAAkB,KACvB,KAAK,YAAc,KACnB,KAAK,YAAc,KACnB,KAAK,aAAe,KACpB,KAAK,OAAS,MAMZ,kBAAiB,EAAG,CACtB,OAAO,KAAK,gBAKV,UAAS,EAAG,CACd,OAAO,KAAK,aAAa,UAAU,EAAI,EAAgB,EAAI,KAG7D,iBAAiB,EAAG,CAClB,KAAK,OAAS,GACd,EAAoB,IAAI,EACxB,IAAM,EAAU,KAAK,aAAa,UAAU,EAC5C,KAAK,YAAc,GAAW,IAAY,GAAK,EAAU,KACzD,KAAK,gBAAgB,EAGvB,oBAAoB,EAAG,CACrB,KAAK,OAAS,GACd,EAAuB,IAAI,EAC3B,KAAK,SAAS,EAGhB,QAAQ,EAAG,CACT,GAAI,KAAK,gBACP,KAAK,gBAAgB,MAAM,EAC3B,KAAK,gBAAkB,KAEzB,GAAI,KAAK,YACP,KAAK,YAAY,MAAM,EACvB,KAAK,YAAc,UAOjB,gBAAe,EAAG,CAMtB,KAAK,kBAAkB,EACvB,IAAM,EAAM,KAAK,SAAS,CAAE,aAAc,EAAK,CAAC,EAChD,KAAK,gBAAkB,IAAI,gBAE3B,GAAI,CACF,IAAM,EAAW,MAAM,MAAM,EAAK,CAChC,QAAS,CAAE,OAAQ,mBAAoB,EACvC,YAAa,cACb,OAAQ,KAAK,gBAAgB,MAC/B,CAAC,EAED,GAAI,CAAC,EAAS,GAAI,CAChB,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,KAAM,EAAS,OAAQ,OAAQ,EAAS,UAAW,CAC/D,CAAC,CAAC,EACF,OAGF,KAAK,aAAa,YAAa,EAAE,EACjC,KAAK,cAAc,IAAI,YAAY,aAAc,CAC/C,OAAQ,CAAE,YAAa,KAAK,YAAa,aAAc,KAAK,YAAa,CAC3E,CAAC,CAAC,EAEF,IAAM,EAAS,EAAS,KAAK,UAAU,EACjC,EAAU,IAAI,YAAY,OAAO,EACnC,EAAS,GAEb,MAAO,CAAC,KAAK,OAAQ,CACnB,IAAQ,QAAO,QAAS,MAAM,EAAO,KAAK,EAC1C,GAAI,EAAM,CACR,KAAK,gBAAgB,WAAW,EAChC,KAAK,oBAAoB,EACzB,OAGF,GAAU,EAAQ,OAAO,EAAO,CAAE,OAAQ,EAAK,CAAC,EAChD,IAAM,EAAS,EAAO,MAAM;AAAA;AAAA,CAAM,EAClC,EAAS,EAAO,IAAI,EAEpB,QAAW,KAAS,EAClB,KAAK,YAAY,CAAK,GAG1B,MAAO,EAAK,CACZ,GAAI,EAAI,OAAS,aACf,KAAK,gBAAgB,WAAW,EAGhC,KAAK,oBAAoB,GAO/B,mBAAmB,EAAG,CACpB,GAAI,KAAK,OAAQ,OAKjB,KAAK,kBAAkB,EACvB,IAAM,EAAM,KAAK,SAAS,CAAE,aAAc,EAAK,CAAC,EAChD,KAAK,YAAc,IAAI,YAAY,EAAK,CAAE,gBAAiB,EAAK,CAAC,EAEjE,KAAK,YAAY,iBAAiB,OAAQ,IAAM,CAC9C,KAAK,aAAa,YAAa,EAAE,EACjC,KAAK,cAAc,IAAI,YAAY,aAAc,CAC/C,OAAQ,CAAE,YAAa,KAAK,YAAa,aAAc,KAAK,YAAa,CAC3E,CAAC,CAAC,EACH,EAED,KAAK,YAAY,iBAAiB,QAAS,IAAM,CAC/C,KAAK,gBAAgB,WAAW,EACjC,EAED,KAAK,YAAY,iBAAiB,eAAgB,CAAC,IAAU,CAC3D,KAAK,YAAc,EAAM,YACzB,KAAK,gBAAgB,EAAM,KAAM,EAAM,WAAW,EACnD,EAED,KAAK,YAAY,iBAAiB,kBAAmB,CAAC,IAAU,CAC9D,KAAK,gBAAgB,EAAM,IAAI,EAChC,EAED,KAAK,YAAY,iBAAiB,qBAAsB,CAAC,IAAU,CACjE,IAAM,EAAS,KAAK,cAAc,EAAM,IAAI,EAC5C,KAAK,cAAc,IAAI,YAAY,qBAAsB,CAAE,QAAO,CAAC,CAAC,EACrE,EAED,KAAK,YAAY,iBAAiB,iBAAkB,IAAM,CACxD,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,KAAM,WAAY,OAAQ,gBAAiB,CACvD,CAAC,CAAC,EACH,EAID,QAAW,KAAQ,KAAK,oBAAoB,EAC1C,KAAK,YAAY,iBAAiB,EAAM,CAAC,IAAU,CACjD,GAAI,EAAM,YAAa,KAAK,YAAc,EAAM,YAChD,KAAK,eAAe,EAAM,EAAM,KAAM,EAAM,WAAW,EACxD,EAKL,WAAW,CAAC,EAAO,CACjB,GAAI,CAAC,GAAS,EAAM,WAAW,GAAG,EAAG,OAErC,IAAI,EAAK,KACL,EAAQ,UACN,EAAY,CAAC,EAEnB,QAAW,KAAQ,EAAM,MAAM;AAAA,CAAI,EACjC,GAAI,EAAK,WAAW,KAAK,EAAG,EAAK,EAAK,MAAM,CAAC,EAAE,KAAK,EAC/C,QAAI,EAAK,WAAW,QAAQ,EAAG,EAAQ,EAAK,MAAM,CAAC,EAAE,KAAK,EAI1D,QAAI,EAAK,WAAW,OAAO,EAAG,EAAU,KAAK,EAAK,MAAM,CAAC,EAAE,QAAQ,KAAM,EAAE,CAAC,EAEnF,IAAM,EAAO,EAAU,KAAK;AAAA,CAAI,EAEhC,GAAI,IAAO,KAAM,KAAK,YAAc,EAEpC,GAAI,IAAU,eACZ,KAAK,gBAAgB,EAAM,CAAE,EACxB,QAAI,IAAU,kBACnB,KAAK,gBAAgB,CAAI,EACpB,QAAI,IAAU,qBACnB,KAAK,cAAc,IAAI,YAAY,qBAAsB,CACvD,OAAQ,KAAK,cAAc,CAAI,CACjC,CAAC,CAAC,EACG,QAAI,IAAU,iBACnB,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,KAAM,WAAY,OAAQ,gBAAiB,CACvD,CAAC,CAAC,EAGF,UAAK,eAAe,EAAO,EAAM,CAAE,EASvC,eAAe,CAAC,EAAM,CACpB,IAAM,EAAS,KAAK,cAAc,CAAI,EAChC,EAAe,GAAU,EAAO,aACtC,GAAI,CAAC,EAAc,OAEnB,KAAK,aAAe,EACpB,KAAK,aAAa,gBAAiB,CAAY,EAC/C,KAAK,cAAc,IAAI,YAAY,kBAAmB,CACpD,OAAQ,CAAE,cAAa,CACzB,CAAC,CAAC,EAOJ,iBAAiB,EAAG,CAClB,KAAK,aAAe,KACpB,KAAK,gBAAgB,eAAe,EAuBtC,eAAe,CAAC,EAAM,EAAI,CACxB,IAAM,EAAQ,IAAO,MAAQ,IAAO,QAAa,IAAO,GAAK,KACxD,OAAO,MAAM,OAAO,CAAE,CAAC,EAAI,EAAK,OAAO,CAAE,EAExC,EAAU,IAAI,aAAa,UAAW,CAAE,OAAM,YAAa,GAAM,KAAO,GAAK,OAAO,CAAE,CAAE,CAAC,EAC/F,KAAK,cAAc,CAAO,EAE1B,KAAK,cAAc,IAAI,YAAY,gBAAiB,CAClD,OAAQ,CAAE,QAAO,MAAK,CACxB,CAAC,CAAC,EAUJ,cAAc,CAAC,EAAO,EAAM,EAAI,CAC9B,IAAM,EAAQ,IAAO,MAAQ,IAAO,QAAa,IAAO,GAAK,KACxD,OAAO,MAAM,OAAO,CAAE,CAAC,EAAI,EAAK,OAAO,CAAE,EAE9C,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,QAAO,OAAM,OAAM,CAC/B,CAAC,CAAC,EACF,KAAK,cAAc,IAAI,YAAY,SAAS,IAAS,CACnD,OAAQ,CAAE,OAAM,OAAM,CACxB,CAAC,CAAC,EASJ,mBAAmB,EAAG,CACpB,IAAM,EAAM,KAAK,aAAa,eAAe,EAC7C,GAAI,CAAC,EAAK,MAAO,CAAC,EAClB,OAAO,EAAI,MAAM,QAAQ,EAAE,OAAO,CAAC,IAAS,GAAQ,IAAS,cAAc,EAG7E,QAAQ,EAAG,gBAAgB,CACzB,IAAM,EAAM,KAAK,aAAa,KAAK,EAC7B,EAAQ,GAAgB,KAAK,YAAc,KAAK,YAAc,KAC9D,EAAO,KAAK,UAClB,GAAI,CAAC,GAAS,CAAC,EAAM,OAAO,EAE5B,IAAM,EAAM,IAAI,IAAI,EAAK,OAAO,SAAS,MAAM,EAC/C,GAAI,EAAO,EAAI,aAAa,IAAI,QAAS,CAAK,EAC9C,GAAI,EAAM,EAAI,aAAa,IAAI,OAAQ,CAAI,EAC3C,OAAO,EAAI,SAAS,EAGtB,aAAa,CAAC,EAAK,CACjB,GAAI,CAAE,OAAO,KAAK,MAAM,CAAG,EAAI,KAAM,CAAE,OAAO,MAElD,CAEA,GAAI,CAAC,eAAe,IAAI,qBAAqB,EAC3C,eAAe,OAAO,sBAAuB,CAAwB","names":[]}
|
|
1
|
+
{"version":3,"sources":["src/stream_source_element.js"],"sourcesContent":["// <pgbus-stream-source> — the browser-side counterpart to\n// Pgbus::StreamsHelper#pgbus_stream_from. Drop-in replacement for\n// turbo-rails' <turbo-cable-stream-source> that speaks SSE + pgbus\n// instead of WebSocket + ActionCable.\n//\n// Attributes (set by the view helper):\n// src — absolute path to the SSE endpoint, including\n// the URL-safe signed stream name\n// since-id — PGMQ msg_id watermark at render time; the\n// client sends this as ?since= on the FIRST\n// connect so the server can replay any\n// broadcasts from the render-to-connect gap\n// (rails/rails#52420 fix)\n// signed-stream-name — present for parity with turbo-rails; unused\n// by this element because the signed name is\n// already in the URL path\n// channel — compatibility shim; ignored\n// presence — boolean; when present the element sends this\n// document's presence seat as ?seat= on every\n// connect (issue #515)\n//\n// Events (dispatched on the element):\n// message (MessageEvent) data = turbo-stream HTML;\n// lastEventId = the frame's msg_id (revision)\n// pgbus:message { msgId, data } — same frame, for optimistic-UI\n// reconciliation (skip morph if a newer rev for the\n// target was already applied). See #168.\n// pgbus:event { event, data, msgId } — a typed broadcast (any SSE\n// event name other than turbo-stream). See #170.\n// pgbus:<event> { data, msgId } — the same typed broadcast, named\n// for ergonomic addEventListener(\"pgbus:presence\").\n// pgbus:open { lastEventId, connectionId }\n// pgbus:connected { connectionId }\n// pgbus:replay-start { fromId, toId }\n// pgbus:replay-end {}\n// pgbus:gap-detected { lastSeenId, archiveOldestId }\n// pgbus:close { code, reason }\n//\n// Connection id (issue #165 — actor-echo suppression): the server sends a\n// `pgbus:connected` frame carrying the server-minted connection id once the\n// streamer has registered the connection and joined its presence seat\n// (issue #521), after the connect replay. This element captures it, reflects it onto\n// the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.\n// A page reads it (from the element or a `<meta name=\"pgbus-connection-id\">`\n// the app mirrors it to) and sends it back as the `X-Pgbus-Connection`\n// header on action requests. The broadcaster then passes\n// `exclude: request.headers[\"X-Pgbus-Connection\"]` so the actor does not\n// receive the echo of its own broadcast.\n//\n// The element integrates with Turbo via connectStreamSource /\n// disconnectStreamSource + dispatching MessageEvent(\"message\") so Turbo\n// Stream HTML is automatically consumed by the existing StreamObserver.\n//\n// Presence seat (issue #515): with the `presence` attribute, the element\n// appends `?seat=<id>` to the SSE request. The seat is minted once per\n// document (shared by every presence element on the page and kept across\n// Turbo Drive visits, so it names the browser tab) and stays the same across\n// reconnects. The server stores it on the connection's presence row, which\n// lets a page tell its own tab apart from the member's other tabs. Read it\n// from `element.pgbusSeat`.\n//\n// Transport strategy:\n// - FIRST connect: use fetch() + ReadableStream to include ?since=\n// in the URL. Native EventSource cannot send Last-Event-ID on the\n// initial request, so we need fetch to carry the watermark.\n// - RECONNECT: switch to native EventSource, which sends Last-Event-ID\n// automatically based on the last id: we observed. The native\n// client is more battle-tested for reconnection backoff.\n\nimport { Turbo } from \"@hotwired/turbo-rails\"\nconst { connectStreamSource, disconnectStreamSource } = Turbo\n\nlet documentSeat = null\n\n// One seat per document, minted on first use (see \"Presence seat\" above).\nfunction seatForDocument() {\n if (!documentSeat) {\n documentSeat = globalThis.crypto && crypto.randomUUID\n ? crypto.randomUUID()\n : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`\n }\n return documentSeat\n}\n\nclass PgbusStreamSourceElement extends HTMLElement {\n static get observedAttributes() {\n return [\"src\", \"since-id\"]\n }\n\n constructor() {\n super()\n this.abortController = null\n this.eventSource = null\n this.lastEventId = null\n this.connectionId = null\n this.closed = false\n }\n\n // The server-minted connection id for this SSE connection, or null\n // until the `pgbus:connected` frame arrives. Public read accessor so\n // a reactive runtime can grab it without poking at attributes.\n get pgbusConnectionId() {\n return this.connectionId\n }\n\n // This document's presence seat when the element has the `presence`\n // attribute, otherwise null.\n get pgbusSeat() {\n return this.hasAttribute(\"presence\") ? seatForDocument() : null\n }\n\n connectedCallback() {\n this.closed = false\n connectStreamSource(this)\n const sinceId = this.getAttribute(\"since-id\")\n this.lastEventId = sinceId && sinceId !== \"\" ? sinceId : null\n this.openFetchStream()\n }\n\n disconnectedCallback() {\n this.closed = true\n disconnectStreamSource(this)\n this.teardown()\n }\n\n teardown() {\n if (this.abortController) {\n this.abortController.abort()\n this.abortController = null\n }\n if (this.eventSource) {\n this.eventSource.close()\n this.eventSource = null\n }\n }\n\n // First connect: use fetch() so we can include ?since=<watermark> on\n // the URL. Parses the SSE event stream by hand because EventSource\n // doesn't expose custom query strings uniformly across browsers.\n async openFetchStream() {\n // Each transport open is a fresh connection: the server mints a new id\n // and sends a new pgbus:connected frame. Clear the previous id so\n // pgbus:open (which fires before that frame arrives) can't surface a\n // stale connection id — a stale id would produce a wrong X-Pgbus-Connection\n // exclude header during reconnect windows.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.abortController = new AbortController()\n\n try {\n const response = await fetch(url, {\n headers: { Accept: \"text/event-stream\" },\n credentials: \"same-origin\",\n signal: this.abortController.signal\n })\n\n if (!response.ok) {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: response.status, reason: response.statusText }\n }))\n return\n }\n\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n\n const reader = response.body.getReader()\n const decoder = new TextDecoder(\"utf-8\")\n let buffer = \"\"\n\n while (!this.closed) {\n const { value, done } = await reader.read()\n if (done) {\n this.removeAttribute(\"connected\")\n this.switchToEventSource()\n return\n }\n\n buffer += decoder.decode(value, { stream: true })\n const events = buffer.split(\"\\n\\n\")\n buffer = events.pop() // last chunk may be incomplete\n\n for (const block of events) {\n this.handleBlock(block)\n }\n }\n } catch (err) {\n if (err.name !== \"AbortError\") {\n this.removeAttribute(\"connected\")\n // Fall through to reconnect via native EventSource, which\n // will carry Last-Event-ID from this.lastEventId forward.\n this.switchToEventSource()\n }\n }\n }\n\n // Reconnect path: native EventSource with Last-Event-ID baked into\n // the initial handshake by the browser.\n switchToEventSource() {\n if (this.closed) return\n\n // Fresh connection on reconnect — drop the previous connection id so\n // pgbus:open can't emit a stale one before the new pgbus:connected\n // frame lands. See openFetchStream.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.eventSource = new EventSource(url, { withCredentials: true })\n\n this.eventSource.addEventListener(\"open\", () => {\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n })\n\n this.eventSource.addEventListener(\"error\", () => {\n this.removeAttribute(\"connected\")\n })\n\n this.eventSource.addEventListener(\"turbo-stream\", (event) => {\n this.lastEventId = event.lastEventId\n this.emitTurboStream(event.data, event.lastEventId)\n })\n\n this.eventSource.addEventListener(\"pgbus:connected\", (event) => {\n this.handleConnected(event.data)\n })\n\n this.eventSource.addEventListener(\"pgbus:gap-detected\", (event) => {\n const detail = this.safeJsonParse(event.data)\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", { detail }))\n })\n\n this.eventSource.addEventListener(\"pgbus:shutdown\", () => {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n })\n\n // Typed broadcasts (issue #170): EventSource only fires listeners\n // registered by name, so we register one per declared typed event.\n for (const name of this.declaredTypedEvents()) {\n this.eventSource.addEventListener(name, (event) => {\n if (event.lastEventId) this.lastEventId = event.lastEventId\n this.emitTypedEvent(name, event.data, event.lastEventId)\n })\n }\n }\n\n // Parses a single SSE event block (: comment | id: ... | event: ... | data: ...)\n handleBlock(block) {\n if (!block || block.startsWith(\":\")) return // comment\n\n let id = null\n let event = \"message\"\n const dataLines = []\n\n for (const line of block.split(\"\\n\")) {\n if (line.startsWith(\"id:\")) id = line.slice(3).trim()\n else if (line.startsWith(\"event:\")) event = line.slice(6).trim()\n // Per the SSE spec: strip only a single leading space after the colon\n // (never trim — payload whitespace is significant, issue #392) and\n // rejoin consecutive data: lines with \\n, matching native EventSource.\n else if (line.startsWith(\"data:\")) dataLines.push(line.slice(5).replace(/^ /, \"\"))\n }\n const data = dataLines.join(\"\\n\")\n\n if (id !== null) this.lastEventId = id\n\n if (event === \"turbo-stream\") {\n this.emitTurboStream(data, id)\n } else if (event === \"pgbus:connected\") {\n this.handleConnected(data)\n } else if (event === \"pgbus:gap-detected\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", {\n detail: this.safeJsonParse(data)\n }))\n } else if (event === \"pgbus:shutdown\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n } else {\n // A typed broadcast (issue #170): event: presence | reactive | ...\n this.emitTypedEvent(event, data, id)\n }\n }\n\n // Captures the server-minted connection id from a `pgbus:connected`\n // frame: stores it, reflects it onto the `connection-id` attribute (so\n // it's visible in the DOM / to MutationObservers), and re-dispatches it\n // as a `pgbus:connected` CustomEvent. Idempotent across reconnects — the\n // server mints a fresh id per connection, so a reconnect updates it.\n handleConnected(data) {\n const detail = this.safeJsonParse(data)\n const connectionId = detail && detail.connectionId\n if (!connectionId) return\n\n this.connectionId = connectionId\n this.setAttribute(\"connection-id\", connectionId)\n this.dispatchEvent(new CustomEvent(\"pgbus:connected\", {\n detail: { connectionId }\n }))\n }\n\n // Clears the cached connection id and its reflected attribute. Called at\n // the start of every transport open so a reconnect doesn't carry the\n // previous connection's id into pgbus:open before the new pgbus:connected\n // frame arrives.\n resetConnectionId() {\n this.connectionId = null\n this.removeAttribute(\"connection-id\")\n }\n\n // Delivers a turbo-stream frame to two audiences (issue #168):\n //\n // 1. Turbo's StreamObserver, via a `message` MessageEvent whose `data`\n // is the turbo-stream HTML. The MessageEvent's standard\n // `lastEventId` field carries the frame's msg_id, so a reactive\n // runtime that listens for `message` can read the revision without\n // any pgbus-specific API. (Turbo ignores lastEventId.)\n //\n // 2. A reactive runtime doing optimistic-UI reconciliation, via a\n // `pgbus:message` CustomEvent carrying { msgId, data }. msgId is the\n // monotonic per-stream revision (a negative value marks an ephemeral\n // frame that was never persisted). Pattern: track the highest msgId\n // you have applied per target; when a frame arrives, skip the morph\n // if you have already applied a newer revision for that target —\n // this stops a late echo from clobbering a newer optimistic edit.\n // Complements #165: exclude handles the actor; this handles\n // out-of-order delivery for everyone else.\n //\n // msgId is parsed to a Number when numeric so consumers can compare\n // revisions with `>` directly; left as-is otherwise.\n emitTurboStream(data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n const message = new MessageEvent(\"message\", { data, lastEventId: id == null ? \"\" : String(id) })\n this.dispatchEvent(message)\n\n this.dispatchEvent(new CustomEvent(\"pgbus:message\", {\n detail: { msgId, data }\n }))\n }\n\n // Delivers a typed broadcast (issue #170) — a frame whose SSE event name\n // is something other than turbo-stream (e.g. \"presence\", \"reactive\").\n // The payload is still whatever the broadcaster sent (usually a Turbo\n // Stream); the typed name lets a client route without sniffing the HTML.\n // Dispatched two ways for ergonomics:\n // - a generic `pgbus:event` { event, data, msgId } (one listener for all)\n // - a named `pgbus:<event>` { data, msgId } (addEventListener by name)\n emitTypedEvent(event, data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n this.dispatchEvent(new CustomEvent(\"pgbus:event\", {\n detail: { event, data, msgId }\n }))\n this.dispatchEvent(new CustomEvent(`pgbus:${event}`, {\n detail: { data, msgId }\n }))\n }\n\n // Typed event names the EventSource (reconnect) path should listen for,\n // declared by the app via the `listen-events` attribute (comma- or\n // space-separated). EventSource only invokes listeners registered by\n // name, so unlike the fetch path it cannot route unknown typed events\n // generically; declaring them here keeps typed delivery working across\n // reconnects. The fetch (initial) path always routes typed events.\n declaredTypedEvents() {\n const raw = this.getAttribute(\"listen-events\")\n if (!raw) return []\n return raw.split(/[\\s,]+/).filter((name) => name && name !== \"turbo-stream\")\n }\n\n buildUrl({ includeSince }) {\n const src = this.getAttribute(\"src\")\n const since = includeSince && this.lastEventId ? this.lastEventId : null\n const seat = this.pgbusSeat\n if (!since && !seat) return src\n\n const url = new URL(src, window.location.origin)\n if (since) url.searchParams.set(\"since\", since)\n if (seat) url.searchParams.set(\"seat\", seat)\n return url.toString()\n }\n\n safeJsonParse(str) {\n try { return JSON.parse(str) } catch { return null }\n }\n}\n\nif (!customElements.get(\"pgbus-stream-source\")) {\n customElements.define(\"pgbus-stream-source\", PgbusStreamSourceElement)\n}\n\nexport { PgbusStreamSourceElement }\n"],"mappings":"AAqEA,gBAAS,8BACT,IAAQ,sBAAqB,0BAA2B,EAEpD,EAAe,KAGnB,SAAS,CAAe,EAAG,CACzB,GAAI,CAAC,EACH,EAAe,WAAW,QAAU,OAAO,WACvC,OAAO,WAAW,EAClB,GAAG,KAAK,IAAI,EAAE,SAAS,EAAE,KAAK,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,EAAG,EAAE,IAE1E,OAAO,EAGT,MAAM,UAAiC,WAAY,WACtC,mBAAkB,EAAG,CAC9B,MAAO,CAAC,MAAO,UAAU,EAG3B,WAAW,EAAG,CACZ,MAAM,EACN,KAAK,gBAAkB,KACvB,KAAK,YAAc,KACnB,KAAK,YAAc,KACnB,KAAK,aAAe,KACpB,KAAK,OAAS,MAMZ,kBAAiB,EAAG,CACtB,OAAO,KAAK,gBAKV,UAAS,EAAG,CACd,OAAO,KAAK,aAAa,UAAU,EAAI,EAAgB,EAAI,KAG7D,iBAAiB,EAAG,CAClB,KAAK,OAAS,GACd,EAAoB,IAAI,EACxB,IAAM,EAAU,KAAK,aAAa,UAAU,EAC5C,KAAK,YAAc,GAAW,IAAY,GAAK,EAAU,KACzD,KAAK,gBAAgB,EAGvB,oBAAoB,EAAG,CACrB,KAAK,OAAS,GACd,EAAuB,IAAI,EAC3B,KAAK,SAAS,EAGhB,QAAQ,EAAG,CACT,GAAI,KAAK,gBACP,KAAK,gBAAgB,MAAM,EAC3B,KAAK,gBAAkB,KAEzB,GAAI,KAAK,YACP,KAAK,YAAY,MAAM,EACvB,KAAK,YAAc,UAOjB,gBAAe,EAAG,CAMtB,KAAK,kBAAkB,EACvB,IAAM,EAAM,KAAK,SAAS,CAAE,aAAc,EAAK,CAAC,EAChD,KAAK,gBAAkB,IAAI,gBAE3B,GAAI,CACF,IAAM,EAAW,MAAM,MAAM,EAAK,CAChC,QAAS,CAAE,OAAQ,mBAAoB,EACvC,YAAa,cACb,OAAQ,KAAK,gBAAgB,MAC/B,CAAC,EAED,GAAI,CAAC,EAAS,GAAI,CAChB,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,KAAM,EAAS,OAAQ,OAAQ,EAAS,UAAW,CAC/D,CAAC,CAAC,EACF,OAGF,KAAK,aAAa,YAAa,EAAE,EACjC,KAAK,cAAc,IAAI,YAAY,aAAc,CAC/C,OAAQ,CAAE,YAAa,KAAK,YAAa,aAAc,KAAK,YAAa,CAC3E,CAAC,CAAC,EAEF,IAAM,EAAS,EAAS,KAAK,UAAU,EACjC,EAAU,IAAI,YAAY,OAAO,EACnC,EAAS,GAEb,MAAO,CAAC,KAAK,OAAQ,CACnB,IAAQ,QAAO,QAAS,MAAM,EAAO,KAAK,EAC1C,GAAI,EAAM,CACR,KAAK,gBAAgB,WAAW,EAChC,KAAK,oBAAoB,EACzB,OAGF,GAAU,EAAQ,OAAO,EAAO,CAAE,OAAQ,EAAK,CAAC,EAChD,IAAM,EAAS,EAAO,MAAM;AAAA;AAAA,CAAM,EAClC,EAAS,EAAO,IAAI,EAEpB,QAAW,KAAS,EAClB,KAAK,YAAY,CAAK,GAG1B,MAAO,EAAK,CACZ,GAAI,EAAI,OAAS,aACf,KAAK,gBAAgB,WAAW,EAGhC,KAAK,oBAAoB,GAO/B,mBAAmB,EAAG,CACpB,GAAI,KAAK,OAAQ,OAKjB,KAAK,kBAAkB,EACvB,IAAM,EAAM,KAAK,SAAS,CAAE,aAAc,EAAK,CAAC,EAChD,KAAK,YAAc,IAAI,YAAY,EAAK,CAAE,gBAAiB,EAAK,CAAC,EAEjE,KAAK,YAAY,iBAAiB,OAAQ,IAAM,CAC9C,KAAK,aAAa,YAAa,EAAE,EACjC,KAAK,cAAc,IAAI,YAAY,aAAc,CAC/C,OAAQ,CAAE,YAAa,KAAK,YAAa,aAAc,KAAK,YAAa,CAC3E,CAAC,CAAC,EACH,EAED,KAAK,YAAY,iBAAiB,QAAS,IAAM,CAC/C,KAAK,gBAAgB,WAAW,EACjC,EAED,KAAK,YAAY,iBAAiB,eAAgB,CAAC,IAAU,CAC3D,KAAK,YAAc,EAAM,YACzB,KAAK,gBAAgB,EAAM,KAAM,EAAM,WAAW,EACnD,EAED,KAAK,YAAY,iBAAiB,kBAAmB,CAAC,IAAU,CAC9D,KAAK,gBAAgB,EAAM,IAAI,EAChC,EAED,KAAK,YAAY,iBAAiB,qBAAsB,CAAC,IAAU,CACjE,IAAM,EAAS,KAAK,cAAc,EAAM,IAAI,EAC5C,KAAK,cAAc,IAAI,YAAY,qBAAsB,CAAE,QAAO,CAAC,CAAC,EACrE,EAED,KAAK,YAAY,iBAAiB,iBAAkB,IAAM,CACxD,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,KAAM,WAAY,OAAQ,gBAAiB,CACvD,CAAC,CAAC,EACH,EAID,QAAW,KAAQ,KAAK,oBAAoB,EAC1C,KAAK,YAAY,iBAAiB,EAAM,CAAC,IAAU,CACjD,GAAI,EAAM,YAAa,KAAK,YAAc,EAAM,YAChD,KAAK,eAAe,EAAM,EAAM,KAAM,EAAM,WAAW,EACxD,EAKL,WAAW,CAAC,EAAO,CACjB,GAAI,CAAC,GAAS,EAAM,WAAW,GAAG,EAAG,OAErC,IAAI,EAAK,KACL,EAAQ,UACN,EAAY,CAAC,EAEnB,QAAW,KAAQ,EAAM,MAAM;AAAA,CAAI,EACjC,GAAI,EAAK,WAAW,KAAK,EAAG,EAAK,EAAK,MAAM,CAAC,EAAE,KAAK,EAC/C,QAAI,EAAK,WAAW,QAAQ,EAAG,EAAQ,EAAK,MAAM,CAAC,EAAE,KAAK,EAI1D,QAAI,EAAK,WAAW,OAAO,EAAG,EAAU,KAAK,EAAK,MAAM,CAAC,EAAE,QAAQ,KAAM,EAAE,CAAC,EAEnF,IAAM,EAAO,EAAU,KAAK;AAAA,CAAI,EAEhC,GAAI,IAAO,KAAM,KAAK,YAAc,EAEpC,GAAI,IAAU,eACZ,KAAK,gBAAgB,EAAM,CAAE,EACxB,QAAI,IAAU,kBACnB,KAAK,gBAAgB,CAAI,EACpB,QAAI,IAAU,qBACnB,KAAK,cAAc,IAAI,YAAY,qBAAsB,CACvD,OAAQ,KAAK,cAAc,CAAI,CACjC,CAAC,CAAC,EACG,QAAI,IAAU,iBACnB,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,KAAM,WAAY,OAAQ,gBAAiB,CACvD,CAAC,CAAC,EAGF,UAAK,eAAe,EAAO,EAAM,CAAE,EASvC,eAAe,CAAC,EAAM,CACpB,IAAM,EAAS,KAAK,cAAc,CAAI,EAChC,EAAe,GAAU,EAAO,aACtC,GAAI,CAAC,EAAc,OAEnB,KAAK,aAAe,EACpB,KAAK,aAAa,gBAAiB,CAAY,EAC/C,KAAK,cAAc,IAAI,YAAY,kBAAmB,CACpD,OAAQ,CAAE,cAAa,CACzB,CAAC,CAAC,EAOJ,iBAAiB,EAAG,CAClB,KAAK,aAAe,KACpB,KAAK,gBAAgB,eAAe,EAuBtC,eAAe,CAAC,EAAM,EAAI,CACxB,IAAM,EAAQ,IAAO,MAAQ,IAAO,QAAa,IAAO,GAAK,KACxD,OAAO,MAAM,OAAO,CAAE,CAAC,EAAI,EAAK,OAAO,CAAE,EAExC,EAAU,IAAI,aAAa,UAAW,CAAE,OAAM,YAAa,GAAM,KAAO,GAAK,OAAO,CAAE,CAAE,CAAC,EAC/F,KAAK,cAAc,CAAO,EAE1B,KAAK,cAAc,IAAI,YAAY,gBAAiB,CAClD,OAAQ,CAAE,QAAO,MAAK,CACxB,CAAC,CAAC,EAUJ,cAAc,CAAC,EAAO,EAAM,EAAI,CAC9B,IAAM,EAAQ,IAAO,MAAQ,IAAO,QAAa,IAAO,GAAK,KACxD,OAAO,MAAM,OAAO,CAAE,CAAC,EAAI,EAAK,OAAO,CAAE,EAE9C,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,QAAO,OAAM,OAAM,CAC/B,CAAC,CAAC,EACF,KAAK,cAAc,IAAI,YAAY,SAAS,IAAS,CACnD,OAAQ,CAAE,OAAM,OAAM,CACxB,CAAC,CAAC,EASJ,mBAAmB,EAAG,CACpB,IAAM,EAAM,KAAK,aAAa,eAAe,EAC7C,GAAI,CAAC,EAAK,MAAO,CAAC,EAClB,OAAO,EAAI,MAAM,QAAQ,EAAE,OAAO,CAAC,IAAS,GAAQ,IAAS,cAAc,EAG7E,QAAQ,EAAG,gBAAgB,CACzB,IAAM,EAAM,KAAK,aAAa,KAAK,EAC7B,EAAQ,GAAgB,KAAK,YAAc,KAAK,YAAc,KAC9D,EAAO,KAAK,UAClB,GAAI,CAAC,GAAS,CAAC,EAAM,OAAO,EAE5B,IAAM,EAAM,IAAI,IAAI,EAAK,OAAO,SAAS,MAAM,EAC/C,GAAI,EAAO,EAAI,aAAa,IAAI,QAAS,CAAK,EAC9C,GAAI,EAAM,EAAI,aAAa,IAAI,OAAQ,CAAI,EAC3C,OAAO,EAAI,SAAS,EAGtB,aAAa,CAAC,EAAK,CACjB,GAAI,CAAE,OAAO,KAAK,MAAM,CAAG,EAAI,KAAM,CAAE,OAAO,MAElD,CAEA,GAAI,CAAC,eAAe,IAAI,qBAAqB,EAC3C,eAAe,OAAO,sBAAuB,CAAwB","names":[]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Configuration
|
|
5
|
+
# The authorize hook the engine-mounted StreamApp uses (issue #522). Same
|
|
6
|
+
# contract as StreamApp.new(authorize:):
|
|
7
|
+
#
|
|
8
|
+
# config.streams_authorize = ->(env, stream_name) {
|
|
9
|
+
# user = current_user_from(env)
|
|
10
|
+
# user if user && may_read?(user, stream_name)
|
|
11
|
+
# }
|
|
12
|
+
#
|
|
13
|
+
# A falsy return answers 403, `true` authorizes with no context, any other
|
|
14
|
+
# value becomes the connection context (what visible_to: filters and
|
|
15
|
+
# connection-driven presence read). nil (the default) authorizes every
|
|
16
|
+
# connection with no context. An app that mounts its own
|
|
17
|
+
# StreamApp.new(authorize:) keeps its hook; this one is not consulted.
|
|
18
|
+
#
|
|
19
|
+
# Authorize per stream: the signed name proves pgbus minted it, not that
|
|
20
|
+
# this user may read it (a removed member keeps a working name until the
|
|
21
|
+
# secret rotates). A non-callable value fails validate!; with
|
|
22
|
+
# eager_validation = false every stream request fails closed with a 500.
|
|
23
|
+
module StreamAuthorization
|
|
24
|
+
attr_accessor :streams_authorize
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def validate_stream_authorization!
|
|
29
|
+
return if streams_authorize.nil? || streams_authorize.respond_to?(:call)
|
|
30
|
+
|
|
31
|
+
raise Pgbus::ConfigurationError,
|
|
32
|
+
"streams_authorize must respond to #call (a ->(env, stream_name) { } lambda) or be nil"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "logger"
|
|
|
5
5
|
module Pgbus
|
|
6
6
|
class Configuration
|
|
7
7
|
include PresenceSettings
|
|
8
|
+
include StreamAuthorization
|
|
8
9
|
|
|
9
10
|
# Connection settings
|
|
10
11
|
attr_accessor :database_url, :connection_params, :pool_size, :pool_timeout
|
|
@@ -217,7 +218,7 @@ module Pgbus
|
|
|
217
218
|
|
|
218
219
|
# Streams (turbo-rails replacement, SSE-based)
|
|
219
220
|
attr_accessor :streams_enabled, :streams_path, :streams_signed_name_secret,
|
|
220
|
-
:streams_default_retention, :streams_retention, :streams_heartbeat_interval,
|
|
221
|
+
:streams_default_retention, :streams_retention, :streams_heartbeat_interval, :streams_peer_close_interval,
|
|
221
222
|
:streams_max_connections, :streams_idle_timeout, :streams_listen_health_check_ms,
|
|
222
223
|
:streams_write_deadline_ms, :streams_falcon_streaming_body,
|
|
223
224
|
:streams_stats_enabled, :streams_test_mode,
|
|
@@ -461,6 +462,10 @@ module Pgbus
|
|
|
461
462
|
@streams_default_retention = 5 * 60 # 5 minutes
|
|
462
463
|
@streams_retention = {}
|
|
463
464
|
@streams_heartbeat_interval = 15
|
|
465
|
+
# Seconds a new SSE connection waits to join the peer-close watch; a
|
|
466
|
+
# client that closes a watched socket is noticed at once (issue #519).
|
|
467
|
+
# nil turns the watcher off and leaves disconnects to the heartbeat.
|
|
468
|
+
@streams_peer_close_interval = 1.0
|
|
464
469
|
@streams_max_connections = 2_000
|
|
465
470
|
@streams_idle_timeout = 3_600 # 1 hour
|
|
466
471
|
# 250ms — this value plays two roles: (1) the TCP keepalive
|
|
@@ -1028,6 +1033,11 @@ module Pgbus
|
|
|
1028
1033
|
raise Pgbus::ConfigurationError, "streams_heartbeat_interval must be a positive number"
|
|
1029
1034
|
end
|
|
1030
1035
|
|
|
1036
|
+
unless streams_peer_close_interval.nil? ||
|
|
1037
|
+
(streams_peer_close_interval.is_a?(Numeric) && streams_peer_close_interval.positive?)
|
|
1038
|
+
raise Pgbus::ConfigurationError, "streams_peer_close_interval must be nil or a positive number"
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1031
1041
|
unless streams_idle_timeout.is_a?(Numeric) && streams_idle_timeout.positive?
|
|
1032
1042
|
raise Pgbus::ConfigurationError, "streams_idle_timeout must be a positive number"
|
|
1033
1043
|
end
|
|
@@ -1096,6 +1106,7 @@ module Pgbus
|
|
|
1096
1106
|
end
|
|
1097
1107
|
|
|
1098
1108
|
validate_presence_settings!
|
|
1109
|
+
validate_stream_authorization!
|
|
1099
1110
|
|
|
1100
1111
|
return if streams_orphan_threshold.nil?
|
|
1101
1112
|
return if streams_orphan_threshold.is_a?(Numeric) && streams_orphan_threshold.positive?
|
|
@@ -49,8 +49,9 @@ module Pgbus
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
# Emits a `pgbus:connected` frame carrying the server-minted
|
|
52
|
-
# connection id as JSON. Sent once
|
|
53
|
-
#
|
|
52
|
+
# connection id as JSON. Sent once by the streamer's Dispatcher, after
|
|
53
|
+
# the connection is registered and its presence seat joined (issue
|
|
54
|
+
# #521), so the page can read its own connection id and send it back as
|
|
54
55
|
# `X-Pgbus-Connection` on action requests (actor-echo suppression,
|
|
55
56
|
# issue #165). Deliberately omits an `id:` line: this is connection
|
|
56
57
|
# metadata, not a broadcast, and giving it a cursor id would corrupt
|
data/lib/pgbus/version.rb
CHANGED
data/lib/pgbus/web/stream_app.rb
CHANGED
|
@@ -16,13 +16,16 @@ module Pgbus
|
|
|
16
16
|
# Call flow (happy path, Puma 6.1+ hijack):
|
|
17
17
|
# 1. Parse the signed name from PATH_INFO
|
|
18
18
|
# 2. Verify it via Pgbus::Streams::SignedName (raises → 404)
|
|
19
|
-
# 3. Run the authorize hook
|
|
19
|
+
# 3. Run the authorize hook: authorize:, else config.streams_authorize
|
|
20
|
+
# (falsy → 403)
|
|
20
21
|
# 4. Parse the cursor from ?since= or Last-Event-ID
|
|
21
22
|
# 5. Check streams_max_connections per worker (over → 503)
|
|
22
23
|
# 6. Check rack.hijack? (missing → 501)
|
|
23
24
|
# 7. Hijack, write the HTTP response line + SSE headers + opening
|
|
24
25
|
# comment directly to the socket
|
|
25
|
-
# 8. Build a Connection, hand to Streamer.current.register(...)
|
|
26
|
+
# 8. Build a Connection, hand to Streamer.current.register(...). The
|
|
27
|
+
# Dispatcher writes the pgbus:connected frame once the connection
|
|
28
|
+
# is registered and its presence seat joined (issue #521)
|
|
26
29
|
# 9. Return [-1, {}, []] (Puma's async protocol)
|
|
27
30
|
#
|
|
28
31
|
# On errors, returns a normal [status, headers, body] response so
|
|
@@ -38,11 +41,15 @@ module Pgbus
|
|
|
38
41
|
TEST_MODE_RETRY_MS = 86_400_000
|
|
39
42
|
private_constant :PATH_PREFIX
|
|
40
43
|
|
|
44
|
+
# Used when neither authorize: nor config.streams_authorize is set.
|
|
45
|
+
ALLOW_ALL = ->(_env, _stream_name) { true }
|
|
46
|
+
private_constant :ALLOW_ALL
|
|
47
|
+
|
|
41
48
|
def initialize(streamer: nil, config: nil, logger: nil, authorize: nil)
|
|
42
49
|
@streamer_override = streamer
|
|
43
50
|
@config_override = config
|
|
44
51
|
@logger_override = logger
|
|
45
|
-
@authorize = authorize
|
|
52
|
+
@authorize = authorize
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
def call(env)
|
|
@@ -55,7 +62,7 @@ module Pgbus
|
|
|
55
62
|
stream_name = verify!(signed_name)
|
|
56
63
|
return not_found("invalid signed stream name") if stream_name.nil?
|
|
57
64
|
|
|
58
|
-
authorize_result =
|
|
65
|
+
authorize_result = authorize_hook.call(env, stream_name)
|
|
59
66
|
return forbidden unless authorize_result
|
|
60
67
|
|
|
61
68
|
# If authorize returned a non-boolean value (e.g. a User model),
|
|
@@ -134,7 +141,7 @@ module Pgbus
|
|
|
134
141
|
io = hijack_result || env["rack.hijack_io"]
|
|
135
142
|
|
|
136
143
|
connection_id = SecureRandom.hex(8)
|
|
137
|
-
write_headers(io, stream_name: stream_name, since_id: since_id
|
|
144
|
+
write_headers(io, stream_name: stream_name, since_id: since_id)
|
|
138
145
|
|
|
139
146
|
connection = Pgbus::Web::Streamer::Connection.new(
|
|
140
147
|
id: connection_id,
|
|
@@ -162,7 +169,6 @@ module Pgbus
|
|
|
162
169
|
body.write(Pgbus::Streams::Envelope.comment(
|
|
163
170
|
"pgbus stream open since_id=#{since_id} stream=#{stream_name}"
|
|
164
171
|
))
|
|
165
|
-
body.write(Pgbus::Streams::Envelope.connected(id: connection_id))
|
|
166
172
|
|
|
167
173
|
connection = Pgbus::Web::Streamer::FalconConnection.new(
|
|
168
174
|
id: connection_id,
|
|
@@ -186,11 +192,22 @@ module Pgbus
|
|
|
186
192
|
}
|
|
187
193
|
end
|
|
188
194
|
|
|
189
|
-
def write_headers(io, stream_name:, since_id
|
|
195
|
+
def write_headers(io, stream_name:, since_id:)
|
|
190
196
|
io.write(Pgbus::Streams::Envelope.http_response_headers)
|
|
191
197
|
io.write(Pgbus::Streams::Envelope.retry_directive(2_000))
|
|
192
198
|
io.write(Pgbus::Streams::Envelope.comment("pgbus stream open since_id=#{since_id} stream=#{stream_name}"))
|
|
193
|
-
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# An explicit authorize: wins; otherwise config.streams_authorize, read
|
|
202
|
+
# per request so the engine's mount (built when routes load) sees a hook
|
|
203
|
+
# set in an initializer that runs later (issue #522). Only nil means
|
|
204
|
+
# "unset": any other non-callable (false included) must reach #call and
|
|
205
|
+
# fail closed with a 500, never fall through to ALLOW_ALL.
|
|
206
|
+
def authorize_hook
|
|
207
|
+
return @authorize unless @authorize.nil?
|
|
208
|
+
|
|
209
|
+
hook = config.streams_authorize
|
|
210
|
+
hook.nil? ? ALLOW_ALL : hook
|
|
194
211
|
end
|
|
195
212
|
|
|
196
213
|
def streamer
|
|
@@ -88,20 +88,21 @@ module Pgbus
|
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
def write_comment(text)
|
|
91
|
-
|
|
92
|
-
result = @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
|
|
93
|
-
if result == :ok
|
|
94
|
-
@last_write_at = monotonic
|
|
95
|
-
else
|
|
96
|
-
mark_dead!
|
|
97
|
-
end
|
|
98
|
-
result
|
|
91
|
+
write_frame(Pgbus::Streams::Envelope.comment(text))
|
|
99
92
|
end
|
|
100
93
|
|
|
101
94
|
def write_sentinel(bytes)
|
|
102
95
|
@writer.write(self, bytes, deadline_ms: @write_deadline_ms)
|
|
103
96
|
end
|
|
104
97
|
|
|
98
|
+
# The pgbus:connected frame carrying this connection's id. Written by
|
|
99
|
+
# the Dispatcher once the connection is registered and its presence
|
|
100
|
+
# seat joined (issue #521), so a client that acts on the id right
|
|
101
|
+
# after the event finds its row.
|
|
102
|
+
def write_connected
|
|
103
|
+
write_frame(Pgbus::Streams::Envelope.connected(id: @id))
|
|
104
|
+
end
|
|
105
|
+
|
|
105
106
|
def idle_for
|
|
106
107
|
monotonic - @last_write_at
|
|
107
108
|
end
|
|
@@ -142,6 +143,16 @@ module Pgbus
|
|
|
142
143
|
|
|
143
144
|
private
|
|
144
145
|
|
|
146
|
+
def write_frame(bytes)
|
|
147
|
+
result = @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
|
|
148
|
+
if result == :ok
|
|
149
|
+
@last_write_at = monotonic
|
|
150
|
+
else
|
|
151
|
+
mark_dead!
|
|
152
|
+
end
|
|
153
|
+
result
|
|
154
|
+
end
|
|
155
|
+
|
|
145
156
|
# The SSE event name for a frame: the envelope's typed event when
|
|
146
157
|
# present, else the default turbo-stream. Plain ReadAfter::Envelopes
|
|
147
158
|
# (no event field) and StreamEnvelopes with a nil event both fall
|
|
@@ -74,14 +74,12 @@ module Pgbus
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def write_comment(text)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
end
|
|
84
|
-
result
|
|
77
|
+
write_frame(Pgbus::Streams::Envelope.comment(text))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# See Connection#write_connected (issue #521).
|
|
81
|
+
def write_connected
|
|
82
|
+
write_frame(Pgbus::Streams::Envelope.connected(id: @id))
|
|
85
83
|
end
|
|
86
84
|
|
|
87
85
|
def write_sentinel(bytes)
|
|
@@ -120,6 +118,16 @@ module Pgbus
|
|
|
120
118
|
event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
|
|
121
119
|
end
|
|
122
120
|
|
|
121
|
+
def write_frame(bytes)
|
|
122
|
+
result = write_to_body(bytes)
|
|
123
|
+
if result == :ok
|
|
124
|
+
@last_write_at = monotonic
|
|
125
|
+
else
|
|
126
|
+
mark_dead!
|
|
127
|
+
end
|
|
128
|
+
result
|
|
129
|
+
end
|
|
130
|
+
|
|
123
131
|
def write_to_body(bytes)
|
|
124
132
|
@mutex.synchronize do
|
|
125
133
|
return :closed if @dead || @body.closed?
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
module Pgbus
|
|
4
4
|
module Web
|
|
5
5
|
module Streamer
|
|
6
|
-
# Composes the Streamer's
|
|
7
|
-
# Heartbeat
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# Composes the Streamer's background threads (Listener, Dispatcher,
|
|
7
|
+
# Heartbeat, and the PeerCloseWatcher unless streams_peer_close_interval
|
|
8
|
+
# is nil) with the shared Registry and dispatch_queue. One Instance per
|
|
9
|
+
# Puma worker. Owns the lifecycle of those threads and the dedicated
|
|
10
|
+
# PG::Connection for LISTEN.
|
|
10
11
|
#
|
|
11
12
|
# Lifecycle:
|
|
12
13
|
# Instance.new(...) — allocates wiring, does NOT start threads
|
|
13
|
-
# #start — spawns listener/dispatcher/heartbeat in order
|
|
14
|
+
# #start — spawns listener/dispatcher/heartbeat/watcher in order
|
|
14
15
|
# #register(conn) — enqueues a ConnectMessage into the dispatch queue
|
|
15
16
|
# #shutdown! — sends shutdown sentinel to every connection and
|
|
16
17
|
# stops all threads in reverse order
|
|
@@ -21,7 +22,7 @@ module Pgbus
|
|
|
21
22
|
# defaults from the configuration.
|
|
22
23
|
class Instance
|
|
23
24
|
attr_reader :registry, :listener, :dispatcher, :heartbeat, :dispatch_queue, :stream_counter, :pump,
|
|
24
|
-
:autoscaler
|
|
25
|
+
:autoscaler, :peer_close_watcher
|
|
25
26
|
|
|
26
27
|
def initialize(
|
|
27
28
|
client: Pgbus.client,
|
|
@@ -77,6 +78,7 @@ module Pgbus
|
|
|
77
78
|
logger: @logger,
|
|
78
79
|
presence_expiry: Array(@config.streams_presence_patterns).any?
|
|
79
80
|
)
|
|
81
|
+
@peer_close_watcher = build_peer_close_watcher
|
|
80
82
|
@started = false
|
|
81
83
|
@shutdown_mutex = Mutex.new
|
|
82
84
|
end
|
|
@@ -91,6 +93,7 @@ module Pgbus
|
|
|
91
93
|
@listener.start
|
|
92
94
|
@dispatcher.start
|
|
93
95
|
@heartbeat.start
|
|
96
|
+
@peer_close_watcher&.start
|
|
94
97
|
self
|
|
95
98
|
end
|
|
96
99
|
|
|
@@ -119,7 +122,9 @@ module Pgbus
|
|
|
119
122
|
end
|
|
120
123
|
|
|
121
124
|
# Graceful shutdown for Puma worker restart. Order matters:
|
|
122
|
-
#
|
|
125
|
+
# 0. Peer-close watcher first (issue #519): stop reaping sockets
|
|
126
|
+
# we're about to close ourselves; #stop wakes its select at once
|
|
127
|
+
# 1. Heartbeat next (stop writing comments to connections we're
|
|
123
128
|
# about to close)
|
|
124
129
|
# 2. Listener next (stop accepting new NOTIFYs)
|
|
125
130
|
# 3. Dispatcher next (drain the queue; it's now finite because
|
|
@@ -150,6 +155,7 @@ module Pgbus
|
|
|
150
155
|
|
|
151
156
|
@started = false
|
|
152
157
|
threads = component_threads
|
|
158
|
+
safely { @peer_close_watcher&.stop }
|
|
153
159
|
safely { @heartbeat.stop }
|
|
154
160
|
safely { @listener.stop }
|
|
155
161
|
safely { @dispatcher.stop }
|
|
@@ -242,6 +248,7 @@ module Pgbus
|
|
|
242
248
|
def component_threads
|
|
243
249
|
{
|
|
244
250
|
"heartbeat" => @heartbeat.threads,
|
|
251
|
+
"peer_close_watcher" => @peer_close_watcher ? @peer_close_watcher.threads : [],
|
|
245
252
|
"listener" => @listener.threads,
|
|
246
253
|
"dispatcher" => @dispatcher.threads,
|
|
247
254
|
"pump" => @pump ? @pump.threads : []
|
|
@@ -276,6 +283,15 @@ module Pgbus
|
|
|
276
283
|
)
|
|
277
284
|
end
|
|
278
285
|
|
|
286
|
+
# Notices a closed SSE client at once instead of on the next
|
|
287
|
+
# heartbeat write (issue #519). nil interval: heartbeat only.
|
|
288
|
+
def build_peer_close_watcher
|
|
289
|
+
interval = @config.streams_peer_close_interval
|
|
290
|
+
return nil unless interval
|
|
291
|
+
|
|
292
|
+
PeerCloseWatcher.new(registry: @registry, dispatch_queue: @dispatch_queue, interval: interval, logger: @logger)
|
|
293
|
+
end
|
|
294
|
+
|
|
279
295
|
def build_pump
|
|
280
296
|
threads = @config.streams_writer_threads
|
|
281
297
|
return nil unless threads.positive?
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
module Streamer
|
|
6
|
+
# Notices an SSE client that went away the moment its socket says so,
|
|
7
|
+
# instead of on the next heartbeat write (issue #519).
|
|
8
|
+
#
|
|
9
|
+
# The server never reads a hijacked SSE socket after the request, so a
|
|
10
|
+
# closed tab used to stay registered (and present) until a heartbeat
|
|
11
|
+
# write failed, up to streams_heartbeat_interval later. Often longer:
|
|
12
|
+
# the first write after the client's FIN still succeeds. The client
|
|
13
|
+
# sends nothing after its request, so the socket only turns readable
|
|
14
|
+
# when the peer closes or resets it. This thread waits on every live
|
|
15
|
+
# socket with IO.select and probes the readable ones with a
|
|
16
|
+
# non-blocking read. EOF or a reset means the client is gone: the
|
|
17
|
+
# connection is marked dead, closed, and handed to the dispatcher's
|
|
18
|
+
# disconnect path, the same DisconnectMessage the heartbeat posts. That
|
|
19
|
+
# path is idempotent, so a heartbeat racing the watcher still leaves
|
|
20
|
+
# the seat once.
|
|
21
|
+
#
|
|
22
|
+
# Only connections with a real socket are watched: Puma's and Falcon's
|
|
23
|
+
# rack.hijack paths. Falcon's experimental streaming-body path
|
|
24
|
+
# (FalconConnection) exposes no socket and keeps the heartbeat.
|
|
25
|
+
#
|
|
26
|
+
# Half-close: a client that shuts down only its write side (SHUT_WR)
|
|
27
|
+
# reads as EOF and is reaped. EventSource, fetch streams and reverse
|
|
28
|
+
# proxies never half-close an SSE request, so EOF means the client left.
|
|
29
|
+
#
|
|
30
|
+
# The probe takes the connection's write mutex with try_lock: a TLS
|
|
31
|
+
# wrapper (Puma's MiniSSL) is not safe to read while IoWriter writes
|
|
32
|
+
# through it. A socket mid-write is skipped and probed on a later pass.
|
|
33
|
+
#
|
|
34
|
+
# `interval` bounds how long a new connection waits to join the
|
|
35
|
+
# watched set; a close on a watched socket wakes the select at once,
|
|
36
|
+
# and #stop wakes it through a self-pipe.
|
|
37
|
+
class PeerCloseWatcher
|
|
38
|
+
# Stray bytes from a client are read and discarded in chunks of this.
|
|
39
|
+
DRAIN_BYTES = 4096
|
|
40
|
+
|
|
41
|
+
# How long #stop waits for the thread. It is woken at once, so this
|
|
42
|
+
# only bounds a reap stuck closing a socket (see Heartbeat#stop).
|
|
43
|
+
STOP_JOIN_SECONDS = 5
|
|
44
|
+
|
|
45
|
+
def initialize(registry:, dispatch_queue:, interval: 1.0, logger: Pgbus.logger)
|
|
46
|
+
@registry = registry
|
|
47
|
+
@queue = dispatch_queue
|
|
48
|
+
@interval = interval
|
|
49
|
+
@logger = logger
|
|
50
|
+
@running = false
|
|
51
|
+
@thread = nil
|
|
52
|
+
@wake_reader, @wake_writer = IO.pipe
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def start
|
|
56
|
+
return if @running
|
|
57
|
+
|
|
58
|
+
@wake_reader, @wake_writer = IO.pipe if @wake_reader.closed?
|
|
59
|
+
@running = true
|
|
60
|
+
@thread = Thread.new { run_loop }
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# See Heartbeat#threads (issue #443).
|
|
65
|
+
def threads
|
|
66
|
+
[@thread].compact
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def stop
|
|
70
|
+
return unless @running
|
|
71
|
+
|
|
72
|
+
@running = false
|
|
73
|
+
wake
|
|
74
|
+
@thread&.join(STOP_JOIN_SECONDS)
|
|
75
|
+
@thread = nil
|
|
76
|
+
@wake_reader.close
|
|
77
|
+
@wake_writer.close
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# One wait-and-probe pass, at most `interval` long. Public for tests.
|
|
82
|
+
def tick
|
|
83
|
+
by_socket = watched_sockets
|
|
84
|
+
ready, = IO.select([@wake_reader, *by_socket.keys], nil, nil, @interval)
|
|
85
|
+
Array(ready).each do |socket|
|
|
86
|
+
socket.equal?(@wake_reader) ? drain_wake : probe(by_socket[socket])
|
|
87
|
+
end
|
|
88
|
+
rescue IOError, Errno::EBADF => e
|
|
89
|
+
# A socket was closed under the select (shutdown, a dispatcher
|
|
90
|
+
# write that failed). The next pass rebuilds the set without it.
|
|
91
|
+
@logger.debug { "[Pgbus::Streamer::PeerCloseWatcher] select interrupted: #{e.class}: #{e.message}" }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def wake
|
|
97
|
+
@wake_writer.write_nonblock(".", exception: false)
|
|
98
|
+
rescue IOError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def drain_wake
|
|
103
|
+
@wake_reader.read_nonblock(64, exception: false)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# One socket's probe never ends the pass for the others.
|
|
107
|
+
def probe(connection)
|
|
108
|
+
return unless connection.mutex.try_lock
|
|
109
|
+
|
|
110
|
+
closed = begin
|
|
111
|
+
peer_closed?(connection)
|
|
112
|
+
ensure
|
|
113
|
+
connection.mutex.unlock
|
|
114
|
+
end
|
|
115
|
+
reap(connection) if closed
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
@logger.error { "[Pgbus::Streamer::PeerCloseWatcher] probe of #{connection.id} raised: #{e.class}: #{e.message}" }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_loop
|
|
121
|
+
while @running
|
|
122
|
+
begin
|
|
123
|
+
tick
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
@logger.error { "[Pgbus::Streamer::PeerCloseWatcher] tick raised: #{e.class}: #{e.message}" }
|
|
126
|
+
sleep @interval
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# select needs a real IO: Puma's TLS socket and Falcon's hijacked
|
|
132
|
+
# stream answer #to_io with the underlying socket.
|
|
133
|
+
def watched_sockets
|
|
134
|
+
sockets = {}
|
|
135
|
+
@registry.each_connection do |connection|
|
|
136
|
+
next if connection.dead?
|
|
137
|
+
|
|
138
|
+
socket = socket_of(connection)
|
|
139
|
+
sockets[socket] = connection if socket
|
|
140
|
+
end
|
|
141
|
+
sockets
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def socket_of(connection)
|
|
145
|
+
io = connection.io
|
|
146
|
+
return nil unless io.respond_to?(:to_io) && io.respond_to?(:read_nonblock)
|
|
147
|
+
|
|
148
|
+
socket = io.to_io
|
|
149
|
+
socket if socket.is_a?(::BasicSocket) && !socket.closed?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def peer_closed?(connection)
|
|
153
|
+
# A nil read is EOF; :wait_readable (TLS, a spurious wake) and stray
|
|
154
|
+
# bytes leave the connection alone.
|
|
155
|
+
connection.io.read_nonblock(DRAIN_BYTES, exception: false).nil?
|
|
156
|
+
rescue IO::WaitReadable
|
|
157
|
+
# A socket wrapper that raises instead of honouring exception:
|
|
158
|
+
# false (EAGAIN is a SystemCallError, so this rescue comes first).
|
|
159
|
+
false
|
|
160
|
+
rescue IOError, SystemCallError, *tls_errors
|
|
161
|
+
true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# A TLS socket whose client vanished without close_notify raises its
|
|
165
|
+
# library's SSL error ("unexpected eof while reading") on read.
|
|
166
|
+
def tls_errors
|
|
167
|
+
errors = []
|
|
168
|
+
errors << ::OpenSSL::SSL::SSLError if defined?(::OpenSSL::SSL::SSLError)
|
|
169
|
+
errors << ::Puma::MiniSSL::SSLError if defined?(::Puma::MiniSSL::SSLError)
|
|
170
|
+
errors
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def reap(connection)
|
|
174
|
+
return if connection.dead?
|
|
175
|
+
|
|
176
|
+
connection.mark_dead!
|
|
177
|
+
connection.close
|
|
178
|
+
@queue << StreamEventDispatcher::DisconnectMessage.new(connection: connection)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -401,6 +401,16 @@ module Pgbus
|
|
|
401
401
|
@stream_counter.increment_connections(stream)
|
|
402
402
|
@registry.register(connection)
|
|
403
403
|
presence_join(connection, stream)
|
|
404
|
+
# Step 6: announce the connection id only now (issue #521). A
|
|
405
|
+
# client that writes keyed by it right after pgbus:connected
|
|
406
|
+
# (Presence#set_state) must find the registered connection and
|
|
407
|
+
# its presence row. If that write finds the socket gone, run the
|
|
408
|
+
# disconnect now rather than leave the connection registered,
|
|
409
|
+
# and its seat on the roster, until the next heartbeat. A later
|
|
410
|
+
# DisconnectMessage for it (heartbeat, peer-close watcher) is a
|
|
411
|
+
# no-op: unregister reports false and the seat has already left.
|
|
412
|
+
connection.write_connected
|
|
413
|
+
handle_disconnect(DisconnectMessage.new(connection: connection)) if connection.dead?
|
|
404
414
|
end
|
|
405
415
|
|
|
406
416
|
record_stat(
|
|
@@ -409,10 +419,12 @@ module Pgbus
|
|
|
409
419
|
started_at: started_at
|
|
410
420
|
)
|
|
411
421
|
rescue StandardError => e
|
|
412
|
-
# Same leak path for exceptions in steps 1-
|
|
422
|
+
# Same leak path for exceptions in steps 1-6. Mark dead and
|
|
413
423
|
# scrub state so a transient failure on a single connect
|
|
414
424
|
# doesn't permanently bloat @full_to_logical or leave a
|
|
415
|
-
# dangling LISTEN on the PG connection.
|
|
425
|
+
# dangling LISTEN on the PG connection. A connection already
|
|
426
|
+
# registered in step 5 is dead now, so the heartbeat sweep
|
|
427
|
+
# unregisters it (and leaves its seat) like any dead connection.
|
|
416
428
|
remove_in_flight(stream, connection)
|
|
417
429
|
@scanned_cursor.delete(connection)
|
|
418
430
|
cleanup_stream_if_unused(stream)
|
|
@@ -429,6 +441,10 @@ module Pgbus
|
|
|
429
441
|
@stream_counter.decrement_connections(stream) if removed
|
|
430
442
|
presence_leave(connection, stream)
|
|
431
443
|
cleanup_stream_if_unused(stream)
|
|
444
|
+
# A second DisconnectMessage for the same connection (heartbeat,
|
|
445
|
+
# peer-close watcher, a failed pgbus:connected write) is a no-op
|
|
446
|
+
# and must not count as another disconnect.
|
|
447
|
+
return unless removed
|
|
432
448
|
|
|
433
449
|
record_stat(
|
|
434
450
|
stream_name: stream,
|
data/lib/pgbus.rb
CHANGED
|
@@ -289,8 +289,10 @@ module Pgbus
|
|
|
289
289
|
# Do NOT call close — the parent's @pgmq_mutex is in undefined
|
|
290
290
|
# state post-fork and attempting to acquire it can deadlock.
|
|
291
291
|
# The next call to Pgbus.client will lazily create a fresh one.
|
|
292
|
+
# Cached streams hold the client they were built with, so they go too.
|
|
292
293
|
def reset_client!
|
|
293
294
|
@client = nil
|
|
295
|
+
@stream_cache = nil
|
|
294
296
|
end
|
|
295
297
|
|
|
296
298
|
def logger
|
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.
|
|
4
|
+
version: 0.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -316,6 +316,7 @@ files:
|
|
|
316
316
|
- lib/pgbus/configuration.rb
|
|
317
317
|
- lib/pgbus/configuration/capsule_dsl.rb
|
|
318
318
|
- lib/pgbus/configuration/presence_settings.rb
|
|
319
|
+
- lib/pgbus/configuration/stream_authorization.rb
|
|
319
320
|
- lib/pgbus/current_attributes.rb
|
|
320
321
|
- lib/pgbus/database_tasks_guard.rb
|
|
321
322
|
- lib/pgbus/dead_letter_header.rb
|
|
@@ -477,6 +478,7 @@ files:
|
|
|
477
478
|
- lib/pgbus/web/streamer/master_hub.rb
|
|
478
479
|
- lib/pgbus/web/streamer/master_hub_boot.rb
|
|
479
480
|
- lib/pgbus/web/streamer/outbound_pump.rb
|
|
481
|
+
- lib/pgbus/web/streamer/peer_close_watcher.rb
|
|
480
482
|
- lib/pgbus/web/streamer/presence_tracker.rb
|
|
481
483
|
- lib/pgbus/web/streamer/registry.rb
|
|
482
484
|
- lib/pgbus/web/streamer/stream_counter.rb
|