Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
a7ed120
docs(realtime): add v3 API design proposal and backend questions
grdsdev May 7, 2026
3b6ad31
docs(realtime): collapse ActiveChannel into ChannelSubscription
grdsdev May 7, 2026
98ba41b
docs(realtime): make PhoenixMessage fully raw, defer subscription sta…
grdsdev May 7, 2026
aaf460c
docs(realtime): add topic to PhoenixMessage for cross-boundary routing
grdsdev May 7, 2026
57a5a20
docs(realtime): redesign ChangeRegistration generics, lock leave-inva…
grdsdev May 7, 2026
d930ae0
docs(realtime): final review pass — fix typing bugs and stale references
grdsdev May 7, 2026
9554cd7
docs(realtime): add backend findings for v3 questions
grdsdev Jun 27, 2026
5a1cb96
docs(realtime): align v3 design with backend findings
grdsdev Jun 27, 2026
d2d0987
docs(realtime): refine v3 channel design
grdsdev Jun 27, 2026
147ce66
docs(realtime): make v3 API Swift 6 idiomatic (isolation contract, pl…
grdsdev Jun 27, 2026
2d24d8e
docs(realtime): drop Sendable hub — Channel owns all state as a plain…
grdsdev Jun 27, 2026
43b686f
docs(realtime): lower floor to Swift 6.1 / iOS 16
grdsdev Jun 27, 2026
62c9760
feat(realtime-v3): scaffold RealtimeV3 target and test target
grdsdev Jun 27, 2026
3619ef6
feat(realtime-v3): add core value types (JSONValue, RealtimeError, Ch…
grdsdev Jun 27, 2026
0a8c21b
feat(realtime-v3): define RealtimeTransport/RealtimeConnection protocols
grdsdev Jun 27, 2026
7a1def1
feat(realtime-v3): add PhoenixMessage and PhoenixPayload
grdsdev Jun 27, 2026
581be4a
feat(realtime-v3): port Phoenix v2 text-frame serialization
grdsdev Jun 27, 2026
094a90b
feat(realtime-v3): port Phoenix v2 binary broadcast serialization
grdsdev Jun 27, 2026
47d08c1
test(realtime-v3): strengthen binary serializer assertions
grdsdev Jun 27, 2026
7feca86
test(realtime-v3): add InMemoryTransport test double
grdsdev Jun 27, 2026
35a040b
test(realtime-v3): fix InMemoryTransport task leak and dead code
grdsdev Jun 27, 2026
7bfb363
feat(realtime-v3): add ref generator and in-flight push/ack registry
grdsdev Jun 27, 2026
4f99783
feat(realtime-v3): add Configuration, ReconnectionPolicy, LifecyclePo…
grdsdev Jun 29, 2026
d3a3691
fix(realtime-v3): clamp reconnection jitter and broaden policy tests
grdsdev Jun 29, 2026
0d43904
feat(realtime-v3): Realtime actor skeleton with channel registry and …
grdsdev Jun 29, 2026
2accc0b
refactor(realtime-v3): [String:String] headers, PhoenixEvent, drop Re…
grdsdev Jun 29, 2026
3b0ed4c
feat(realtime-v3): inbound frame routing and URLSessionTransport
grdsdev Jun 29, 2026
acbb6ff
fix(realtime-v3): tighten routing task visibility, transport send gua…
grdsdev Jun 29, 2026
a083f64
feat(realtime-v3): heartbeat scheduling with injected clock
grdsdev Jun 29, 2026
9e49bea
fix(realtime-v3): idempotent handleConnectionLost, heartbeat latency …
grdsdev Jun 29, 2026
053ad93
feat(realtime-v3): automatic reconnection with backoff policy
grdsdev Jun 29, 2026
c34643a
fix(realtime-v3): silence transport warning, correct reconnection doc…
grdsdev Jun 29, 2026
edb54ab
feat(realtime-v3): disconnect() preserving channel cache
grdsdev Jun 29, 2026
8ed7225
feat(realtime-v3): channel state stream and weak Realtime back-reference
grdsdev Jun 29, 2026
db49740
feat(realtime-v3): channel subscribe() with phx_join and join timeout
grdsdev Jun 29, 2026
4fbb078
fix(realtime-v3): buffer early push replies, harden subscribe state g…
grdsdev Jun 29, 2026
9ab2e92
feat(realtime-v3): channel leave() with global teardown
grdsdev Jun 29, 2026
c315bbd
test(realtime-v3): clean up fan-out subscriber continuations on termi…
grdsdev Jun 29, 2026
78984b5
feat(realtime-v3): raw messages() feed with per-call fan-out
grdsdev Jun 29, 2026
b43fd78
fix(realtime-v3): remove redundant error casts and awaits for a zero-…
grdsdev Jun 29, 2026
a26ed61
feat(realtime-v3): updateToken pushes access_token to joined channels
grdsdev Jun 29, 2026
b00b6e8
feat(realtime-v3): typed broadcasts(of:event:) receive
grdsdev Jun 29, 2026
c4fa4a2
feat(realtime-v3): WebSocket broadcast send with ack and Data overload
grdsdev Jun 29, 2026
3eb84bf
feat(realtime-v3): HTTP one-shot and batch broadcast via _HTTPClient
grdsdev Jun 29, 2026
95fa6aa
fix(realtime-v3): correctly assign isPrivate and inject auth header f…
grdsdev Jun 29, 2026
f28ea39
feat(realtime-v3): presence state/diff decoding types
grdsdev Jun 29, 2026
475870c
fix(realtime-v3): strong Channel ref in Presence, add presence decode…
grdsdev Jun 29, 2026
aff5f60
feat(realtime-v3): presence track/update/cancel
grdsdev Jun 29, 2026
67ef36e
feat(realtime-v3): presence observe/diffs streams
grdsdev Jun 29, 2026
b9766e4
perf(realtime-v3): derive presence diff from single ref-aware decode
grdsdev Jun 29, 2026
8de888f
feat(realtime-v3): UntypedFilter and postgres filter value encoding
grdsdev Jun 29, 2026
3d046ed
feat(realtime-v3): postgres change registration baked into phx_join
grdsdev Jun 29, 2026
b6d3647
feat(realtime-v3): postgresChanges(for:) consumption with ids fan-out
grdsdev Jun 29, 2026
1e5f622
fix(realtime-v3): postgres decode failures finish the stream with .de…
grdsdev Jun 29, 2026
833bf9d
feat(realtime-v3): transparent channel re-join and presence re-track …
grdsdev Jun 29, 2026
e77715a
fix(realtime-v3): clear shouldRejoin on server-rejected rejoin to avo…
grdsdev Jun 29, 2026
28e1ffc
feat(realtime-v3): automatic app lifecycle reconnection
grdsdev Jun 29, 2026
d8a2d69
test(realtime-v3): exercise real connected-state guard in lifecycle n…
grdsdev Jun 29, 2026
c9e09de
feat(realtime-v3): RealtimeLogger with OSLog and stdout sinks
grdsdev Jun 29, 2026
5e7e750
fix(realtime-v3): UTC timestamps in StdoutLogger, private Channel.log
grdsdev Jun 29, 2026
e362f24
feat(realtime-v3): debug warning for leaked (joined-but-unleft) channels
grdsdev Jun 29, 2026
b19e057
fix(realtime-v3): discard unused withValue results in joinedTopics ma…
grdsdev Jun 29, 2026
fa9c9b0
fix(realtime-v3): handle server-initiated channel close, honor Config…
grdsdev Jun 29, 2026
d4787a0
feat(realtime-v3): idle-socket close via disconnectOnEmptyChannelsAfter
grdsdev Jun 29, 2026
edcc857
test(realtime-v3): integration harness + connection/subscribe e2e aga…
grdsdev Jun 29, 2026
1d034a6
fix(realtime-v3): append /websocket and prepend realtime: topic in ch…
grdsdev Jun 29, 2026
68a49a0
test(realtime-v3): broadcast/presence/postgres/reconnect e2e against …
grdsdev Jun 29, 2026
1c2329e
fix(realtime-v3): strip realtime: prefix from Channel.httpBroadcast H…
grdsdev Jun 29, 2026
609dfe4
docs(realtime-v3): note service-role requirement for HTTP broadcast a…
grdsdev Jun 29, 2026
bbd9af2
refactor(realtime-v3): unify per-call streams onto a single channel e…
grdsdev Jun 29, 2026
f712254
refactor(realtime-v3): centralize stream-transform boilerplate into h…
grdsdev Jun 29, 2026
d8b48ca
refactor(realtime-v3): unify channel send paths behind a single _push…
grdsdev Jun 29, 2026
1f3f36a
refactor(realtime-v3): remove presence re-track on rejoin
grdsdev Jun 29, 2026
f3b585d
refactor(realtime-v3): make Realtime._push the single connection send…
grdsdev Jun 30, 2026
8bd6265
feat(realtime-v3): emit broadcast.ack_latency_ms; share _encodeToJSON
grdsdev Jun 30, 2026
eb4f514
fix(realtime-v3): wait for postgres system event before considering c…
grdsdev Jun 30, 2026
645bdc0
refactor(realtime-v3): split oversized Realtime/Channel into extensio…
grdsdev Jun 30, 2026
ec5fc9d
refactor(realtime-v3): extract shared _sendJoin from _performJoin and…
grdsdev Jun 30, 2026
fa9554c
refactor(realtime-v3): extract ChannelRegistry + ConnectionStatusBroa…
grdsdev Jun 30, 2026
cc1410b
refactor(realtime-v3): dedup connection teardown into _teardownConnec…
grdsdev Jun 30, 2026
e611765
refactor(realtime-v3): address review low-priority items
grdsdev Jun 30, 2026
c36b395
refactor(realtime-v3): decouple Channel from concrete Realtime via Ch…
grdsdev Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .superpowers/sdd/ie-flows-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# IE-3..6 E2E Integration Test Report

## Summary

All 13 integration tests (IE-1..6, 6 suites) pass GREEN against the live local Supabase instance (Docker, project `realtimev3`, `realtime:v2.107.5`).

**Branch**: `claude/charming-euler-49c5a1`
**HEAD after commit**: see `git log --oneline -1`
**Test run**: `swift test --filter RealtimeV3IntegrationTests`
**Unit suite**: `swift test --filter RealtimeV3Tests` — 132 tests pass, 1 pre-existing known issue (unchanged)
**Supabase instance**: LEFT RUNNING (not stopped)

---

## IE-3 Broadcast (BroadcastE2ETests.swift) — GREEN

### IE-3a: WS round-trip between two clients — PASS
Two separate `Realtime` clients join the same topic. B opens a `broadcasts(of:event:)` stream before subscribing. A broadcasts a `ChatMsg`. B receives it within the timeout. Confirmed against live server.

### IE-3b: HTTP broadcast received by WS subscriber — PASS (with SDK GAP noted)
B subscribes via WebSocket. HTTP broadcast via `Realtime.httpBroadcastBatch` delivers the message. **SDK GAP discovered and documented** (see Concerns below). Test uses `httpBroadcastBatch` directly with the correct short topic as a workaround.

### IE-3c: `acknowledge=true` returns without timeout — PASS
Channel created with `broadcast.acknowledge = true`. `broadcast(...)` returns cleanly; server ACKs the push.

---

## IE-4 Presence (PresenceE2ETests.swift) — GREEN

### IE-4a: presence sync between two clients — PASS
A and B join same topic with `presence.enabled = true`. A tracks `UserPresence(userId: "user-a", status: "active")`. B's `presence.observe(UserPresence.self)` stream sees A appear in the active map. A cancels the track handle; B sees A's leave. Handle correctly cancelled to suppress leak warning.

---

## IE-5 Postgres Changes (PostgresChangesE2ETests.swift) — GREEN

### IE-5a: INSERT delivers postgres change — PASS
Registered `channel.inserts(schema:table:filter:)` with a unique `room_id` UUID before `subscribe()`. After join, inserted a row via `PostgrestClient`. The `postgresChanges(for:)` stream yielded the row within 15 seconds. `record["content"]` matched expected value. Server-id routing (`_buildServerIDRouting`) works correctly — join reply includes `postgres_changes: [{id: <int>, ...}]` which maps to the registration UUID.

### IE-5b: UPDATE and DELETE deliver old_record — PASS (with server behavior note)
UPDATE: `old_record` contains the full original row (REPLICA IDENTITY FULL working). New `record` contains updated values. DELETE: `old_record` contains **only the primary key**, not all columns. This is **not an SDK bug** — it is intentional Realtime server v2.x behavior: for DELETE, the deleted row no longer exists for RLS evaluation, so the server returns only the PK in `old_record`. Test updated to assert `old_record.id` matches the deleted row's UUID.

---

## IE-6 Reconnection (ReconnectionE2ETests.swift) — GREEN (with SDK GAP noted)

### IE-6a: leave → disconnect → connect → subscribe — PASS
Channel leaves cleanly, transitions to `.closed`. After `disconnect()` + `subscribe()` (which internally calls `connect()`), the channel rejoins and reaches `.joined`.

### IE-6b: broadcast stream after leave → disconnect → connect cycle — PASS
Pre-disconnect: receiver gets `Ping(seq: 1)`. After `leave()` + `disconnect()` + `subscribe()`: receiver re-joins, gets `Ping(seq: 2)` from the post-reconnect stream.

**SDK GAP discovered**: After `disconnect()` WITHOUT a prior `leave()`, the channel state remains `.joined` in the SDK (transport severed, but logical state preserved for unclean-drop transparent rejoin). Calling `subscribe()` on a `.joined` channel is idempotent (returns immediately). Callers that want to reuse a channel after an intentional disconnect must call `leave()` before `disconnect()`. Forced-drop (unclean) reconnection is covered deterministically by `RealtimeV3Tests/RejoinTests` using `InMemoryTransport`.

---

## Real-Server Findings and SDK Gaps

### SDK GAP 1 — `Channel.httpBroadcast` topic format

**File**: `Sources/RealtimeV3/HTTP/HttpBroadcast.swift`, `Channel.httpBroadcast`
**Symptom**: `Channel.httpBroadcast(event:payload:)` sends HTTP 202 but the message is **not delivered** to WebSocket subscribers.
**Root cause**: `Channel.httpBroadcast` passes `topic` (the full `realtime:<short>` string) as the topic in the HTTP broadcast body. The Realtime server's `/api/broadcast` endpoint expects the **short topic without the `realtime:` prefix** to route to WS subscribers. Using the full prefixed topic causes a routing mismatch — accepted (202) but not delivered.
**Evidence**: `curl` tests confirmed that `"topic":"room:foo"` delivers; `"topic":"realtime:room:foo"` does not.
**Workaround in tests**: Uses `Realtime.httpBroadcastBatch` directly with the short topic.
**Fix needed**: Strip the `realtime:` prefix when building `HttpBroadcastMessage.topic` in `Channel.httpBroadcast`.

### SDK GAP 2 — HTTP broadcast requires service-role JWT

**File**: `Sources/RealtimeV3/HTTP/HttpBroadcast.swift`
**Symptom**: HTTP broadcast with `apikey: <anon_key>` returns HTTP 500 from the Realtime server.
**Root cause**: The Realtime `/api/broadcast` endpoint requires a JWT with `service_role` role (Bearer auth), not just the anon key header. The SDK sends `apikey: <key>` when no `accessToken` provider is configured, which is rejected.
**Evidence**: `curl` with `Authorization: Bearer <service_role_key>` → 202; with `apikey: <anon_key>` → 500.
**Fix needed**: Document that `httpBroadcast` requires the Realtime client to be initialized with a service-role `accessToken` provider, or add a specific error message when the server returns 500 for this call.

### SDK GAP 3 — Intentional disconnect does not clear channel state

**File**: `Sources/RealtimeV3/Realtime.swift`, `disconnect()`
**Symptom**: After `disconnect()`, channels remain in `.joined` state. Calling `subscribe()` is a no-op.
**Root cause**: `disconnect()` does not cascade state transitions to channels — this is by design (channels preserve state for unclean-drop transparent rejoin). But it creates a footgun for intentional disconnect+reconnect scenarios.
**Fix needed**: Either document explicitly that `leave()` must be called before `disconnect()` when the caller wants to resubscribe, or add a `disconnectMode` parameter to `disconnect()` that optionally leaves all channels first.

### Server behavior note — DELETE old_record

The Realtime server v2.107.5 returns only the PK columns in `old_record` for DELETE events, even with `REPLICA IDENTITY FULL` and permissive RLS. This is intentional security behavior (deleted row can't be evaluated against RLS). The SDK correctly surfaces what the server sends. No SDK fix needed; documentation note added in the test.

---

## Warnings Check

Clean: `swift build --build-tests 2>&1 | grep "warning:" | grep -E "BroadcastE2E|PresenceE2E|PostgresChanges|Reconnection|IntegrationTests"` → no output.

---

## Files

- `Tests/RealtimeV3IntegrationTests/BroadcastE2ETests.swift` (IE-3, 3 tests)
- `Tests/RealtimeV3IntegrationTests/PresenceE2ETests.swift` (IE-4, 1 test)
- `Tests/RealtimeV3IntegrationTests/PostgresChangesE2ETests.swift` (IE-5, 2 tests)
- `Tests/RealtimeV3IntegrationTests/ReconnectionE2ETests.swift` (IE-6, 2 tests)
- `Tests/RealtimeV3IntegrationTests/Support/IntegrationEnv.swift` (added `serviceRoleKey` + `makeRealtimeWithServiceRole()`)
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ test-integration:
swift test --filter IntegrationTests
cd Tests/IntegrationTests && supabase stop

# Run RealtimeV3 integration tests against a live local Supabase instance.
# Starts the dedicated realtimev3 project, resets the DB (applies migrations + seed),
# then runs the integration suite. The instance is intentionally left running after
# the first invocation so subsequent runs skip the slow start step.
# To stop: cd Tests/RealtimeV3IntegrationTests/supabase && supabase stop
test-realtime-v3-integration:
cd Tests/RealtimeV3IntegrationTests/supabase && supabase start && supabase db reset --local
swift test --filter RealtimeV3IntegrationTests

build-for-library-evolution:
swift build \
-q \
Expand Down Expand Up @@ -81,7 +90,7 @@ format:
-not -path '*/.*' -print0 \
| xargs -0 swift format --ignore-unparsable-files --in-place

.PHONY: build-for-library-evolution format warm-simulator xcodebuild test-docs test-integration
.PHONY: build-for-library-evolution format warm-simulator xcodebuild test-docs test-integration test-realtime-v3-integration

.PHONY: coverage
coverage:
Expand Down
35 changes: 35 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ let package = Package(
.library(name: "Functions", targets: ["Functions"]),
.library(name: "PostgREST", targets: ["PostgREST"]),
.library(name: "Realtime", targets: ["Realtime"]),
.library(name: "RealtimeV3", targets: ["RealtimeV3"]),
.library(name: "Storage", targets: ["Storage"]),
.library(name: "Supabase", targets: ["Supabase"]),
],
Expand Down Expand Up @@ -159,6 +160,40 @@ let package = Package(
"TestHelpers",
]
),
.target(
name: "RealtimeV3",
dependencies: [
.product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),
.product(name: "HTTPTypes", package: "swift-http-types"),
.product(name: "Clocks", package: "swift-clocks"),
.product(name: "IssueReporting", package: "xctest-dynamic-overlay"),
"Helpers",
]
),
.testTarget(
name: "RealtimeV3Tests",
dependencies: [
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Clocks", package: "swift-clocks"),
.product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),
"Mocker",
"RealtimeV3",
"TestHelpers",
]
),
.testTarget(
name: "RealtimeV3IntegrationTests",
dependencies: [
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),
"RealtimeV3",
"PostgREST",
"Helpers",
],
exclude: [
"supabase",
]
),
.target(
name: "Storage",
dependencies: [
Expand Down
160 changes: 160 additions & 0 deletions Sources/RealtimeV3/Channel+Broadcast.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//
// Channel+Broadcast.swift
// RealtimeV3
//
// Created by Guilherme Souza on 29/06/26.
//

import Foundation
import Helpers

// MARK: - Channel + broadcast send

extension Channel {

/// Sends a broadcast message with an `Encodable` payload.
///
/// ## State gating
/// - `.unsubscribed` / `.joining` → throws `.notSubscribed`
/// - `.leaving` / `.closed` → throws `.channelClosed(reason)`
/// - `.joined` → encodes payload and sends the binary push frame
///
/// ## Ack mode
/// When `options.broadcast.acknowledge == true`, the call suspends until the server
/// sends a `phx_reply` for this push, or `configuration.broadcastAckTimeout` elapses
/// (throws `.broadcastAckTimeout`). When `acknowledge == false` the frame is sent
/// fire-and-forget.
///
/// ## Wire format
/// The binary frame is a Phoenix 2.0.0 broadcast push (kind byte `0x03`). The JSON
/// payload inside the frame is:
/// ```json
/// {"type": "broadcast", "event": "<event>", "payload": <encodedT>}
/// ```
/// This is symmetric with the receive side (`broadcasts(of:event:)`).
///
/// - Parameters:
/// - payload: The message payload. Encoded to JSON before sending.
/// - event: The broadcast event name (e.g. `"chat"`).
/// - Throws: `RealtimeError`
public func broadcast<T: Encodable & Sendable>(_ payload: T, as event: String)
async throws(RealtimeError)
{
// Guard: ensure the owning Realtime is still alive (needed for the ack timeout below).
guard let realtime else { throw .channelClosed(.clientDisconnected) }
try _requireJoinedForSend()

// Build the inner broadcast envelope (user payload encoded via Configuration.encoder).
let envelope: JSONObject = [
"type": .string("broadcast"),
"event": .string(event),
"payload": try _encodeToJSON(payload),
]

try await _push(
.broadcast, .broadcastJSON(envelope),
ack: options.broadcast.acknowledge
? .require(timeout: realtime.configuration.broadcastAckTimeout, error: .broadcastAckTimeout)
: .none
)
}

/// Sends a broadcast message with a raw binary payload.
///
/// The binary data is shipped as-is inside the Phoenix 2.0.0 broadcast push frame
/// (kind byte `0x03`, encoding byte `0x00`). Ack semantics are identical to the
/// `Encodable` overload.
///
/// - Parameters:
/// - data: The raw binary payload.
/// - event: The broadcast event name.
/// - Throws: `RealtimeError`
public func broadcast(_ data: Data, as event: String) async throws(RealtimeError) {
// Guard: ensure the owning Realtime is still alive (needed for the ack timeout below).
guard let realtime else { throw .channelClosed(.clientDisconnected) }
try _requireJoinedForSend()

try await _push(
.broadcast, .broadcastData(data),
ack: options.broadcast.acknowledge
? .require(timeout: realtime.configuration.broadcastAckTimeout, error: .broadcastAckTimeout)
: .none
)
}
}

// MARK: - Channel + broadcasts(of:event:)

extension Channel {
/// Returns an `AsyncThrowingStream` that yields every broadcast message for the
/// given `event` name, decoded to `T`.
///
/// ## Wire shape
/// A broadcast Phoenix frame has `event == "broadcast"` and a JSON payload of the form:
/// ```json
/// { "type": "broadcast", "event": "<name>", "payload": { ... } }
/// ```
/// This method filters frames whose inner `event` matches the requested name and
/// decodes the inner `payload` object to `T` using `Configuration.decoder`.
///
/// ## Per-call fan-out (Decision 8)
/// Each call mints an independent stream. N concurrent calls each receive a copy
/// of every matching message. Streams created before `subscribe()` are valid —
/// they start producing once frames arrive after the join.
///
/// ## Decode failure
/// If the inner `payload` cannot be decoded to `T`, the stream terminates by
/// throwing `RealtimeError.decoding(type:underlying:)`. Non-matching events are
/// silently ignored.
///
/// ## Terminal close
/// When the channel transitions to `.closed(reason)` (e.g. via `leave()`), the
/// stream terminates by throwing `RealtimeError.channelClosed(reason)`.
///
/// - Note: The thrown error is always a `RealtimeError`. Cast with `as? RealtimeError`
/// or use `if case` matching on the caught `any Error`.
public func broadcasts<T: Decodable & Sendable>(
of type: T.Type,
event: String
) -> AsyncThrowingStream<T, any Error> {
// Capture the decoder from realtime configuration, falling back to the default if
// realtime has already been deallocated (e.g. stream registered after client teardown).
let decoder = realtime?.configuration.decoder ?? .realtimeDefault

// The helper handles terminal close (→ `.channelClosed`) and task lifecycle; the body
// filters for the requested broadcast event and decodes, throwing on decode failure.
return _makeThrowingStream(initialState: ()) { _, message, continuation in
// Only handle broadcast Phoenix events matching the requested inner event.
guard message.event == .broadcast,
case .json(let jsonValue) = message.payload,
let obj = jsonValue.objectValue,
let innerEvent = obj["event"]?.stringValue, innerEvent == event
else { return }

// Extract the inner "payload" value.
guard let innerPayload = obj["payload"] else {
// No payload key — decode failure terminates the stream.
throw RealtimeError.decoding(
type: String(describing: T.self),
underlying: MissingPayloadError()
)
}

// Re-encode the JSONValue to Data, then decode T using the configured decoder.
// A decode failure throws and terminates the stream (spec: decode failure throws).
do {
let data = try JSONEncoder().encode(innerPayload)
continuation.yield(try decoder.decode(T.self, from: data))
} catch {
throw RealtimeError.decoding(type: String(describing: T.self), underlying: error)
}
}
}
}

// MARK: - MissingPayloadError

/// Sentinel error used when a broadcast frame has no inner `payload` key.
private struct MissingPayloadError: Error, Sendable {
var localizedDescription: String { "Broadcast frame is missing the inner 'payload' key." }
}
Loading
Loading