@kubun/plugin-p2p 0.13.1 → 0.15.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 (51) hide show
  1. package/lib/context/peer.js +250 -3
  2. package/lib/context/sync.js +134 -25
  3. package/lib/context/types.d.ts +7 -0
  4. package/lib/groups/broadcast-message.d.ts +29 -0
  5. package/lib/groups/broadcast.d.ts +11 -1
  6. package/lib/groups/broadcast.js +44 -2
  7. package/lib/groups/credential-apply.d.ts +64 -2
  8. package/lib/groups/credential-apply.js +215 -30
  9. package/lib/groups/credential-grant.d.ts +22 -0
  10. package/lib/groups/credential-grant.js +76 -2
  11. package/lib/groups/credential-manifest-token.d.ts +31 -0
  12. package/lib/groups/credential-manifest-token.js +49 -0
  13. package/lib/groups/credential-readiness.d.ts +69 -0
  14. package/lib/groups/credential-readiness.js +172 -0
  15. package/lib/groups/credential-wrapping-deps.d.ts +23 -0
  16. package/lib/groups/credential-wrapping-deps.js +25 -0
  17. package/lib/groups/grantor-authority.d.ts +65 -0
  18. package/lib/groups/grantor-authority.js +107 -0
  19. package/lib/groups/group-handlers.js +7 -0
  20. package/lib/groups/group-peer-manager.d.ts +25 -0
  21. package/lib/groups/group-peer-manager.js +71 -0
  22. package/lib/groups/group-protocols.d.ts +47 -0
  23. package/lib/groups/group-protocols.js +28 -0
  24. package/lib/hub/wiring.d.ts +24 -0
  25. package/lib/hub/wiring.js +17 -1
  26. package/lib/index.d.ts +14 -0
  27. package/lib/index.js +157 -6
  28. package/lib/peer/blob-fetch.d.ts +2 -18
  29. package/lib/protocol.d.ts +30 -0
  30. package/lib/protocol.js +36 -0
  31. package/lib/schema.d.ts +16 -1
  32. package/lib/schema.js +113 -4
  33. package/lib/sync/group-sync-workflow.d.ts +77 -0
  34. package/lib/sync/group-sync-workflow.js +96 -0
  35. package/lib/sync/handlers.js +21 -2
  36. package/lib/sync/held-delegations.d.ts +14 -0
  37. package/lib/sync/held-delegations.js +34 -0
  38. package/lib/sync/hub-tunnel-service-listener.d.ts +75 -0
  39. package/lib/sync/hub-tunnel-service-listener.js +289 -0
  40. package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
  41. package/lib/sync/hub-tunnel-service-provider.js +100 -0
  42. package/lib/sync/service-tunnel-listeners.d.ts +35 -0
  43. package/lib/sync/service-tunnel-listeners.js +165 -0
  44. package/lib/sync/sync-manager.d.ts +7 -0
  45. package/lib/sync/sync-manager.js +4 -1
  46. package/lib/sync/tunnel-topics.d.ts +19 -1
  47. package/lib/sync/tunnel-topics.js +7 -3
  48. package/lib/types.d.ts +182 -7
  49. package/lib/util/handler-error.d.ts +8 -5
  50. package/lib/util/handler-error.js +10 -23
  51. package/package.json +51 -46
