@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
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
|
+
}
|