@meshline/sdk 0.0.0-stage → 0.1.0-alpha.1

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.
Files changed (338) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/channels/repository.d.ts +73 -0
  4. package/dist/channels/repository.d.ts.map +1 -0
  5. package/dist/channels/repository.js +367 -0
  6. package/dist/channels/repository.js.map +1 -0
  7. package/dist/client/account-operations.d.ts +41 -0
  8. package/dist/client/account-operations.d.ts.map +1 -0
  9. package/dist/client/account-operations.js +304 -0
  10. package/dist/client/account-operations.js.map +1 -0
  11. package/dist/client/conversations.d.ts +36 -0
  12. package/dist/client/conversations.d.ts.map +1 -0
  13. package/dist/client/conversations.js +144 -0
  14. package/dist/client/conversations.js.map +1 -0
  15. package/dist/client.d.ts +53 -0
  16. package/dist/client.d.ts.map +1 -0
  17. package/dist/client.js +162 -0
  18. package/dist/client.js.map +1 -0
  19. package/dist/components/account.d.ts +38 -0
  20. package/dist/components/account.d.ts.map +1 -0
  21. package/dist/components/account.js +243 -0
  22. package/dist/components/account.js.map +1 -0
  23. package/dist/components/channel.d.ts +78 -0
  24. package/dist/components/channel.d.ts.map +1 -0
  25. package/dist/components/channel.js +475 -0
  26. package/dist/components/channel.js.map +1 -0
  27. package/dist/components/component.d.ts +49 -0
  28. package/dist/components/component.d.ts.map +1 -0
  29. package/dist/components/component.js +194 -0
  30. package/dist/components/component.js.map +1 -0
  31. package/dist/components/device.d.ts +60 -0
  32. package/dist/components/device.d.ts.map +1 -0
  33. package/dist/components/device.js +648 -0
  34. package/dist/components/device.js.map +1 -0
  35. package/dist/components/group.d.ts +141 -0
  36. package/dist/components/group.d.ts.map +1 -0
  37. package/dist/components/group.js +933 -0
  38. package/dist/components/group.js.map +1 -0
  39. package/dist/components/message.d.ts +63 -0
  40. package/dist/components/message.d.ts.map +1 -0
  41. package/dist/components/message.js +439 -0
  42. package/dist/components/message.js.map +1 -0
  43. package/dist/components/profile.d.ts +37 -0
  44. package/dist/components/profile.d.ts.map +1 -0
  45. package/dist/components/profile.js +232 -0
  46. package/dist/components/profile.js.map +1 -0
  47. package/dist/crypto/groups.d.ts +56 -0
  48. package/dist/crypto/groups.d.ts.map +1 -0
  49. package/dist/crypto/groups.js +163 -0
  50. package/dist/crypto/groups.js.map +1 -0
  51. package/dist/crypto/messages.d.ts +52 -0
  52. package/dist/crypto/messages.d.ts.map +1 -0
  53. package/dist/crypto/messages.js +180 -0
  54. package/dist/crypto/messages.js.map +1 -0
  55. package/dist/crypto/primitives.d.ts +30 -0
  56. package/dist/crypto/primitives.d.ts.map +1 -0
  57. package/dist/crypto/primitives.js +102 -0
  58. package/dist/crypto/primitives.js.map +1 -0
  59. package/dist/errors.d.ts +11 -0
  60. package/dist/errors.d.ts.map +1 -0
  61. package/dist/errors.js +14 -0
  62. package/dist/errors.js.map +1 -0
  63. package/dist/groups/account-sync.d.ts +28 -0
  64. package/dist/groups/account-sync.d.ts.map +1 -0
  65. package/dist/groups/account-sync.js +235 -0
  66. package/dist/groups/account-sync.js.map +1 -0
  67. package/dist/groups/messages.d.ts +39 -0
  68. package/dist/groups/messages.d.ts.map +1 -0
  69. package/dist/groups/messages.js +141 -0
  70. package/dist/groups/messages.js.map +1 -0
  71. package/dist/groups/operations.d.ts +57 -0
  72. package/dist/groups/operations.d.ts.map +1 -0
  73. package/dist/groups/operations.js +225 -0
  74. package/dist/groups/operations.js.map +1 -0
  75. package/dist/groups/repository.d.ts +56 -0
  76. package/dist/groups/repository.d.ts.map +1 -0
  77. package/dist/groups/repository.js +254 -0
  78. package/dist/groups/repository.js.map +1 -0
  79. package/dist/groups/secrets.d.ts +67 -0
  80. package/dist/groups/secrets.d.ts.map +1 -0
  81. package/dist/groups/secrets.js +452 -0
  82. package/dist/groups/secrets.js.map +1 -0
  83. package/dist/groups/state.d.ts +39 -0
  84. package/dist/groups/state.d.ts.map +1 -0
  85. package/dist/groups/state.js +201 -0
  86. package/dist/groups/state.js.map +1 -0
  87. package/dist/identity/identifiers.d.ts +9 -0
  88. package/dist/identity/identifiers.d.ts.map +1 -0
  89. package/dist/identity/identifiers.js +37 -0
  90. package/dist/identity/identifiers.js.map +1 -0
  91. package/dist/identity/neo.d.ts +11 -0
  92. package/dist/identity/neo.d.ts.map +1 -0
  93. package/dist/identity/neo.js +77 -0
  94. package/dist/identity/neo.js.map +1 -0
  95. package/dist/index.d.ts +53 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +46 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/interactions.d.ts +31 -0
  100. package/dist/interactions.d.ts.map +1 -0
  101. package/dist/interactions.js +2 -0
  102. package/dist/interactions.js.map +1 -0
  103. package/dist/messages/account-sender.d.ts +9 -0
  104. package/dist/messages/account-sender.d.ts.map +1 -0
  105. package/dist/messages/account-sender.js +46 -0
  106. package/dist/messages/account-sender.js.map +1 -0
  107. package/dist/messages/contacts.d.ts +103 -0
  108. package/dist/messages/contacts.d.ts.map +1 -0
  109. package/dist/messages/contacts.js +525 -0
  110. package/dist/messages/contacts.js.map +1 -0
  111. package/dist/messages/outbox.d.ts +66 -0
  112. package/dist/messages/outbox.d.ts.map +1 -0
  113. package/dist/messages/outbox.js +167 -0
  114. package/dist/messages/outbox.js.map +1 -0
  115. package/dist/messages/receiver.d.ts +35 -0
  116. package/dist/messages/receiver.d.ts.map +1 -0
  117. package/dist/messages/receiver.js +113 -0
  118. package/dist/messages/receiver.js.map +1 -0
  119. package/dist/messages/repository.d.ts +89 -0
  120. package/dist/messages/repository.d.ts.map +1 -0
  121. package/dist/messages/repository.js +295 -0
  122. package/dist/messages/repository.js.map +1 -0
  123. package/dist/models/authentication.d.ts +37 -0
  124. package/dist/models/authentication.d.ts.map +1 -0
  125. package/dist/models/authentication.js +53 -0
  126. package/dist/models/authentication.js.map +1 -0
  127. package/dist/models/channels.d.ts +141 -0
  128. package/dist/models/channels.d.ts.map +1 -0
  129. package/dist/models/channels.js +258 -0
  130. package/dist/models/channels.js.map +1 -0
  131. package/dist/models/contacts.d.ts +68 -0
  132. package/dist/models/contacts.d.ts.map +1 -0
  133. package/dist/models/contacts.js +186 -0
  134. package/dist/models/contacts.js.map +1 -0
  135. package/dist/models/content.d.ts +37 -0
  136. package/dist/models/content.d.ts.map +1 -0
  137. package/dist/models/content.js +137 -0
  138. package/dist/models/content.js.map +1 -0
  139. package/dist/models/device-state.d.ts +8 -0
  140. package/dist/models/device-state.d.ts.map +1 -0
  141. package/dist/models/device-state.js +22 -0
  142. package/dist/models/device-state.js.map +1 -0
  143. package/dist/models/group-admission.d.ts +111 -0
  144. package/dist/models/group-admission.d.ts.map +1 -0
  145. package/dist/models/group-admission.js +153 -0
  146. package/dist/models/group-admission.js.map +1 -0
  147. package/dist/models/group-keys.d.ts +30 -0
  148. package/dist/models/group-keys.d.ts.map +1 -0
  149. package/dist/models/group-keys.js +52 -0
  150. package/dist/models/group-keys.js.map +1 -0
  151. package/dist/models/group-management.d.ts +190 -0
  152. package/dist/models/group-management.d.ts.map +1 -0
  153. package/dist/models/group-management.js +263 -0
  154. package/dist/models/group-management.js.map +1 -0
  155. package/dist/models/groups.d.ts +81 -0
  156. package/dist/models/groups.d.ts.map +1 -0
  157. package/dist/models/groups.js +127 -0
  158. package/dist/models/groups.js.map +1 -0
  159. package/dist/models/identity.d.ts +47 -0
  160. package/dist/models/identity.d.ts.map +1 -0
  161. package/dist/models/identity.js +136 -0
  162. package/dist/models/identity.js.map +1 -0
  163. package/dist/models/messages.d.ts +104 -0
  164. package/dist/models/messages.d.ts.map +1 -0
  165. package/dist/models/messages.js +168 -0
  166. package/dist/models/messages.js.map +1 -0
  167. package/dist/models/profile.d.ts +24 -0
  168. package/dist/models/profile.d.ts.map +1 -0
  169. package/dist/models/profile.js +47 -0
  170. package/dist/models/profile.js.map +1 -0
  171. package/dist/models/relay.d.ts +39 -0
  172. package/dist/models/relay.d.ts.map +1 -0
  173. package/dist/models/relay.js +160 -0
  174. package/dist/models/relay.js.map +1 -0
  175. package/dist/protocol/codec.d.ts +36 -0
  176. package/dist/protocol/codec.d.ts.map +1 -0
  177. package/dist/protocol/codec.js +144 -0
  178. package/dist/protocol/codec.js.map +1 -0
  179. package/dist/protocol/context.d.ts +14 -0
  180. package/dist/protocol/context.d.ts.map +1 -0
  181. package/dist/protocol/context.js +43 -0
  182. package/dist/protocol/context.js.map +1 -0
  183. package/dist/protocol/encoding.d.ts +15 -0
  184. package/dist/protocol/encoding.d.ts.map +1 -0
  185. package/dist/protocol/encoding.js +133 -0
  186. package/dist/protocol/encoding.js.map +1 -0
  187. package/dist/protocol/json.d.ts +16 -0
  188. package/dist/protocol/json.d.ts.map +1 -0
  189. package/dist/protocol/json.js +172 -0
  190. package/dist/protocol/json.js.map +1 -0
  191. package/dist/protocol/validation.d.ts +4 -0
  192. package/dist/protocol/validation.d.ts.map +1 -0
  193. package/dist/protocol/validation.js +18 -0
  194. package/dist/protocol/validation.js.map +1 -0
  195. package/dist/runtime/abort.d.ts +6 -0
  196. package/dist/runtime/abort.d.ts.map +1 -0
  197. package/dist/runtime/abort.js +32 -0
  198. package/dist/runtime/abort.js.map +1 -0
  199. package/dist/runtime/async-gate.d.ts +6 -0
  200. package/dist/runtime/async-gate.d.ts.map +1 -0
  201. package/dist/runtime/async-gate.js +15 -0
  202. package/dist/runtime/async-gate.js.map +1 -0
  203. package/dist/runtime/async-pulse.d.ts +7 -0
  204. package/dist/runtime/async-pulse.d.ts.map +1 -0
  205. package/dist/runtime/async-pulse.js +23 -0
  206. package/dist/runtime/async-pulse.js.map +1 -0
  207. package/dist/runtime/clock.d.ts +16 -0
  208. package/dist/runtime/clock.d.ts.map +1 -0
  209. package/dist/runtime/clock.js +62 -0
  210. package/dist/runtime/clock.js.map +1 -0
  211. package/dist/runtime/events.d.ts +11 -0
  212. package/dist/runtime/events.d.ts.map +1 -0
  213. package/dist/runtime/events.js +45 -0
  214. package/dist/runtime/events.js.map +1 -0
  215. package/dist/runtime/url.d.ts +3 -0
  216. package/dist/runtime/url.d.ts.map +1 -0
  217. package/dist/runtime/url.js +23 -0
  218. package/dist/runtime/url.js.map +1 -0
  219. package/dist/storage/signed-request.d.ts +14 -0
  220. package/dist/storage/signed-request.d.ts.map +1 -0
  221. package/dist/storage/signed-request.js +26 -0
  222. package/dist/storage/signed-request.js.map +1 -0
  223. package/dist/storage/store.d.ts +58 -0
  224. package/dist/storage/store.d.ts.map +1 -0
  225. package/dist/storage/store.js +52 -0
  226. package/dist/storage/store.js.map +1 -0
  227. package/dist/storage/transaction.d.ts +7 -0
  228. package/dist/storage/transaction.d.ts.map +1 -0
  229. package/dist/storage/transaction.js +22 -0
  230. package/dist/storage/transaction.js.map +1 -0
  231. package/dist/transport/client.d.ts +61 -0
  232. package/dist/transport/client.d.ts.map +1 -0
  233. package/dist/transport/client.js +392 -0
  234. package/dist/transport/client.js.map +1 -0
  235. package/dist/transport/discovery.d.ts +19 -0
  236. package/dist/transport/discovery.d.ts.map +1 -0
  237. package/dist/transport/discovery.js +75 -0
  238. package/dist/transport/discovery.js.map +1 -0
  239. package/dist/transport/endpoint.d.ts +6 -0
  240. package/dist/transport/endpoint.d.ts.map +1 -0
  241. package/dist/transport/endpoint.js +35 -0
  242. package/dist/transport/endpoint.js.map +1 -0
  243. package/dist/transport/http.d.ts +52 -0
  244. package/dist/transport/http.d.ts.map +1 -0
  245. package/dist/transport/http.js +128 -0
  246. package/dist/transport/http.js.map +1 -0
  247. package/dist/transport/pool.d.ts +31 -0
  248. package/dist/transport/pool.d.ts.map +1 -0
  249. package/dist/transport/pool.js +137 -0
  250. package/dist/transport/pool.js.map +1 -0
  251. package/dist/transport/relay-error.d.ts +23 -0
  252. package/dist/transport/relay-error.d.ts.map +1 -0
  253. package/dist/transport/relay-error.js +47 -0
  254. package/dist/transport/relay-error.js.map +1 -0
  255. package/dist/transport/rpc.d.ts +31 -0
  256. package/dist/transport/rpc.d.ts.map +1 -0
  257. package/dist/transport/rpc.js +56 -0
  258. package/dist/transport/rpc.js.map +1 -0
  259. package/dist/transport/session.d.ts +49 -0
  260. package/dist/transport/session.d.ts.map +1 -0
  261. package/dist/transport/session.js +169 -0
  262. package/dist/transport/session.js.map +1 -0
  263. package/dist/transport/socket.d.ts +55 -0
  264. package/dist/transport/socket.d.ts.map +1 -0
  265. package/dist/transport/socket.js +266 -0
  266. package/dist/transport/socket.js.map +1 -0
  267. package/dist/transport/subscription.d.ts +36 -0
  268. package/dist/transport/subscription.d.ts.map +1 -0
  269. package/dist/transport/subscription.js +169 -0
  270. package/dist/transport/subscription.js.map +1 -0
  271. package/package.json +35 -4
  272. package/src/channels/repository.ts +244 -0
  273. package/src/client/account-operations.ts +183 -0
  274. package/src/client/conversations.ts +95 -0
  275. package/src/client.ts +108 -0
  276. package/src/components/account.ts +206 -0
  277. package/src/components/channel.ts +253 -0
  278. package/src/components/component.ts +151 -0
  279. package/src/components/device.ts +455 -0
  280. package/src/components/group.ts +546 -0
  281. package/src/components/message.ts +253 -0
  282. package/src/components/profile.ts +179 -0
  283. package/src/crypto/groups.ts +107 -0
  284. package/src/crypto/messages.ts +129 -0
  285. package/src/crypto/primitives.ts +103 -0
  286. package/src/errors.ts +13 -0
  287. package/src/groups/account-sync.ts +144 -0
  288. package/src/groups/messages.ts +89 -0
  289. package/src/groups/operations.ts +145 -0
  290. package/src/groups/repository.ts +161 -0
  291. package/src/groups/secrets.ts +256 -0
  292. package/src/groups/state.ts +138 -0
  293. package/src/identity/identifiers.ts +41 -0
  294. package/src/identity/neo.ts +72 -0
  295. package/src/index.ts +52 -0
  296. package/src/interactions.ts +35 -0
  297. package/src/messages/account-sender.ts +33 -0
  298. package/src/messages/contacts.ts +379 -0
  299. package/src/messages/outbox.ts +152 -0
  300. package/src/messages/receiver.ts +86 -0
  301. package/src/messages/repository.ts +230 -0
  302. package/src/models/authentication.ts +63 -0
  303. package/src/models/channels.ts +155 -0
  304. package/src/models/contacts.ts +141 -0
  305. package/src/models/content.ts +105 -0
  306. package/src/models/device-state.ts +18 -0
  307. package/src/models/group-admission.ts +110 -0
  308. package/src/models/group-keys.ts +34 -0
  309. package/src/models/group-management.ts +182 -0
  310. package/src/models/groups.ts +100 -0
  311. package/src/models/identity.ts +164 -0
  312. package/src/models/messages.ts +139 -0
  313. package/src/models/profile.ts +46 -0
  314. package/src/models/relay.ts +151 -0
  315. package/src/protocol/codec.ts +144 -0
  316. package/src/protocol/context.ts +39 -0
  317. package/src/protocol/encoding.ts +106 -0
  318. package/src/protocol/json.ts +140 -0
  319. package/src/protocol/validation.ts +13 -0
  320. package/src/runtime/abort.ts +27 -0
  321. package/src/runtime/async-gate.ts +16 -0
  322. package/src/runtime/async-pulse.ts +20 -0
  323. package/src/runtime/clock.ts +63 -0
  324. package/src/runtime/events.ts +31 -0
  325. package/src/runtime/url.ts +17 -0
  326. package/src/storage/signed-request.ts +26 -0
  327. package/src/storage/store.ts +90 -0
  328. package/src/storage/transaction.ts +17 -0
  329. package/src/transport/client.ts +328 -0
  330. package/src/transport/discovery.ts +64 -0
  331. package/src/transport/endpoint.ts +32 -0
  332. package/src/transport/http.ts +129 -0
  333. package/src/transport/pool.ts +115 -0
  334. package/src/transport/relay-error.ts +46 -0
  335. package/src/transport/rpc.ts +56 -0
  336. package/src/transport/session.ts +154 -0
  337. package/src/transport/socket.ts +248 -0
  338. package/src/transport/subscription.ts +117 -0
