@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,253 @@
1
+ import { systemRandom, verifyDevice, type RandomSource } from '../crypto/primitives.js';
2
+ import { ProtocolError, StateConflictError } from '../errors.js';
3
+ import { createIdentifier, deriveResourceId, validateIdentifier } from '../identity/identifiers.js';
4
+ import { validateAccountId, validateRelayId } from '../identity/neo.js';
5
+ import {
6
+ channelCloseRequestCodec, channelDescriptorCodec, channelPayloadCodec, channelPayloadInput, channelPostCodec, channelPostEditCodec, channelReadPageCodec,
7
+ channelReadQueryCodec, channelReportRequestCodec, channelResolveQueryCodec, channelResolveResultCodec, channelSubscriptionRequestCodec, channelTimelineChangedCodec,
8
+ validateChannelPayload, validateChannelPostRef, validateChannelReadQuery, validateChannelRef, validateChannelReportRequest, validateChannelTimelineChanged, verifyChannelDescriptor,
9
+ type ChannelDescriptor, type ChannelPayload, type ChannelPost, type ChannelPostRef, type ChannelReadQuery, type ChannelRef, type ChannelResolveResult,
10
+ } from '../models/channels.js';
11
+ import type { ContentReference, MessageBody } from '../models/content.js';
12
+ import { certificateId } from '../models/identity.js';
13
+ import { ChannelRepository, type ChannelChanges, type ChannelInfo, type ChannelOperation, type ChannelPostChange, type ChannelPostInfo, type ChannelReader } from '../channels/repository.js';
14
+ import { snapshotReader } from '../messages/repository.js';
15
+ import { nextRevision } from '../protocol/context.js';
16
+ import { canonicalJson, requireObject, requireSafeInteger } from '../protocol/json.js';
17
+ import { AsyncGate } from '../runtime/async-gate.js';
18
+ import { AsyncPulse } from '../runtime/async-pulse.js';
19
+ import { throwIfAborted } from '../runtime/clock.js';
20
+ import { EventHub, type EventListener } from '../runtime/events.js';
21
+ import type { MeshlineStore, QueryReader } from '../storage/store.js';
22
+ import type { RelayClient } from '../transport/client.js';
23
+ import type { HttpMethod } from '../transport/http.js';
24
+ import type { RelayClientPool } from '../transport/pool.js';
25
+ import { RelayError } from '../transport/relay-error.js';
26
+ import type { RelaySubscription } from '../transport/subscription.js';
27
+ import type { DeviceManager } from './device.js';
28
+ import { ClientComponent, type ClientOptions } from './component.js';
29
+
30
+ export interface ChannelManagerOptions extends ClientOptions { readonly store: MeshlineStore; readonly relayClients: RelayClientPool; readonly deviceManager: DeviceManager; readonly random?: RandomSource }
31
+ export interface ChannelUpdate { readonly name?: string; readonly description?: string | null; readonly moderators?: readonly string[] | null }
32
+ export interface ChannelPostDraft { readonly body?: MessageBody; readonly attachments?: readonly ContentReference[] }
33
+ export interface ChannelPostUpdate { readonly body?: MessageBody | null; readonly attachments?: readonly ContentReference[] | null }
34
+ export interface ChannelHistoryRequest { readonly cursor?: string; readonly limit?: number }
35
+ export interface ChannelHistoryPage { readonly items: readonly ChannelPostInfo[]; readonly nextCursor?: string }
36
+ export interface ChannelEvents {
37
+ readonly channelChanged: ChannelInfo;
38
+ readonly timelineChanged: { readonly channel: ChannelRef; readonly changes: readonly ChannelPostChange[] };
39
+ readonly followChanged: { readonly channel: ChannelRef; readonly isFollowed: boolean };
40
+ }
41
+ interface Subscription { client: RelayClient; subscription: RelaySubscription; selected: Set<string>; detach: (() => void)[] }
42
+
43
+ /** Public channels with verified historical permissions, durable exact requests and explicit runtime ownership. */
44
+ export class ChannelManager extends ClientComponent {
45
+ readonly #store: MeshlineStore; readonly #pool: RelayClientPool; readonly #device: DeviceManager; readonly #random: RandomSource;
46
+ readonly #repository: ChannelRepository; readonly #writes = new AsyncGate(); readonly #events = new EventHub<ChannelEvents>();
47
+ readonly #subscriptions = new Map<string, Subscription>(); #pulse: AsyncPulse | undefined; #jobs: Promise<void>[] = [];
48
+ constructor(options: ChannelManagerOptions) {
49
+ super(options); for (const dependency of [options.relayClients, options.deviceManager]) if (dependency.accountId !== this.accountId || dependency.context.toString() !== this.context.toString()) throw new ProtocolError('invalid_context', 'Channel dependencies belong to another network or account.');
50
+ this.#store = options.store; this.#pool = options.relayClients; this.#device = options.deviceManager; this.#random = options.random ?? systemRandom;
51
+ this.#repository = new ChannelRepository(this.#store, this.context, this.accountId);
52
+ }
53
+ on<K extends keyof ChannelEvents>(event: K, listener: EventListener<ChannelEvents[K]>): () => void { return this.#events.on(event, listener); }
54
+ #notify<K extends keyof ChannelEvents>(event: K, value: ChannelEvents[K]): void { this.#events.notify(event, value, error => this.notifyBackgroundError({ operation: 'observer', resource: event, error })); }
55
+ #changed(channel: ChannelRef, changes: ChannelChanges): void { if (changes.channel) this.#notify('channelChanged', changes.channel); if (changes.posts.length) this.#notify('timelineChanged', { channel, changes: changes.posts }); }
56
+ protected override async onInitialize(signal: AbortSignal): Promise<void> { this.#device.ensureInitialized(); await this.#store.initialize({ context: this.context.toString(), accountId: this.accountId }, signal); }
57
+ async #hosting(relayId: string, signal: AbortSignal): Promise<RelayClient> {
58
+ validateRelayId(relayId); const relay = await this.#pool.get(relayId, { mode: 'device', signer: this.#device }, signal);
59
+ if (!(await relay.getDescriptor(signal)).capabilities?.includes('channel.host.v1')) throw new ProtocolError('unsupported_capability', 'Relay does not support channel hosting.'); return relay;
60
+ }
61
+ async #resolve(relay: RelayClient, channel: ChannelRef, revision: number | undefined, signal: AbortSignal): Promise<ChannelResolveResult> {
62
+ const result = channelResolveResultCodec.decode((await relay.requestHttp('GET', 'channel.resolve', channelResolveQueryCodec.encode({ channelId: channel.channelId, ...(revision === undefined ? {} : { revision }) }), { signal }))!);
63
+ verifyChannelDescriptor(result, this.context, channel); if (revision !== undefined && result.descriptor.revision !== revision) throw new ProtocolError('invalid_revision', 'Relay returned another descriptor revision.'); return result;
64
+ }
65
+ #reader(relay: RelayClient, channel: ChannelRef, signal: AbortSignal): ChannelReader { return {
66
+ read: async query => channelReadPageCodec.decode((await relay.requestHttp('GET', 'channel.read', channelReadQueryCodec.encode(query), { signal }))!),
67
+ resolve: revision => this.#resolve(relay, channel, revision, signal),
68
+ }; }
69
+ async #sign<T extends ChannelPayload>(payload: T, signal: AbortSignal): Promise<T> {
70
+ const copy = channelPayloadCodec.decode(channelPayloadCodec.encode(payload)); validateChannelPayload(copy, this.context);
71
+ const certificate = this.#device.certificate; const id = certificateId(certificate, this.context); const input = channelPayloadInput(copy, this.context);
72
+ const signature = await this.#device.sign(input, signal);
73
+ if (id !== certificateId(this.#device.certificate, this.context) || !verifyDevice(input, signature, certificate.signingPublicKey)) throw new StateConflictError('Local channel signing identity changed or returned an invalid signature.');
74
+ return { ...copy, value: { ...copy.value, deviceSignature: signature } } as T;
75
+ }
76
+ #canWrite(descriptor: ChannelDescriptor, ownerOnly = false): void {
77
+ if (descriptor.status !== 'active') throw new ProtocolError('closed_channel', 'Channel is closed.');
78
+ if (descriptor.creator !== this.accountId && (ownerOnly || !descriptor.moderators?.includes(this.accountId))) throw new ProtocolError('forbidden', 'Account is not permitted to modify this channel.');
79
+ }
80
+ async #saveOperation(operation: ChannelOperation, signal: AbortSignal): Promise<void> { try { await this.#repository.saveOperation(operation, signal); } finally { this.#pulse?.pulse(); } }
81
+ async #send(relay: RelayClient, operation: ChannelOperation, recovering: boolean, signal: AbortSignal): Promise<number | undefined> {
82
+ const methods: Record<ChannelOperation['method'], HttpMethod> = { 'channel.create': 'POST', 'channel.update': 'PUT', 'channel.close': 'DELETE', 'channel.post': 'PUT', 'channel.post.edit': 'PATCH', 'channel.post.delete': 'DELETE' };
83
+ const value = operation.payload; const request = operation.method === 'channel.close' && value.kind === 'descriptor'
84
+ ? channelCloseRequestCodec.encode({ channelId: value.value.channelId, revision: value.value.revision, updatedAt: value.value.updatedAt, deviceSignature: value.value.deviceSignature }) : requireObject(channelPayloadCodec.encode(value));
85
+ try {
86
+ const result = await relay.requestHttp(methods[operation.method], operation.method, request, { signal });
87
+ if (operation.method === 'channel.post') { const sequence = requireObject(result!).sequence; requireSafeInteger(sequence, 1); return sequence; }
88
+ return undefined;
89
+ } catch (error) { if (!recovering && error instanceof RelayError && error.isDefinitiveRejection) await this.#repository.completeOperation(operation); throw error; }
90
+ }
91
+ async #publishDescriptor(relay: RelayClient, operation: ChannelOperation, recovering: boolean, signal: AbortSignal): Promise<void> {
92
+ if (operation.payload.kind !== 'descriptor') throw new ProtocolError('invalid_operation', 'Expected a descriptor operation.');
93
+ const descriptor = operation.payload.value; let accepted: ChannelResolveResult | undefined;
94
+ try { accepted = await this.#resolve(relay, operation.channel, descriptor.revision, signal); }
95
+ catch (error) { if (!(error instanceof RelayError && error.code === 'not_found')) throw error; }
96
+ if (accepted && channelDescriptorCodec.stringify(accepted.descriptor) !== channelDescriptorCodec.stringify(descriptor)) {
97
+ const changed = await this.#repository.saveDescriptor(operation.channel, accepted, operation); if (changed) this.#notify('channelChanged', changed);
98
+ throw new StateConflictError('Channel revision is occupied by a different descriptor.');
99
+ }
100
+ if (!accepted) { await this.#send(relay, operation, recovering, signal); accepted = { descriptor, signerCertificate: this.#device.certificate }; }
101
+ const changed = await this.#repository.saveDescriptor(operation.channel, accepted, operation); if (changed) this.#notify('channelChanged', changed); this.#pulse?.pulse();
102
+ }
103
+ async #synchronize(relay: RelayClient, channel: ChannelRef, after: number, advance: boolean, signal: AbortSignal): Promise<void> {
104
+ for (;;) { const result = await this.#repository.readPage(channel, { channelId: channel.channelId, after }, this.#reader(relay, channel, signal), advance, signal); this.#changed(channel, result.changes);
105
+ if (!result.page.hasMore) return; after = result.page.events.at(-1)!.sequence; }
106
+ }
107
+ async #publishPost(relay: RelayClient, operation: ChannelOperation, recovering: boolean, signal: AbortSignal): Promise<ChannelPostRef> {
108
+ if (operation.payload.kind !== 'post') throw new ProtocolError('invalid_operation', 'Expected a post publication.'); const request = operation.payload.value;
109
+ let sequence = operation.acceptedSequence ?? await this.#repository.publication(operation.channel, request, signal);
110
+ if (sequence === undefined) { sequence = (await this.#send(relay, operation, recovering, signal))!; operation = await this.#repository.acknowledgePublication(operation, sequence); }
111
+ await this.#synchronize(relay, operation.channel, sequence - 1, false, signal); await this.#repository.confirmPublication(operation.channel, sequence, request, signal);
112
+ await this.#repository.completeOperation(operation); this.#pulse?.pulse(); return { channel: operation.channel, sequence };
113
+ }
114
+ async #mutate(relay: RelayClient, operation: ChannelOperation, recovering: boolean, signal: AbortSignal): Promise<void> {
115
+ if (operation.payload.kind !== 'edit' && operation.payload.kind !== 'delete') throw new ProtocolError('invalid_operation', 'Expected a post edit or deletion.');
116
+ await this.#send(relay, operation, recovering, signal); const change = await this.#repository.completeOperation(operation, operation.payload.kind === 'delete');
117
+ if (change) this.#changed(operation.channel, { posts: [change] }); this.#pulse?.pulse();
118
+ if (!recovering) await this.#synchronize(relay, operation.channel, operation.payload.value.targetSequence - 1, false, signal);
119
+ }
120
+ createChannel(relayId: string, name: string, options: { readonly description?: string; readonly moderators?: readonly string[]; readonly signal?: AbortSignal } = {}): Promise<ChannelInfo> {
121
+ validateRelayId(relayId); const description = options.description; const moderators = options.moderators && [...options.moderators];
122
+ return this.runOperation(scope => this.#writes.run(async () => {
123
+ const relay = await this.#hosting(relayId, scope); const nonce = this.#random.bytes(16); const now = this.clock.nowSeconds();
124
+ const payload = await this.#sign({ kind: 'descriptor', value: { channelId: deriveResourceId('channel', this.accountId, relayId, nonce, this.context), nonce, creator: this.accountId, relayId,
125
+ name, ...(description === undefined ? {} : { description }), ...(moderators === undefined ? {} : { moderators }), revision: 0, status: 'active', createdAt: now, updatedAt: now, deviceSignature: new Uint8Array(64) } }, scope);
126
+ const channel = { channelId: payload.value.channelId, relayId }; const operation: ChannelOperation = { channel, method: 'channel.create', payload };
127
+ await this.#saveOperation(operation, scope); await this.#publishDescriptor(relay, operation, false, scope); return this.#info(channel, scope);
128
+ }, scope), options.signal);
129
+ }
130
+ async #info(channel: ChannelRef, signal: AbortSignal): Promise<ChannelInfo> { const record = await this.#repository.get(channel, signal); return { ref: channel, isFollowed: record.isFollowed, ...(record.descriptor ? { descriptor: record.descriptor } : {}) }; }
131
+ getChannel(channel: ChannelRef, signal?: AbortSignal): Promise<ChannelInfo> {
132
+ channel = { ...channel }; validateChannelRef(channel);
133
+ return this.runOperation(async scope => { const relay = await this.#hosting(channel.relayId, scope); const changed = await this.#repository.saveDescriptor(channel, await this.#resolve(relay, channel, undefined, scope), undefined, scope);
134
+ if (changed) this.#notify('channelChanged', changed); return this.#info(channel, scope); }, signal);
135
+ }
136
+ updateChannel(channel: ChannelRef, update: ChannelUpdate, signal?: AbortSignal): Promise<ChannelInfo> {
137
+ channel = { ...channel }; validateChannelRef(channel); const copy = { ...update, ...(Array.isArray(update.moderators) ? { moderators: [...update.moderators] } : {}) };
138
+ if (copy.name === null) throw new TypeError('Channel name cannot be deleted.');
139
+ return this.runOperation(scope => this.#writes.run(async () => {
140
+ const relay = await this.#hosting(channel.relayId, scope); const current = (await this.#resolve(relay, channel, undefined, scope)).descriptor; this.#canWrite(current, true);
141
+ const { description, moderators, ...base } = current; const nextDescription = copy.description === undefined ? description : copy.description; const nextModerators = copy.moderators === undefined ? moderators : copy.moderators;
142
+ const payload = await this.#sign({ kind: 'descriptor', value: { ...base, name: copy.name ?? current.name, ...(nextDescription == null ? {} : { description: nextDescription }), ...(nextModerators == null ? {} : { moderators: nextModerators }), revision: nextRevision(current.revision), updatedAt: this.clock.nowSeconds() } }, scope);
143
+ const operation: ChannelOperation = { channel, method: 'channel.update', payload }; await this.#saveOperation(operation, scope); await this.#publishDescriptor(relay, operation, false, scope); return this.#info(channel, scope);
144
+ }, scope), signal);
145
+ }
146
+ closeChannel(channel: ChannelRef, signal?: AbortSignal): Promise<void> {
147
+ channel = { ...channel }; validateChannelRef(channel);
148
+ return this.runOperation(scope => this.#writes.run(async () => { const relay = await this.#hosting(channel.relayId, scope); const current = (await this.#resolve(relay, channel, undefined, scope)).descriptor; this.#canWrite(current, true);
149
+ const payload = await this.#sign({ kind: 'descriptor', value: { ...current, status: 'closed', revision: nextRevision(current.revision), updatedAt: this.clock.nowSeconds() } }, scope);
150
+ const operation: ChannelOperation = { channel, method: 'channel.close', payload }; await this.#saveOperation(operation, scope); await this.#publishDescriptor(relay, operation, false, scope);
151
+ }, scope), signal);
152
+ }
153
+ publishPost(channel: ChannelRef, draft: ChannelPostDraft, signal?: AbortSignal): Promise<ChannelPostInfo> {
154
+ channel = { ...channel }; validateChannelRef(channel); const basis = channelPostCodec.decode(channelPostCodec.encode({ ...draft, channelId: channel.channelId, messageId: createIdentifier('message', this.#random), deviceSignature: new Uint8Array(64) }));
155
+ return this.runOperation(scope => this.#writes.run(async () => { const relay = await this.#hosting(channel.relayId, scope); this.#canWrite((await this.#resolve(relay, channel, undefined, scope)).descriptor);
156
+ const payload = await this.#sign({ kind: 'post', value: basis }, scope); const operation: ChannelOperation = { channel, method: 'channel.post', payload }; await this.#saveOperation(operation, scope);
157
+ const reference = await this.#publishPost(relay, operation, false, scope); const result = await this.#repository.post(reference, scope); if (!result) throw new ProtocolError('post_deleted', 'The accepted post was already deleted.'); return result;
158
+ }, scope), signal);
159
+ }
160
+ editPost(post: ChannelPostRef, update: ChannelPostUpdate, signal?: AbortSignal): Promise<ChannelPostInfo> {
161
+ post = { channel: { ...post.channel }, sequence: post.sequence }; validateChannelPostRef(post);
162
+ const basis = channelPostEditCodec.decode(channelPostEditCodec.encode({ ...update, channelId: post.channel.channelId, targetSequence: post.sequence, deviceSignature: new Uint8Array(64) }));
163
+ return this.runOperation(scope => this.#writes.run(async () => { const relay = await this.#hosting(post.channel.relayId, scope); this.#canWrite((await this.#resolve(relay, post.channel, undefined, scope)).descriptor);
164
+ const payload = await this.#sign({ kind: 'edit', value: basis }, scope); const operation: ChannelOperation = { channel: post.channel, method: 'channel.post.edit', payload }; await this.#saveOperation(operation, scope); await this.#mutate(relay, operation, false, scope);
165
+ const result = await this.#repository.post(post, scope); if (!result) throw new ProtocolError('post_unavailable', 'The edited post is no longer available.'); return result;
166
+ }, scope), signal);
167
+ }
168
+ deletePost(post: ChannelPostRef, signal?: AbortSignal): Promise<void> {
169
+ post = { channel: { ...post.channel }, sequence: post.sequence }; validateChannelPostRef(post);
170
+ return this.runOperation(scope => this.#writes.run(async () => { const relay = await this.#hosting(post.channel.relayId, scope); this.#canWrite((await this.#resolve(relay, post.channel, undefined, scope)).descriptor);
171
+ const payload = await this.#sign({ kind: 'delete', value: { channelId: post.channel.channelId, targetSequence: post.sequence, deviceSignature: new Uint8Array(64) } }, scope);
172
+ const operation: ChannelOperation = { channel: post.channel, method: 'channel.post.delete', payload }; await this.#saveOperation(operation, scope); await this.#mutate(relay, operation, false, scope);
173
+ }, scope), signal);
174
+ }
175
+ reportPost(post: ChannelPostRef, reason: string, signal?: AbortSignal): Promise<void> {
176
+ post = { channel: { ...post.channel }, sequence: post.sequence }; validateChannelPostRef(post); const request = { channelId: post.channel.channelId, targetSequence: post.sequence, reason }; validateChannelReportRequest(request);
177
+ return this.runOperation(async scope => { const relay = await this.#hosting(post.channel.relayId, scope); await relay.requestHttp('PUT', 'channel.post.report', channelReportRequestCodec.encode(request), { signal: scope }); }, signal);
178
+ }
179
+ loadChannelHistory(channel: ChannelRef, page: ChannelHistoryRequest = {}, signal?: AbortSignal): Promise<ChannelHistoryPage> {
180
+ channel = { ...channel }; validateChannelRef(channel); let before: number | undefined;
181
+ if (page.cursor !== undefined) { if (!/^(0|[1-9][0-9]*)$/.test(page.cursor)) throw new TypeError('History cursor must be a nonnegative safe integer.'); before = Number(page.cursor); requireSafeInteger(before, 0); }
182
+ const query: ChannelReadQuery = { channelId: channel.channelId, ...(before === undefined ? {} : { before }), ...(page.limit === undefined ? {} : { limit: page.limit }) }; validateChannelReadQuery(query);
183
+ return this.runOperation(scope => this.#writes.run(async () => { const relay = await this.#hosting(channel.relayId, scope); const result = await this.#repository.readPage(channel, query, this.#reader(relay, channel, scope), false, scope); this.#changed(channel, result.changes);
184
+ const items: ChannelPostInfo[] = []; for (const event of result.page.events) if (event.payload.kind === 'post') { const post = await this.#repository.post({ channel, sequence: event.sequence }, scope); if (post) items.push(post); }
185
+ return { items, ...(result.page.hasMore ? { nextCursor: String(result.page.events[0]!.sequence) } : {}) };
186
+ }, scope), signal);
187
+ }
188
+ getPosts(query: { readonly channelId?: string; readonly author?: string } = {}, signal?: AbortSignal): Promise<QueryReader<ChannelPostInfo>> {
189
+ const { channelId, author } = query; if (channelId !== undefined) validateIdentifier('channel', channelId); if (author !== undefined) validateAccountId(author); return this.runOperation(scope => this.#repository.posts(channelId, author, scope), signal);
190
+ }
191
+ async follow(channel: ChannelRef, signal?: AbortSignal): Promise<void> {
192
+ channel = { ...channel }; validateChannelRef(channel); await this.getChannel(channel, signal);
193
+ const changed = await this.runOperation(scope => this.#repository.follow(channel, true, scope), signal); if (changed) this.#notify('followChanged', { channel, isFollowed: true }); this.#pulse?.pulse();
194
+ }
195
+ async unfollow(channel: ChannelRef, signal?: AbortSignal): Promise<void> {
196
+ channel = { ...channel }; validateChannelRef(channel); const changed = await this.runOperation(scope => this.#repository.follow(channel, false, scope), signal); if (changed) this.#notify('followChanged', { channel, isFollowed: false }); this.#pulse?.pulse();
197
+ }
198
+ getFollowed(relayId?: string, signal?: AbortSignal): Promise<QueryReader<ChannelInfo>> { if (relayId !== undefined) validateRelayId(relayId); return this.runOperation(async scope => snapshotReader((await this.#repository.followed(relayId, scope)).map(record => ({ ref: record.channel, isFollowed: record.isFollowed, ...(record.descriptor ? { descriptor: record.descriptor } : {}) }))), signal); }
199
+ protected override async onStart(): Promise<void> { void this.#device.certificate; this.#pulse = new AsyncPulse(); this.#jobs = [this.#refresh(this.runtimeSignal), this.#poll(this.runtimeSignal)]; this.#pulse.pulse(); }
200
+ async #poll(signal: AbortSignal): Promise<void> { try { for (;;) { await this.clock.delay(30000, signal); this.#pulse?.pulse(); } } catch (error) { if (!signal.aborted) throw error; } }
201
+ async #recover(signal: AbortSignal): Promise<void> {
202
+ await this.#writes.run(async () => { for (const operation of await this.#repository.operations(signal)) {
203
+ try {
204
+ const relay = await this.#hosting(operation.channel.relayId, signal);
205
+ if (operation.payload.kind === 'descriptor') await this.#publishDescriptor(relay, operation, true, signal);
206
+ else if (operation.payload.kind === 'post') {
207
+ if (operation.acceptedSequence === undefined) await this.#synchronize(relay, operation.channel, (await this.#repository.get(operation.channel, signal)).syncSequence, false, signal);
208
+ await this.#publishPost(relay, operation, true, signal);
209
+ } else {
210
+ await this.#synchronize(relay, operation.channel, operation.payload.value.targetSequence - 1, false, signal);
211
+ if (await this.#repository.hasOperation(operation, signal)) await this.#mutate(relay, operation, true, signal);
212
+ }
213
+ } catch (error) { throwIfAborted(signal); this.notifyBackgroundError({ operation: 'recover_channel', resource: operation.channel.channelId, error }); }
214
+ } }, signal);
215
+ }
216
+ async #subscribe(client: RelayClient, channelIds: readonly string[], signal: AbortSignal): Promise<void> {
217
+ if (!(await client.getDescriptor(signal)).endpoints.some(endpoint => endpoint.startsWith('wss://'))) return;
218
+ const limit = (await client.getInfo(signal)).limits.maxChannelSubscriptions ?? 0; const selected = channelIds.slice(0, limit);
219
+ let observed = this.#subscriptions.get(client.relayId);
220
+ if (observed?.client !== client) {
221
+ if (observed) { for (const detach of observed.detach) detach(); await observed.subscription.dispose(); }
222
+ const subscription = client.createSubscription('channel.subscribe', { channel_ids: [] }, { signal, onSubscribed: () => { this.#pulse?.pulse(); }, onError: error => this.notifyBackgroundError({ operation: 'subscribe_channel', resource: client.relayId, error }) });
223
+ observed = { client, subscription, selected: new Set(), detach: [] }; this.#subscriptions.set(client.relayId, observed); const owner = observed;
224
+ owner.detach.push(client.on('notificationReceived', notification => { if (notification.method !== 'channel.timeline.changed' || !owner.selected.size) return;
225
+ const value = channelTimelineChangedCodec.decode(notification.params!); validateChannelTimelineChanged(value); if (owner.selected.has(value.channelId)) this.#pulse?.pulse(); }),
226
+ client.on('socketConnected', () => { this.#pulse?.pulse(); }), client.on('errorOccurred', error => this.notifyBackgroundError({ operation: 'connect_channel', resource: client.relayId, error })));
227
+ }
228
+ observed.selected = new Set(selected); observed.subscription.update(channelSubscriptionRequestCodec.encode({ channelIds: selected }));
229
+ }
230
+ async #refresh(signal: AbortSignal): Promise<void> {
231
+ try { for (;;) { await this.#pulse!.wait(signal);
232
+ try {
233
+ await this.#recover(signal); const records = await this.#repository.followed(undefined, signal); const relays = new Set(records.map(record => record.channel.relayId));
234
+ for (const [id, observed] of this.#subscriptions) if (!relays.has(id)) { observed.selected.clear(); observed.subscription.update({ channel_ids: [] }); }
235
+ for (const id of relays) {
236
+ try {
237
+ const client = await this.#hosting(id, signal); const channels = records.filter(record => record.channel.relayId === id);
238
+ try { await this.#subscribe(client, channels.map(record => record.channel.channelId), signal); } catch (error) { throwIfAborted(signal); this.notifyBackgroundError({ operation: 'subscribe_channel', resource: id, error }); }
239
+ for (const record of channels) {
240
+ try { await this.#writes.run(async () => { const changed = await this.#repository.saveDescriptor(record.channel, await this.#resolve(client, record.channel, undefined, signal), undefined, signal); if (changed) this.#notify('channelChanged', changed);
241
+ await this.#synchronize(client, record.channel, (await this.#repository.get(record.channel, signal)).syncSequence, true, signal); }, signal);
242
+ } catch (error) { throwIfAborted(signal); this.notifyBackgroundError({ operation: 'synchronize_channel', resource: record.channel.channelId, error }); }
243
+ }
244
+ } catch (error) { throwIfAborted(signal); this.notifyBackgroundError({ operation: 'connect_channel', resource: id, error }); }
245
+ }
246
+ } catch (error) { throwIfAborted(signal); this.notifyBackgroundError({ operation: 'synchronize_channels', error }); }
247
+ } } catch (error) { if (!signal.aborted) throw error; }
248
+ }
249
+ protected override async onStop(): Promise<void> {
250
+ try { await Promise.all(this.#jobs); } finally { for (const observed of this.#subscriptions.values()) for (const detach of observed.detach) detach(); await Promise.all([...this.#subscriptions.values()].map(observed => observed.subscription.dispose())); this.#subscriptions.clear(); this.#jobs = []; this.#pulse = undefined; }
251
+ }
252
+ protected override async onDispose(): Promise<void> { this.#events.clear(); }
253
+ }
@@ -0,0 +1,151 @@
1
+ import { createAbortController } from '../runtime/abort.js';
2
+ import { ProtocolError } from '../errors.js';
3
+ import { validateAccountId } from '../identity/neo.js';
4
+ import type { NetworkContext } from '../protocol/context.js';
5
+ import { AsyncGate } from '../runtime/async-gate.js';
6
+ import { abortScope, systemClock, throwIfAborted, type RuntimeClock } from '../runtime/clock.js';
7
+ import { EventHub, type EventListener } from '../runtime/events.js';
8
+
9
+ export type ComponentState = 'uninitialized' | 'stopped' | 'running' | 'stopping' | 'disposed';
10
+ export interface ClientOptions { readonly context: NetworkContext; readonly accountId: string; readonly clock?: RuntimeClock }
11
+ export interface BackgroundFailure { readonly operation: string; readonly resource?: string; readonly error: unknown }
12
+ export interface ComponentEvents {
13
+ readonly stateChanged: { readonly previous: ComponentState; readonly current: ComponentState };
14
+ readonly backgroundError: BackgroundFailure;
15
+ }
16
+
17
+ /** Constructors do no I/O. The application owns shared stores, signers and relay pools. */
18
+ export abstract class ClientComponent {
19
+ readonly #gate = new AsyncGate();
20
+ readonly #lifetime = createAbortController();
21
+ readonly #operations = new Set<Promise<unknown>>();
22
+ readonly #events = new EventHub<ComponentEvents>();
23
+ #runtime: AbortController | undefined;
24
+ #state: ComponentState = 'uninitialized';
25
+ #disposing = false;
26
+ #disposal: Promise<void> | undefined;
27
+ #backgroundFailure: BackgroundFailure | undefined;
28
+ readonly context: NetworkContext;
29
+ readonly accountId: string;
30
+ protected readonly clock: RuntimeClock;
31
+
32
+ protected constructor(options: ClientOptions) {
33
+ validateAccountId(options.accountId);
34
+ this.context = options.context; this.accountId = options.accountId; this.clock = options.clock ?? systemClock;
35
+ }
36
+ get lifecycleState(): ComponentState { return this.#state; }
37
+ get lastBackgroundError(): BackgroundFailure | undefined { return this.#backgroundFailure; }
38
+ onLifecycle<K extends keyof ComponentEvents>(event: K, listener: EventListener<ComponentEvents[K]>): () => void { return this.#events.on(event, listener); }
39
+ #stateChanged(previous: ComponentState, current: ComponentState): void {
40
+ this.#events.notify('stateChanged', { previous, current }, error => this.notifyBackgroundError({ operation: 'observer', resource: 'stateChanged', error }));
41
+ }
42
+ protected get runtimeSignal(): AbortSignal {
43
+ if (!this.#runtime) throw new ProtocolError('not_running', 'The component has not started.');
44
+ return this.#runtime.signal;
45
+ }
46
+ #check(): void { if (this.#disposing) throw new ProtocolError('disposed', 'The component is disposing or disposed.'); }
47
+ ensureInitialized(): void {
48
+ this.#check();
49
+ if (this.#state === 'uninitialized') throw new ProtocolError('not_initialized', 'Initialize the component before using it.');
50
+ }
51
+ async initialize(signal?: AbortSignal): Promise<void> {
52
+ this.#check();
53
+ const scope = abortScope([signal, this.#lifetime.signal]);
54
+ let changed = false;
55
+ try {
56
+ await this.#gate.run(async () => {
57
+ this.#check();
58
+ if (this.#state !== 'uninitialized') return;
59
+ await this.onInitialize(scope.signal); throwIfAborted(scope.signal);
60
+ this.#state = 'stopped'; changed = true;
61
+ }, scope.signal);
62
+ } finally { scope.dispose(); }
63
+ if (changed) this.#stateChanged('uninitialized', 'stopped');
64
+ }
65
+ async start(signal?: AbortSignal): Promise<void> {
66
+ this.ensureInitialized();
67
+ const scope = abortScope([signal, this.#lifetime.signal]);
68
+ let changed = false;
69
+ try {
70
+ await this.#gate.run(async () => {
71
+ this.ensureInitialized();
72
+ if (this.#state === 'running') return;
73
+ this.#runtime = createAbortController();
74
+ try {
75
+ await this.onStart(scope.signal); throwIfAborted(scope.signal);
76
+ } catch (error) {
77
+ try { await this.#endRuntime(); } catch (cleanup) { throw new AggregateError([error, cleanup], 'Startup and cleanup failed.'); }
78
+ throw error;
79
+ }
80
+ this.#state = 'running'; changed = true;
81
+ }, scope.signal);
82
+ } finally { scope.dispose(); }
83
+ if (changed) this.#stateChanged('stopped', 'running');
84
+ }
85
+ async stop(signal?: AbortSignal): Promise<void> {
86
+ this.#check();
87
+ let changed = false;
88
+ try {
89
+ await this.#gate.run(async () => {
90
+ this.#check();
91
+ if (this.#state !== 'running') return;
92
+ this.#state = 'stopping';
93
+ try { await this.#endRuntime(); }
94
+ finally { this.#state = 'stopped'; changed = true; }
95
+ }, signal);
96
+ } finally {
97
+ if (changed) this.#stateChanged('running', 'stopped');
98
+ }
99
+ }
100
+ async #endRuntime(): Promise<void> {
101
+ if (!this.#runtime) return;
102
+ this.#runtime.abort(new DOMException('Component runtime stopped.', 'AbortError'));
103
+ try { await this.onStop(); } finally { this.#runtime = undefined; }
104
+ }
105
+ /** Derived public operations use this scope; emit observer events after it releases. */
106
+ protected runOperation<T>(action: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
107
+ this.ensureInitialized(); throwIfAborted(signal);
108
+ const scope = abortScope([signal, this.#lifetime.signal]);
109
+ const job = Promise.resolve().then(() => { throwIfAborted(scope.signal); return action(scope.signal); });
110
+ this.#operations.add(job);
111
+ const released = (): void => { scope.dispose(); this.#operations.delete(job); };
112
+ job.then(released, released);
113
+ return job;
114
+ }
115
+ protected reportBackgroundError(failure: BackgroundFailure): Promise<void> {
116
+ this.#backgroundFailure = Object.freeze({ ...failure });
117
+ this.#events.notify('backgroundError', failure, observerError => {
118
+ this.#backgroundFailure = Object.freeze({ ...failure, error: new AggregateError([failure.error, observerError], 'Background operation and error observer failed.') });
119
+ });
120
+ return Promise.resolve();
121
+ }
122
+ /** Runtime jobs must not wait for application handlers that can stop or dispose that same runtime. */
123
+ protected notifyBackgroundError(failure: BackgroundFailure): void {
124
+ void this.reportBackgroundError(failure);
125
+ }
126
+ dispose(): Promise<void> {
127
+ if (this.#disposal) return this.#disposal;
128
+ this.#disposing = true;
129
+ this.#lifetime.abort(new DOMException('Component disposed.', 'AbortError'));
130
+ this.#runtime?.abort(new DOMException('Component disposed.', 'AbortError'));
131
+ this.#disposal = this.#gate.run(async () => {
132
+ const previous = this.#state;
133
+ const errors: unknown[] = [];
134
+ try { await this.#endRuntime(); } catch (error) { errors.push(error); }
135
+ // Foreground failures belong to their callers; disposal only waits for cleanup.
136
+ await Promise.allSettled([...this.#operations]);
137
+ try { await this.onDispose(); } catch (error) { errors.push(error); }
138
+ this.#state = 'disposed';
139
+ return { previous, errors };
140
+ }).then(({ previous, errors }) => {
141
+ this.#stateChanged(previous, 'disposed'); this.#events.clear();
142
+ if (errors.length) throw new AggregateError(errors, 'Component disposal failed.');
143
+ });
144
+ return this.#disposal;
145
+ }
146
+ protected async onInitialize(_signal: AbortSignal): Promise<void> {}
147
+ /** Use runtimeSignal for background jobs; signal only controls startup. */
148
+ protected async onStart(_signal: AbortSignal): Promise<void> {}
149
+ protected async onStop(): Promise<void> {}
150
+ protected async onDispose(): Promise<void> {}
151
+ }