@estiva-app/protocol 0.1.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.
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/bridge.d.ts +91 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +138 -0
- package/dist/bridge.js.map +1 -0
- package/dist/events.d.ts +432 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +616 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/live.d.ts +205 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +398 -0
- package/dist/live.js.map +1 -0
- package/dist/nip19.d.ts +98 -0
- package/dist/nip19.d.ts.map +1 -0
- package/dist/nip19.js +320 -0
- package/dist/nip19.js.map +1 -0
- package/dist/nip98.d.ts +61 -0
- package/dist/nip98.d.ts.map +1 -0
- package/dist/nip98.js +134 -0
- package/dist/nip98.js.map +1 -0
- package/dist/sign.d.ts +67 -0
- package/dist/sign.d.ts.map +1 -0
- package/dist/sign.js +58 -0
- package/dist/sign.js.map +1 -0
- package/dist/subscriptions.d.ts +120 -0
- package/dist/subscriptions.d.ts.map +1 -0
- package/dist/subscriptions.js +68 -0
- package/dist/subscriptions.js.map +1 -0
- package/package.json +59 -0
- package/src/bridge.ts +198 -0
- package/src/events.ts +821 -0
- package/src/index.ts +159 -0
- package/src/live.ts +536 -0
- package/src/nip19.ts +354 -0
- package/src/nip98.ts +164 -0
- package/src/sign.ts +113 -0
- package/src/subscriptions.ts +200 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One REQ per channel, refcounted (PEE-6).
|
|
3
|
+
*
|
|
4
|
+
* Two components can be looking at the same channel — the conversation and the
|
|
5
|
+
* sidebar — and the last one to unmount is the one that should close the REQ.
|
|
6
|
+
* This sits over `liveRelay` and owns exactly that: one relay subscription per
|
|
7
|
+
* channel uuid, shared by every consumer, closed when the last releases.
|
|
8
|
+
*
|
|
9
|
+
* Reconnection is deliberately **not** handled here. `createLiveRelay` re-issues
|
|
10
|
+
* every registered subscription after it re-authenticates, so a channel with a
|
|
11
|
+
* live refcount comes back on its own. Duplicating that logic would give two
|
|
12
|
+
* places to get it wrong.
|
|
13
|
+
*
|
|
14
|
+
* **Built inside Peek** (PEE-6) because Gate 2 had not happened when it was due.
|
|
15
|
+
* SHA-3 is the ticket that owed the move.
|
|
16
|
+
*
|
|
17
|
+
* ## The trap: one subscription per channel, always
|
|
18
|
+
*
|
|
19
|
+
* **Never build one subscription covering several channels.** Depending on the
|
|
20
|
+
* filter it either fails loudly or, worse, returns correct history and then
|
|
21
|
+
* receives **zero live events** — EOSE right, live empty, nothing to
|
|
22
|
+
* distinguish it from working until somebody notices nothing ever arrives.
|
|
23
|
+
*
|
|
24
|
+
* Verified in Buzz rather than taken on trust, because the whole point is that
|
|
25
|
+
* it is invisible:
|
|
26
|
+
*
|
|
27
|
+
* 1. `extract_channel_id_from_filters` (`handlers/req.rs`) returns `None` the
|
|
28
|
+
* moment two distinct `#h` values appear, or any filter lacks `#h`.
|
|
29
|
+
* 2. With `channel_id: None` the subscription registers in the **global**
|
|
30
|
+
* indexes (`subscription.rs`).
|
|
31
|
+
* 3. `fan_out_scoped` handles a channel-scoped event by consulting only
|
|
32
|
+
* `channel_kind_index` and `channel_wildcard_index`. A global subscription
|
|
33
|
+
* is in neither.
|
|
34
|
+
* 4. The file states it outright: *"Global subscriptions (channel_id = None)
|
|
35
|
+
* do NOT receive channel-scoped events."*
|
|
36
|
+
*
|
|
37
|
+
* Historical delivery at REQ time takes a different path (`per_filter_channel`)
|
|
38
|
+
* which handles multi-`#h` correctly. That asymmetry is the whole illusion.
|
|
39
|
+
*
|
|
40
|
+
* **Which of the two failures you get depends on whether the filter names
|
|
41
|
+
* kinds**, and this was measured against production rather than reasoned about.
|
|
42
|
+
* A global subscription must clear `p_gated_filters_authorized`
|
|
43
|
+
* (`handlers/req.rs`), whose first test is:
|
|
44
|
+
*
|
|
45
|
+
* let can_match_p_gated = filter.kinds.as_ref().is_none_or(|ks| …);
|
|
46
|
+
* if !can_match_p_gated { return true; }
|
|
47
|
+
*
|
|
48
|
+
* So a **kindless** multi-`#h` filter *could* match a p-gated kind, has no
|
|
49
|
+
* `#p`, and is refused outright — a live probe against
|
|
50
|
+
* `wss://estiva.estiva.app` got `CLOSED … "restricted: p-gated events require
|
|
51
|
+
* #p matching your pubkey"` immediately. But a filter naming only ordinary
|
|
52
|
+
* kinds — `{"#h":[a,b],"kinds":[9]}` — returns early as authorized, registers
|
|
53
|
+
* globally, and dies **silently**.
|
|
54
|
+
*
|
|
55
|
+
* That is the dangerous one, and it is the shape somebody optimising "one
|
|
56
|
+
* subscription for messages across all my channels" would naturally write. The
|
|
57
|
+
* ticket describes this variant; the loud one is a newer gate sitting in front
|
|
58
|
+
* of it.
|
|
59
|
+
*
|
|
60
|
+
* **There is a second entrance to the same trap, and it is the likelier one.**
|
|
61
|
+
* `extract_channel_id_from_filters` only counts an `#h` value it can
|
|
62
|
+
* `parse::<uuid::Uuid>()`; anything else leaves `filter_has_channel` false and
|
|
63
|
+
* falls through to the same global registration. So passing a **topic id**
|
|
64
|
+
* where a channel uuid belongs produces the same broken subscription — and
|
|
65
|
+
* Peek's topic ids are Convex ids, which are not uuids. Because this module
|
|
66
|
+
* always builds a kindless filter, that lands on the loud arm above rather than
|
|
67
|
+
* the silent one; it is still a subscription that never delivers, and it still
|
|
68
|
+
* fails asynchronously as a `CLOSED` frame the app would have to interpret.
|
|
69
|
+
* Throwing at the call site names the cause instead. RFC 0.3's note on this ticket
|
|
70
|
+
* asks specifically that a topic id never become the subscription key; that is
|
|
71
|
+
* why {@link createChannelSubscriptions} validates the shape and throws rather
|
|
72
|
+
* than letting a bad key reach the relay. In Peek `topics.channelUuid` is also
|
|
73
|
+
* `v.optional`, so "absent on older topics" is a real case, not a theoretical
|
|
74
|
+
* one, and it must not arrive here as `undefined`.
|
|
75
|
+
*
|
|
76
|
+
* ## One kindless filter is enough
|
|
77
|
+
*
|
|
78
|
+
* `{"#h":[uuid]}` with no `kinds` registers in the channel **wildcard** index
|
|
79
|
+
* and therefore receives every kind in the channel. Reactions (kind:7) and
|
|
80
|
+
* deletions (kind:5) carry no `h` tag of their own, but `filters_match`
|
|
81
|
+
* (`buzz-core/src/filter.rs`) falls back to `StoredEvent.channel_id` for `#h`
|
|
82
|
+
* when an event has no `h` tags at all — the channel is derived from the target
|
|
83
|
+
* at ingest. So messages, reactions, deletions and assertions all arrive on this
|
|
84
|
+
* one subscription.
|
|
85
|
+
*
|
|
86
|
+
* That is strictly better than the HTTP path it replaces, which needs four
|
|
87
|
+
* sequential round trips and caps reactions to the newest 100 messages per
|
|
88
|
+
* topic. The cap is deliberately not ported.
|
|
89
|
+
*
|
|
90
|
+
* `kinds: []` would be worse than useless — Buzz indexes such a subscription
|
|
91
|
+
* *nowhere* and it silently receives nothing — which is another reason this
|
|
92
|
+
* builds the filter itself rather than accepting one.
|
|
93
|
+
*/
|
|
94
|
+
import type { SignedEvent } from './events.js'
|
|
95
|
+
import type { LiveRelay, Subscription } from './live.js'
|
|
96
|
+
|
|
97
|
+
/** Canonical v4-shaped uuid, as `crypto.randomUUID()` produces. */
|
|
98
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
99
|
+
|
|
100
|
+
export type ChannelEventHandler = (event: SignedEvent) => void
|
|
101
|
+
|
|
102
|
+
export interface ChannelSubscription {
|
|
103
|
+
/** Idempotent. Closes the REQ only when the last consumer releases. */
|
|
104
|
+
release(): void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ChannelSubscriptions {
|
|
108
|
+
/**
|
|
109
|
+
* Watch one channel. Safe to call many times for the same channel — the
|
|
110
|
+
* relay sees one REQ, and every consumer sees every event.
|
|
111
|
+
*
|
|
112
|
+
* @throws if `channelUuid` is not a uuid. See the trap above: a topic id here
|
|
113
|
+
* would be accepted by the relay and then silently deliver nothing.
|
|
114
|
+
*/
|
|
115
|
+
subscribe(channelUuid: string, onEvent: ChannelEventHandler): ChannelSubscription
|
|
116
|
+
/** Channels with at least one live consumer. Test and diagnostic seam. */
|
|
117
|
+
activeChannels(): string[]
|
|
118
|
+
/** How many consumers hold `channelUuid`. Test and diagnostic seam. */
|
|
119
|
+
subscriberCount(channelUuid: string): number
|
|
120
|
+
/** Release everything. Does not close the underlying relay connection. */
|
|
121
|
+
close(): void
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface ChannelEntry {
|
|
125
|
+
relaySub: Subscription
|
|
126
|
+
listeners: Set<ChannelEventHandler>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createChannelSubscriptions(
|
|
130
|
+
relay: Pick<LiveRelay, 'subscribe'>,
|
|
131
|
+
options: { onListenerError?: (error: unknown) => void } = {},
|
|
132
|
+
): ChannelSubscriptions {
|
|
133
|
+
const channels = new Map<string, ChannelEntry>()
|
|
134
|
+
|
|
135
|
+
function dispatch(channelUuid: string, event: SignedEvent) {
|
|
136
|
+
const entry = channels.get(channelUuid)
|
|
137
|
+
if (!entry) return
|
|
138
|
+
// A copy, because a listener is allowed to release during dispatch — and
|
|
139
|
+
// one that throws must not stop the others from being told. A single
|
|
140
|
+
// component's bug should not silently stop the whole channel updating.
|
|
141
|
+
for (const listener of [...entry.listeners]) {
|
|
142
|
+
try {
|
|
143
|
+
listener(event)
|
|
144
|
+
} catch (error) {
|
|
145
|
+
options.onListenerError?.(error)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
subscribe(channelUuid, onEvent) {
|
|
152
|
+
if (!UUID.test(channelUuid)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`channelSubscriptions: "${channelUuid}" is not a channel uuid. ` +
|
|
155
|
+
'Buzz can only scope a subscription by an #h it can parse as a uuid; ' +
|
|
156
|
+
'anything else registers globally and then receives no channel events at all. ' +
|
|
157
|
+
"Pass the channel's uuid, never an application id for the thing " +
|
|
158
|
+
'rendered in it — Peek\'s topic ids are Convex ids, which are not uuids.',
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let entry = channels.get(channelUuid)
|
|
163
|
+
if (!entry) {
|
|
164
|
+
const listeners = new Set<ChannelEventHandler>()
|
|
165
|
+
// One channel, one filter, no `kinds` — see the header. Built here
|
|
166
|
+
// rather than accepted from the caller so neither half of the trap is
|
|
167
|
+
// reachable through this API.
|
|
168
|
+
const relaySub = relay.subscribe([{ '#h': [channelUuid] }], (event) =>
|
|
169
|
+
dispatch(channelUuid, event),
|
|
170
|
+
)
|
|
171
|
+
entry = { relaySub, listeners }
|
|
172
|
+
channels.set(channelUuid, entry)
|
|
173
|
+
}
|
|
174
|
+
entry.listeners.add(onEvent)
|
|
175
|
+
|
|
176
|
+
let released = false
|
|
177
|
+
return {
|
|
178
|
+
release() {
|
|
179
|
+
if (released) return
|
|
180
|
+
released = true
|
|
181
|
+
const current = channels.get(channelUuid)
|
|
182
|
+
if (!current) return
|
|
183
|
+
current.listeners.delete(onEvent)
|
|
184
|
+
if (current.listeners.size > 0) return
|
|
185
|
+
// Last one out closes the REQ.
|
|
186
|
+
channels.delete(channelUuid)
|
|
187
|
+
current.relaySub.close()
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
activeChannels: () => [...channels.keys()],
|
|
193
|
+
subscriberCount: (channelUuid) => channels.get(channelUuid)?.listeners.size ?? 0,
|
|
194
|
+
|
|
195
|
+
close() {
|
|
196
|
+
for (const entry of channels.values()) entry.relaySub.close()
|
|
197
|
+
channels.clear()
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|