@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/src/index.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `@estiva-app/protocol` — the Estiva wire format, once.
3
+ *
4
+ * ## What is in here, and what is deliberately not
5
+ *
6
+ * **In:** the bytes. Event construction, the NIP-01 id preimage, NIP-19 `naddr`,
7
+ * NIP-98 HTTP auth, Schnorr signing, and the two relay clients (the HTTP bridge
8
+ * and the live socket). Everything whose correctness the *relay* judges.
9
+ *
10
+ * **Not in: the fold, or anything that interprets events.** How an app turns a
11
+ * stream of events into current truth is where apps are supposed to differ —
12
+ * `estiva-docs/decisions/0001` §8, and the README of this repo. Ship folds
13
+ * issues, Peek folds conversations, and neither should inherit the other's
14
+ * opinion. `foldFolder`, `foldResolution` and the projection all stay in their
15
+ * apps, and each app's conformance fixture stays with it.
16
+ *
17
+ * That line is the one thing to hold when adding to this package. "Both apps
18
+ * need it" is not the test; "the relay would notice if we disagreed" is.
19
+ *
20
+ * ## Why one implementation, when the suite's whole claim is independence
21
+ *
22
+ * SPEC §10 used to say the duplication *was* the architecture: three apps each
23
+ * implementing NIP-01 serialization is what makes "apps sharing no code work on
24
+ * the same data" a real statement. That argument was right about the *claim* and
25
+ * wrong about the *mechanism*, and the third copy is what settled it.
26
+ *
27
+ * A second hand-written event-id hash is not a demonstration of independence, it
28
+ * is a divergence the relay notices and we do not. It had already happened:
29
+ * Peek's `buildMessage` emitted `a` tags and Ship's could not, so the same
30
+ * logical message produced different bytes depending on which app sent it — and
31
+ * nothing failed, because each copy was self-consistent. `diff -r` between two
32
+ * vendored trees was the only thing holding the other two together, and it is a
33
+ * check somebody has to remember to run.
34
+ *
35
+ * What makes the interop claim true is that the apps share **no interpretation
36
+ * and no database** — which is still exactly the case. They now agree on the
37
+ * wire format on purpose rather than by coincidence.
38
+ *
39
+ * ## A MAJOR here is a protocol event, not a TypeScript event
40
+ *
41
+ * A change to the bytes an app publishes — id computation, serialization order,
42
+ * tag semantics, signature input — is a MAJOR even when the TypeScript signature
43
+ * is identical (ADR 0002 §4b). Every release note answers the wire question
44
+ * explicitly, including when the answer is "unchanged".
45
+ */
46
+
47
+ /** Bumped by hand with the version in package.json — `test/version.test.ts`
48
+ * pins the two together. It exists so "the upgrade reached the app" can be
49
+ * checked by grepping a built bundle rather than by trusting a lockfile. */
50
+ export const PROTOCOL_VERSION = '0.1.0'
51
+
52
+ export {
53
+ // types
54
+ type NostrTag,
55
+ type UnsignedEvent,
56
+ type SignedEvent,
57
+ type Profile,
58
+ type ThreadRef,
59
+ type ChannelVisibility,
60
+ type ChannelKind,
61
+ type MemberRole,
62
+ type Label,
63
+ type ResolutionAction,
64
+ // constants
65
+ KIND,
66
+ MAX_EMOJI_CHARS,
67
+ MAX_MESSAGE_BYTES,
68
+ MAX_MENTIONS,
69
+ MAX_RATIONALE_BYTES,
70
+ RELAY_AUTH_TOLERANCE_SECS,
71
+ ASSERTION_SUBTYPE,
72
+ // the id preimage and the pieces that feed it
73
+ computeEventId,
74
+ toNostrSeconds,
75
+ canonicalChannelName,
76
+ threadTags,
77
+ addr,
78
+ relayAuthUrl,
79
+ // builders
80
+ buildProfile,
81
+ parseProfile,
82
+ buildCreateChannel,
83
+ buildDeleteChannel,
84
+ buildEditChannelMetadata,
85
+ buildAddMember,
86
+ buildReaction,
87
+ buildDeletion,
88
+ buildMessage,
89
+ buildResolution,
90
+ buildFile,
91
+ buildComponent,
92
+ buildHighlight,
93
+ buildUnsignedRelayAuthEvent,
94
+ } from './events.js'
95
+
96
+ export {
97
+ type AddressPointer,
98
+ convertBits,
99
+ bech32Encode,
100
+ bech32Decode,
101
+ encodeNaddr,
102
+ decodeNaddr,
103
+ pointerToAddress,
104
+ addressToPointer,
105
+ addrToNaddr,
106
+ naddrToAddr,
107
+ referenceToPointer,
108
+ NADDR_RE,
109
+ findNaddrs,
110
+ stripNaddrs,
111
+ } from './nip19.js'
112
+
113
+ export {
114
+ type AuthEventArgs,
115
+ TIMESTAMP_TOLERANCE_SECS,
116
+ normalizeUrl,
117
+ buildUnsignedAuthEvent,
118
+ base64,
119
+ authorizationHeaderFor,
120
+ authorizationHeader,
121
+ } from './nip98.js'
122
+
123
+ export {
124
+ type Signer,
125
+ type SignerKind,
126
+ publicKeyFromSecret,
127
+ signEvent,
128
+ secretKeySigner,
129
+ } from './sign.js'
130
+
131
+ export {
132
+ type PublishResult,
133
+ type QueryResult,
134
+ type RelayHeaders,
135
+ type RelayOptions,
136
+ type FetchLike,
137
+ parsePublishResponse,
138
+ parseQueryResponse,
139
+ Relay,
140
+ } from './bridge.js'
141
+
142
+ export {
143
+ type RelayState,
144
+ type SocketLike,
145
+ type RelayCredential,
146
+ type LiveRelayOptions,
147
+ type Subscription,
148
+ type LiveRelay,
149
+ toWebSocketUrl,
150
+ parseFrame,
151
+ createLiveRelay,
152
+ } from './live.js'
153
+
154
+ export {
155
+ type ChannelEventHandler,
156
+ type ChannelSubscription,
157
+ type ChannelSubscriptions,
158
+ createChannelSubscriptions,
159
+ } from './subscriptions.js'
package/src/live.ts ADDED
@@ -0,0 +1,536 @@
1
+ /**
2
+ * One live relay socket, with NIP-42 AUTH and reconnect (PEE-5).
3
+ *
4
+ * A WebSocket that stays open, authenticates as the viewer, and keeps
5
+ * subscriptions alive across disconnects. Nothing is *interpreted* here: this
6
+ * delivers frames, and folding them into state is the consuming app's business.
7
+ *
8
+ * The socket belongs in the **client**, not in a backend: a request-scoped
9
+ * server runtime cannot hold one, and more importantly the viewer's own
10
+ * credential is the only thing that can see the viewer's channels. Buzz gates
11
+ * reads per channel (`is_member_cached`), so a service identity would need
12
+ * membership in every conversation — a product decision, not an implementation
13
+ * detail.
14
+ *
15
+ * **This was built inside Peek** (PEE-5, PEE-6) because Gate 2 had not happened
16
+ * when it was due, and it was Peek-only for two days. SHA-3 is the ticket that
17
+ * owed the move; `subscriptions.ts` beside this file is PEE-6's half.
18
+ *
19
+ * No nostr library. Raw `WebSocket` plus `buildUnsignedRelayAuthEvent` is
20
+ * enough, and the tag layout stays in one place.
21
+ *
22
+ * ## Three things the relay does that shape this file
23
+ *
24
+ * All three were read out of `crates/buzz-*` rather than assumed, because each
25
+ * one fails as a healthy-looking socket that delivers nothing.
26
+ *
27
+ * 1. **A second AUTH on an authenticated connection is refused.** `handle_auth`
28
+ * matches on `AuthState::{Pending, Authenticated, Failed}` and answers
29
+ * anything but `Pending` with `OK … false "auth-required: already
30
+ * authenticated"`. So re-authenticating in place is not possible, and
31
+ * {@link LiveRelay} never tries.
32
+ *
33
+ * 2. **A failed AUTH poisons the connection and leaves it open.** Only a *ban*
34
+ * closes the socket; a verification failure sets `AuthState::Failed` and
35
+ * returns. Every later REQ is answered `CLOSED … "auth-required:
36
+ * authenticate before subscribing"`, forever, on a socket whose readyState
37
+ * is OPEN. Treating an AUTH refusal as fatal-for-this-socket and reconnecting
38
+ * is the only way out.
39
+ *
40
+ * 3. **The `relay` tag is bound to the connection's host**, not the deployment
41
+ * URL — see `relayAuthUrl`.
42
+ *
43
+ * ## Token rotation needs nothing here, and that is worth stating
44
+ *
45
+ * PEE-5 asked for re-authentication when the app renews its access token,
46
+ * preferring it to a reconnect. Neither is needed, and the first is impossible
47
+ * (point 1 above).
48
+ *
49
+ * The relay authenticates a **pubkey**, by verifying a Schnorr signature. It
50
+ * never sees the Estiva ID access token and has no idea one exists. Renewal
51
+ * issues a new token for the *same* keypair, so nothing the relay checked has
52
+ * changed and the connection stays valid. The token is needed only to *sign* a
53
+ * fresh 22242, which happens on the next connect.
54
+ *
55
+ * What genuinely does not propagate is offboarding: a person whose Estiva ID
56
+ * access is revoked keeps an already-authenticated socket until it drops. That
57
+ * is the relay's session model — its own ban gate is the control for it — and
58
+ * re-authenticating on a timer would not have fixed it either, since a banned
59
+ * pubkey is caught at connect.
60
+ *
61
+ * ## Never logged, never persisted
62
+ *
63
+ * The AUTH event and the access token appear in no log line. Buzz refuses to
64
+ * store kind:22242 for this reason, and Convex surfaces function arguments in
65
+ * its dashboard logs — which is the entire reason Peek's credential lives in the
66
+ * browser. {@link LiveRelayOptions.log} receives states and reasons, never
67
+ * events or credentials, and this module must keep it that way.
68
+ */
69
+ import {
70
+ buildUnsignedRelayAuthEvent,
71
+ type SignedEvent,
72
+ type UnsignedEvent,
73
+ } from './events.js'
74
+
75
+ /**
76
+ * `WebSocket` and the timer functions are not in `lib.es2022`, and this package
77
+ * compiles with `types: []` and no `lib: dom` (ADR 0002 §4a). Declared inside
78
+ * this module so nothing lands in a consumer's global scope, and read inside
79
+ * function bodies so importing this module touches no global — which matters
80
+ * more here than anywhere else in the package: `WebSocket` does not exist in
81
+ * Convex's default runtime, and an eager `const WS = WebSocket` at module scope
82
+ * would throw on import in Peek's backend, from a barrel it imports for the
83
+ * builders. `test/runtime-agnostic.test.ts` is what holds that.
84
+ */
85
+ declare const WebSocket: { new (url: string): unknown }
86
+ declare const setTimeout: (fn: () => void, ms: number) => unknown
87
+ declare const clearTimeout: (handle: unknown) => void
88
+
89
+ /**
90
+ * What the connection is doing, for PEE-9 to render.
91
+ *
92
+ * `failed` is terminal and deliberate: it means repeated *authentication*
93
+ * refusals, which retrying cannot fix — a missing 22242 grant, a clock more
94
+ * than 60s out, a revoked identity. A relay that is merely down stays in
95
+ * `reconnecting` forever, because that one does fix itself.
96
+ */
97
+ export type RelayState = 'connecting' | 'authenticating' | 'live' | 'reconnecting' | 'failed'
98
+
99
+ /** Just enough of `WebSocket` to be faked in a test. */
100
+ export interface SocketLike {
101
+ send(data: string): void
102
+ close(): void
103
+ onopen: ((this: unknown, ev: unknown) => unknown) | null
104
+ onmessage: ((this: unknown, ev: { data: unknown }) => unknown) | null
105
+ onclose: ((this: unknown, ev: unknown) => unknown) | null
106
+ onerror: ((this: unknown, ev: unknown) => unknown) | null
107
+ }
108
+
109
+ /** The credential a connect attempt needs. `null` means "not signed in". */
110
+ export interface RelayCredential {
111
+ accessToken: string
112
+ pubkey: string
113
+ }
114
+
115
+ export interface LiveRelayOptions {
116
+ /**
117
+ * The relay origin, `https://…` or `wss://…`.
118
+ *
119
+ * Peek reads this from a Convex query rather than a `VITE_` build-time
120
+ * variable, so that the bundle and the backend cannot drift. Where it comes
121
+ * from is the app's decision; that it is one value is not.
122
+ */
123
+ url: string
124
+ /** Read fresh on every connect, so a reconnect picks up a renewed token. */
125
+ getCredential: () => RelayCredential | null
126
+ /**
127
+ * Injected so this module does no fetching of its own. Peek passes
128
+ * `signViaEstivaId`; a keyed client passes a `signEvent` wrapper.
129
+ *
130
+ * `expectedPubkey` is not a request — a remote `/sign` signs as the token's
131
+ * subject whatever it is handed, so a mismatch comes back as HTTP 200 with a
132
+ * valid event authored by somebody else. The implementation must check it.
133
+ */
134
+ sign: (unsigned: UnsignedEvent, token: string, expectedPubkey: string) => Promise<SignedEvent>
135
+ onState?: (state: RelayState) => void
136
+ /** Never receives event bodies or credentials. */
137
+ log?: (message: string, detail?: Record<string, unknown>) => void
138
+ /** Test seams. */
139
+ socketFactory?: (url: string) => SocketLike
140
+ now?: () => number
141
+ setTimer?: (fn: () => void, ms: number) => unknown
142
+ clearTimer?: (handle: unknown) => void
143
+ /** Backoff shape. Exposed so a test does not wait real seconds. */
144
+ backoff?: { baseMs: number; maxMs: number; jitter: () => number }
145
+ /**
146
+ * Prefix for REQ subscription ids. Cosmetic — it appears in the relay's logs
147
+ * and nowhere else — but a relay operator reading two apps' traffic wants to
148
+ * know which is which. Was hardcoded `peek-` while this lived in Peek.
149
+ */
150
+ subscriptionPrefix?: string
151
+ }
152
+
153
+ export interface Subscription {
154
+ /** Idempotent. Sends CLOSE when the socket is live, and forgets it either way. */
155
+ close(): void
156
+ }
157
+
158
+ export interface LiveRelay {
159
+ /**
160
+ * Drop the current socket and connect again now, ignoring any backoff.
161
+ *
162
+ * For the case a socket cannot detect on its own: the network went away and
163
+ * came back without the connection ever closing. A WebSocket whose peer has
164
+ * become unreachable stays `OPEN` until TCP gives up, which can be minutes —
165
+ * and browser devtools' offline mode frequently does not close it at all. The
166
+ * socket therefore reports `live`, delivers nothing, and would never
167
+ * reconnect, because reconnection is driven by `onclose`.
168
+ *
169
+ * `liveTopics` calls this from the browser's own `online`/`offline` events,
170
+ * which are the one authority here that a socket cannot second-guess.
171
+ */
172
+ reconnect(): void
173
+ /**
174
+ * Subscribe now if the socket is live, and on every future reconnect.
175
+ *
176
+ * Registering before `live` is normal and supported — that is what makes a
177
+ * subscription survive a reconnect rather than being lost with the socket.
178
+ */
179
+ subscribe(
180
+ filters: Record<string, unknown>[],
181
+ onEvent: (event: SignedEvent) => void,
182
+ options?: { onEose?: () => void; onClosed?: (reason: string) => void },
183
+ ): Subscription
184
+ state(): RelayState
185
+ /** Stop for good. Does not reconnect afterwards. */
186
+ close(): void
187
+ }
188
+
189
+ /** How many consecutive AUTH refusals before giving up rather than looping. */
190
+ const MAX_AUTH_FAILURES = 3
191
+
192
+ interface LiveSubscription {
193
+ id: string
194
+ filters: Record<string, unknown>[]
195
+ onEvent: (event: SignedEvent) => void
196
+ onEose?: (() => void) | undefined
197
+ onClosed?: ((reason: string) => void) | undefined
198
+ }
199
+
200
+ /** `https://` → `wss://`, `http://` → `ws://`; a `ws`-scheme URL is left alone. */
201
+ export function toWebSocketUrl(url: string): string {
202
+ const trimmed = url.trim().replace(/\/+$/, '')
203
+ if (trimmed.startsWith('wss://') || trimmed.startsWith('ws://')) return trimmed
204
+ if (trimmed.startsWith('https://')) return `wss://${trimmed.slice('https://'.length)}`
205
+ if (trimmed.startsWith('http://')) return `ws://${trimmed.slice('http://'.length)}`
206
+ return `wss://${trimmed}`
207
+ }
208
+
209
+ /**
210
+ * A relay message, parsed far enough to route.
211
+ *
212
+ * Deliberately tolerant: an unknown verb is ignored rather than throwing, so a
213
+ * relay that grows a frame does not take the socket down with it.
214
+ */
215
+ type RelayFrame =
216
+ | { type: 'AUTH'; challenge: string }
217
+ | { type: 'OK'; eventId: string; accepted: boolean; message: string }
218
+ | { type: 'EVENT'; subId: string; event: SignedEvent }
219
+ | { type: 'EOSE'; subId: string }
220
+ | { type: 'CLOSED'; subId: string; message: string }
221
+ | { type: 'NOTICE'; message: string }
222
+ | { type: 'OTHER' }
223
+
224
+ export function parseFrame(raw: unknown): RelayFrame {
225
+ if (typeof raw !== 'string') return { type: 'OTHER' }
226
+ let parsed: unknown
227
+ try {
228
+ parsed = JSON.parse(raw)
229
+ } catch {
230
+ return { type: 'OTHER' }
231
+ }
232
+ if (!Array.isArray(parsed) || typeof parsed[0] !== 'string') return { type: 'OTHER' }
233
+
234
+ switch (parsed[0]) {
235
+ case 'AUTH':
236
+ return typeof parsed[1] === 'string'
237
+ ? { type: 'AUTH', challenge: parsed[1] }
238
+ : { type: 'OTHER' }
239
+ case 'OK':
240
+ return typeof parsed[1] === 'string' && typeof parsed[2] === 'boolean'
241
+ ? {
242
+ type: 'OK',
243
+ eventId: parsed[1],
244
+ accepted: parsed[2],
245
+ message: typeof parsed[3] === 'string' ? parsed[3] : '',
246
+ }
247
+ : { type: 'OTHER' }
248
+ case 'EVENT':
249
+ return typeof parsed[1] === 'string' && parsed[2] && typeof parsed[2] === 'object'
250
+ ? { type: 'EVENT', subId: parsed[1], event: parsed[2] as SignedEvent }
251
+ : { type: 'OTHER' }
252
+ case 'EOSE':
253
+ return typeof parsed[1] === 'string' ? { type: 'EOSE', subId: parsed[1] } : { type: 'OTHER' }
254
+ case 'CLOSED':
255
+ return typeof parsed[1] === 'string'
256
+ ? {
257
+ type: 'CLOSED',
258
+ subId: parsed[1],
259
+ message: typeof parsed[2] === 'string' ? parsed[2] : '',
260
+ }
261
+ : { type: 'OTHER' }
262
+ case 'NOTICE':
263
+ return typeof parsed[1] === 'string'
264
+ ? { type: 'NOTICE', message: parsed[1] }
265
+ : { type: 'OTHER' }
266
+ default:
267
+ return { type: 'OTHER' }
268
+ }
269
+ }
270
+
271
+ export function createLiveRelay(options: LiveRelayOptions): LiveRelay {
272
+ const wsUrl = toWebSocketUrl(options.url)
273
+ const now = options.now ?? (() => Date.now())
274
+ const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
275
+ const clearTimer = options.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>))
276
+ const makeSocket =
277
+ options.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as SocketLike)
278
+ const backoff = options.backoff ?? { baseMs: 1_000, maxMs: 30_000, jitter: () => Math.random() }
279
+ const log = options.log ?? (() => {})
280
+
281
+ const subscriptions = new Map<string, LiveSubscription>()
282
+ let socket: SocketLike | null = null
283
+ let state: RelayState = 'connecting'
284
+ let attempt = 0
285
+ let authFailures = 0
286
+ let stopped = false
287
+ let reconnectTimer: unknown = null
288
+ /** The id of the AUTH event in flight, so its `OK` is distinguishable. */
289
+ let pendingAuthEventId: string | null = null
290
+ let nextSubId = 0
291
+
292
+ function setState(next: RelayState) {
293
+ if (state === next) return
294
+ state = next
295
+ options.onState?.(next)
296
+ }
297
+
298
+ function send(frame: unknown[]) {
299
+ try {
300
+ socket?.send(JSON.stringify(frame))
301
+ } catch (error) {
302
+ // A send on a socket the browser has already torn down. The close
303
+ // handler is what recovers; swallowing here keeps that the only path.
304
+ log('send failed', { reason: (error as Error).message })
305
+ }
306
+ }
307
+
308
+ function openSubscription(sub: LiveSubscription) {
309
+ send(['REQ', sub.id, ...sub.filters])
310
+ }
311
+
312
+ /**
313
+ * Tear the socket down and schedule another attempt.
314
+ *
315
+ * `fatal` is for authentication refusals, which reconnecting cannot fix past
316
+ * a point — see {@link MAX_AUTH_FAILURES}.
317
+ */
318
+ function scheduleReconnect(reason: string) {
319
+ if (stopped) return
320
+ if (socket) {
321
+ // Drop the handlers before closing so our own `onclose` does not fire and
322
+ // schedule a second reconnect on top of this one.
323
+ socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null
324
+ try {
325
+ socket.close()
326
+ } catch {
327
+ // Already closing. Nothing to recover.
328
+ }
329
+ socket = null
330
+ }
331
+ pendingAuthEventId = null
332
+
333
+ if (authFailures >= MAX_AUTH_FAILURES) {
334
+ setState('failed')
335
+ log('giving up after repeated auth refusals', { authFailures, reason })
336
+ return
337
+ }
338
+
339
+ setState('reconnecting')
340
+ const delay = Math.min(backoff.maxMs, backoff.baseMs * 2 ** attempt) * (0.5 + backoff.jitter() / 2)
341
+ attempt += 1
342
+ log('reconnecting', { reason, delayMs: Math.round(delay), attempt })
343
+ reconnectTimer = setTimer(() => {
344
+ reconnectTimer = null
345
+ connect()
346
+ }, delay)
347
+ }
348
+
349
+ async function answerChallenge(challenge: string) {
350
+ const credential = options.getCredential()
351
+ if (!credential) {
352
+ // Not signed in. Not an auth *failure* — there is nothing to sign with,
353
+ // and a session may yet appear, so this does not count toward the cap.
354
+ log('no credential; deferring auth')
355
+ scheduleReconnect('no credential')
356
+ return
357
+ }
358
+
359
+ setState('authenticating')
360
+ try {
361
+ const unsigned = buildUnsignedRelayAuthEvent({
362
+ // Left empty deliberately: `/sign` overwrites it with the token's
363
+ // subject, and `expectedPubkey` below is the check that it matched.
364
+ pubkey: '',
365
+ relayUrl: wsUrl,
366
+ challenge,
367
+ nowMs: now(),
368
+ })
369
+ // `expectedPubkey` is not a request — `/sign` signs as the token's
370
+ // subject whatever it is handed, so a mismatch returns HTTP 200 with a
371
+ // valid event authored by somebody else. Passing it is the only thing
372
+ // that catches that, and `bridge.ts` says not to drop it.
373
+ const signed = await options.sign(unsigned, credential.accessToken, credential.pubkey)
374
+ if (stopped || !socket) return
375
+ pendingAuthEventId = signed.id
376
+ send(['AUTH', signed])
377
+ } catch (error) {
378
+ // Signing failed: an expired token, /sign refusing the kind, the network.
379
+ // Not counted as an auth refusal — the relay never saw anything, and the
380
+ // common cause (a token that just expired) fixes itself on reconnect.
381
+ log('could not sign the auth challenge', { reason: (error as Error).message })
382
+ scheduleReconnect('sign failed')
383
+ }
384
+ }
385
+
386
+ function onAuthResult(frame: { accepted: boolean; message: string }) {
387
+ pendingAuthEventId = null
388
+ if (!frame.accepted) {
389
+ // The connection is now `AuthState::Failed` relay-side and will refuse
390
+ // every REQ while staying open. There is no recovery on this socket.
391
+ authFailures += 1
392
+ log('relay refused the auth event', { reason: frame.message, authFailures })
393
+ scheduleReconnect('auth refused')
394
+ return
395
+ }
396
+
397
+ authFailures = 0
398
+ attempt = 0
399
+ setState('live')
400
+ log('authenticated', { subscriptions: subscriptions.size })
401
+ // Re-issue every live subscription. A reconnect that restores the socket
402
+ // and not the subscriptions is the silent half of this failure: the app
403
+ // looks connected and never hears anything again.
404
+ for (const sub of subscriptions.values()) openSubscription(sub)
405
+ }
406
+
407
+ function onFrame(raw: unknown) {
408
+ const frame = parseFrame(raw)
409
+ switch (frame.type) {
410
+ case 'AUTH':
411
+ void answerChallenge(frame.challenge)
412
+ return
413
+ case 'OK':
414
+ if (pendingAuthEventId && frame.eventId === pendingAuthEventId) onAuthResult(frame)
415
+ return
416
+ case 'EVENT':
417
+ subscriptions.get(frame.subId)?.onEvent(frame.event)
418
+ return
419
+ case 'EOSE':
420
+ subscriptions.get(frame.subId)?.onEose?.()
421
+ return
422
+ case 'CLOSED': {
423
+ const sub = subscriptions.get(frame.subId)
424
+ log('subscription closed by the relay', { subId: frame.subId, reason: frame.message })
425
+ sub?.onClosed?.(frame.message)
426
+ // `auth-required` here means the connection is unauthenticated — the
427
+ // poisoned-but-open state. Reconnecting is the only fix, and dropping
428
+ // the subscription would hide it.
429
+ if (frame.message.startsWith('auth-required')) scheduleReconnect('req refused')
430
+ return
431
+ }
432
+ case 'NOTICE':
433
+ log('relay notice', { message: frame.message })
434
+ return
435
+ default:
436
+ return
437
+ }
438
+ }
439
+
440
+ function connect() {
441
+ if (stopped) return
442
+ setState(attempt === 0 ? 'connecting' : 'reconnecting')
443
+ let created: SocketLike
444
+ try {
445
+ created = makeSocket(wsUrl)
446
+ } catch (error) {
447
+ log('could not open a socket', { reason: (error as Error).message })
448
+ scheduleReconnect('open threw')
449
+ return
450
+ }
451
+ socket = created
452
+
453
+ created.onopen = () => {
454
+ // Nothing to do but wait: Buzz sends `["AUTH", challenge]` immediately on
455
+ // connect, so authentication starts from the message handler.
456
+ log('socket open')
457
+ }
458
+ created.onmessage = (ev) => {
459
+ if (socket !== created) return
460
+ onFrame(ev.data)
461
+ }
462
+ created.onerror = () => {
463
+ // `onclose` always follows, and that is where recovery lives. Logging
464
+ // here only helps distinguish a refused connection from a clean drop.
465
+ log('socket error')
466
+ }
467
+ created.onclose = () => {
468
+ if (socket !== created) return
469
+ socket = null
470
+ scheduleReconnect('socket closed')
471
+ }
472
+ }
473
+
474
+ connect()
475
+
476
+ return {
477
+ subscribe(filters, onEvent, subOptions) {
478
+ const id = `${options.subscriptionPrefix ?? 'sub'}-${nextSubId++}`
479
+ const sub: LiveSubscription = {
480
+ id,
481
+ filters,
482
+ onEvent,
483
+ onEose: subOptions?.onEose,
484
+ onClosed: subOptions?.onClosed,
485
+ }
486
+ subscriptions.set(id, sub)
487
+ if (state === 'live') openSubscription(sub)
488
+ return {
489
+ close() {
490
+ if (!subscriptions.delete(id)) return
491
+ if (state === 'live') send(['CLOSE', id])
492
+ },
493
+ }
494
+ },
495
+ reconnect() {
496
+ if (stopped) return
497
+ if (reconnectTimer !== null) {
498
+ clearTimer(reconnectTimer)
499
+ reconnectTimer = null
500
+ }
501
+ // Reset the backoff: this is not another failed attempt in a series, it
502
+ // is new information that the network changed.
503
+ attempt = 0
504
+ if (socket) {
505
+ socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null
506
+ try {
507
+ socket.close()
508
+ } catch {
509
+ // Already gone.
510
+ }
511
+ socket = null
512
+ }
513
+ pendingAuthEventId = null
514
+ connect()
515
+ },
516
+
517
+ state: () => state,
518
+ close() {
519
+ stopped = true
520
+ if (reconnectTimer !== null) {
521
+ clearTimer(reconnectTimer)
522
+ reconnectTimer = null
523
+ }
524
+ subscriptions.clear()
525
+ if (socket) {
526
+ socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null
527
+ try {
528
+ socket.close()
529
+ } catch {
530
+ // Already gone.
531
+ }
532
+ socket = null
533
+ }
534
+ },
535
+ }
536
+ }