@@ -0,0 +1,75 @@
1
+ import type { ServerTransportOf } from '@enkaku/protocol';
2
+ import type { Server } from '@enkaku/server';
3
+ import type { Logger } from '@kubun/logger';
4
+ import type { ServiceConfig, ServiceProtocol } from '@kubun/plugin-service-api';
5
+ import { type MailboxHub } from '@kumiai/hub-tunnel';
6
+ import type { Runtime } from '@sozai/runtime';
7
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
8
+ /**
9
+ * Mirrors `ServiceServerPluginAPI['serve']` (`@kubun/plugin-service-server`)
10
+ * without importing it, so this listener has no direct dependency on that
11
+ * plugin. `serve()` applies provider discovery, handler/access-rule merge,
12
+ * handler-authorization wrapping, and controller-DID verification that a bare
13
+ * `new Server` would not.
14
+ */
15
+ export type ServeService = (params: {
16
+ transport: ServerTransportOf<ServiceProtocol>;
17
+ services: Record<string, ServiceConfig>;
18
+ requireAuth?: false;
19
+ }) => Server<ServiceProtocol>;
20
+ /**
21
+ * The injected {@link ServeService} was called before the `service-server`
22
+ * plugin API had resolved. Transient — the listener retries on its normal backoff.
23
+ */
24
+ export declare class ServiceServeNotReadyError extends Error {
25
+ constructor(message: string);
26
+ }
27
+ /**
28
+ * The injected {@link ServeService} failed for a reason a retry cannot change
29
+ * (plugin absent, unknown/colliding service name). Terminal — the listener
30
+ * stops answering for this peer instead of spinning on backoff.
31
+ */
32
+ export declare class ServiceServeUnavailableError extends Error {
33
+ constructor(message: string, options?: {
34
+ cause?: unknown;
35
+ });
36
+ }
37
+ export type HubTunnelServiceListenerParams = {
38
+ /**
39
+ * Shared multi-subscriber device hub. Every listener on a device receives on
40
+ * the same topic, so the hub is fronted by a per-peer view that drops other
41
+ * peers' frames before they reach the cipher.
42
+ */
43
+ hub: MailboxHub;
44
+ registry: GroupHandleRegistry;
45
+ groupID: string;
46
+ localDID: string;
47
+ peerDID: string;
48
+ /**
49
+ * Platform primitives, including the per-session id generator. Threaded in
50
+ * from the caller since this package runs on React Native and browser too,
51
+ * not just node.
52
+ */
53
+ runtime: Runtime;
54
+ /** Injected `plugin-service-server` `serve()` — see {@link ServeService}. */
55
+ serve: ServeService;
56
+ /** Which services to serve, forwarded verbatim to {@link serve} on every spawn. */
57
+ services: Record<string, ServiceConfig>;
58
+ idleTimeoutMs?: number;
59
+ logger?: Logger;
60
+ };
61
+ /**
62
+ * Answer side of the service tunnel — the same directed hub-tunnel machinery as
63
+ * `HubTunnelSyncListener`, on a distinct lane so a service session never
64
+ * collides with a sync session's topics or ratchet generation.
65
+ *
66
+ * Unlike the sync listener, this does not build a bare `Server`: it hands the
67
+ * spawned transport to the injected {@link ServeService} callback so
68
+ * provider discovery, handler wrapping, and controller-DID verification apply.
69
+ */
70
+ export declare class HubTunnelServiceListener {
71
+ #private;
72
+ constructor(params: HubTunnelServiceListenerParams);
73
+ start(): void;
74
+ stop(): Promise<void>;
75
+ }
@@ -0,0 +1,289 @@
1
+ import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
2
+ import { APP_TOPIC_LABEL } from '@kumiai/rpc';
3
+ import { createGroupCrypto } from '../groups/group-crypto.js';
4
+ import { MLSEncryptor } from '../groups/mls-encryptor.js';
5
+ import { createPeerScopedHubView } from '../hub/peer-scoped-hub-view.js';
6
+ import { SERVICE_TUNNEL_PROTOCOL, tunnelTopic } from './tunnel-topics.js';
7
+ /**
8
+ * The injected {@link ServeService} was called before the `service-server`
9
+ * plugin API had resolved. Transient — the listener retries on its normal backoff.
10
+ */ export class ServiceServeNotReadyError extends Error {
11
+ constructor(message){
12
+ super(message);
13
+ this.name = 'ServiceServeNotReadyError';
14
+ }
15
+ }
16
+ /**
17
+ * The injected {@link ServeService} failed for a reason a retry cannot change
18
+ * (plugin absent, unknown/colliding service name). Terminal — the listener
19
+ * stops answering for this peer instead of spinning on backoff.
20
+ */ export class ServiceServeUnavailableError extends Error {
21
+ constructor(message, options){
22
+ super(message, options);
23
+ this.name = 'ServiceServeUnavailableError';
24
+ }
25
+ }
26
+ /**
27
+ * Answer side of the service tunnel — the same directed hub-tunnel machinery as
28
+ * `HubTunnelSyncListener`, on a distinct lane so a service session never
29
+ * collides with a sync session's topics or ratchet generation.
30
+ *
31
+ * Unlike the sync listener, this does not build a bare `Server`: it hands the
32
+ * spawned transport to the injected {@link ServeService} callback so
33
+ * provider discovery, handler wrapping, and controller-DID verification apply.
34
+ */ export class HubTunnelServiceListener {
35
+ #hub;
36
+ #registry;
37
+ #groupID;
38
+ #localDID;
39
+ #peerDID;
40
+ #runtime;
41
+ #serve;
42
+ #services;
43
+ #idleTimeoutMs;
44
+ #logger;
45
+ #started = false;
46
+ #stopped = false;
47
+ /** Consecutive failed spawns, for the re-arm backoff. Reset by a success. */ #respawnAttempt = 0;
48
+ #respawnTimer;
49
+ #current;
50
+ // Single MLSEncryptor reused across spawns. Ordering across overlapping
51
+ // spawns (old transport's last decrypt vs. new spawn's first encrypt) is
52
+ // covered by the GroupHandleRegistry's per-group mutex.
53
+ #encryptor;
54
+ // Per-peer view over the device hub, built once and shared by every spawn: it
55
+ // holds the inbox subscription for the listener's whole life, so respawns
56
+ // neither re-arm nor release a topic other listeners are draining.
57
+ #peerHub;
58
+ constructor(params){
59
+ this.#hub = params.hub;
60
+ this.#registry = params.registry;
61
+ this.#groupID = params.groupID;
62
+ this.#localDID = params.localDID;
63
+ this.#peerDID = params.peerDID;
64
+ this.#runtime = params.runtime;
65
+ this.#serve = params.serve;
66
+ this.#services = params.services;
67
+ this.#idleTimeoutMs = params.idleTimeoutMs;
68
+ this.#logger = params.logger;
69
+ }
70
+ start() {
71
+ if (this.#started || this.#stopped) return;
72
+ this.#started = true;
73
+ this.#encryptor = new MLSEncryptor({
74
+ registry: this.#registry,
75
+ groupID: this.#groupID
76
+ });
77
+ this.#peerHub = createPeerScopedHubView({
78
+ hub: this.#hub,
79
+ peerDID: this.#peerDID
80
+ });
81
+ this.#spawnGuarded();
82
+ }
83
+ /**
84
+ * Spawn, and survive a spawn that throws. `#spawn` can reject (e.g.
85
+ * `exportSecret` when the group handle is unavailable); left bare that would
86
+ * be both an unhandled rejection and a listener that never answers again,
87
+ * since the only other respawn trigger — the transport's `disposed` event —
88
+ * can't fire for a session that was never built.
89
+ */ #spawnGuarded() {
90
+ void this.#spawn().then(()=>{
91
+ this.#respawnAttempt = 0;
92
+ }).catch((error)=>{
93
+ // Terminal serve() failure (plugin absent, unknown/colliding service):
94
+ // stop rather than respawn forever. `stop()` marks `#stopped`, so the
95
+ // scheduled-respawn path becomes a no-op; the dialing side idles out and
96
+ // falls back exactly as it does for a peer that never answers.
97
+ if (error instanceof ServiceServeUnavailableError) {
98
+ this.#logger?.error('hub tunnel service listener stopping: serve() failed permanently; the service lane will not answer for this peer', {
99
+ lane: 'service',
100
+ groupID: this.#groupID,
101
+ peerDID: this.#peerDID,
102
+ error
103
+ });
104
+ void this.stop();
105
+ return;
106
+ }
107
+ this.#logger?.warn('hub tunnel service listener spawn failed', {
108
+ lane: 'service',
109
+ groupID: this.#groupID,
110
+ peerDID: this.#peerDID,
111
+ attempt: this.#respawnAttempt,
112
+ error
113
+ });
114
+ this.#scheduleRespawn();
115
+ });
116
+ }
117
+ #scheduleRespawn() {
118
+ if (this.#stopped || this.#respawnTimer != null) return;
119
+ // Bounded: a handle that is unavailable because the group was left never
120
+ // becomes available, and this must not spin on it.
121
+ const delay = Math.min(30_000, 250 * 2 ** Math.min(this.#respawnAttempt++, 7));
122
+ const timer = setTimeout(()=>{
123
+ this.#respawnTimer = undefined;
124
+ if (this.#stopped) return;
125
+ this.#spawnGuarded();
126
+ }, delay);
127
+ timer.unref?.();
128
+ this.#respawnTimer = timer;
129
+ }
130
+ async stop() {
131
+ if (this.#stopped) return;
132
+ this.#stopped = true;
133
+ if (this.#respawnTimer != null) {
134
+ clearTimeout(this.#respawnTimer);
135
+ this.#respawnTimer = undefined;
136
+ }
137
+ const current = this.#current;
138
+ this.#current = undefined;
139
+ if (current != null) {
140
+ try {
141
+ await current.transport.dispose();
142
+ } catch {
143
+ // ignore — best-effort teardown
144
+ }
145
+ try {
146
+ await current.server.dispose();
147
+ } catch {
148
+ // ignore — best-effort teardown
149
+ }
150
+ }
151
+ this.#encryptor = undefined;
152
+ this.#peerHub = undefined;
153
+ }
154
+ async #spawn() {
155
+ if (this.#stopped) return;
156
+ const encryptor = this.#encryptor;
157
+ const peerHub = this.#peerHub;
158
+ if (encryptor == null || peerHub == null) {
159
+ throw new Error('HubTunnelServiceListener: not initialized; call start() first');
160
+ }
161
+ // Epoch-bound, role-scoped topics for this spawn, on the SERVICE lane — a
162
+ // distinct protocol label so a service session never shares a ratchet
163
+ // generation with a sync session. The server listens on this device's
164
+ // RESPONDER topic and replies on the peer's DIALER topic — the mirror of
165
+ // the dialing side, and disjoint from it. Topics rotate with the MLS
166
+ // epoch, so each spawn re-derives them; MLS decrypt inside the registry
167
+ // lock remains the authoritative membership/forward-secrecy gate.
168
+ // Seed epoch is unread here: this port never classifies commit frames.
169
+ const crypto = createGroupCrypto({
170
+ registry: this.#registry,
171
+ groupID: this.#groupID,
172
+ initialEpoch: 0,
173
+ runtime: this.#runtime
174
+ });
175
+ const secret = await crypto.exportSecret(APP_TOPIC_LABEL);
176
+ const epoch = crypto.epoch();
177
+ const sendTopicID = tunnelTopic(secret, epoch, 'dialer', this.#peerDID, SERVICE_TUNNEL_PROTOCOL);
178
+ const receiveTopicID = tunnelTopic(secret, epoch, 'responder', this.#localDID, SERVICE_TUNNEL_PROTOCOL);
179
+ // A stop() that landed while we awaited the handle must abort the spawn.
180
+ if (this.#stopped) return;
181
+ // No sessionID: a responder locks to whatever session dials it, so the id
182
+ // is not known until the first frame. `role` is what separates this
183
+ // listener's log lines from the dialer's on the same device at a glance.
184
+ const sessionLogger = this.#logger?.with({
185
+ role: 'listener',
186
+ lane: 'service',
187
+ groupID: this.#groupID,
188
+ peerDID: this.#peerDID,
189
+ epoch,
190
+ receiveTopicID
191
+ });
192
+ sessionLogger?.debug('tunnel session opening');
193
+ let transportRef;
194
+ const transport = createEncryptedHubTunnelTransport({
195
+ hub: peerHub,
196
+ encryptor,
197
+ groupID: this.#groupID,
198
+ sessionID: {
199
+ auto: true
200
+ },
201
+ localDID: this.#localDID,
202
+ sendTopicID,
203
+ receiveTopicID,
204
+ idleTimeoutMs: this.#idleTimeoutMs,
205
+ onSessionEnd: ()=>{
206
+ // Peer signaled end-of-session. Dispose the transport deterministically
207
+ // so the `disposed` event fires and the spawn loop re-arms for the
208
+ // next session arriving on the shared device drain.
209
+ void transportRef?.dispose().catch(()=>{
210
+ // ignore — best-effort
211
+ });
212
+ }
213
+ });
214
+ transportRef = transport;
215
+ // Hand the transport to the injected `serve()` rather than building a bare
216
+ // `Server` — the delta from the sync listener.
217
+ //
218
+ // `serve()` can throw synchronously (plugin API not yet resolved, or
219
+ // genuinely absent). By this point `transport` already exists and has
220
+ // already subscribed the hub, with no `disposed` handler attached yet to
221
+ // re-arm anything — left undisposed here, a throw would leak one
222
+ // orphaned, still-subscribed transport per retry. Dispose before
223
+ // rethrowing so every failed attempt cleans up after itself.
224
+ let server;
225
+ try {
226
+ server = this.#serve({
227
+ transport,
228
+ services: this.#services
229
+ });
230
+ } catch (error) {
231
+ await transport.dispose().catch(()=>{
232
+ // ignore — best-effort teardown
233
+ });
234
+ // Preserve transient/terminal classification for #spawnGuarded: a
235
+ // not-ready or already-terminal error rethrows as-is; any other throw is
236
+ // a permanent misconfiguration, wrapped terminal so the listener stops.
237
+ if (error instanceof ServiceServeNotReadyError || error instanceof ServiceServeUnavailableError) {
238
+ throw error;
239
+ }
240
+ throw new ServiceServeUnavailableError('service tunnel serve() failed permanently', {
241
+ cause: error
242
+ });
243
+ }
244
+ const session = {
245
+ transport,
246
+ server
247
+ };
248
+ this.#current = session;
249
+ // Defence in depth: no `await` sits between the `#stopped` check above and
250
+ // this assignment today, but re-checking after the assignment means a
251
+ // `stop()` landing in a future await window still gets torn down —
252
+ // whichever side runs second does the teardown.
253
+ if (this.#stopped) {
254
+ this.#current = undefined;
255
+ try {
256
+ await transport.dispose();
257
+ } catch {
258
+ // ignore — best-effort teardown
259
+ }
260
+ try {
261
+ await server.dispose();
262
+ } catch {
263
+ // ignore — best-effort teardown
264
+ }
265
+ return;
266
+ }
267
+ // `disposed` may fire more than once for a single session (disposing the
268
+ // server can re-enter the transport's dispose). Collapse to exactly one
269
+ // teardown + respawn so one session leaves exactly one successor.
270
+ let handled = false;
271
+ transport.events.on('disposed', ()=>{
272
+ if (handled) return;
273
+ handled = true;
274
+ sessionLogger?.debug('tunnel session closed');
275
+ if (this.#current === session) {
276
+ this.#current = undefined;
277
+ }
278
+ // Server stays around until its handlers drain; explicit cleanup here so
279
+ // long-running sessions don't leak server instances.
280
+ void server.dispose().catch(()=>{
281
+ // ignore
282
+ });
283
+ // Nothing re-arms the inbox here — this fires even after stop(), but the
284
+ // per-peer view never releases the topic, so a departing transport's
285
+ // teardown can't empty it. Respawn re-derives topics for the next session.
286
+ this.#spawnGuarded();
287
+ });
288
+ }
289
+ }
@@ -0,0 +1,46 @@
1
+ import type { ClientTransportOf } from '@enkaku/protocol';
2
+ import type { Logger } from '@kubun/logger';
3
+ import type { ServiceProtocol } from '@kubun/plugin-service-api';
4
+ import type { Runtime } from '@sozai/runtime';
5
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
6
+ import type { DeviceHub } from '../hub/hub-like.js';
7
+ export type HubTunnelServiceProviderParams = {
8
+ /**
9
+ * Shared multi-subscriber device hub (from `createHubLike`). Each session
10
+ * attaches its own sink to this device-wide drain, narrowed to the peer it
11
+ * dialled, and filters to `receiveTopicID`; lifecycle events ride
12
+ * `hub.events`.
13
+ */
14
+ hub: DeviceHub;
15
+ registry: GroupHandleRegistry;
16
+ groupID: string;
17
+ localDID: string;
18
+ peerDID: string;
19
+ /**
20
+ * Platform primitives, including the per-session id generator. Threaded in
21
+ * from the caller since this package runs on React Native and browser too,
22
+ * not just node.
23
+ */
24
+ runtime: Runtime;
25
+ idleTimeoutMs?: number;
26
+ logger?: Logger;
27
+ };
28
+ /**
29
+ * Dial side of the SERVICE tunnel — the same directed hub-tunnel machinery as
30
+ * {@link HubTunnelSyncProvider}, riding a distinct lane so a service session to
31
+ * a peer never collides with a sync session's topics or ratchet generation.
32
+ */
33
+ export declare class HubTunnelServiceProvider {
34
+ #private;
35
+ constructor(params: HubTunnelServiceProviderParams);
36
+ /**
37
+ * Build a fresh client transport for one tunnel service session.
38
+ *
39
+ * Topics are group- and role-scoped, derived from the current MLS epoch
40
+ * secret on the service lane: outbound frames publish to the peer's
41
+ * responder topic, and the transport subscribes to this device's own dialer
42
+ * topic. Topics rotate with the epoch, so callers create a new transport per
43
+ * session.
44
+ */
45
+ createServiceTransport(signal?: AbortSignal): Promise<ClientTransportOf<ServiceProtocol>>;
46
+ }
@@ -0,0 +1,100 @@
1
+ import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
2
+ import { APP_TOPIC_LABEL } from '@kumiai/rpc';
3
+ import { createGroupCrypto } from '../groups/group-crypto.js';
4
+ import { MLSEncryptor } from '../groups/mls-encryptor.js';
5
+ import { createSenderScopedHubView } from '../hub/sender-scoped-hub-view.js';
6
+ import { SERVICE_TUNNEL_PROTOCOL, tunnelTopic } from './tunnel-topics.js';
7
+ /**
8
+ * Dial side of the SERVICE tunnel — the same directed hub-tunnel machinery as
9
+ * {@link HubTunnelSyncProvider}, riding a distinct lane so a service session to
10
+ * a peer never collides with a sync session's topics or ratchet generation.
11
+ */ export class HubTunnelServiceProvider {
12
+ #hub;
13
+ #registry;
14
+ #groupID;
15
+ #localDID;
16
+ #peerDID;
17
+ #runtime;
18
+ #idleTimeoutMs;
19
+ #logger;
20
+ constructor(params){
21
+ this.#hub = params.hub;
22
+ this.#registry = params.registry;
23
+ this.#groupID = params.groupID;
24
+ this.#localDID = params.localDID;
25
+ this.#peerDID = params.peerDID;
26
+ this.#runtime = params.runtime;
27
+ this.#idleTimeoutMs = params.idleTimeoutMs;
28
+ this.#logger = params.logger;
29
+ }
30
+ /**
31
+ * Build a fresh client transport for one tunnel service session.
32
+ *
33
+ * Topics are group- and role-scoped, derived from the current MLS epoch
34
+ * secret on the service lane: outbound frames publish to the peer's
35
+ * responder topic, and the transport subscribes to this device's own dialer
36
+ * topic. Topics rotate with the epoch, so callers create a new transport per
37
+ * session.
38
+ */ async createServiceTransport(signal) {
39
+ const sessionID = this.#runtime.getRandomID();
40
+ const encryptor = new MLSEncryptor({
41
+ registry: this.#registry,
42
+ groupID: this.#groupID
43
+ });
44
+ // The seed epoch is unread here: this port never classifies commit frames,
45
+ // and `exportSecret()` below records the live epoch before `epoch()` is
46
+ // asked for it. Only a peer that opens a receive drain needs a real seed.
47
+ const crypto = createGroupCrypto({
48
+ registry: this.#registry,
49
+ groupID: this.#groupID,
50
+ initialEpoch: 0,
51
+ runtime: this.#runtime
52
+ });
53
+ const secret = await crypto.exportSecret(APP_TOPIC_LABEL);
54
+ const epoch = crypto.epoch();
55
+ // SERVICE lane — the only substantive difference from the sync provider.
56
+ const sendTopicID = tunnelTopic(secret, epoch, 'responder', this.#peerDID, SERVICE_TUNNEL_PROTOCOL);
57
+ const receiveTopicID = tunnelTopic(secret, epoch, 'dialer', this.#localDID, SERVICE_TUNNEL_PROTOCOL);
58
+ // Bound once and carried by every line this session logs — the transport's
59
+ // own idle timeout raises a bare `TimeoutInterruption` with none of this
60
+ // context.
61
+ const sessionLogger = this.#logger?.with({
62
+ role: 'dialer',
63
+ lane: 'service',
64
+ groupID: this.#groupID,
65
+ peerDID: this.#peerDID,
66
+ sessionID,
67
+ epoch,
68
+ receiveTopicID
69
+ });
70
+ sessionLogger?.debug('tunnel session opening');
71
+ const transport = createEncryptedHubTunnelTransport({
72
+ // The peer-scoped view, never the device hub: two sessions open at once
73
+ // share the same inbox topic, and MLS consumes a ratchet generation per
74
+ // open, destroying the frame for whichever session loses the race.
75
+ // The idle timeout doubles as the publish bound so "no receive" and "no
76
+ // send" give up on the same promise rather than drifting apart.
77
+ hub: createSenderScopedHubView({
78
+ hub: this.#hub,
79
+ peerDID: this.#peerDID,
80
+ ...this.#idleTimeoutMs == null ? {} : {
81
+ publishTimeoutMs: this.#idleTimeoutMs
82
+ }
83
+ }),
84
+ encryptor,
85
+ groupID: this.#groupID,
86
+ sessionID,
87
+ localDID: this.#localDID,
88
+ sendTopicID,
89
+ receiveTopicID,
90
+ signal,
91
+ idleTimeoutMs: this.#idleTimeoutMs
92
+ });
93
+ // Close is as load-bearing as open: an unreachable peer looks like a
94
+ // session ending without having received anything.
95
+ transport.events.on('disposed', ()=>{
96
+ sessionLogger?.debug('tunnel session closed');
97
+ });
98
+ return transport;
99
+ }
100
+ }
@@ -0,0 +1,35 @@
1
+ import type { OwnIdentity } from '@kokuin/token';
2
+ import type { StoreProvider } from '@kubun/db';
3
+ import type { Logger } from '@kubun/logger';
4
+ import type { ServiceConfig } from '@kubun/plugin-service-api';
5
+ import type { MailboxHub } from '@kumiai/hub-tunnel';
6
+ import type { Runtime } from '@sozai/runtime';
7
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
8
+ import { type ServeService } from './hub-tunnel-service-listener.js';
9
+ import type { TunnelListeners } from './tunnel-listeners.js';
10
+ export type ServiceTunnelListenersParams = {
11
+ stores: StoreProvider;
12
+ registry: GroupHandleRegistry;
13
+ identity: OwnIdentity;
14
+ localDID: string;
15
+ runtime: Runtime;
16
+ /** Injected `plugin-service-server` `serve()` — see {@link ServeService}. */
17
+ serve: ServeService;
18
+ /** Which services to serve, forwarded verbatim to `serve` on every spawn. */
19
+ services: Record<string, ServiceConfig>;
20
+ /** The group's device-wide drain, or `undefined` when no hub is bound. */
21
+ tunnelHub: (groupID: string) => MailboxHub | undefined;
22
+ logger?: Logger;
23
+ idleTimeoutMs?: number;
24
+ };
25
+ /**
26
+ * The answering half of the service tunnel — one `HubTunnelServiceListener` per
27
+ * co-member per group, so a device dialing on the service lane finds something
28
+ * draining the mailbox and handing the session to the injected `serve()`.
29
+ *
30
+ * Structurally identical to {@link createTunnelListeners} (the sync lane's
31
+ * answering half): same groupID→peerDID map, same epoch/hub-change teardown,
32
+ * same per-group serialized reconciles — only the listener class and what's
33
+ * threaded into it differ, so the two lanes never share an instance or ratchet.
34
+ */
35
+ export declare function createServiceTunnelListeners(params: ServiceTunnelListenersParams): TunnelListeners;