@kubun/plugin-p2p 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/context/group.js +1 -1
- package/lib/context/hub.d.ts +4 -0
- package/lib/context/hub.js +1 -0
- package/lib/context/join.js +1 -1
- package/lib/context/sync.js +1 -1
- package/lib/context/types.d.ts +12 -0
- package/lib/groups/broadcast-service.d.ts +71 -17
- package/lib/groups/broadcast-service.js +1 -1
- package/lib/groups/broadcast.d.ts +112 -5
- package/lib/groups/broadcast.js +1 -1
- package/lib/groups/events.d.ts +11 -0
- package/lib/groups/group-handle-registry.d.ts +90 -0
- package/lib/groups/group-handle-registry.js +1 -0
- package/lib/groups/invite-payload.d.ts +5 -1
- package/lib/groups/join-utils.d.ts +2 -8
- package/lib/groups/join-utils.js +1 -1
- package/lib/groups/manager.d.ts +20 -14
- package/lib/groups/manager.js +1 -1
- package/lib/groups/mls-codec.d.ts +7 -0
- package/lib/groups/mls-codec.js +1 -0
- package/lib/groups/mls-encryptor.d.ts +25 -0
- package/lib/groups/mls-encryptor.js +1 -0
- package/lib/hub/connection-pool.d.ts +24 -0
- package/lib/hub/connection-pool.js +1 -1
- package/lib/hub/did-observing-transport.d.ts +64 -0
- package/lib/hub/did-observing-transport.js +1 -0
- package/lib/hub/errors.d.ts +28 -0
- package/lib/hub/errors.js +1 -0
- package/lib/hub/forward-remote-broadcast.d.ts +15 -0
- package/lib/hub/forward-remote-broadcast.js +1 -0
- package/lib/hub/group-channel.d.ts +29 -21
- package/lib/hub/group-channel.js +1 -1
- package/lib/hub/http-client.d.ts +17 -0
- package/lib/hub/http-client.js +1 -0
- package/lib/hub/hub-connection.d.ts +96 -0
- package/lib/hub/hub-connection.js +1 -0
- package/lib/hub/manager.d.ts +117 -0
- package/lib/hub/manager.js +1 -0
- package/lib/hub/receive-handler.d.ts +3 -6
- package/lib/hub/receive-handler.js +1 -1
- package/lib/hub/relay-manager.d.ts +85 -2
- package/lib/hub/relay-manager.js +1 -1
- package/lib/hub/send-handler.d.ts +12 -16
- package/lib/hub/send-handler.js +1 -1
- package/lib/hub/tunnel-inbox.d.ts +20 -0
- package/lib/hub/tunnel-inbox.js +1 -0
- package/lib/hub/wait-for-gate.d.ts +14 -0
- package/lib/hub/wait-for-gate.js +1 -0
- package/lib/hub/wiring.d.ts +73 -0
- package/lib/hub/wiring.js +1 -0
- package/lib/index.d.ts +102 -6
- package/lib/index.js +1 -1
- package/lib/schema.js +68 -20
- package/lib/sync/broadcast-queue.d.ts +59 -0
- package/lib/sync/broadcast-queue.js +1 -0
- package/lib/sync/broadcast-sender.d.ts +52 -0
- package/lib/sync/broadcast-sender.js +1 -0
- package/lib/sync/catalog-match.d.ts +13 -0
- package/lib/sync/catalog-match.js +1 -0
- package/lib/sync/forwarder.d.ts +96 -0
- package/lib/sync/forwarder.js +1 -0
- package/lib/sync/handlers.d.ts +15 -0
- package/lib/sync/handlers.js +1 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +24 -0
- package/lib/sync/hub-tunnel-sync-listener.js +1 -0
- package/lib/sync/hub-tunnel-sync-provider.d.ts +36 -0
- package/lib/sync/hub-tunnel-sync-provider.js +1 -0
- package/lib/sync/merkle-apply.d.ts +41 -0
- package/lib/sync/merkle-apply.js +1 -1
- package/lib/sync/merkle-channel.d.ts +15 -0
- package/lib/sync/merkle-channel.js +1 -1
- package/lib/sync/receive-access-gate.d.ts +36 -0
- package/lib/sync/receive-access-gate.js +1 -0
- package/lib/sync/scope-resolver.d.ts +32 -0
- package/lib/sync/scope-resolver.js +1 -0
- package/lib/sync/sync-manager.d.ts +10 -0
- package/lib/sync/sync-manager.js +1 -1
- package/lib/types.d.ts +95 -26
- package/lib/util/mutex.d.ts +4 -0
- package/lib/util/mutex.js +1 -0
- package/package.json +40 -37
package/lib/groups/manager.d.ts
CHANGED
|
@@ -1,22 +1,29 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type GroupPermission, type Invite, type KeyPackageBundle } from '@enkaku/group';
|
|
2
2
|
import type { Identity, OwnIdentity } from '@enkaku/token';
|
|
3
3
|
import type { StoreProvider } from '@kubun/db';
|
|
4
4
|
import type { GroupBroadcastMessage } from './broadcast.js';
|
|
5
|
+
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
6
|
+
import type { SuggestedHub } from './invite-payload.js';
|
|
5
7
|
export type GroupManagerParams = {
|
|
6
8
|
identity: Identity;
|
|
7
9
|
getRandomID: () => string;
|
|
10
|
+
/**
|
|
11
|
+
* Single canonical access point for the device's MLS `GroupHandle` instances.
|
|
12
|
+
* Required: all member ops + createGroup/joinGroup route MLS state through
|
|
13
|
+
* the registry's per-group mutex with write-through persist.
|
|
14
|
+
*/
|
|
15
|
+
registry: GroupHandleRegistry;
|
|
8
16
|
};
|
|
9
17
|
export type CreateGroupParams = {
|
|
10
18
|
stores: StoreProvider;
|
|
11
19
|
identity: OwnIdentity;
|
|
12
20
|
name: string;
|
|
13
21
|
description?: string;
|
|
14
|
-
|
|
22
|
+
hubs?: Array<SuggestedHub>;
|
|
15
23
|
createdBy?: string;
|
|
16
24
|
};
|
|
17
25
|
export type CreateGroupResult = {
|
|
18
26
|
groupID: string;
|
|
19
|
-
groupHandle: GroupHandle;
|
|
20
27
|
};
|
|
21
28
|
export type UpdateGroupParams = {
|
|
22
29
|
stores: StoreProvider;
|
|
@@ -24,7 +31,6 @@ export type UpdateGroupParams = {
|
|
|
24
31
|
update: {
|
|
25
32
|
name?: string;
|
|
26
33
|
description?: string;
|
|
27
|
-
hubURLs?: Array<string>;
|
|
28
34
|
};
|
|
29
35
|
};
|
|
30
36
|
export type UpdateGroupResult = {
|
|
@@ -34,7 +40,6 @@ export type InviteToGroupParams = {
|
|
|
34
40
|
stores: StoreProvider;
|
|
35
41
|
groupID: string;
|
|
36
42
|
identity: OwnIdentity;
|
|
37
|
-
groupHandle: GroupHandle;
|
|
38
43
|
recipientDID: string;
|
|
39
44
|
recipientKeyPackage: KeyPackageBundle['publicPackage'];
|
|
40
45
|
permission: GroupPermission;
|
|
@@ -43,42 +48,43 @@ export type InviteToGroupResult = {
|
|
|
43
48
|
invite: Invite;
|
|
44
49
|
welcomeMessage: unknown;
|
|
45
50
|
commitMessage: unknown;
|
|
46
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Ratchet tree from the post-commit handle. Required by `joinGroup`
|
|
53
|
+
* callers that build an invite payload — they no longer hold a
|
|
54
|
+
* `GroupHandle` reference, so the manager surfaces the tree directly.
|
|
55
|
+
* Typed `unknown` to match `@enkaku/group`'s `ProcessWelcomeParams.ratchetTree`,
|
|
56
|
+
* which is opaque on that side too.
|
|
57
|
+
*/
|
|
58
|
+
ratchetTree: unknown;
|
|
47
59
|
};
|
|
48
60
|
export type JoinGroupParams = {
|
|
49
61
|
stores: StoreProvider;
|
|
50
62
|
identity: OwnIdentity;
|
|
51
63
|
groupID: string;
|
|
52
64
|
groupName: string;
|
|
53
|
-
|
|
65
|
+
hubs?: Array<SuggestedHub>;
|
|
54
66
|
invite: Invite;
|
|
55
67
|
keyPackageBundle: KeyPackageBundle;
|
|
56
68
|
welcomeMessage: unknown;
|
|
57
69
|
ratchetTree: unknown;
|
|
58
70
|
};
|
|
59
|
-
export type JoinGroupResult =
|
|
60
|
-
groupHandle: GroupHandle;
|
|
61
|
-
};
|
|
71
|
+
export type JoinGroupResult = undefined;
|
|
62
72
|
export type RemoveMemberParams = {
|
|
63
73
|
stores: StoreProvider;
|
|
64
74
|
groupID: string;
|
|
65
|
-
groupHandle: GroupHandle;
|
|
66
75
|
leafIndex: number;
|
|
67
76
|
memberDID: string;
|
|
68
77
|
};
|
|
69
78
|
export type RemoveMemberResult = {
|
|
70
79
|
commitMessage: unknown;
|
|
71
|
-
updatedGroupHandle: GroupHandle;
|
|
72
80
|
};
|
|
73
81
|
export type RemoveGroupMemberParams = {
|
|
74
82
|
stores: StoreProvider;
|
|
75
83
|
groupID: string;
|
|
76
|
-
groupHandle: GroupHandle;
|
|
77
84
|
memberDID: string;
|
|
78
85
|
};
|
|
79
86
|
export type RemoveGroupMemberResult = {
|
|
80
87
|
commitMessage: unknown;
|
|
81
|
-
updatedGroupHandle: GroupHandle;
|
|
82
88
|
};
|
|
83
89
|
export type LeaveGroupParams = {
|
|
84
90
|
stores: StoreProvider;
|
package/lib/groups/manager.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{commitInvite as e,createGroup as
|
|
1
|
+
import{commitInvite as e,createGroup as r,createInvite as t,processWelcome as i,removeMember as a}from"@enkaku/group";import{HLC as o}from"@kubun/hlc";import{getGraphStore as s}from"@kubun/store-graph";import{getP2PStore as c}from"@kubun/store-p2p";import{bindHubToGroup as l,upsertHub as n}from"../hub/manager.js";export class GroupManager{#e;#r;#t;#i;constructor(e){this.#e=e.identity.id,this.#r=e.getRandomID,this.#t=new o({nodeID:e.identity.id}),this.#i=e.registry}async createGroup(e){let t=this.#r(),{group:i}=await r(e.identity,t),a=e.hubs??[];return await e.stores.withTransaction(async r=>{let s=await c(r);for(let c of(await s.createGroup({id:t,name:e.name,description:e.description??"",created_by:e.createdBy??e.identity.id,hlc:o.serialize(this.#t.now())}),await this.#i.seed({groupID:t,handle:i,stores:r}),await s.addGroupMember({group_id:t,member_did:e.identity.id,role:"admin"}),a)){let e=await n({stores:r,url:c.url,serverDID:c.serverDID??null});await l({stores:r,hubID:e.id,groupID:t})}}),{groupID:t}}async updateGroup(e){let r=o.serialize(this.#t.now()),t=await c(e.stores);return await t.updateGroup(e.groupID,{name:e.update.name,description:e.update.description,hlc:r}),{broadcast:{type:"group:update",groupID:e.groupID,update:{...e.update,hlc:r}}}}async inviteToGroup(r){let i=await this.#i.withHandleReplacing(r.groupID,async i=>{let{invite:a}=await t({group:i,identity:r.identity,recipientDID:r.recipientDID,permission:r.permission}),{commitMessage:o,welcomeMessage:s,newGroup:c}=await e(i,r.recipientKeyPackage);return{result:{invite:a,commitMessage:o,welcomeMessage:s,ratchetTree:c.state.ratchetTree},updated:c}},{stores:r.stores}),a=await c(r.stores);return await a.addGroupMember({group_id:r.groupID,member_did:r.recipientDID,role:"admin"===r.permission?"admin":"member"}),i}async joinGroup(e){let{group:r}=await i({identity:e.identity,invite:e.invite,welcome:e.welcomeMessage,keyPackageBundle:e.keyPackageBundle,ratchetTree:e.ratchetTree}),t=e.hubs??[];await e.stores.withTransaction(async i=>{await this.#i.seed({groupID:e.groupID,handle:r,stores:i});let a=await c(i);for(let r of(null==await a.getGroup(e.groupID)&&await a.createGroup({id:e.groupID,name:e.groupName,description:"",created_by:e.identity.id,hlc:o.serialize(this.#t.now())}),await a.addGroupMember({group_id:e.groupID,member_did:e.identity.id,role:"member"}),t)){let t=await n({stores:i,url:r.url,serverDID:r.serverDID??null});await l({stores:i,hubID:t.id,groupID:e.groupID})}})}async removeMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let{commitMessage:t,newGroup:i}=await a(r,e.leafIndex);return{result:{commitMessage:t},updated:i}},{stores:e.stores}),t=await c(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID),{commitMessage:r}}async removeGroupMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let t=r.findMemberLeafIndex(e.memberDID);if(null==t)throw Error(`Member ${e.memberDID} not found in MLS group`);let{commitMessage:i,newGroup:o}=await a(r,t);return{result:{commitMessage:i},updated:o}},{stores:e.stores}),t=await c(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID),{commitMessage:r}}async leaveGroup(e){let r=await c(e.stores);await r.deleteMLSState(e.groupID,this.#e),await r.removeGroupMember(e.groupID,e.identity.id),this.#i.invalidate(e.groupID)}async loadGroup(e,r){let t=await c(e),i=await t.getMLSState(r,this.#e);if(null!=i)return{epoch:i.epoch,credential:i.credential}}async createCircle(e){let r=this.#r(),t=o.serialize(this.#t.now()),i=e.catalogIDs??[],a=await c(e.stores);return await a.createCircle({id:r,group_id:e.groupID,name:e.name,description:e.description??"",catalog_ids:i,hlc:t}),{circleID:r,broadcast:{type:"circle:create",circle:{id:r,groupID:e.groupID,name:e.name,description:e.description??"",catalogIDs:i,hlc:t}}}}async updateCircle(e){let r=o.serialize(this.#t.now()),t=[],i=await c(e.stores);if(null!=e.update.catalogIDs){let r=await i.getCircle(e.circleID),a=new Set(null!=r?r.catalog_ids:[]),o=e.update.catalogIDs.filter(e=>!a.has(e)),c=await s(e.stores);for(let e of(await Promise.all(o.map(e=>c.getCatalog(e)))))null!=e&&t.push({type:"catalog:create",catalog:{id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:e.filter_criteria,hlc:e.hlc}})}await i.updateCircle(e.circleID,{name:e.update.name,description:e.update.description,catalog_ids:e.update.catalogIDs,hlc:r});let a={type:"circle:update",circleID:e.circleID,update:{...e.update,hlc:r}};return{broadcast:a,broadcasts:[a,...t]}}async deleteCircle(e){let r=o.serialize(this.#t.now()),t=await c(e.stores);return await t.deleteCircle(e.circleID),{broadcast:{type:"circle:delete",circleID:e.circleID,hlc:r}}}async addCircleMember(e){let r=await c(e.stores);if(!await r.isGroupMember(e.groupID,e.memberDID))throw Error(`${e.memberDID} is not a member of group ${e.groupID}`);let t=o.serialize(this.#t.now());return await r.addCircleMember({circle_id:e.circleID,member_did:e.memberDID,role:e.role,hlc:t}),{broadcast:{type:"member:add",member:{circleID:e.circleID,memberDID:e.memberDID,role:e.role,hlc:t}}}}async removeCircleMember(e){let r=o.serialize(this.#t.now()),t=await c(e.stores);return await t.removeCircleMember(e.circleID,e.memberDID),{broadcast:{type:"member:remove",circleID:e.circleID,memberDID:e.memberDID,hlc:r}}}}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GroupHandle } from '@enkaku/group';
|
|
2
|
+
export type MLSEncryptFramedResult = {
|
|
3
|
+
framed: Uint8Array;
|
|
4
|
+
consumed: Array<Uint8Array>;
|
|
5
|
+
};
|
|
6
|
+
export declare function mlsEncryptFramed(handle: GroupHandle, plaintext: Uint8Array): Promise<MLSEncryptFramedResult>;
|
|
7
|
+
export declare function mlsDecryptFramed(handle: GroupHandle, framed: Uint8Array): Promise<Uint8Array>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{replacer as e,reviver as r}from"./mls-json.js";let t=new TextEncoder,n=new TextDecoder;export async function mlsEncryptFramed(r,n){let{message:c,consumed:o}=await r.encrypt(n);return{framed:t.encode(JSON.stringify(c,e)),consumed:o}}export async function mlsDecryptFramed(e,t){let c=JSON.parse(n.decode(t),r);return await e.decrypt(c)}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Encryptor } from '@enkaku/hub-tunnel';
|
|
2
|
+
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
3
|
+
export type MLSEncryptorParams = {
|
|
4
|
+
registry: GroupHandleRegistry;
|
|
5
|
+
groupID: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Adapts the `GroupHandleRegistry` to the `@enkaku/hub-tunnel` `Encryptor`
|
|
9
|
+
* interface. The registry's per-group mutex serializes encrypt + decrypt
|
|
10
|
+
* across all encryptor instances bound to the same `(registry, groupID)`.
|
|
11
|
+
*
|
|
12
|
+
* Wedge-protection note: encrypt persists the advanced ratchet immediately
|
|
13
|
+
* on callback success — BEFORE the wire send happens inside the hub-tunnel
|
|
14
|
+
* transport. If the wire send fails, the persisted state is already at
|
|
15
|
+
* generation N+1; the next encrypt advances to N+2 and generation N is
|
|
16
|
+
* "burned" without wire egress. This is the documented forward-secrecy
|
|
17
|
+
* trade-off for tunnel sessions; the broadcast path (`BroadcastService.
|
|
18
|
+
* prepareSend`) inverts the order by pulling send into the registry callback.
|
|
19
|
+
*/
|
|
20
|
+
export declare class MLSEncryptor implements Encryptor {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(params: MLSEncryptorParams);
|
|
23
|
+
encrypt(plaintext: Uint8Array): Promise<Uint8Array>;
|
|
24
|
+
decrypt(ciphertext: Uint8Array): Promise<Uint8Array>;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{mlsDecryptFramed as r,mlsEncryptFramed as t}from"./mls-codec.js";export class MLSEncryptor{#r;#t;constructor(r){this.#r=r.registry,this.#t=r.groupID}encrypt(r){return this.#r.withHandle(this.#t,async s=>{let{framed:e}=await t(s,r);return e})}decrypt(t){return this.#r.withHandle(this.#t,s=>r(s,t))}}
|
|
@@ -6,9 +6,33 @@ export type HubClientRef = {
|
|
|
6
6
|
client: Client<HubProtocol>;
|
|
7
7
|
refID: string;
|
|
8
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>;
|
|
9
31
|
export type HubConnectionPoolParams = {
|
|
10
32
|
createHubClient: CreateHubClient;
|
|
11
33
|
logger?: Logger;
|
|
34
|
+
onServerDIDObserved?: OnServerDIDObserved;
|
|
35
|
+
resolvePinnedDID?: ResolvePinnedServerDID;
|
|
12
36
|
};
|
|
13
37
|
export declare class HubConnectionPool {
|
|
14
38
|
#private;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export class HubConnectionPool{#e;#t;#i=new Map;#
|
|
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()}}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Disposer } from '@enkaku/async';
|
|
2
|
+
import type { EventEmitter } from '@enkaku/event';
|
|
3
|
+
import type { AnyClientMessageOf, AnyServerMessageOf, ClientTransportOf, ProtocolDefinition } from '@enkaku/protocol';
|
|
4
|
+
import type { TransportEvents } from '@enkaku/transport';
|
|
5
|
+
export type ServerDIDObserver = (serverDID: string) => void | Promise<void>;
|
|
6
|
+
export type ServerDIDMismatchObserver = (params: {
|
|
7
|
+
hubURL: string;
|
|
8
|
+
expectedServerDID: string;
|
|
9
|
+
observedServerDID: string;
|
|
10
|
+
}) => void;
|
|
11
|
+
export type DIDObservingTransportParams<Protocol extends ProtocolDefinition> = {
|
|
12
|
+
inner: ClientTransportOf<Protocol>;
|
|
13
|
+
/**
|
|
14
|
+
* Fired on the first message bearing `payload.iss` when no `expectedServerDID`
|
|
15
|
+
* is set (TOFU capture). When `expectedServerDID` is set and matches, the
|
|
16
|
+
* callback is NOT fired — the pin is already authoritative.
|
|
17
|
+
*/
|
|
18
|
+
onServerDID: ServerDIDObserver;
|
|
19
|
+
/**
|
|
20
|
+
* Pinned hub server DID. When set, the wrapper enforces it: a non-matching
|
|
21
|
+
* first observed `iss` triggers a permanent failed state — `read()` rejects
|
|
22
|
+
* with `HubServerDIDMismatchError`, the offending message is NOT delivered
|
|
23
|
+
* to the inner Client, and every subsequent `read()` re-raises the same
|
|
24
|
+
* error. When unset, the wrapper falls back to TOFU capture.
|
|
25
|
+
*/
|
|
26
|
+
expectedServerDID?: string;
|
|
27
|
+
/** Hub URL — only used to populate `HubServerDIDMismatchError` for callers. */
|
|
28
|
+
hubURL?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Optional side-channel notification fired on mismatch. Lets the pool log /
|
|
31
|
+
* evict without having to inspect the rejected `read()` cause. Inner
|
|
32
|
+
* transport disposal is the pool's job, not the wrapper's.
|
|
33
|
+
*/
|
|
34
|
+
onMismatch?: ServerDIDMismatchObserver;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Wraps a `ClientTransportOf<Protocol>` to enforce hub-server-DID
|
|
38
|
+
* TOFU + pinned-mismatch semantics on the receive side.
|
|
39
|
+
*
|
|
40
|
+
* - `expectedServerDID == null`: TOFU mode. First message bearing
|
|
41
|
+
* `payload.iss` fires `onServerDID(iss)` exactly once; subsequent messages
|
|
42
|
+
* never re-fire, even with a differing `iss`. Every message is forwarded
|
|
43
|
+
* to the inner Client untouched.
|
|
44
|
+
* - `expectedServerDID != null` and the first observed `iss` matches: the
|
|
45
|
+
* pin is already authoritative; `onServerDID` is NOT fired and forwarding
|
|
46
|
+
* continues normally.
|
|
47
|
+
* - `expectedServerDID != null` and the first observed `iss` differs: the
|
|
48
|
+
* wrapper enters a permanent failed state (`HubServerDIDMismatchError`).
|
|
49
|
+
* The current `read()` call AND every subsequent `read()` reject with the
|
|
50
|
+
* error; the offending message is NOT delivered. Inner-transport lifecycle
|
|
51
|
+
* stays the pool's responsibility — the wrapper does not dispose its inner.
|
|
52
|
+
*
|
|
53
|
+
* Extends `Disposer` and matches the `ClientTransportOf<Protocol>` shape
|
|
54
|
+
* structurally so it can be passed straight to `Client<Protocol>`.
|
|
55
|
+
*/
|
|
56
|
+
export declare class DIDObservingTransport<Protocol extends ProtocolDefinition> extends Disposer {
|
|
57
|
+
#private;
|
|
58
|
+
constructor(params: DIDObservingTransportParams<Protocol>);
|
|
59
|
+
get events(): EventEmitter<TransportEvents>;
|
|
60
|
+
getWritable(): WritableStream<AnyClientMessageOf<Protocol>>;
|
|
61
|
+
read(): Promise<ReadableStreamReadResult<AnyServerMessageOf<Protocol>>>;
|
|
62
|
+
write(value: AnyClientMessageOf<Protocol>): Promise<void>;
|
|
63
|
+
[Symbol.asyncIterator](): AsyncIterator<AnyServerMessageOf<Protocol>, AnyServerMessageOf<Protocol> | null>;
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{Disposer as e}from"@enkaku/async";import{HubServerDIDMismatchError as r}from"./errors.js";export class DIDObservingTransport extends e{#e;#r;#t;#n;#i;#s=!1;#l=null;constructor(e){super({signal:e.inner.signal,dispose:async r=>{await e.inner.dispose(r)}}),this.#e=e.inner,this.#r=e.onServerDID,this.#t=e.expectedServerDID,this.#n=e.hubURL,this.#i=e.onMismatch}get events(){return this.#e.events}getWritable(){return this.#e.getWritable()}async read(){if(null!=this.#l)throw this.#l;let e=await this.#e.read(),r=this.#a(e);if(null!=r)throw r;return e}async write(e){await this.#e.write(e)}[Symbol.asyncIterator](){return{next:async()=>{let e=await this.read();return e.done?{done:!0,value:e.value??null}:{done:!1,value:e.value}}}}#a(e){if(this.#s||e.done||null==e.value)return null;let t=e.value.payload,n=t?.iss;if("string"!=typeof n||0===n.length)return null;if(this.#s=!0,null!=this.#t){if(n===this.#t)return null;let e=new r({hubURL:this.#n??"",expectedServerDID:this.#t,observedServerDID:n});if(this.#l=e,null!=this.#i)try{this.#i({hubURL:this.#n??"",expectedServerDID:this.#t,observedServerDID:n})}catch{}return e}return Promise.resolve().then(()=>this.#r(n)).catch(()=>{}),null}}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare class ReconnectingError extends Error {
|
|
2
|
+
readonly hubURL: string;
|
|
3
|
+
readonly attempt: number;
|
|
4
|
+
constructor(hubURL: string, attempt: number);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Thrown by `DIDObservingTransport.read()` when the first observed `payload.iss`
|
|
8
|
+
* does not match the pinned `expectedServerDID`. The transport enters a
|
|
9
|
+
* permanent failed state: no message bearing the offending `iss` is delivered
|
|
10
|
+
* to the inner Client, and every subsequent `read()` rejects with the same
|
|
11
|
+
* error. In-flight Enkaku RPCs see this error as the `cause` of the Client's
|
|
12
|
+
* "Transport read failed" abort.
|
|
13
|
+
*
|
|
14
|
+
* Distinct from `HubServerDIDConflictError` (raised by manager-level
|
|
15
|
+
* `captureServerDID` when the pin row already differs from the observed DID
|
|
16
|
+
* on the TOFU capture path); this class is raised client-side, before any
|
|
17
|
+
* message is delivered, when a row was already pinned at connect time.
|
|
18
|
+
*/
|
|
19
|
+
export declare class HubServerDIDMismatchError extends Error {
|
|
20
|
+
readonly hubURL: string;
|
|
21
|
+
readonly expectedServerDID: string;
|
|
22
|
+
readonly observedServerDID: string;
|
|
23
|
+
constructor(params: {
|
|
24
|
+
hubURL: string;
|
|
25
|
+
expectedServerDID: string;
|
|
26
|
+
observedServerDID: string;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class ReconnectingError extends Error{hubURL;attempt;constructor(e,r){super(`broadcast wait timed out: hub ${e} reconnecting (attempt ${r})`),this.name="ReconnectingError",this.hubURL=e,this.attempt=r}}export class HubServerDIDMismatchError extends Error{hubURL;expectedServerDID;observedServerDID;constructor(e){super(`hub at ${e.hubURL} signed responses with ${e.observedServerDID}; pinned to ${e.expectedServerDID}`),this.name="HubServerDIDMismatchError",this.hubURL=e.hubURL,this.expectedServerDID=e.expectedServerDID,this.observedServerDID=e.observedServerDID}}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { BroadcastEvent } from '../groups/broadcast-service.js';
|
|
3
|
+
import type { GroupEventEmitter } from '../groups/events.js';
|
|
4
|
+
/**
|
|
5
|
+
* Map a `BroadcastEvent` from `BroadcastService` into the matching
|
|
6
|
+
* `GroupEventEmitter` 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: GroupEventEmitter, event: BroadcastEvent, logger?: Logger): Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
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(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,26 +1,9 @@
|
|
|
1
|
+
import { Disposer } from '@enkaku/async';
|
|
1
2
|
import type { Logger } from '@kubun/logger';
|
|
2
3
|
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
3
4
|
import type { GroupBroadcastMessage } from '../groups/broadcast.js';
|
|
4
5
|
import type { BroadcastService } from '../groups/broadcast-service.js';
|
|
5
|
-
import type {
|
|
6
|
-
export type GroupChannelParams = {
|
|
7
|
-
groupID: string;
|
|
8
|
-
hubURL: string;
|
|
9
|
-
deviceID: string;
|
|
10
|
-
pool: HubConnectionPool;
|
|
11
|
-
broadcastService: BroadcastService;
|
|
12
|
-
p2pStore: P2PStoreAPI;
|
|
13
|
-
logger?: Logger;
|
|
14
|
-
ackFlushMs: number;
|
|
15
|
-
ackFlushMax: number;
|
|
16
|
-
backoffBaseMs: number;
|
|
17
|
-
backoffMaxMs: number;
|
|
18
|
-
backoffJitter: number;
|
|
19
|
-
};
|
|
20
|
-
export type Mutex = {
|
|
21
|
-
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
22
|
-
};
|
|
23
|
-
export declare function createMutex(): Mutex;
|
|
6
|
+
import type { HubConnection } from './hub-connection.js';
|
|
24
7
|
export type AckBatchParams = {
|
|
25
8
|
flushMs: number;
|
|
26
9
|
flushMax: number;
|
|
@@ -42,10 +25,35 @@ export declare class AckBatch {
|
|
|
42
25
|
}
|
|
43
26
|
export declare function computeBackoff(attempt: number, base: number, max: number, jitter: number): number;
|
|
44
27
|
export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
|
|
45
|
-
export
|
|
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 {
|
|
46
52
|
#private;
|
|
47
53
|
constructor(params: GroupChannelParams);
|
|
48
54
|
open(): Promise<void>;
|
|
49
55
|
close(): Promise<void>;
|
|
50
|
-
broadcast(message: GroupBroadcastMessage
|
|
56
|
+
broadcast(message: GroupBroadcastMessage, opts?: {
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
}): Promise<void>;
|
|
51
59
|
}
|
package/lib/hub/group-channel.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{Disposer as e}from"@enkaku/async";import{handleReceivedMessage as t}from"./receive-handler.js";import{handleSendBroadcast as r}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;#l=!1;constructor(e){super({dispose:async()=>{await this.#d()}}),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.#l)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.#l=!0}#p(e){return t({groupID:this.#n,message:e,broadcastService:this.#u,logger:this.#i})}async close(){this.#l&&await this.dispose()}async #d(){if(this.#l){try{await this.#a.unsubscribe(this.#n)}catch(e){this.#i?.warn("hub-connection unsubscribe failed during close",{groupID:this.#n,error:e})}this.#l=!1}}broadcast(e,t){return this.#l?this.#a.runInGroup(this.#n,async()=>{if(!this.#l)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`))}}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Identity } from '@enkaku/token';
|
|
2
|
+
import { type ServerDIDObserver } from './did-observing-transport.js';
|
|
3
|
+
import type { CreateHubClient } from './relay-manager.js';
|
|
4
|
+
export type CreateHTTPHubClientParams = {
|
|
5
|
+
identity: Identity;
|
|
6
|
+
fetch?: typeof globalThis.fetch;
|
|
7
|
+
serverID?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Fired once per hub client when the first signed response from the server
|
|
10
|
+
* arrives carrying `payload.iss = serverDID`. Subsequent responses do NOT
|
|
11
|
+
* re-fire this hook, even if `iss` differs. Used by `HubConnectionPool` to
|
|
12
|
+
* drive TOFU pinning into the hub manager. Per-call `onServerDID` from
|
|
13
|
+
* `CreateHubClientOptions` overrides this factory-level default.
|
|
14
|
+
*/
|
|
15
|
+
onServerDID?: ServerDIDObserver;
|
|
16
|
+
};
|
|
17
|
+
export declare function createHTTPHubClient(params: CreateHTTPHubClientParams): CreateHubClient;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{Client as e}from"@enkaku/client";import{ClientTransport as r}from"@enkaku/http-client-transport";import{DIDObservingTransport as t}from"./did-observing-transport.js";export function createHTTPHubClient(n){return async(o,i)=>{let l=new r({url:o,fetch:n.fetch}),u=i?.onServerDID??n.onServerDID,D=i?.expectedServerDID,c=i?.onMismatch;return new e({transport:null!=u||null!=D||null!=c?new t({inner:l,onServerDID:u??(()=>{}),expectedServerDID:D,hubURL:o,onMismatch:c}):l,identity:n.identity,serverID:n.serverID})}}
|
|
@@ -0,0 +1,96 @@
|
|
|
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 {};
|
|
@@ -0,0 +1 @@
|
|
|
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;#w=new Map;#y;constructor(e){super({dispose:async()=>{await this.#I()}}),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.#y=new n}get events(){return this.#y}async #D(e){try{await this.#y.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.#L(),this.#f=!0,await this.#D({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 #L(){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,isAllowedSender:t,onRejected:s}){if(this.#w.has(e))throw Error("tunnelMessageStream: peerDID already registered");let n=new c,i={inbox:n,isAllowedSender:t??(()=>!0),onRejected:s};this.#w.set(e,i);let r=!1;return{[Symbol.asyncIterator]:()=>n.iterator(),return:()=>{r||(r=!0,this.#w.get(e)===i&&this.#w.delete(e),n.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.#k(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.#D({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.#D({type:"reconnecting",attempt:this.#g,delayMs:e});try{this.#b?.dispose(),this.#b=null,this.#u=null,await this.#L(),this.#g=0,await this.#D({type:"connected"});return}catch(e){this.#n?.warn("hub-connection reconnect attempt failed",{hubURL:this.#e,attempt:this.#g,error:e}),await this.#D({type:"error",error:e})}}}finally{this.#m=null,e()}}#k(e){if(null==e.groupID){let s=this.#w.get(e.senderDID);if(null!=s){if(!s.isAllowedSender(e.senderDID)){this.#n?.debug("tunnel frame rejected: sender not member",{hubURL:this.#e,sequenceID:e.sequenceID,senderDID:e.senderDID});try{s.onRejected?.("sender-not-member",e.senderDID)}catch(t){this.#n?.warn("tunnel onRejected callback threw",{hubURL:this.#e,senderDID:e.senderDID,error:t})}this.#b?.add(e.sequenceID);return}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 #I(){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.#w.values()))e.inbox.close();this.#w.clear(),this.#f=!1}}
|