@kubun/plugin-p2p 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +12 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +122 -38
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +7 -2
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +69 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +56 -0
  28. package/lib/groups/group-health-monitor.js +139 -0
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +29 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +4 -8
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.d.ts +5 -4
  73. package/lib/hub/manager.js +198 -1
  74. package/lib/hub/wiring.d.ts +28 -41
  75. package/lib/hub/wiring.js +132 -1
  76. package/lib/index.d.ts +29 -13
  77. package/lib/index.js +392 -1
  78. package/lib/protocol.d.ts +8 -1
  79. package/lib/protocol.js +251 -1
  80. package/lib/schema.d.ts +2 -1
  81. package/lib/schema.js +471 -2
  82. package/lib/sync/authorize.d.ts +25 -0
  83. package/lib/sync/authorize.js +73 -0
  84. package/lib/sync/broadcast-queue.js +183 -1
  85. package/lib/sync/broadcast-sender.js +179 -1
  86. package/lib/sync/catalog-scope.js +68 -1
  87. package/lib/sync/errors.d.ts +12 -0
  88. package/lib/sync/errors.js +19 -0
  89. package/lib/sync/forwarder.js +277 -1
  90. package/lib/sync/handlers.d.ts +3 -22
  91. package/lib/sync/handlers.js +160 -1
  92. package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
  93. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  94. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  95. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  96. package/lib/sync/merkle-apply.d.ts +5 -0
  97. package/lib/sync/merkle-apply.js +194 -1
  98. package/lib/sync/merkle-channel.d.ts +19 -0
  99. package/lib/sync/merkle-channel.js +182 -1
  100. package/lib/sync/merkle-tree.js +120 -1
  101. package/lib/sync/peer-registry.d.ts +8 -1
  102. package/lib/sync/peer-registry.js +107 -1
  103. package/lib/sync/receive-access-gate.js +24 -1
  104. package/lib/sync/scope-resolver.js +41 -1
  105. package/lib/sync/sync-client.d.ts +5 -3
  106. package/lib/sync/sync-client.js +192 -1
  107. package/lib/sync/sync-manager.d.ts +2 -2
  108. package/lib/sync/sync-manager.js +272 -1
  109. package/lib/types.d.ts +41 -0
  110. package/lib/types.js +2 -1
  111. package/lib/util/mutex.js +10 -1
  112. package/lib/utils.d.ts +5 -0
  113. package/lib/utils.js +21 -0
  114. package/package.json +48 -41
  115. package/lib/groups/broadcast-service.d.ts +0 -189
  116. package/lib/groups/broadcast-service.js +0 -1
  117. package/lib/groups/wire-frame.d.ts +0 -32
  118. package/lib/groups/wire-frame.js +0 -1
  119. package/lib/hub/connection-pool.d.ts +0 -43
  120. package/lib/hub/connection-pool.js +0 -1
  121. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  122. package/lib/hub/forward-remote-broadcast.js +0 -1
  123. package/lib/hub/group-channel.d.ts +0 -67
  124. package/lib/hub/group-channel.js +0 -1
  125. package/lib/hub/hub-connection.d.ts +0 -96
  126. package/lib/hub/hub-connection.js +0 -1
  127. package/lib/hub/receive-handler.d.ts +0 -33
  128. package/lib/hub/receive-handler.js +0 -1
  129. package/lib/hub/relay-manager.d.ts +0 -127
  130. package/lib/hub/relay-manager.js +0 -1
  131. package/lib/hub/send-handler.d.ts +0 -40
  132. package/lib/hub/send-handler.js +0 -1
  133. package/lib/hub/tunnel-inbox.d.ts +0 -20
  134. package/lib/hub/tunnel-inbox.js +0 -1
  135. package/lib/hub/wait-for-gate.d.ts +0 -14
  136. package/lib/hub/wait-for-gate.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/plugin-p2p",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "license": "see LICENSE.md",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -14,54 +14,61 @@
14
14
  ],
15
15
  "sideEffects": false,
16
16
  "dependencies": {
17
- "@enkaku/async": "^0.16.0",
18
- "@enkaku/capability": "^0.16.0",
19
- "@enkaku/client": "^0.16.0",
20
- "@enkaku/codec": "^0.16.0",
21
- "@enkaku/event": "^0.16.0",
22
- "@enkaku/generator": "^0.16.0",
23
- "@enkaku/group": "^0.16.1",
24
- "@enkaku/http-client-transport": "^0.16.0",
25
- "@enkaku/http-server-transport": "^0.16.0",
26
- "@enkaku/hub-protocol": "^0.16.0",
27
- "@enkaku/hub-tunnel": "^0.16.0",
28
- "@enkaku/protocol": "^0.16.0",
29
- "@enkaku/runtime": "^0.16.0",
30
- "@enkaku/schema": "^0.16.0",
31
- "@enkaku/server": "^0.16.0",
32
- "@enkaku/stream": "^0.16.0",
33
- "@enkaku/token": "^0.16.0",
34
- "@enkaku/transport": "^0.16.0",
17
+ "@sozai/async": "^0.1.0",
18
+ "@kumiai/broadcast": "^0.1.0",
19
+ "@kokuin/capability": "^0.1.0",
20
+ "@enkaku/client": "^0.18.1",
21
+ "@sozai/codec": "^0.1.0",
22
+ "@sozai/event": "^0.1.0",
23
+ "@sozai/generator": "^0.1.0",
24
+ "@kumiai/mls": "^0.1.0",
25
+ "@kumiai/rpc": "^0.1.0",
26
+ "@enkaku/http-fetch": "^0.18.1",
27
+ "@enkaku/http-serve": "^0.18.1",
28
+ "@kumiai/hub-protocol": "^0.1.0",
29
+ "@kumiai/hub-tunnel": "^0.1.0",
30
+ "@enkaku/protocol": "^0.18.1",
31
+ "@sozai/runtime": "^0.1.0",
32
+ "@sozai/schema": "^0.1.0",
33
+ "@enkaku/server": "^0.18.1",
34
+ "@sozai/stream": "^0.1.0",
35
+ "@kokuin/token": "^0.1.1",
36
+ "@enkaku/transport": "^0.18.1",
35
37
  "@noble/hashes": "^2.2.0",
36
- "graphql": "^16.13.2",
38
+ "graphql": "^16.14.2",
37
39
  "kysely": "^0.29.2",
38
- "@kubun/client": "^0.10.0",
39
- "@kubun/db": "^0.10.0",
40
- "@kubun/store-graph": "^0.10.0",
41
- "@kubun/graphql": "^0.10.0",
42
- "@kubun/db-adapter": "^0.10.0",
43
- "@kubun/hlc": "^0.10.0",
44
- "@kubun/id": "^0.10.0",
45
- "@kubun/engine": "^0.10.0",
46
- "@kubun/store-p2p": "^0.10.0",
47
- "@kubun/mutation": "^0.10.0",
48
- "@kubun/logger": "^0.10.0",
49
- "@kubun/protocol": "^0.10.0"
40
+ "ts-mls": "2.0.0-rc.13",
41
+ "@kubun/db": "^0.11.0",
42
+ "@kubun/db-adapter": "^0.11.0",
43
+ "@kubun/graphql": "^0.11.0",
44
+ "@kubun/hlc": "^0.11.0",
45
+ "@kubun/engine": "^0.11.0",
46
+ "@kubun/http-util": "^0.11.0",
47
+ "@kubun/logger": "^0.11.0",
48
+ "@kubun/client": "^0.11.0",
49
+ "@kubun/id": "^0.11.0",
50
+ "@kubun/mutation": "^0.11.0",
51
+ "@kubun/store-graph": "^0.11.0",
52
+ "@kubun/protocol": "^0.11.0",
53
+ "@kubun/store-delegation": "^0.11.0",
54
+ "@kubun/store-p2p": "^0.11.0"
50
55
  },