@@ -0,0 +1,115 @@
1
+ import { createAbortController } from '../runtime/abort.js';
2
+ import { ProtocolError } from '../errors.js';
3
+ import { validateAccountId, validateRelayId } from '../identity/neo.js';
4
+ import type { RelayRegistry } from '../interactions.js';
5
+ import { certificateId } from '../models/identity.js';
6
+ import type { NetworkContext } from '../protocol/context.js';
7
+ import { awaitWithSignal, throwIfAborted } from '../runtime/clock.js';
8
+ import { EventHub, type EventListener } from '../runtime/events.js';
9
+ import { RelayClient, type RelayTransportOptions } from './client.js';
10
+ import { RelayAuthenticator, type AuthenticationIdentity } from './session.js';
11
+
12
+ export interface RelayClientPoolOptions extends RelayTransportOptions { readonly context: NetworkContext; readonly accountId: string }
13
+ export interface RelayPoolEvents { readonly poolChanged: readonly RelayClient[]; readonly relayChanged: RelayClient; readonly errorOccurred: unknown }
14
+ interface Entry { client: RelayClient; ready: Promise<void>; mode: 'account' | 'device' | undefined; retired: boolean }
15
+
16
+ /** Owns account/device sessions separately; a pool binds to one network, account and device. */
17
+ export class RelayClientPool {
18
+ readonly #events = new EventHub<RelayPoolEvents>();
19
+ readonly #entries = new Map<string, Entry[]>();
20
+ readonly #retirements = new Set<Promise<void>>();
21
+ readonly #lifetime = createAbortController();
22
+ readonly #options: RelayClientPoolOptions;
23
+ #deviceId: string | undefined;
24
+ #disposal: Promise<void> | undefined;
25
+ #lastError: unknown;
26
+ readonly context: NetworkContext;
27
+ readonly accountId: string;
28
+ constructor(options: RelayClientPoolOptions, readonly registry: RelayRegistry) {
29
+ validateAccountId(options.accountId);
30
+ if (options.context.toString() !== registry.context.toString()) throw new ProtocolError('invalid_context', 'Registry and client network contexts differ.');
31
+ this.context = options.context; this.accountId = options.accountId; this.#options = { ...options };
32
+ }
33
+ get clients(): readonly RelayClient[] { return Object.freeze([...this.#entries.values()].flatMap(entries => entries.map(entry => entry.client))); }
34
+ /** Last application observer failure; transport failures remain on each relay client's state. */
35
+ get lastError(): unknown { return this.#lastError; }
36
+ on<K extends keyof RelayPoolEvents>(event: K, listener: EventListener<RelayPoolEvents[K]>): () => void { return this.#events.on(event, listener); }
37
+ #notify<K extends Exclude<keyof RelayPoolEvents, 'errorOccurred'>>(event: K, value: RelayPoolEvents[K]): void {
38
+ this.#events.notify(event, value, error => {
39
+ this.#lastError = error;
40
+ this.#events.notify('errorOccurred', error, observerError => {
41
+ this.#lastError = new AggregateError([error, observerError], 'Pool observer and error observer failed.');
42
+ });
43
+ });
44
+ }
45
+
46
+ async get(relayId: string, identity?: AuthenticationIdentity, signal?: AbortSignal): Promise<RelayClient> {
47
+ throwIfAborted(signal); throwIfAborted(this.#lifetime.signal); validateRelayId(relayId);
48
+ if (this.registry.context.toString() !== this.context.toString()) throw new ProtocolError('context_changed', 'Registry network context changed.');
49
+ if (identity) {
50
+ const account = identity.mode === 'account' ? identity.signer.accountId : identity.signer.certificate.account;
51
+ if (account !== this.accountId) throw new ProtocolError('invalid_identity', 'Signer belongs to another account.');
52
+ if (identity.mode === 'device') {
53
+ const device = certificateId(identity.signer.certificate, this.context);
54
+ if (this.#deviceId !== undefined && this.#deviceId !== device) throw new ProtocolError('invalid_identity', 'Pool is already bound to another device.');
55
+ this.#deviceId = device;
56
+ }
57
+ }
58
+ let entries = this.#entries.get(relayId);
59
+ let entry = identity ? entries?.find(entry => entry.mode === identity.mode) : entries?.[0];
60
+ if (!entry) {
61
+ const authenticator = identity === undefined ? undefined : new RelayAuthenticator(relayId, this.context, identity, this.#options.clock);
62
+ const client = new RelayClient(relayId, this.registry, this.#options, authenticator);
63
+ entry = { client, mode: identity?.mode, retired: false, ready: Promise.resolve() };
64
+ if (!entries) { entries = []; this.#entries.set(relayId, entries); }
65
+ entries.push(entry);
66
+ const captured = entry;
67
+ client.on('stateChanged', () => this.#notify('relayChanged', client));
68
+ client.on('faulted', () => { this.#retire(captured); });
69
+ // Publish only after the readiness promise is installed, so a callback
70
+ // acquiring the same relay waits for this authentication too.
71
+ entry.ready = Promise.resolve().then(async () => {
72
+ this.#notify('poolChanged', this.clients);
73
+ if (authenticator) await client.authenticate(this.#lifetime.signal);
74
+ });
75
+ entry.ready.then(() => undefined, () => { this.#retire(captured); });
76
+ }
77
+ await awaitWithSignal(entry.ready, signal);
78
+ throwIfAborted(this.#lifetime.signal); throwIfAborted(signal);
79
+ if (entry.retired) throw new ProtocolError('retired_client', 'Relay client was retired while being acquired.');
80
+ return entry.client;
81
+ }
82
+
83
+ #retire(entry: Entry): void {
84
+ if (entry.retired) return;
85
+ entry.retired = true;
86
+ const entries = this.#entries.get(entry.client.relayId)!;
87
+ entries.splice(entries.indexOf(entry), 1);
88
+ if (entries.length === 0) this.#entries.delete(entry.client.relayId);
89
+ const cleanup = (async () => { await entry.client.dispose(); this.#notify('poolChanged', this.clients); })();
90
+ this.#retirements.add(cleanup);
91
+ // Keep failed retirements for dispose/invalidate to surface; successful ones need no retained task.
92
+ cleanup.then(() => this.#retirements.delete(cleanup), () => undefined);
93
+ }
94
+
95
+ /** Device registration/key changes require new device sessions; account sessions remain independent. */
96
+ async invalidateDevice(): Promise<void> {
97
+ throwIfAborted(this.#lifetime.signal);
98
+ for (const entries of [...this.#entries.values()]) for (const entry of [...entries]) if (entry.mode === 'device') this.#retire(entry);
99
+ this.#deviceId = undefined;
100
+ await this.#finishRetirements();
101
+ }
102
+ async #finishRetirements(): Promise<void> {
103
+ const jobs = [...this.#retirements]; const results = await Promise.allSettled(jobs);
104
+ for (const job of jobs) this.#retirements.delete(job);
105
+ const errors = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected').map(result => result.reason);
106
+ if (errors.length) throw new AggregateError(errors, 'Relay client retirement failed.');
107
+ }
108
+ dispose(): Promise<void> {
109
+ return this.#disposal ??= (async () => {
110
+ this.#lifetime.abort(new DOMException('Relay client pool was disposed.', 'AbortError'));
111
+ for (const entries of [...this.#entries.values()]) for (const entry of [...entries]) this.#retire(entry);
112
+ try { await this.#finishRetirements(); } finally { this.#events.clear(); }
113
+ })();
114
+ }
115
+ }
@@ -0,0 +1,46 @@
1
+ import { ProtocolError } from '../errors.js';
2
+ import { defineCodec, text, type ExtensibleModel } from '../protocol/codec.js';
3
+ import { requireObject, requireSafeInteger, type JsonObject } from '../protocol/json.js';
4
+
5
+ export interface RelayFailure extends ExtensibleModel { readonly code: string; readonly message: string; readonly data?: JsonObject }
6
+ export const relayFailureCodec = defineCodec<RelayFailure>({
7
+ code: { wire: 'code', codec: text }, message: { wire: 'message', codec: text },
8
+ data: { wire: 'data', codec: { decode: requireObject, encode: requireObject }, optional: true },
9
+ });
10
+
11
+ export const rpcErrorCodes: Readonly<Record<string, number>> = Object.freeze({
12
+ bad_request: -32602, method_not_found: -32601, internal_error: -32603, unauthorized: -32001, forbidden: -32003,
13
+ invalid_signature: -32004, device_unknown: -32005, clock_skew: -32006, not_found: -32010, state_conflict: -32011,
14
+ stale_state: -32012, invalid_state: -32013, message_expired: -32014, request_too_large: -32015, target_not_local: -32020,
15
+ route_stale: -32021, route_not_found: -32022, bad_gateway: -32023, rate_limited: -32030, temporarily_unavailable: -32031,
16
+ });
17
+ const definitiveCodes = new Set(Object.keys(rpcErrorCodes).filter(code => !['internal_error', 'bad_gateway', 'temporarily_unavailable'].includes(code)));
18
+
19
+ export function retryAfter(failure: RelayFailure): number | undefined {
20
+ if (failure.code !== 'rate_limited' || failure.data?.retry_after === undefined) return undefined;
21
+ requireSafeInteger(failure.data.retry_after, 0);
22
+ return failure.data.retry_after;
23
+ }
24
+
25
+ /** Only isDefinitiveRejection proves that a business operation was not accepted. */
26
+ export class RelayError extends Error {
27
+ override readonly name = 'RelayError';
28
+ readonly failure: RelayFailure;
29
+ constructor(failure: RelayFailure, readonly httpStatus?: number, readonly rpcCode?: number) {
30
+ super(failure.message);
31
+ this.failure = relayFailureCodec.decode(relayFailureCodec.encode(failure));
32
+ retryAfter(this.failure);
33
+ }
34
+ get code(): string { return this.failure.code; }
35
+ get retryAfter(): number | undefined { return retryAfter(this.failure); }
36
+ get isDefinitiveRejection(): boolean { return definitiveCodes.has(this.code); }
37
+ }
38
+
39
+ export function validateRetryAfterHeader(failure: RelayFailure, status: number, header: string | null): void {
40
+ if (failure.code !== 'rate_limited') return;
41
+ const seconds = retryAfter(failure);
42
+ if (header !== null && (seconds === undefined || !/^[0-9]+$/.test(header) || /\s/.test(header)
43
+ || !Number.isSafeInteger(Number(header)) || Number(header) !== seconds)
44
+ || status === 429 && seconds !== undefined && header === null)
45
+ throw new ProtocolError('invalid_retry_after', 'Retry-After must match the error retry_after value.');
46
+ }
@@ -0,0 +1,56 @@
1
+ import { ProtocolError } from '../errors.js';
2
+ import { encodeUtf8 } from '../protocol/encoding.js';
3
+ import { canonicalJson, parseJson, requireObject, requireSafeInteger, type JsonObject, type JsonValue } from '../protocol/json.js';
4
+ import { validateMethodName } from './endpoint.js';
5
+ import { RelayError, rpcErrorCodes } from './relay-error.js';
6
+
7
+ export const maxSocketMessageBytes = 1_048_576;
8
+ export type RpcId = string | number;
9
+ export interface RpcNotification { readonly method: string; readonly params?: JsonObject }
10
+ export interface RpcFailure { readonly code: number; readonly message: string; readonly data?: JsonObject }
11
+ export type RpcMessage = { readonly kind: 'notification'; readonly notification: RpcNotification }
12
+ | { readonly kind: 'success'; readonly id: RpcId; readonly result: JsonValue }
13
+ | { readonly kind: 'failure'; readonly id: RpcId | null; readonly error: RpcFailure };
14
+
15
+ function requireId(value: JsonValue | undefined): RpcId {
16
+ if (typeof value === 'number') { requireSafeInteger(value); return value; }
17
+ if (typeof value !== 'string' || encodeUtf8(value).length > 128) throw new ProtocolError('invalid_rpc_id', 'RPC IDs must be safe integers or strings of at most 128 UTF-8 bytes.');
18
+ return value;
19
+ }
20
+
21
+ export function encodeRpcRequest(id: RpcId, method: string, params?: JsonObject): string {
22
+ requireId(id);
23
+ validateMethodName(method);
24
+ const value: JsonObject = { jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params: requireObject(params) }) };
25
+ const text = canonicalJson(value);
26
+ if (encodeUtf8(text).length > maxSocketMessageBytes) throw new ProtocolError('request_too_large', 'A WebSocket request cannot exceed 1 MiB.');
27
+ return text;
28
+ }
29
+
30
+ /** Parses the whole message before ignoring unknown JSON-RPC wrapper extensions. */
31
+ export function decodeRpcMessage(text: string): RpcMessage {
32
+ if (encodeUtf8(text).length > maxSocketMessageBytes) throw new ProtocolError('message_too_large', 'A WebSocket message cannot exceed 1 MiB.');
33
+ const value = requireObject(parseJson(text));
34
+ if (value.jsonrpc !== '2.0') throw new ProtocolError('invalid_rpc', 'Expected JSON-RPC 2.0.');
35
+ const has = (key: string): boolean => Object.hasOwn(value, key);
36
+ if (!has('id')) {
37
+ if (typeof value.method !== 'string' || value.method.length === 0)
38
+ throw new ProtocolError('invalid_rpc', 'A notification must have a method and no id.');
39
+ // Future method names are retained. Dispatchers may ignore notifications they do not know.
40
+ return { kind: 'notification', notification: { method: value.method, ...(has('params') ? { params: requireObject(value.params!) } : {}) } };
41
+ }
42
+ if (has('result') === has('error')) throw new ProtocolError('invalid_rpc', 'A response must contain exactly one of result or error.');
43
+ if (has('result')) return { kind: 'success', id: requireId(value.id), result: value.result! };
44
+ const error = requireObject(value.error!);
45
+ requireSafeInteger(error.code);
46
+ if (typeof error.message !== 'string') throw new ProtocolError('invalid_rpc', 'An RPC error must have a string message.');
47
+ return { kind: 'failure', id: value.id === null ? null : requireId(value.id),
48
+ error: { code: error.code, message: error.message, ...(Object.hasOwn(error, 'data') ? { data: requireObject(error.data!) } : {}) } };
49
+ }
50
+
51
+ export function rpcFailureError(error: RpcFailure): RelayError | ProtocolError {
52
+ const code = Object.keys(rpcErrorCodes).find(key => rpcErrorCodes[key] === error.code);
53
+ return code === undefined
54
+ ? new ProtocolError('unknown_rpc_error', `Unmapped JSON-RPC error ${error.code}: ${error.message}`)
55
+ : new RelayError({ code, message: error.message, ...(error.data === undefined ? {} : { data: error.data }) }, undefined, error.code);
56
+ }
@@ -0,0 +1,154 @@
1
+ import { createAbortController } from '../runtime/abort.js';
2
+ import { verifyAccount, verifyDevice } from '../crypto/primitives.js';
3
+ import { ProtocolError } from '../errors.js';
4
+ import { matchesAccount, validateRelayId } from '../identity/neo.js';
5
+ import type { AccountSigner, DeviceSigner } from '../interactions.js';
6
+ import {
7
+ accountAuthenticationCodec, accountAuthenticationInput, authenticationChallengeCodec, deviceAuthenticationCodec,
8
+ deviceAuthenticationInput, sessionCredentialsCodec, validateChallenge, validateSession,
9
+ type AccountAuthenticationRequest, type DeviceAuthenticationRequest, type SessionCredentials, type SessionMode,
10
+ } from '../models/authentication.js';
11
+ import { certificateId, deviceCertificateCodec, validateCertificate } from '../models/identity.js';
12
+ import { NetworkContext } from '../protocol/context.js';
13
+ import { equalBytes } from '../protocol/encoding.js';
14
+ import type { JsonObject, JsonValue } from '../protocol/json.js';
15
+ import { abortScope, awaitWithSignal, systemClock, throwIfAborted, type RuntimeClock } from '../runtime/clock.js';
16
+ import { relayOrigin } from './endpoint.js';
17
+ import type { HttpRelayTransport } from './http.js';
18
+
19
+ export type AuthenticationIdentity = { readonly mode: 'account'; readonly signer: AccountSigner } | { readonly mode: 'device'; readonly signer: DeviceSigner };
20
+ export type AuthenticationSender = (method: string, parameters: JsonObject, signal?: AbortSignal) => Promise<JsonValue | undefined>;
21
+
22
+ /** Keeps bearer credentials out of ordinary object enumeration and diagnostic serialization. */
23
+ export class RelaySession {
24
+ readonly #credentials: SessionCredentials;
25
+ readonly #clock: RuntimeClock;
26
+ readonly #started: number;
27
+ readonly #lifetimeMilliseconds: number;
28
+ constructor(credentials: SessionCredentials, readonly origin: string, challengeCreatedAt: number, started: number, clock: RuntimeClock) {
29
+ this.#credentials = sessionCredentialsCodec.decode(sessionCredentialsCodec.encode(credentials));
30
+ this.#started = started;
31
+ this.#clock = clock;
32
+ this.#lifetimeMilliseconds = (credentials.expiresAt - challengeCreatedAt - 1) * 1000;
33
+ }
34
+ get mode(): SessionMode { return this.#credentials.mode; }
35
+ get lifetimeSeconds(): number { return this.#lifetimeMilliseconds / 1000; }
36
+ get remainingSeconds(): number { return (this.#lifetimeMilliseconds - (this.#clock.monotonicMilliseconds() - this.#started)) / 1000; }
37
+ /** Return only for sending a request to origin. Do not log or persist. */
38
+ getToken(endpoint: string): string {
39
+ if (relayOrigin(endpoint) !== this.origin) throw new ProtocolError('invalid_origin', 'A session cannot be sent to a different origin.');
40
+ if (this.remainingSeconds <= 0) throw new ProtocolError('expired_session', 'The relay session has expired.');
41
+ return this.#credentials.token;
42
+ }
43
+ }
44
+
45
+ /** Captures immutable identity at binding time; certificate renewal for the same device is allowed. */
46
+ export class RelayAuthenticator {
47
+ readonly #identity: AuthenticationIdentity;
48
+ readonly #publicKey: Uint8Array | undefined;
49
+ readonly accountId: string;
50
+ readonly deviceId: string | undefined;
51
+ readonly mode: SessionMode;
52
+ constructor(readonly relayId: string, readonly context: NetworkContext, identity: AuthenticationIdentity, readonly clock: RuntimeClock = systemClock) {
53
+ validateRelayId(relayId);
54
+ this.#identity = { ...identity };
55
+ this.mode = identity.mode;
56
+ if (identity.mode === 'account') {
57
+ this.accountId = identity.signer.accountId;
58
+ this.#publicKey = identity.signer.publicKey.slice();
59
+ if (!matchesAccount(this.accountId, this.#publicKey)) throw new ProtocolError('invalid_identity', 'The signer public key does not identify its account.');
60
+ } else {
61
+ const certificate = identity.signer.certificate;
62
+ validateCertificate(certificate, context);
63
+ this.accountId = certificate.account;
64
+ this.deviceId = certificateId(certificate, context);
65
+ }
66
+ }
67
+
68
+ assertIdentity(): void {
69
+ const identity = this.#identity;
70
+ if (identity.mode === 'account') {
71
+ if (identity.signer.accountId !== this.accountId || !equalBytes(identity.signer.publicKey, this.#publicKey!))
72
+ throw new ProtocolError('identity_changed', 'The account signer identity has changed.');
73
+ } else {
74
+ const certificate = identity.signer.certificate;
75
+ if (certificate.account !== this.accountId || certificateId(certificate, this.context) !== this.deviceId)
76
+ throw new ProtocolError('identity_changed', 'The device signer identity has changed.');
77
+ }
78
+ }
79
+
80
+ async authenticate(endpoint: string, send: AuthenticationSender, signal?: AbortSignal): Promise<RelaySession> {
81
+ throwIfAborted(signal);
82
+ this.assertIdentity();
83
+ const origin = relayOrigin(endpoint);
84
+ // Clone before awaits: a mutable application object must not change the signed proof in flight.
85
+ const identity = this.#identity;
86
+ const certificate = identity.mode === 'device' ? deviceCertificateCodec.decode(deviceCertificateCodec.encode(identity.signer.certificate)) : undefined;
87
+ if (certificate) validateCertificate(certificate, this.context);
88
+ const started = this.clock.monotonicMilliseconds();
89
+ const challengeWire = await send('auth.challenge', { account: this.accountId }, signal);
90
+ if (challengeWire === undefined) throw new ProtocolError('missing_result', 'Authentication challenge has no result.');
91
+ const challenge = authenticationChallengeCodec.decode(challengeWire);
92
+ validateChallenge(challenge);
93
+ this.assertIdentity();
94
+ let method: string;
95
+ let proof: JsonObject;
96
+ if (identity.mode === 'device') {
97
+ const request: DeviceAuthenticationRequest = { nonce: challenge.nonce, timestamp: this.clock.nowSeconds(), signerCertificate: certificate!, deviceSignature: new Uint8Array(64) };
98
+ const input = deviceAuthenticationInput(request, this.relayId, endpoint, this.context);
99
+ const signature = await identity.signer.sign(input.slice(), signal);
100
+ if (!verifyDevice(input, signature, certificate!.signingPublicKey)) throw new ProtocolError('invalid_signature', 'The device signer returned an invalid signature.');
101
+ proof = deviceAuthenticationCodec.encode({ ...request, deviceSignature: signature });
102
+ method = 'auth.device.verify';
103
+ } else {
104
+ const request: AccountAuthenticationRequest = { nonce: challenge.nonce, accountPublicKey: this.#publicKey!.slice(), accountSignature: new Uint8Array(64) };
105
+ const input = accountAuthenticationInput(request, this.accountId, this.relayId, endpoint, this.context);
106
+ const signature = await identity.signer.sign(input.slice(), signal);
107
+ if (!verifyAccount(input, signature, this.#publicKey!)) throw new ProtocolError('invalid_signature', 'The account signer returned an invalid signature.');
108
+ proof = accountAuthenticationCodec.encode({ ...request, accountSignature: signature });
109
+ method = 'auth.account.verify';
110
+ }
111
+ throwIfAborted(signal);
112
+ this.assertIdentity();
113
+ if (this.clock.monotonicMilliseconds() - started >= (challenge.expiresAt - challenge.createdAt) * 1000)
114
+ throw new ProtocolError('expired_challenge', 'The challenge expired before the proof could be submitted.');
115
+ const sessionWire = await send(method, proof, signal);
116
+ throwIfAborted(signal);
117
+ this.assertIdentity();
118
+ if (sessionWire === undefined) throw new ProtocolError('missing_result', 'Authentication verification has no result.');
119
+ const credentials = sessionCredentialsCodec.decode(sessionWire);
120
+ validateSession(credentials);
121
+ const session = new RelaySession(credentials, origin, challenge.createdAt, started, this.clock);
122
+ if (session.mode !== this.mode || session.remainingSeconds <= 0) throw new ProtocolError('invalid_session', 'Relay returned an expired session or a different authentication mode.');
123
+ return session;
124
+ }
125
+ }
126
+
127
+ /** Shares only authentication work. Canceling a waiter never cancels another caller's refresh. */
128
+ export class HttpRelaySessions {
129
+ readonly #lifetime = createAbortController();
130
+ #session: RelaySession | undefined;
131
+ #pending: { origin: string; promise: Promise<RelaySession> } | undefined;
132
+ constructor(readonly authenticator: RelayAuthenticator, readonly transport: HttpRelayTransport) {}
133
+
134
+ get(endpoint: string, signal?: AbortSignal): Promise<RelaySession> {
135
+ throwIfAborted(signal);
136
+ throwIfAborted(this.#lifetime.signal);
137
+ this.authenticator.assertIdentity();
138
+ const origin = relayOrigin(endpoint);
139
+ if (this.#session?.origin === origin && this.#session.remainingSeconds > Math.min(5, this.#session.lifetimeSeconds * 0.2)) return Promise.resolve(this.#session);
140
+ if (this.#pending?.origin === origin) return awaitWithSignal(this.#pending.promise, signal);
141
+ const scope = abortScope([this.#lifetime.signal]);
142
+ const pending = { origin, promise: this.authenticator.authenticate(endpoint,
143
+ (name, parameters, token) => this.transport.request(endpoint, 'POST', name, parameters, undefined, token), scope.signal) };
144
+ this.#pending = pending;
145
+ pending.promise.then(session => {
146
+ scope.dispose();
147
+ if (this.#pending === pending) { this.#session = session; this.#pending = undefined; }
148
+ }, () => { scope.dispose(); if (this.#pending === pending) this.#pending = undefined; });
149
+ return awaitWithSignal(pending.promise, signal);
150
+ }
151
+
152
+ invalidate(session?: RelaySession): void { if (session === undefined || this.#session === session) this.#session = undefined; }
153
+ dispose(): void { this.#session = undefined; this.#pending = undefined; this.#lifetime.abort(new DOMException('Relay sessions were disposed.', 'AbortError')); }
154
+ }
@@ -0,0 +1,248 @@
1
+ import { createAbortController, abortReason } from '../runtime/abort.js';
2
+ import { systemRandom, type RandomSource } from '../crypto/primitives.js';
3
+ import { ProtocolError } from '../errors.js';
4
+ import { sessionCredentialsCodec, validateSession, type SessionMode } from '../models/authentication.js';
5
+ import { encodeBase64Url } from '../protocol/encoding.js';
6
+ import type { JsonObject, JsonValue } from '../protocol/json.js';
7
+ import { abortScope, awaitWithSignal, systemClock, throwIfAborted, type RuntimeClock } from '../runtime/clock.js';
8
+ import { webEndpoint } from './endpoint.js';
9
+ import { RelayBackoff } from './http.js';
10
+ import { RelayError } from './relay-error.js';
11
+ import { decodeRpcMessage, encodeRpcRequest, rpcFailureError, type RpcId, type RpcMessage, type RpcNotification } from './rpc.js';
12
+
13
+ export interface RelaySocketEvents {
14
+ open: undefined;
15
+ message: unknown;
16
+ error: Error;
17
+ close: { readonly code: number; readonly reason: string };
18
+ }
19
+
20
+ /** Platform socket adapters normalize events and report the actual close code they can send. */
21
+ export interface RelaySocket {
22
+ readonly readyState: number;
23
+ readonly bufferedAmount: number;
24
+ send(text: string): void;
25
+ close(code: number, reason: string): number;
26
+ on<K extends keyof RelaySocketEvents>(event: K, listener: (value: RelaySocketEvents[K]) => void): () => void;
27
+ }
28
+ export type RelaySocketFactory = (endpoint: string) => RelaySocket;
29
+
30
+ /** Browsers only allow 1000 or application codes in close(); 3003/3009 report the corresponding local rejection. */
31
+ export const browserSocketFactory: RelaySocketFactory = endpoint => {
32
+ const socket = new WebSocket(endpoint);
33
+ socket.binaryType = 'arraybuffer';
34
+ return {
35
+ get readyState() { return socket.readyState; },
36
+ get bufferedAmount() { return socket.bufferedAmount; },
37
+ send(text) { socket.send(text); },
38
+ close(code, reason) { const actual = code === 1000 ? code : 3000 + code - 1000; socket.close(actual, reason); return actual; },
39
+ on(event, listener) {
40
+ const handler = (value: Event): void => {
41
+ const normalized = event === 'open' ? undefined : event === 'message' ? (value as MessageEvent<unknown>).data
42
+ : event === 'close' ? { code: (value as CloseEvent).code, reason: (value as CloseEvent).reason }
43
+ : new Error('The browser WebSocket failed; inspect the browser network diagnostics.');
44
+ listener(normalized as RelaySocketEvents[typeof event]);
45
+ };
46
+ socket.addEventListener(event, handler);
47
+ return () => socket.removeEventListener(event, handler);
48
+ },
49
+ };
50
+ };
51
+
52
+ export interface SocketFailure { readonly error: unknown; readonly requestedCloseCode: number; readonly sentCloseCode: number | undefined }
53
+ interface Pending { readonly method: string; resolve(message: Exclude<RpcMessage, { kind: 'notification' }>): void; reject(error: unknown): void }
54
+ export interface RpcConnectionOptions {
55
+ readonly socketFactory?: RelaySocketFactory;
56
+ readonly clock?: RuntimeClock;
57
+ readonly random?: RandomSource;
58
+ readonly backoff?: RelayBackoff;
59
+ readonly requestTimeoutMilliseconds?: number;
60
+ }
61
+
62
+ /** One connection only. Pool/session owners handle reconnect, renewal and notification catch-up. */
63
+ export class RpcConnection {
64
+ readonly #factory: RelaySocketFactory;
65
+ readonly #clock: RuntimeClock;
66
+ readonly #random: RandomSource;
67
+ readonly #backoff: RelayBackoff;
68
+ readonly #timeout: number;
69
+ readonly #lifetime = createAbortController();
70
+ readonly #pending = new Map<RpcId, Pending>();
71
+ readonly #queue: RpcNotification[] = [];
72
+ readonly #waiters: { resolve(value: RpcNotification): void; reject(error: unknown): void }[] = [];
73
+ #socket: RelaySocket | undefined;
74
+ #opening: Promise<void> | undefined;
75
+ #rejectOpening: ((error: unknown) => void) | undefined;
76
+ #mode: SessionMode | undefined;
77
+ #sequence = 0;
78
+ #requestPrefix: string | undefined;
79
+ #failure: SocketFailure | undefined;
80
+ #resolveClosed!: (failure: SocketFailure) => void;
81
+ #unlisten: (() => void)[] = [];
82
+ readonly closed = new Promise<SocketFailure>(resolve => { this.#resolveClosed = resolve; });
83
+
84
+ constructor(readonly endpoint: string, options: RpcConnectionOptions = {}) {
85
+ webEndpoint(endpoint, 'wss:');
86
+ this.#factory = options.socketFactory ?? browserSocketFactory;
87
+ this.#clock = options.clock ?? systemClock;
88
+ this.#random = options.random ?? systemRandom;
89
+ this.#backoff = options.backoff ?? new RelayBackoff(this.#clock);
90
+ this.#timeout = options.requestTimeoutMilliseconds ?? 60000;
91
+ if (!Number.isSafeInteger(this.#timeout) || this.#timeout < 1 || this.#timeout > 2_147_483_647) throw new RangeError('Invalid socket timeout.');
92
+ }
93
+
94
+ get failure(): SocketFailure | undefined { return this.#failure; }
95
+ get establishedMode(): SessionMode | undefined { return this.#mode; }
96
+
97
+ connect(signal?: AbortSignal): Promise<void> {
98
+ throwIfAborted(signal);
99
+ throwIfAborted(this.#lifetime.signal);
100
+ if (this.#opening) return awaitWithSignal(this.#opening, signal);
101
+ this.#requestPrefix = encodeBase64Url(this.#random.bytes(16));
102
+ const scope = abortScope([this.#lifetime.signal], this.#timeout);
103
+ this.#opening = new Promise<void>((resolve, reject) => {
104
+ this.#rejectOpening = reject;
105
+ let socket: RelaySocket;
106
+ try { socket = this.#factory(this.endpoint); this.#socket = socket; }
107
+ catch (error) { this.#stop(error); reject(error); return; }
108
+ const onAbort = (): void => { this.#stop(abortReason(scope.signal)); reject(abortReason(scope.signal)); };
109
+ scope.signal.addEventListener('abort', onAbort, { once: true });
110
+ this.#unlisten.push(() => scope.signal.removeEventListener('abort', onAbort));
111
+ this.#unlisten.push(socket.on('open', () => {
112
+ if (this.#failure) return;
113
+ scope.dispose(); scope.signal.removeEventListener('abort', onAbort); this.#rejectOpening = undefined; resolve();
114
+ }));
115
+ this.#unlisten.push(socket.on('message', value => this.#receive(value)));
116
+ this.#unlisten.push(socket.on('error', cause => {
117
+ const error = new ProtocolError('socket_error', 'The relay WebSocket transport failed.', { cause });
118
+ this.#stop(error); reject(error);
119
+ }));
120
+ this.#unlisten.push(socket.on('close', event => {
121
+ const error = new ProtocolError('socket_closed', `Relay closed the WebSocket (${event.code}): ${event.reason}`);
122
+ this.#stop(error); reject(error);
123
+ this.#detach();
124
+ }));
125
+ });
126
+ // Both branches handle cleanup; the original rejection remains visible to every waiter.
127
+ this.#opening.then(() => scope.dispose(), () => scope.dispose());
128
+ return awaitWithSignal(this.#opening, signal);
129
+ }
130
+
131
+ async request(method: string, parameters?: JsonObject, signal?: AbortSignal): Promise<JsonValue> {
132
+ throwIfAborted(signal);
133
+ throwIfAborted(this.#lifetime.signal);
134
+ const scope = abortScope([signal, this.#lifetime.signal], this.#timeout);
135
+ let id: string | undefined;
136
+ let sent = false;
137
+ const authentication = method === 'auth.account.verify' || method === 'auth.device.verify';
138
+ try {
139
+ await this.connect(scope.signal);
140
+ await this.#backoff.wait(scope.signal);
141
+ if (this.#sequence === Number.MAX_SAFE_INTEGER) throw new ProtocolError('counter_overflow', 'Socket request sequence is exhausted.');
142
+ id = `${this.#requestPrefix}:${++this.#sequence}`;
143
+ const text = encodeRpcRequest(id, method, parameters);
144
+ while (this.#socket!.bufferedAmount > 1_048_576) await this.#clock.delay(10, scope.signal);
145
+ throwIfAborted(scope.signal);
146
+ if (this.#socket!.readyState !== 1) throw new ProtocolError('socket_closed', 'WebSocket is not open.');
147
+ const response = new Promise<Exclude<RpcMessage, { kind: 'notification' }>>((resolve, reject) => this.#pending.set(id!, { method, resolve, reject }));
148
+ // Register a rejection handler before send, which may synchronously emit an error.
149
+ const completion = awaitWithSignal(response, scope.signal);
150
+ try { this.#socket!.send(text); sent = true; }
151
+ catch (error) { this.#stop(error); }
152
+ const message = await completion;
153
+ if (message.kind === 'failure') {
154
+ const error = rpcFailureError(message.error);
155
+ if (error instanceof RelayError) {
156
+ this.#backoff.apply(error.failure);
157
+ if (error.code === 'unauthorized') this.#stop(error);
158
+ }
159
+ throw error;
160
+ }
161
+ return message.result;
162
+ } catch (error) {
163
+ if (authentication && sent && scope.signal.aborted) this.#stop(error);
164
+ throw error;
165
+ } finally { if (id !== undefined) this.#pending.delete(id); scope.dispose(); }
166
+ }
167
+
168
+ /** A single consumer normally drains this into the client notification dispatcher. */
169
+ nextNotification(signal?: AbortSignal): Promise<RpcNotification> {
170
+ throwIfAborted(signal);
171
+ throwIfAborted(this.#lifetime.signal);
172
+ const queued = this.#queue.shift();
173
+ if (queued) return Promise.resolve(queued);
174
+ return new Promise<RpcNotification>((resolve, reject) => {
175
+ const waiter = { resolve: (value: RpcNotification) => { signal?.removeEventListener('abort', onAbort); resolve(value); },
176
+ reject: (error: unknown) => { signal?.removeEventListener('abort', onAbort); reject(error); } };
177
+ const onAbort = (): void => {
178
+ const index = this.#waiters.indexOf(waiter);
179
+ if (index >= 0) this.#waiters.splice(index, 1);
180
+ waiter.reject(abortReason(signal!));
181
+ };
182
+ signal?.addEventListener('abort', onAbort, { once: true });
183
+ this.#waiters.push(waiter);
184
+ });
185
+ }
186
+
187
+ #receive(value: unknown): void {
188
+ if (this.#failure) return;
189
+ try {
190
+ if (typeof value !== 'string') { this.#stop(new ProtocolError('binary_message', 'Relay WebSocket messages must use text.'), 1003); return; }
191
+ const message = decodeRpcMessage(value);
192
+ if (message.kind === 'notification') {
193
+ if (this.#mode !== 'device') throw new ProtocolError('unauthorized_notification', 'Only an authenticated device connection can receive notifications.');
194
+ const waiting = this.#waiters.shift();
195
+ if (waiting) waiting.resolve(message.notification);
196
+ else {
197
+ if (this.#queue.length >= 256) throw new ProtocolError('notification_overflow', 'Notification queue is full; reconnect and synchronize to recover missed updates.');
198
+ this.#queue.push(message.notification);
199
+ }
200
+ return;
201
+ }
202
+ if (message.id === null) throw new ProtocolError('uncorrelated_response', 'Relay response has no correlatable request ID.');
203
+ const pending = this.#pending.get(message.id);
204
+ // Late responses to canceled requests remain valid frames but have no waiting caller.
205
+ if (!pending) return;
206
+ if (message.kind === 'success' && (pending.method === 'auth.account.verify' || pending.method === 'auth.device.verify')) {
207
+ const credentials = sessionCredentialsCodec.decode(message.result);
208
+ validateSession(credentials);
209
+ const mode = pending.method === 'auth.account.verify' ? 'account' : 'device';
210
+ if (credentials.mode !== mode || this.#mode !== undefined && this.#mode !== mode) throw new ProtocolError('invalid_session', 'The relay changed the socket session mode.');
211
+ // Set before resolving so a same-tick notification cannot race authentication.
212
+ this.#mode = mode;
213
+ }
214
+ this.#pending.delete(message.id);
215
+ pending.resolve(message);
216
+ } catch (error) { this.#stop(error, error instanceof ProtocolError && error.code === 'message_too_large' ? 1009 : 1000); }
217
+ }
218
+
219
+ #stop(error: unknown, code = 1000): void {
220
+ if (this.#failure) return;
221
+ // Set before abort: abort handlers can reenter stop.
222
+ this.#failure = { error, requestedCloseCode: code, sentCloseCode: undefined };
223
+ this.#rejectOpening?.(error);
224
+ this.#rejectOpening = undefined;
225
+ this.#lifetime.abort(error);
226
+ for (const pending of this.#pending.values()) pending.reject(error);
227
+ this.#pending.clear();
228
+ for (const waiter of this.#waiters.splice(0)) waiter.reject(error);
229
+ this.#queue.length = 0;
230
+ try {
231
+ if (this.#socket && this.#socket.readyState < 2) {
232
+ const sentCloseCode = this.#socket.close(code, code === 1003 ? 'Only text messages are supported.' : code === 1009 ? 'Message exceeds 1 MiB.' : 'Meshline connection ended.');
233
+ this.#failure = { error, requestedCloseCode: code, sentCloseCode };
234
+ }
235
+ } catch (closeError) { this.#failure = { error: new AggregateError([error, closeError], 'Socket failure and close failure.'), requestedCloseCode: code, sentCloseCode: undefined }; }
236
+ if (!this.#socket || this.#socket.readyState === 3) this.#detach();
237
+ this.#resolveClosed(this.#failure);
238
+ }
239
+
240
+ #detach(): void {
241
+ for (const unlisten of this.#unlisten) unlisten();
242
+ this.#unlisten = [];
243
+ }
244
+
245
+ dispose(): void { this.#stop(new DOMException('The RPC connection was disposed.', 'AbortError')); }
246
+ /** Retires this exact connection when authentication or subscription outcome is uncertain. */
247
+ fail(error: unknown): void { this.#stop(error); }
248
+ }