51
56
  "devDependencies": {
52
- "@enkaku/hub-server": "^0.16.0",
57
+ "@kumiai/hub-server": "^0.1.0",
58
+ "@testcontainers/postgresql": "^12.0.4",
53
59
  "get-port": "^7.2.0",
54
- "@kubun/db-better-sqlite": "^0.10.0",
55
- "@kubun/plugin-connector": "^0.10.0",
56
- "@kubun/hub": "^0.10.0",
57
- "@kubun/db-node-sqlite": "^0.10.0",
58
- "@kubun/plugin-http": "^0.10.0",
59
- "@kubun/plugin-rpc": "^0.10.0",
60
- "@kubun/test-utils": "^0.10.0"
60
+ "@kubun/db-better-sqlite": "^0.11.0",
61
+ "@kubun/db-node-sqlite": "^0.11.0",
62
+ "@kubun/plugin-connector": "^0.11.0",
63
+ "@kubun/db-postgres": "^0.11.0",
64
+ "@kubun/plugin-rpc": "^0.11.0",
65
+ "@kubun/plugin-http": "^0.11.0",
66
+ "@kubun/hub": "^0.11.0",
67
+ "@kubun/test-utils": "^0.11.0"
61
68
  },
62
69
  "scripts": {
63
70
  "build:clean": "del lib",
64
- "build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
71
+ "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
65
72
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
66
73
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
67
74
  "test:types": "tsc --noEmit -p tsconfig.test.json",
@@ -1,189 +0,0 @@
1
- import { EventEmitter } from '@enkaku/event';
2
- import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
3
- import { HLC } from '@kubun/hlc';
4
- import type { Logger } from '@kubun/logger';
5
- import type { GraphStoreAPI } from '@kubun/store-graph';
6
- import type { P2PStoreAPI } from '@kubun/store-p2p';
7
- import type { ForwardingConfig } from '../sync/forwarder.js';
8
- import type { StoreUnreadableMode } from '../types.js';
9
- import { type AffectedRow, type GroupBroadcastMessage } from './broadcast.js';
10
- import type { P2PEventEmitter } from './events.js';
11
- import type { GroupHandleRegistry } from './group-handle-registry.js';
12
- /**
13
- * Thrown when a received broadcast fails to decrypt (MLS restore, decrypt,
14
- * or deserialize). Callers should typically ack-and-skip these, since the
15
- * message is unrecoverable and redelivery will not help.
16
- */
17
- export declare class DecryptError extends Error {
18
- readonly cause: unknown;
19
- constructor(message: string, cause: unknown);
20
- }
21
- /**
22
- * Thrown when a successfully decrypted broadcast fails to apply to the local
23
- * DB. Callers should typically NOT ack these, so the message can be
24
- * redelivered after the transient failure clears.
25
- */
26
- export declare class ApplyError extends Error {
27
- readonly cause: unknown;
28
- constructor(message: string, cause: unknown);
29
- }
30
- /**
31
- * Thrown when a received MLS Commit targets a future epoch the local handle
32
- * has not reached yet (a gap in the in-order Commit stream). Callers should
33
- * NOT ack — the hub redelivers in `sequence_id` order, so the gap-filling
34
- * Commit arrives first and this one applies on a later delivery.
35
- */
36
- export declare class CommitOutOfOrderError extends Error {
37
- readonly messageEpoch: bigint;
38
- readonly handleEpoch: bigint;
39
- constructor(messageEpoch: bigint, handleEpoch: bigint);
40
- }
41
- export type BroadcastEvent = {
42
- groupID: string;
43
- message: GroupBroadcastMessage;
44
- applied: boolean;
45
- /**
46
- * Row affected by the broadcast. After-state for create / update,
47
- * before-state for delete / remove. Undefined when `applied === false`.
48
- */
49
- affected?: AffectedRow;
50
- /**
51
- * Count of `mutation:apply` entries the local broadcast log already had.
52
- * Only set on the `mutation:apply` branch; absent on control-plane
53
- * variants.
54
- */
55
- duplicates?: number;
56
- };
57
- export type BroadcastServiceEvents = {
58
- broadcast: BroadcastEvent;
59
- };
60
- export type SendBroadcastParams = {
61
- groupID: string;
62
- message: GroupBroadcastMessage;
63
- /**
64
- * Wire-send hook invoked with the MLS-encrypted payload INSIDE the
65
- * registry's per-group lock. A throw here propagates out of `prepareSend`
66
- * and the registry skips persist — so a failed send leaves the device's
67
- * MLS state at its pre-encrypt epoch, preventing the wedge where local
68
- * state outpaces recipients.
69
- */
70
- send: (encrypted: Uint8Array) => Promise<void>;
71
- };
72
- export type SendCommitParams = {
73
- groupID: string;
74
- /**
75
- * Canonical MLS-encoded handshake Commit bytes (an encrypted
76
- * `PrivateMessage`) produced by the committer's `GroupManager` call.
77
- */
78
- commitBytes: Uint8Array;
79
- /** Wire-send hook; receives the `mls`-framed bytes. */
80
- send: (framed: Uint8Array) => Promise<void>;
81
- };
82
- export type ReceiveBroadcastParams = {
83
- groupID: string;
84
- encrypted: Uint8Array;
85
- };
86
- export type ReceiveAppResult = {
87
- kind: 'app';
88
- message: GroupBroadcastMessage;
89
- applied: boolean;
90
- affected?: AffectedRow;
91
- duplicates?: number;
92
- };
93
- /**
94
- * Result of processing a received MLS handshake Commit (`mls` wire frame).
95
- * `removed`/`added` are the membership deltas derived by diffing the group
96
- * roster before and after applying the Commit. `removed` DIDs are tombstoned
97
- * via `markMemberRemoved` so the apply-time membership check can reject their
98
- * post-removal writes.
99
- */
100
- export type ReceiveCommitResult = {
101
- kind: 'mls';
102
- epoch: bigint;
103
- removed: Array<string>;
104
- added: Array<string>;
105
- };
106
- export type ReceiveBroadcastResult = ReceiveAppResult | ReceiveCommitResult;
107
- /**
108
- * BroadcastService handles MLS encrypt/decrypt for group broadcasts
109
- * and applies received messages to the local DB.
110
- *
111
- * Encrypt and decrypt go through `GroupHandleRegistry.withHandle` so MLS
112
- * state mutations serialize through the per-group mutex with write-through
113
- * persist. The DB-apply step (`processBroadcast`) for receives runs OUTSIDE
114
- * the lock so a slow apply does not block subsequent encrypt/decrypt on the
115
- * same group.
116
- */
117
- export type BroadcastServiceParams = {
118
- p2pStore: P2PStoreAPI;
119
- graphStore: GraphStoreAPI;
120
- /**
121
- * Engine graph internals — required so `mutation:apply` broadcasts route
122
- * through {@link GraphInternals.applyVerifiedMutation} with `origin: 'peer'`.
123
- */
124
- graph: GraphInternals;
125
- /** Self DID for `mutation:apply` loopback detection. */
126
- selfDID: string;
127
- /** Single canonical access point for MLS `GroupHandle` instances. */
128
- registry: GroupHandleRegistry;
129
- /**
130
- * Local HLC clock for stamping the `removed_at_hlc` of members dropped by a
131
- * received MLS Commit (the receive side has no HLC otherwise). Defaults to a
132
- * fresh clock keyed on `selfDID` when omitted.
133
- */
134
- hlc?: HLC;
135
- /**
136
- * Receiver storage mode for incoming `mutation:apply` entries. Defaults
137
- * to `'persist'`. When `'drop'`, `defaultAccessLevel` must be supplied
138
- * so the gate factory can resolve server-side default access rules.
139
- */
140
- storeUnreadable?: StoreUnreadableMode;
141
- /** Server default access level — required when `storeUnreadable === 'drop'`. */
142
- defaultAccessLevel?: DefaultAccessLevel;
143
- /**
144
- * Forwarding config. Threaded into `processBroadcast` per call.
145
- * `false` / undefined → forwarding disabled (default).
146
- */
147
- forwarding?: ForwardingConfig;
148
- /**
149
- * Hub forwarding hook — required when `forwarding` is set. Called once per
150
- * surviving candidate group on the receive path.
151
- */
152
- scheduleBroadcast?: (groupID: string, message: GroupBroadcastMessage) => void | Promise<void>;
153
- /**
154
- * Emitter forwarded to `processBroadcast` so `delegation:share` receives
155
- * surface a `delegationTokenAdded` event to local subscribers.
156
- */
157
- emitter?: P2PEventEmitter;
158
- logger?: Logger;
159
- };
160
- export declare class BroadcastService extends EventEmitter<BroadcastServiceEvents> {
161
- #private;
162
- constructor(params: BroadcastServiceParams);
163
- /**
164
- * Encrypt + send a broadcast for a group, then persist the advanced MLS state.
165
- *
166
- * Failure semantics: send happens inside the registry's per-group lock. A
167
- * throw from `send` propagates out and the registry skips persist — so the
168
- * device's MLS state stays at its pre-encrypt epoch and the next attempt
169
- * re-derives a fresh handle from the prior generation. Wedge protection
170
- * intact.
171
- */
172
- prepareSend(params: SendBroadcastParams): Promise<void>;
173
- /**
174
- * Send a pre-produced MLS handshake Commit to the group, tagged `mls` on the
175
- * wire. The Commit is already a self-protected (encrypted `PrivateMessage`)
176
- * framed message produced by the committer's `GroupManager` call — it is NOT
177
- * re-encrypted through the application path, so this does not touch the
178
- * handle. The committer's MLS state has already advanced + persisted; the
179
- * hub's durable + ordered mailbox carries the Commit to existing members and
180
- * replays it on reconnect.
181
- */
182
- prepareSendCommit(params: SendCommitParams): Promise<void>;
183
- /**
184
- * Process a received broadcast: MLS decrypt INSIDE the registry lock,
185
- * then apply to the DB OUTSIDE the lock so a slow apply doesn't block
186
- * subsequent encrypt/decrypt on the same group.
187
- */
188
- processReceived(params: ReceiveBroadcastParams): Promise<ReceiveBroadcastResult>;
189
- }
@@ -1 +0,0 @@
1
- import{EventEmitter as r}from"@enkaku/event";import{readMessageEpoch as e}from"@enkaku/group";import{HLC as t}from"@kubun/hlc";import{processBroadcast as a}from"./broadcast.js";import{deserializeBroadcast as o,serializeBroadcast as s}from"./broadcast-codec.js";import{mlsDecryptFramed as i,mlsEncryptFramed as c}from"./mls-codec.js";import{frameWire as p,unframeWire as m,WireKind as d}from"./wire-frame.js";export class DecryptError extends Error{cause;constructor(r,e){super(r),this.name="DecryptError",this.cause=e}}export class ApplyError extends Error{cause;constructor(r,e){super(r),this.name="ApplyError",this.cause=e}}export class CommitOutOfOrderError extends Error{messageEpoch;handleEpoch;constructor(r,e){super(`MLS commit epoch ${r} ahead of handle epoch ${e}`),this.name="CommitOutOfOrderError",this.messageEpoch=r,this.handleEpoch=e}}export class BroadcastService extends r{#r;#e;#t;constructor(r){super(),this.#t=r.hlc??new t({nodeID:r.selfDID}),this.#r={p2pStore:r.p2pStore,graphStore:r.graphStore,graph:r.graph,selfDID:r.selfDID,storeUnreadable:r.storeUnreadable,defaultAccessLevel:r.defaultAccessLevel,forwarding:r.forwarding,scheduleBroadcast:r.scheduleBroadcast,emitter:r.emitter,logger:r.logger},this.#e=r.registry}async prepareSend(r){let{groupID:e,message:t,send:a}=r,o=s(t);await this.#e.withHandle(e,async r=>{let{framed:e}=await c(r,o);await a(p(d.app,e))})}async prepareSendCommit(r){let{commitBytes:e,send:t}=r;await t(p(d.mls,e))}async processReceived(r){let e,t,s,c,{groupID:p,encrypted:l}=r;try{({kind:e,payload:t}=m(l))}catch(r){throw new DecryptError("Malformed group wire frame",r)}if(e===d.mls)return await this.#a(p,t);try{s=await this.#e.withHandle(p,async r=>{let e=await i(r,t);return o(e)})}catch(r){throw new DecryptError("Failed to decrypt broadcast",r)}try{c=await a(this.#r,s,p)}catch(r){throw new ApplyError("Failed to apply broadcast",r)}let{applied:h,affected:n,duplicates:u}=c;return await this.emit("broadcast",{groupID:p,message:s,applied:h,affected:n,duplicates:u}),{kind:"app",message:s,applied:h,affected:n,duplicates:u}}async #a(r,a){return await this.#e.withHandle(r,async o=>{let s=e(a),i=o.epoch;if(null!=s&&s<i)return{kind:"mls",epoch:i,removed:[],added:[]};if(null!=s&&s>i)throw new CommitOutOfOrderError(s,i);let c=o.listMembers();try{await o.processMessage(a)}catch(r){throw new DecryptError("Failed to process MLS commit",r)}let p=o.listMembers(),m=new Set(c.map(r=>r.id)),d=new Set(p.map(r=>r.id)),l=c.filter(r=>!d.has(r.id)).map(r=>r.id),h=p.filter(r=>!m.has(r.id)).map(r=>r.id),n=t.serialize(this.#t.now()),{p2pStore:u}=this.#r;for(let e of l)await u.markMemberRemoved(r,e,n);for(let e of h)await u.addGroupMember({group_id:r,member_did:e,role:"member",hlc:n});return{kind:"mls",epoch:o.epoch,removed:l,added:h}})}}
@@ -1,32 +0,0 @@
1
- /**
2
- * Outer wire-frame discriminator for group hub payloads.
3
- *
4
- * The hub relay treats a group send payload as opaque bytes. Kubun prepends a
5
- * single `kind` byte so the receiver can route a payload to the right decoder
6
- * without trial-decryption:
7
- *
8
- * - `app` — an MLS-encrypted application broadcast (`GroupBroadcastMessage`),
9
- * decoded via `handle.decrypt` + `deserializeBroadcast`.
10
- * - `mls` — a framed MLS handshake message (a Commit), fed straight to
11
- * `handle.processMessage`. The handshake is self-protected (encrypted
12
- * `PrivateMessage`), so it is NOT wrapped in the application-encryption path.
13
- *
14
- * The discriminator lives entirely in kubun's payload bytes; the hub protocol
15
- * is unchanged.
16
- */
17
- export declare const WireKind: {
18
- readonly app: 0;
19
- readonly mls: 1;
20
- };
21
- export type WireKind = (typeof WireKind)[keyof typeof WireKind];
22
- /** Prepend the `kind` byte to a payload. */
23
- export declare function frameWire(kind: WireKind, payload: Uint8Array): Uint8Array;
24
- /**
25
- * Split a framed payload into its `kind` and the remaining payload bytes.
26
- * Throws on an empty frame or an unrecognized `kind` byte. The returned
27
- * payload is a view (`subarray`) over the input, not a copy.
28
- */
29
- export declare function unframeWire(framed: Uint8Array): {
30
- kind: WireKind;
31
- payload: Uint8Array;
32
- };
@@ -1 +0,0 @@
1
- export const WireKind={app:0,mls:1};export function frameWire(r,e){let n=new Uint8Array(e.length+1);return n[0]=r,n.set(e,1),n}export function unframeWire(r){if(0===r.length)throw Error("empty wire frame");let e=r[0];if(e!==WireKind.app&&e!==WireKind.mls)throw Error(`unknown wire frame kind: ${e}`);return{kind:e,payload:r.subarray(1)}}
@@ -1,43 +0,0 @@
1
- import type { Client } from '@enkaku/client';
2
- import type { HubProtocol } from '@enkaku/hub-protocol';
3
- import type { Logger } from '@kubun/logger';
4
- import type { CreateHubClient } from './relay-manager.js';
5
- export type HubClientRef = {
6
- client: Client<HubProtocol>;
7
- refID: string;
8
- };
9
- /**
10
- * Fired once per freshly-spawned hub client when its first signed response
11
- * arrives. The pool binds the hub URL into the call site; consumers (hub
12
- * manager `captureServerDID`) use the URL to look up the local hub row and
13
- * apply the conflict matrix. Errors are caught and logged — capture never
14
- * blocks message forwarding, even on `HubServerDIDConflictError`.
15
- */
16
- export type OnServerDIDObserved = (params: {
17
- hubURL: string;
18
- serverDID: string;
19
- }) => void | Promise<void>;
20
- /**
21
- * Resolves the locally-pinned `server_did` for a hub URL at spawn time. When
22
- * the hub row for this URL exists and has `server_did` set, the pool passes
23
- * it as `expectedServerDID` to the client factory so the wrapper enforces
24
- * the pin. Returning `null` falls back to TOFU.
25
- *
26
- * Resolution happens fresh on every spawn so `updateHub({ serverDID: null })`
27
- * re-arms TOFU automatically: the next disposed/evicted client triggers a
28
- * fresh resolve.
29
- */
30
- export type ResolvePinnedServerDID = (hubURL: string) => Promise<string | null>;
31
- export type HubConnectionPoolParams = {
32
- createHubClient: CreateHubClient;
33
- logger?: Logger;
34
- onServerDIDObserved?: OnServerDIDObserved;
35
- resolvePinnedDID?: ResolvePinnedServerDID;
36
- };
37
- export declare class HubConnectionPool {
38
- #private;
39
- constructor(params: HubConnectionPoolParams);
40
- acquire(hubURL: string): Promise<HubClientRef>;
41
- release(hubURL: string, refID: string): Promise<void>;
42
- disposeAll(): Promise<void>;
43
- }
@@ -1 +0,0 @@
1
- export class HubConnectionPool{#e;#t;#i;#r;#s=new Map;#n=0;constructor(e){this.#e=e.createHubClient,this.#t=e.logger,this.#i=e.onServerDIDObserved,this.#r=e.resolvePinnedDID}async acquire(e){let t=this.#s.get(e);if(null!=t&&null!=t.disposing&&(await t.disposing,this.#s.get(e)===t&&this.#s.delete(e),t=void 0),null==t){let i,r=this.#i;if(null!=this.#r)try{let t=await this.#r(e);null!=t&&(i=t)}catch(t){this.#t?.warn("failed to resolve pinned server DID, falling back to TOFU",{hubURL:e,error:t})}let s=await this.#e(e,{onServerDID:null==r?void 0:async t=>{try{await r({hubURL:e,serverDID:t})}catch(i){this.#t?.warn("hub server DID capture failed",{hubURL:e,serverDID:t,error:i})}},expectedServerDID:i,onMismatch:null==i?void 0:e=>{this.#t?.warn("hub server DID mismatch — pinning enforcement triggered",{hubURL:e.hubURL,expectedServerDID:e.expectedServerDID,observedServerDID:e.observedServerDID})}}),n={client:s,refs:new Set};t=n,this.#s.set(e,n),s.disposed.then(()=>{this.#s.get(e)===n&&(this.#t?.debug("hub client disposed externally, evicting pool entry",{hubURL:e,outstandingRefs:[...n.refs]}),this.#s.delete(e))}).catch(()=>{})}let i=String(++this.#n);return t.refs.add(i),{client:t.client,refID:i}}async release(e,t){let i=this.#s.get(e);if(null==i)return void this.#t?.debug("HubConnectionPool.release: unknown hubURL",{hubURL:e,refID:t});if(!i.refs.has(t))return void this.#t?.debug("HubConnectionPool.release: unknown refID",{hubURL:e,refID:t});if(i.refs.delete(t),i.refs.size>0)return;let r=i.client.dispose();i.disposing=r;try{await r}finally{this.#s.get(e)===i&&this.#s.delete(e)}}async disposeAll(){let e=Array.from(this.#s.entries());await Promise.all(e.map(async([e,t])=>{t.refs.size>0&&this.#t?.warn("Disposing hub client with outstanding refs",{hubURL:e,refs:Array.from(t.refs)}),await t.client.dispose()})),this.#s.clear()}}
@@ -1,15 +0,0 @@
1
- import type { Logger } from '@kubun/logger';
2
- import type { BroadcastEvent } from '../groups/broadcast-service.js';
3
- import type { P2PEventEmitter } from '../groups/events.js';
4
- /**
5
- * Map a `BroadcastEvent` from `BroadcastService` into the matching
6
- * `P2PEventEmitter` event.
7
- *
8
- * - Events with `applied === false` are ignored (no state change).
9
- * - `catalog:*` messages are currently a no-op. Catalog send-side broadcasts
10
- * and event propagation are deferred to a future task (see design spec).
11
- * - Payloads are built from `event.affected.row`, which `processBroadcast`
12
- * captures at the appropriate moment (after-state for create / update,
13
- * before-state for delete / remove).
14
- */
15
- export declare function forwardRemoteBroadcast(emitter: P2PEventEmitter, event: BroadcastEvent, logger?: Logger): Promise<void>;
@@ -1 +0,0 @@
1
- import{toISO as e}from"../context/types.js";export async function forwardRemoteBroadcast(r,t,a){if(!t.applied)return;let{message:i,affected:c}=t;if("delegation:share"!==i.type){if(null==c)return void a?.warn("remote broadcast applied but affected row missing",{type:i.type});switch(i.type){case"circle:create":{if("circle"!==c.kind)return;let{row:t}=c;await r.emit("circleCreated",{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at)});return}case"circle:update":{if("circle"!==c.kind)return;let{row:t}=c,a=null!=i.update.catalogIDs?"circleCatalogsChanged":"circleDataChanged";await r.emit(a,{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at),circleID:t.id});return}case"circle:delete":{if("circle"!==c.kind)return;let{row:t}=c;await r.emit("circleDeleted",{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at)});return}case"member:add":{if("member"!==c.kind)return;let{row:t}=c;await r.emit("circleMemberAdded",{circleID:t.circle_id,memberDID:t.member_did,role:t.role,createdAt:e(t.created_at)});return}case"member:remove":{if("member"!==c.kind)return;let{row:t}=c;await r.emit("circleMemberRemoved",{circleID:t.circle_id,memberDID:t.member_did,role:t.role,createdAt:e(t.created_at)});return}case"group:update":{if("group"!==c.kind)return;let{row:t}=c;await r.emit("groupDataChanged",{id:t.id,name:t.name,description:t.description,createdBy:t.created_by,createdAt:e(t.created_at),groupID:t.id});return}case"catalog:create":case"catalog:update":case"catalog:delete":return void a?.debug("catalog broadcast ignored (not yet wired)",{type:i.type})}}}
@@ -1,67 +0,0 @@
1
- import { Disposer } from '@enkaku/async';
2
- import type { Logger } from '@kubun/logger';
3
- import type { P2PStoreAPI } from '@kubun/store-p2p';
4
- import type { GroupBroadcastMessage } from '../groups/broadcast.js';
5
- import type { BroadcastService } from '../groups/broadcast-service.js';
6
- import type { HubConnection } from './hub-connection.js';
7
- export type AckBatchParams = {
8
- flushMs: number;
9
- flushMax: number;
10
- send: (ids: Array<string>) => Promise<void>;
11
- logger?: Logger;
12
- };
13
- export declare class AckBatch {
14
- #private;
15
- constructor(params: AckBatchParams);
16
- add(id: string): void;
17
- /**
18
- * Drain the pending id set and send it via the configured sender. Never
19
- * rejects: send errors are logged and the ids are dropped silently (the hub
20
- * will redeliver unacknowledged messages, so the next receive will produce a
21
- * fresh ack).
22
- */
23
- flush(): Promise<void>;
24
- dispose(): void;
25
- }
26
- export declare function computeBackoff(attempt: number, base: number, max: number, jitter: number): number;
27
- export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
28
- export type GroupChannelParams = {
29
- groupID: string;
30
- deviceID: string;
31
- hubConnection: HubConnection;
32
- broadcastService: BroadcastService;
33
- p2pStore: P2PStoreAPI;
34
- logger?: Logger;
35
- };
36
- /**
37
- * Per-group handle: thin wrapper around a shared `HubConnection`.
38
- *
39
- * `open()` subscribes to the connection (issuing `hub/group/join` once),
40
- * `close()` unsubscribes (drains the per-group mutex then issues
41
- * `hub/group/leave`), and `broadcast()` runs the MLS encrypt+send pipeline
42
- * inside `HubConnection.runInGroup()` so the per-group mutex serializes send
43
- * and receive on the same MLS state.
44
- *
45
- * All transport state (refID, ack batching, receive loop, reconnect/backoff)
46
- * lives on `HubConnection` and is shared across every channel for that
47
- * `hubURL`. A peer with N groups uses ONE `hub/receive` stream per
48
- * `(deviceDID, hubURL)` rather than N — this preserves the hub-server's
49
- * single-writer-per-DID invariant for bridge peers.
50
- */
51
- export declare class GroupChannel extends Disposer {
52
- #private;
53
- constructor(params: GroupChannelParams);
54
- open(): Promise<void>;
55
- close(): Promise<void>;
56
- broadcast(message: GroupBroadcastMessage, opts?: {
57
- timeoutMs?: number;
58
- }): Promise<void>;
59
- /**
60
- * Fan out a pre-produced MLS handshake Commit to the group's existing
61
- * members. Mirrors {@link broadcast} but routes through `handleSendCommit`
62
- * (no application encryption — the Commit is already self-protected).
63
- */
64
- sendCommit(commitBytes: Uint8Array, opts?: {
65
- timeoutMs?: number;
66
- }): Promise<void>;
67
- }
@@ -1 +0,0 @@
1
- import{Disposer as e}from"@enkaku/async";import{handleReceivedMessage as t}from"./receive-handler.js";import{handleSendBroadcast as r,handleSendCommit as i}from"./send-handler.js";export class AckBatch{#e;#t;#r;#i;#s=new Set;#o=null;constructor(e){this.#e=e.flushMs,this.#t=e.flushMax,this.#r=e.send,this.#i=e.logger}add(e){let t=0===this.#s.size;if(this.#s.add(e),this.#s.size>=this.#t){null!==this.#o&&(clearTimeout(this.#o),this.#o=null),this.flush();return}t&&null===this.#o&&(this.#o=setTimeout(()=>{this.#o=null,this.flush()},this.#e))}async flush(){if(null!==this.#o&&(clearTimeout(this.#o),this.#o=null),0===this.#s.size)return;let e=Array.from(this.#s);this.#s.clear();try{await this.#r(e)}catch(t){this.#i?.error("ack batch send failed",{error:t,count:e.length})}}dispose(){null!==this.#o&&(clearTimeout(this.#o),this.#o=null),this.#s.clear()}}export function computeBackoff(e,t,r,i){let s=Math.min(t*2**e*(1+i*(2*Math.random()-1)),r);return s<0?0:s}export function abortableSleep(e,t){return new Promise((r,i)=>{if(t.aborted)return void i(t.reason??Error("aborted"));let s=setTimeout(()=>{t.removeEventListener("abort",o),r()},e),o=()=>{clearTimeout(s),t.removeEventListener("abort",o),i(t.reason??Error("aborted"))};t.addEventListener("abort",o)})}export class GroupChannel extends e{#n;#h;#a;#u;#c;#i;#d=!1;constructor(e){super({dispose:async()=>{await this.#l()}}),this.#n=e.groupID,this.#h=e.deviceID,this.#a=e.hubConnection,this.#u=e.broadcastService,this.#c=e.p2pStore,this.#i=e.logger}async open(){if(this.#d)throw Error("GroupChannel already opened");let e=await this.#c.getMLSState(this.#n,this.#h);if(null==e)throw Error(`MLS state missing for group ${this.#n}; cannot join hub group`);await this.#a.subscribe(this.#n,e.credential,e=>this.#p(e)),this.#d=!0}#p(e){return t({groupID:this.#n,message:e,broadcastService:this.#u,logger:this.#i})}async close(){this.#d&&await this.dispose()}async #l(){if(this.#d){try{await this.#a.unsubscribe(this.#n)}catch(e){this.#i?.warn("hub-connection unsubscribe failed during close",{groupID:this.#n,error:e})}this.#d=!1}}broadcast(e,t){return this.#d?this.#a.runInGroup(this.#n,async()=>{if(!this.#d)throw Error(`GroupChannel for ${this.#n} is not ready for broadcast`);await r({groupID:this.#n,message:e,broadcastService:this.#u,send:(e,r)=>this.#a.broadcast(e,r,{timeoutMs:t?.timeoutMs}),logger:this.#i})},{timeoutMs:t?.timeoutMs}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for broadcast`))}sendCommit(e,t){return this.#d?this.#a.runInGroup(this.#n,async()=>{if(!this.#d)throw Error(`GroupChannel for ${this.#n} is not ready for sendCommit`);await i({groupID:this.#n,commitBytes:e,broadcastService:this.#u,send:(e,r)=>this.#a.broadcast(e,r,{timeoutMs:t?.timeoutMs}),logger:this.#i})},{timeoutMs:t?.timeoutMs}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for sendCommit`))}}
@@ -1,96 +0,0 @@
1
- import { Disposer } from '@enkaku/async';
2
- import { EventEmitter } from '@enkaku/event';
3
- import type { StoredMessage } from '@enkaku/hub-protocol';
4
- import type { Logger } from '@kubun/logger';
5
- import type { HubConnectionPool } from './connection-pool.js';
6
- import type { ReceivedHubMessage } from './receive-handler.js';
7
- export type TunnelMessageStream = AsyncIterable<StoredMessage> & {
8
- return(): void;
9
- };
10
- export type TunnelMessageStreamParams = {
11
- peerDID: string;
12
- };
13
- export type SubscriptionHandler = (message: ReceivedHubMessage) => Promise<{
14
- ack: boolean;
15
- }>;
16
- export type ConnectionLifecycleEvent = {
17
- type: 'connected';
18
- } | {
19
- type: 'disconnected';
20
- reason?: string;
21
- } | {
22
- type: 'error';
23
- error: unknown;
24
- } | {
25
- type: 'reconnecting';
26
- attempt: number;
27
- delayMs: number;
28
- };
29
- type HubConnectionEvents = {
30
- lifecycle: ConnectionLifecycleEvent;
31
- };
32
- export type HubConnectionParams = {
33
- hubURL: string;
34
- deviceID: string;
35
- pool: HubConnectionPool;
36
- logger?: Logger;
37
- ackFlushMs?: number;
38
- ackFlushMax?: number;
39
- backoffBaseMs?: number;
40
- backoffMaxMs?: number;
41
- backoffJitter?: number;
42
- };
43
- export declare class HubConnection extends Disposer {
44
- #private;
45
- constructor(params: HubConnectionParams);
46
- get events(): EventEmitter<HubConnectionEvents>;
47
- open(): Promise<void>;
48
- subscribe(groupID: string, credential: string, handler: SubscriptionHandler): Promise<void>;
49
- unsubscribe(groupID: string): Promise<void>;
50
- broadcast(groupID: string, encryptedPayload: string, opts?: {
51
- timeoutMs?: number;
52
- }): Promise<void>;
53
- /**
54
- * Send an opaque tunnel-style message to explicit recipients via
55
- * `hub/send`. No `groupID` is attached at the hub level — recipients
56
- * receive the message on the tunnel-routing path (see
57
- * {@link tunnelMessageStream}). Used by hub-mediated document sync.
58
- */
59
- send(params: {
60
- recipients: Array<string>;
61
- payload: Uint8Array;
62
- timeoutMs?: number;
63
- }): Promise<{
64
- sequenceID: string;
65
- }>;
66
- /**
67
- * Run `fn` inside the same per-group mutex used by receive `#dispatch`. This
68
- * lets callers (e.g. `GroupChannel.broadcast`) serialize MLS-state-mutating
69
- * pipelines with incoming-message handling on the same group, avoiding races
70
- * between concurrent send-encrypt and receive-decrypt against shared MLS
71
- * group state.
72
- *
73
- * The gate-await ensures the subscription map is settled before lookup —
74
- * i.e. mid-reconnect callers wait for the subscription to be (re)registered
75
- * rather than throwing.
76
- */
77
- runInGroup<T>(groupID: string, fn: () => Promise<T>, opts?: {
78
- timeoutMs?: number;
79
- }): Promise<T>;
80
- /**
81
- * Register a per-peer tunnel inbox and return a disposable async iterable
82
- * of {@link StoredMessage} values received from that peer over the tunnel
83
- * (i.e. direct hub messages with no `groupID`).
84
- *
85
- * Used by the hub-mediated document sync server adapter (see Q3 of the
86
- * hub-mediated document sync plan). The hub's receive-side dispatcher
87
- * keys tunnel routing on `senderDID` because hub-level messages cannot
88
- * reveal the in-frame `sessionID` (the body is encrypted). P2P pairs
89
- * `peerDID` and `sessionID` at session creation, so peer-keying is
90
- * sufficient. Concurrent tunnel sessions to the same peer are not
91
- * supported and are the caller's responsibility to avoid.
92
- */
93
- tunnelMessageStream({ peerDID }: TunnelMessageStreamParams): TunnelMessageStream;
94
- close(): Promise<void>;
95
- }
96
- export {};
@@ -1 +0,0 @@
1
- import{Disposer as e}from"@enkaku/async";import{fromB64 as t,toB64 as s}from"@enkaku/codec";import{EventEmitter as n}from"@enkaku/event";import{createMutex as i}from"../util/mutex.js";import{ReconnectingError as r}from"./errors.js";import{AckBatch as o,abortableSleep as h,computeBackoff as a}from"./group-channel.js";import{TunnelInbox as c}from"./tunnel-inbox.js";import{waitForGate as l}from"./wait-for-gate.js";let u=Symbol("disposed");export class HubConnection extends e{#e;#t;#s;#n;#i;#r;#o;#h;#a;#c=null;#l=null;#u=null;#b=null;#p=null;#d=new Map;#f=!1;#g=0;#m=null;#y=new Map;#w;constructor(e){super({dispose:async()=>{await this.#L()}}),this.#e=e.hubURL,this.#t=e.deviceID,this.#s=e.pool,this.#n=e.logger,this.#i=e.ackFlushMs??500,this.#r=e.ackFlushMax??10,this.#o=e.backoffBaseMs??1e3,this.#h=e.backoffMaxMs??6e4,this.#a=e.backoffJitter??.25,this.#w=new n}get events(){return this.#w}async #I(e){try{await this.#w.emit("lifecycle",e)}catch(t){this.#n?.warn("hub-connection lifecycle subscriber threw",{hubURL:this.#e,type:e.type,error:t})}}async open(){if(this.#f)throw Error("HubConnection already opened");try{await this.#k(),this.#f=!0,await this.#I({type:"connected"})}catch(e){if(null!=this.#l){try{await this.#s.release(this.#e,this.#l)}catch(e){this.#n?.debug("hub-connection pool release after open failure failed",{hubURL:this.#e,error:e})}this.#l=null}throw this.#c=null,e}}async #k(){if(null!=this.#l){let e=this.#l;this.#l=null,this.#c=null;try{await this.#s.release(this.#e,e)}catch(e){this.#n?.debug("hub-connection pool release before reconnect failed",{hubURL:this.#e,error:e})}}let{client:e,refID:t}=await this.#s.acquire(this.#e);this.#c=e,this.#l=t;let s=e.createChannel("hub/receive",{param:{},signal:this.signal});this.#u=s,s.catch(()=>{}),this.#b=new o({flushMs:this.#i,flushMax:this.#r,send:async e=>{await s.send({ack:e})},logger:this.#n}),this.#p=this.#R(e).catch(e=>{this.#n?.debug("hub-connection receive task tail error",{hubURL:this.#e,error:e})});let n=[...this.#d.entries()];if(n.length>0){let t=await Promise.allSettled(n.map(async([t,s])=>{await e.request("hub/group/join",{param:{groupID:t,credential:s.credential}})}));for(let e=0;e<t.length;e++){let s=t[e];if("rejected"===s.status){let t=n[e][0];this.#n?.warn("hub-connection rejoin failed",{hubURL:this.#e,groupID:t,error:s.reason})}}}}async subscribe(e,t,s){if(await l(this.#m,void 0),!this.#f||null==this.#c)throw Error("HubConnection not open");if(this.#d.has(e))throw Error(`already subscribed to group ${e}`);await this.#c.request("hub/group/join",{param:{groupID:e,credential:t}}),this.#d.set(e,{credential:t,handler:s,mutex:i()})}async unsubscribe(e){await l(this.#m,void 0);let t=this.#c;if(!this.#f||null==t)throw Error("HubConnection not open");let s=this.#d.get(e);if(null==s)throw Error(`not subscribed to group ${e}`);await s.mutex.run(async()=>{}),await t.request("hub/group/leave",{param:{groupID:e}}),this.#d.delete(e)}async broadcast(e,t,s){if(await l(this.#m,s?.timeoutMs,()=>new r(this.#e,this.#g)),!this.#f||null==this.#c)throw Error("HubConnection not open");await this.#c.request("hub/group/send",{param:{groupID:e,payload:t}})}async send(e){if(await l(this.#m,e.timeoutMs,()=>new r(this.#e,this.#g)),!this.#f||null==this.#c)throw Error("HubConnection not open");return{sequenceID:(await this.#c.request("hub/send",{param:{recipients:e.recipients,payload:s(e.payload)}})).sequenceID}}async runInGroup(e,t,s){if(await l(this.#m,s?.timeoutMs,()=>new r(this.#e,this.#g)),!this.#f)throw Error("HubConnection not open");let n=this.#d.get(e);if(null==n)throw Error(`not subscribed to group ${e}`);return n.mutex.run(t)}tunnelMessageStream({peerDID:e}){if(this.#y.has(e))throw Error("tunnelMessageStream: peerDID already registered");let t=new c,s={inbox:t};this.#y.set(e,s);let n=!1;return{[Symbol.asyncIterator]:()=>t.iterator(),return:()=>{n||(n=!0,this.#y.get(e)===s&&this.#y.delete(e),t.close())}}}async #R(e){let t=this.#u;if(null==t)return;let s=t.readable.getReader(),n=null;try{for(;!this.signal.aborted;){let t=s.read(),n=e.disposed.then(()=>u),i=await Promise.race([t,n]);if("symbol"==typeof i){this.#n?.debug("hub-connection receive loop observed hub client disposal",{hubURL:this.#e});break}let{done:r,value:o}=i;if(r)break;this.#D(o),this.#g=0}}catch(e){n=e,this.#n?.warn("hub-connection receive loop error",{hubURL:this.#e,error:e})}finally{try{s.releaseLock()}catch{}}if(!this.signal.aborted){let e=n instanceof Error&&n.message?n.message:"receive loop ended";await this.#I({type:"disconnected",reason:e}),this.#U()}}#U(){this.#p=this.#M().catch(e=>{this.#n?.debug("hub-connection reconnect task tail error",{hubURL:this.#e,error:e})})}async #M(){let e=()=>{};this.#m=new Promise(t=>{e=t});try{for(;!this.signal.aborted;){let e=a(this.#g++,this.#o,this.#h,this.#a);this.#n?.debug("hub-connection reconnect scheduled",{hubURL:this.#e,delay:e,attempt:this.#g});try{await h(e,this.signal)}catch{return}await this.#I({type:"reconnecting",attempt:this.#g,delayMs:e});try{this.#b?.dispose(),this.#b=null,this.#u=null,await this.#k(),this.#g=0,await this.#I({type:"connected"});return}catch(e){this.#n?.warn("hub-connection reconnect attempt failed",{hubURL:this.#e,attempt:this.#g,error:e}),await this.#I({type:"error",error:e})}}}finally{this.#m=null,e()}}#D(e){if(null==e.groupID){let s=this.#y.get(e.senderDID);if(null!=s){let n={sequenceID:e.sequenceID,senderDID:e.senderDID,payload:t(e.payload)};s.inbox.push(n),this.#b?.add(e.sequenceID);return}this.#n?.debug("direct message received, no handler",{hubURL:this.#e,sequenceID:e.sequenceID}),this.#b?.add(e.sequenceID);return}let s=this.#d.get(e.groupID);null==s?this.#n?.debug("message for unsubscribed group",{hubURL:this.#e,groupID:e.groupID,sequenceID:e.sequenceID}):s.mutex.run(()=>s.handler(e)).then(t=>{t.ack&&this.#b?.add(e.sequenceID)}).catch(t=>{this.#n?.warn("hub-connection subscription handler error",{hubURL:this.#e,groupID:e.groupID,sequenceID:e.sequenceID,error:t})})}async close(){this.#f&&await this.dispose()}async #L(){if(!this.#f)return;let e=this.#b;if(this.#b=null,null!=e){try{await e.flush()}catch(e){this.#n?.debug("hub-connection ack flush during close failed",{hubURL:this.#e,error:e})}e.dispose()}try{this.#u?.close()}catch{}try{await this.#p}catch{}let t=[...this.#d.values()].map(e=>e.mutex.run(async()=>{}).catch(()=>{}));if(await Promise.allSettled(t),null!=this.#l){try{await this.#s.release(this.#e,this.#l)}catch(e){this.#n?.debug("hub-connection pool release during close failed",{hubURL:this.#e,error:e})}this.#l=null}for(let e of(this.#c=null,this.#u=null,this.#p=null,this.#d.clear(),this.#y.values()))e.inbox.close();this.#y.clear(),this.#f=!1}}
@@ -1,33 +0,0 @@
1
- import type { Logger } from '@kubun/logger';
2
- import { type BroadcastService } from '../groups/broadcast-service.js';
3
- /**
4
- * Shape of a message pushed by `hub/receive`. Matches `@enkaku/hub-protocol`
5
- * server-to-client push payload.
6
- */
7
- export type ReceivedHubMessage = {
8
- sequenceID: string;
9
- senderDID: string;
10
- groupID?: string;
11
- /** base64-encoded encrypted MLS application message */
12
- payload: string;
13
- };
14
- export type HandleReceivedMessageParams = {
15
- groupID: string;
16
- message: ReceivedHubMessage;
17
- broadcastService: BroadcastService;
18
- logger?: Logger;
19
- };
20
- export type HandleReceivedMessageResult = {
21
- ack: boolean;
22
- };
23
- /**
24
- * Apply a single hub-received message for a group.
25
- *
26
- * Returns `{ ack: true }` when the message has been processed to a state where
27
- * redelivery serves no purpose (applied successfully, or decrypt failed with
28
- * no recovery path). Returns `{ ack: false }` when the hub should redeliver
29
- * (transient apply failure or unexpected error). Skips with `{ ack: false }`
30
- * on groupID mismatch so the message stays queued for whichever recipient it
31
- * was intended for.
32
- */
33
- export declare function handleReceivedMessage(params: HandleReceivedMessageParams): Promise<HandleReceivedMessageResult>;
@@ -1 +0,0 @@
1
- import{fromB64 as e}from"@enkaku/codec";import{ApplyError as r,CommitOutOfOrderError as c,DecryptError as s}from"../groups/broadcast-service.js";export async function handleReceivedMessage(a){let{groupID:n,message:o,broadcastService:i,logger:t}=a;if(o.groupID!==n)return t?.debug("hub message groupID mismatch, skipping",{groupID:n,messageGroupID:o.groupID,sequenceID:o.sequenceID}),{ack:!1};let u=e(o.payload);try{await i.processReceived({groupID:n,encrypted:u})}catch(e){if(e instanceof s)return t?.debug("decrypt failed, acking and skipping",{groupID:n,sequenceID:o.sequenceID,error:e}),{ack:!0};if(e instanceof c)return t?.debug("mls commit out of order, will redeliver after gap fills",{groupID:n,sequenceID:o.sequenceID,messageEpoch:e.messageEpoch.toString(),handleEpoch:e.handleEpoch.toString()}),{ack:!1};if(e instanceof r)return t?.error("broadcast apply failed, will redeliver",{groupID:n,sequenceID:o.sequenceID,error:e}),{ack:!1};return t?.error("unexpected error processing hub message",{groupID:n,sequenceID:o.sequenceID,error:e}),{ack:!1}}return{ack:!0}}