@kumiai/hub-server 0.1.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/handlers.d.ts +28 -0
- package/lib/handlers.js +229 -0
- package/lib/hub.d.ts +48 -0
- package/lib/hub.js +56 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +5 -0
- package/lib/memoryStore.d.ts +12 -0
- package/lib/memoryStore.js +230 -0
- package/lib/rateLimit.d.ts +12 -0
- package/lib/rateLimit.js +25 -0
- package/lib/registry.d.ts +22 -0
- package/lib/registry.js +51 -0
- package/package.json +50 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ProcedureHandlers } from '@enkaku/server';
|
|
2
|
+
import type { HubProtocol, HubStore } from '@kumiai/hub-protocol';
|
|
3
|
+
import { type RateLimitConfig } from './rateLimit.js';
|
|
4
|
+
import type { HubClientRegistry } from './registry.js';
|
|
5
|
+
export type AuthorizeAction = 'publish' | 'subscribe';
|
|
6
|
+
export type AuthorizeHook = (did: string, action: AuthorizeAction, topicID: string) => boolean | Promise<boolean>;
|
|
7
|
+
export type HubRateLimits = {
|
|
8
|
+
perDID: RateLimitConfig;
|
|
9
|
+
perTopic: RateLimitConfig;
|
|
10
|
+
};
|
|
11
|
+
export declare const DEFAULT_RATE_LIMITS: HubRateLimits;
|
|
12
|
+
export type KeyPackageFetchLimits = {
|
|
13
|
+
/** Maximum number of key packages returned per fetch. Default: 10 */
|
|
14
|
+
maxCount: number;
|
|
15
|
+
/** Maximum number of fetch requests per requester DID per window. Default: 30 */
|
|
16
|
+
maxRequests: number;
|
|
17
|
+
/** Rate-limit window duration in milliseconds. Default: 60000 (1 min) */
|
|
18
|
+
windowMs: number;
|
|
19
|
+
};
|
|
20
|
+
export declare const DEFAULT_KEYPACKAGE_FETCH_LIMITS: KeyPackageFetchLimits;
|
|
21
|
+
export type CreateHandlersParams = {
|
|
22
|
+
registry: HubClientRegistry;
|
|
23
|
+
store: HubStore;
|
|
24
|
+
authorize?: AuthorizeHook;
|
|
25
|
+
rateLimits?: Partial<HubRateLimits>;
|
|
26
|
+
keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
|
|
27
|
+
};
|
|
28
|
+
export declare function createHandlers(params: CreateHandlersParams): ProcedureHandlers<HubProtocol>;
|
package/lib/handlers.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { HandlerError } from '@enkaku/server';
|
|
2
|
+
import { fromB64, toB64 } from '@sozai/codec';
|
|
3
|
+
import { createRateLimiter } from './rateLimit.js';
|
|
4
|
+
export const DEFAULT_RATE_LIMITS = {
|
|
5
|
+
perDID: {
|
|
6
|
+
rate: 20,
|
|
7
|
+
burst: 50
|
|
8
|
+
},
|
|
9
|
+
perTopic: {
|
|
10
|
+
rate: 100,
|
|
11
|
+
burst: 200
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
export const DEFAULT_KEYPACKAGE_FETCH_LIMITS = {
|
|
15
|
+
maxCount: 10,
|
|
16
|
+
maxRequests: 30,
|
|
17
|
+
windowMs: 60_000
|
|
18
|
+
};
|
|
19
|
+
function getClientDID(ctx) {
|
|
20
|
+
const iss = ctx.message.payload.iss;
|
|
21
|
+
if (typeof iss !== 'string' || iss.length === 0) {
|
|
22
|
+
throw new Error('Unauthenticated message: missing verified issuer DID');
|
|
23
|
+
}
|
|
24
|
+
return iss;
|
|
25
|
+
}
|
|
26
|
+
export function createHandlers(params) {
|
|
27
|
+
const { store, registry } = params;
|
|
28
|
+
const authorize = params.authorize ?? (()=>true);
|
|
29
|
+
const rateLimits = {
|
|
30
|
+
perDID: {
|
|
31
|
+
...DEFAULT_RATE_LIMITS.perDID,
|
|
32
|
+
...params.rateLimits?.perDID
|
|
33
|
+
},
|
|
34
|
+
perTopic: {
|
|
35
|
+
...DEFAULT_RATE_LIMITS.perTopic,
|
|
36
|
+
...params.rateLimits?.perTopic
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const didLimiter = createRateLimiter(rateLimits.perDID);
|
|
40
|
+
const topicLimiter = createRateLimiter(rateLimits.perTopic);
|
|
41
|
+
const fetchLimits = {
|
|
42
|
+
...DEFAULT_KEYPACKAGE_FETCH_LIMITS,
|
|
43
|
+
...params.keyPackageFetchLimits
|
|
44
|
+
};
|
|
45
|
+
const fetchWindows = new Map();
|
|
46
|
+
function assertKeyPackageFetchAllowed(requesterDID) {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
if (fetchWindows.size > 1024) {
|
|
49
|
+
for (const [did, window] of fetchWindows){
|
|
50
|
+
if (window.resetAt <= now) {
|
|
51
|
+
fetchWindows.delete(did);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const window = fetchWindows.get(requesterDID);
|
|
56
|
+
if (window == null || window.resetAt <= now) {
|
|
57
|
+
fetchWindows.set(requesterDID, {
|
|
58
|
+
count: 1,
|
|
59
|
+
resetAt: now + fetchLimits.windowMs
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (window.count >= fetchLimits.maxRequests) {
|
|
64
|
+
throw new Error('Key package fetch rate limit exceeded');
|
|
65
|
+
}
|
|
66
|
+
window.count++;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
'hub/publish': async (ctx)=>{
|
|
70
|
+
const { topicID, payload } = ctx.param;
|
|
71
|
+
const senderDID = getClientDID(ctx);
|
|
72
|
+
if (!await authorize(senderDID, 'publish', topicID)) {
|
|
73
|
+
throw new HandlerError({
|
|
74
|
+
code: 'EK02',
|
|
75
|
+
message: 'Not authorized to publish to topic'
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (!didLimiter.tryConsume(senderDID)) {
|
|
79
|
+
throw new HandlerError({
|
|
80
|
+
code: 'EK01',
|
|
81
|
+
message: 'Publish rate limit exceeded for DID'
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (!topicLimiter.tryConsume(topicID)) {
|
|
85
|
+
throw new HandlerError({
|
|
86
|
+
code: 'EK01',
|
|
87
|
+
message: 'Publish rate limit exceeded for topic'
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const payloadBytes = fromB64(payload);
|
|
91
|
+
const sequenceID = await store.publish({
|
|
92
|
+
senderDID,
|
|
93
|
+
topicID,
|
|
94
|
+
payload: payloadBytes
|
|
95
|
+
});
|
|
96
|
+
// Live-deliver to currently-connected subscribers (minus the sender).
|
|
97
|
+
const subscribers = await store.getSubscribers(topicID);
|
|
98
|
+
for (const recipientDID of subscribers){
|
|
99
|
+
if (recipientDID === senderDID) continue;
|
|
100
|
+
const client = registry.getClient(recipientDID);
|
|
101
|
+
if (client?.sendMessage != null) {
|
|
102
|
+
client.sendMessage({
|
|
103
|
+
sequenceID,
|
|
104
|
+
senderDID,
|
|
105
|
+
topicID,
|
|
106
|
+
payload: payloadBytes
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
sequenceID
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
'hub/subscribe': async (ctx)=>{
|
|
115
|
+
const { topicID } = ctx.param;
|
|
116
|
+
const clientDID = getClientDID(ctx);
|
|
117
|
+
if (!await authorize(clientDID, 'subscribe', topicID)) {
|
|
118
|
+
throw new HandlerError({
|
|
119
|
+
code: 'EK02',
|
|
120
|
+
message: 'Not authorized to subscribe to topic'
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
await store.subscribe(clientDID, topicID);
|
|
124
|
+
return {
|
|
125
|
+
subscribed: true
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
'hub/unsubscribe': async (ctx)=>{
|
|
129
|
+
const { topicID } = ctx.param;
|
|
130
|
+
const clientDID = getClientDID(ctx);
|
|
131
|
+
await store.unsubscribe(clientDID, topicID);
|
|
132
|
+
return {
|
|
133
|
+
unsubscribed: true
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
'hub/receive': async (ctx)=>{
|
|
137
|
+
const clientDID = getClientDID(ctx);
|
|
138
|
+
const { after } = ctx.param ?? {};
|
|
139
|
+
registry.register(clientDID);
|
|
140
|
+
if (registry.isWriterBound(clientDID)) {
|
|
141
|
+
throw new HandlerError({
|
|
142
|
+
code: 'EK01',
|
|
143
|
+
message: `receive writer already bound for DID ${clientDID}`
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const writer = ctx.writable.getWriter();
|
|
147
|
+
const reader = ctx.readable.getReader();
|
|
148
|
+
try {
|
|
149
|
+
registry.setReceiveWriter(clientDID, (message)=>{
|
|
150
|
+
writer.write({
|
|
151
|
+
sequenceID: message.sequenceID,
|
|
152
|
+
senderDID: message.senderDID,
|
|
153
|
+
topicID: message.topicID,
|
|
154
|
+
payload: toB64(message.payload)
|
|
155
|
+
}).catch(()=>{});
|
|
156
|
+
});
|
|
157
|
+
let cursor = after;
|
|
158
|
+
while(true){
|
|
159
|
+
const result = await store.fetch({
|
|
160
|
+
recipientDID: clientDID,
|
|
161
|
+
after: cursor ?? undefined,
|
|
162
|
+
limit: 50
|
|
163
|
+
});
|
|
164
|
+
for (const msg of result.messages){
|
|
165
|
+
await writer.write({
|
|
166
|
+
sequenceID: msg.sequenceID,
|
|
167
|
+
senderDID: msg.senderDID,
|
|
168
|
+
topicID: msg.topicID,
|
|
169
|
+
payload: toB64(msg.payload)
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
cursor = result.cursor;
|
|
173
|
+
if (!result.hasMore) break;
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
registry.clearReceiveWriter(clientDID);
|
|
177
|
+
registry.unregisterIfIdle(clientDID);
|
|
178
|
+
reader.cancel().catch(()=>{});
|
|
179
|
+
writer.abort(error).catch(()=>{});
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
void (async ()=>{
|
|
183
|
+
try {
|
|
184
|
+
while(true){
|
|
185
|
+
const { done, value } = await reader.read();
|
|
186
|
+
if (done) break;
|
|
187
|
+
if (value?.ack != null) {
|
|
188
|
+
await store.ack({
|
|
189
|
+
recipientDID: clientDID,
|
|
190
|
+
sequenceIDs: value.ack
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Channel closed
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
return new Promise((resolve)=>{
|
|
199
|
+
ctx.signal.addEventListener('abort', ()=>{
|
|
200
|
+
registry.clearReceiveWriter(clientDID);
|
|
201
|
+
registry.unregisterIfIdle(clientDID);
|
|
202
|
+
reader.cancel().catch(()=>{});
|
|
203
|
+
writer.close().catch(()=>{});
|
|
204
|
+
resolve(undefined);
|
|
205
|
+
}, {
|
|
206
|
+
once: true
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
'hub/keypackage/upload': async (ctx)=>{
|
|
211
|
+
const { keyPackages } = ctx.param;
|
|
212
|
+
const clientDID = getClientDID(ctx);
|
|
213
|
+
await Promise.all(keyPackages.map((kp)=>store.storeKeyPackage(clientDID, kp)));
|
|
214
|
+
return {
|
|
215
|
+
stored: keyPackages.length
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
'hub/keypackage/fetch': async (ctx)=>{
|
|
219
|
+
const requesterDID = getClientDID(ctx);
|
|
220
|
+
assertKeyPackageFetchAllowed(requesterDID);
|
|
221
|
+
const { did, count } = ctx.param;
|
|
222
|
+
const cappedCount = Math.min(Math.max(count ?? 1, 1), fetchLimits.maxCount);
|
|
223
|
+
const keyPackages = await store.fetchKeyPackages(did, cappedCount);
|
|
224
|
+
return {
|
|
225
|
+
keyPackages
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
package/lib/hub.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ServerTransportOf } from '@enkaku/protocol';
|
|
2
|
+
import type { AccessRules, ResourceLimits, Server } from '@enkaku/server';
|
|
3
|
+
import type { Identity } from '@kokuin/token';
|
|
4
|
+
import { type HubProtocol, type HubStore } from '@kumiai/hub-protocol';
|
|
5
|
+
import { type AuthorizeHook, type HubRateLimits, type KeyPackageFetchLimits } from './handlers.js';
|
|
6
|
+
import { HubClientRegistry } from './registry.js';
|
|
7
|
+
/**
|
|
8
|
+
* Default access rules: any authenticated DID may call hub procedures.
|
|
9
|
+
* The hub is a blind relay — per-procedure authorization (the `authorize`
|
|
10
|
+
* hook) happens in the handlers.
|
|
11
|
+
*/
|
|
12
|
+
export declare const DEFAULT_HUB_ACCESS_RULES: AccessRules;
|
|
13
|
+
export type HubPurgeOptions = {
|
|
14
|
+
/** Interval between purge runs in milliseconds. Default: 3600000 (1 hour) */
|
|
15
|
+
interval?: number;
|
|
16
|
+
/** Age in seconds after which unacked stored messages are purged. Default: 604800 (7 days) */
|
|
17
|
+
olderThan?: number;
|
|
18
|
+
};
|
|
19
|
+
export type CreateHubParams = {
|
|
20
|
+
transport: ServerTransportOf<HubProtocol>;
|
|
21
|
+
store: HubStore;
|
|
22
|
+
/**
|
|
23
|
+
* Hub server identity. Required: all hub procedures derive the client DID
|
|
24
|
+
* from the verified `iss` of signed messages.
|
|
25
|
+
*/
|
|
26
|
+
identity: Identity;
|
|
27
|
+
/** Access rules enforced by the server. Defaults to {@link DEFAULT_HUB_ACCESS_RULES}. */
|
|
28
|
+
accessRules?: AccessRules;
|
|
29
|
+
/** Per-procedure publish/subscribe authorization. Defaults to allow-any-authed. */
|
|
30
|
+
authorize?: AuthorizeHook;
|
|
31
|
+
/** Publish rate limits. Merged over {@link DEFAULT_RATE_LIMITS}. */
|
|
32
|
+
rateLimits?: Partial<HubRateLimits>;
|
|
33
|
+
/** Quotas applied to hub/keypackage/fetch. Merged over {@link DEFAULT_KEYPACKAGE_FETCH_LIMITS}. */
|
|
34
|
+
keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
|
|
35
|
+
/** Scheduled purge of expired stored messages. Set to `false` to disable. */
|
|
36
|
+
purge?: HubPurgeOptions | false;
|
|
37
|
+
/**
|
|
38
|
+
* Server resource limits. `hub/receive` is always added to
|
|
39
|
+
* `longLivedProcedures` so open mailbox channels are exempt from
|
|
40
|
+
* `controllerTimeoutMs` and from the `maxConcurrentHandlers` cap.
|
|
41
|
+
*/
|
|
42
|
+
limits?: Partial<ResourceLimits>;
|
|
43
|
+
};
|
|
44
|
+
export type HubInstance = {
|
|
45
|
+
registry: HubClientRegistry;
|
|
46
|
+
server: Server<HubProtocol>;
|
|
47
|
+
};
|
|
48
|
+
export declare function createHub(params: CreateHubParams): HubInstance;
|
package/lib/hub.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { serve } from '@enkaku/server';
|
|
2
|
+
import { hubProtocol } from '@kumiai/hub-protocol';
|
|
3
|
+
import { createHandlers } from './handlers.js';
|
|
4
|
+
import { HubClientRegistry } from './registry.js';
|
|
5
|
+
/**
|
|
6
|
+
* Default access rules: any authenticated DID may call hub procedures.
|
|
7
|
+
* The hub is a blind relay — per-procedure authorization (the `authorize`
|
|
8
|
+
* hook) happens in the handlers.
|
|
9
|
+
*/ export const DEFAULT_HUB_ACCESS_RULES = {
|
|
10
|
+
'hub/*': {
|
|
11
|
+
allow: true
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
export function createHub(params) {
|
|
15
|
+
const registry = new HubClientRegistry();
|
|
16
|
+
const handlers = createHandlers({
|
|
17
|
+
registry,
|
|
18
|
+
store: params.store,
|
|
19
|
+
authorize: params.authorize,
|
|
20
|
+
rateLimits: params.rateLimits,
|
|
21
|
+
keyPackageFetchLimits: params.keyPackageFetchLimits
|
|
22
|
+
});
|
|
23
|
+
const limits = {
|
|
24
|
+
...params.limits,
|
|
25
|
+
longLivedProcedures: [
|
|
26
|
+
...new Set([
|
|
27
|
+
...params.limits?.longLivedProcedures ?? [],
|
|
28
|
+
'hub/receive'
|
|
29
|
+
])
|
|
30
|
+
]
|
|
31
|
+
};
|
|
32
|
+
const server = serve({
|
|
33
|
+
handlers,
|
|
34
|
+
protocol: hubProtocol,
|
|
35
|
+
transport: params.transport,
|
|
36
|
+
identity: params.identity,
|
|
37
|
+
accessRules: params.accessRules ?? DEFAULT_HUB_ACCESS_RULES,
|
|
38
|
+
limits
|
|
39
|
+
});
|
|
40
|
+
if (params.purge !== false) {
|
|
41
|
+
const interval = params.purge?.interval ?? 3_600_000;
|
|
42
|
+
const olderThan = params.purge?.olderThan ?? 604_800;
|
|
43
|
+
const purgeTimer = setInterval(()=>{
|
|
44
|
+
params.store.purge({
|
|
45
|
+
olderThan
|
|
46
|
+
}).catch(()=>{
|
|
47
|
+
// Purge failures are non-fatal; retried on the next interval
|
|
48
|
+
});
|
|
49
|
+
}, interval);
|
|
50
|
+
server.disposed.then(()=>clearInterval(purgeTimer));
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
registry,
|
|
54
|
+
server
|
|
55
|
+
};
|
|
56
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { AuthorizeAction, AuthorizeHook, CreateHandlersParams, HubRateLimits, KeyPackageFetchLimits, } from './handlers.js';
|
|
2
|
+
export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS } from './handlers.js';
|
|
3
|
+
export type { CreateHubParams, HubInstance, HubPurgeOptions } from './hub.js';
|
|
4
|
+
export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
|
|
5
|
+
export { createMemoryStore, type MemoryStoreOptions } from './memoryStore.js';
|
|
6
|
+
export { createRateLimiter, type RateLimitConfig, type RateLimiter } from './rateLimit.js';
|
|
7
|
+
export type { ClientEntry } from './registry.js';
|
|
8
|
+
export { HubClientRegistry } from './registry.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS } from './handlers.js';
|
|
2
|
+
export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
|
|
3
|
+
export { createMemoryStore } from './memoryStore.js';
|
|
4
|
+
export { createRateLimiter } from './rateLimit.js';
|
|
5
|
+
export { HubClientRegistry } from './registry.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { HubStore } from '@kumiai/hub-protocol';
|
|
2
|
+
export type MemoryStoreOptions = {
|
|
3
|
+
/** Per-topic max retained messages; oldest are trimmed beyond this. Default 1000. */
|
|
4
|
+
maxDepth?: number;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* In-memory implementation of HubStore for testing and development.
|
|
8
|
+
*
|
|
9
|
+
* Single message copy + per-subscriber delivery index + refcount GC. Recipients
|
|
10
|
+
* are resolved from the subscription table at publish time, never passed in.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createMemoryStore(options?: MemoryStoreOptions): HubStore;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { EventEmitter } from '@sozai/event';
|
|
2
|
+
const DEFAULT_MAX_DEPTH = 1000;
|
|
3
|
+
function formatSequenceID(counter) {
|
|
4
|
+
return String(counter).padStart(12, '0');
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* In-memory implementation of HubStore for testing and development.
|
|
8
|
+
*
|
|
9
|
+
* Single message copy + per-subscriber delivery index + refcount GC. Recipients
|
|
10
|
+
* are resolved from the subscription table at publish time, never passed in.
|
|
11
|
+
*/ export function createMemoryStore(options = {}) {
|
|
12
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
13
|
+
let counter = 0;
|
|
14
|
+
const messages = new Map();
|
|
15
|
+
const deliveries = new Map();
|
|
16
|
+
const subscriptions = new Map();
|
|
17
|
+
const topicMessages = new Map();
|
|
18
|
+
const keyPackages = new Map();
|
|
19
|
+
const events = new EventEmitter();
|
|
20
|
+
// Remove a message entirely: every recipient delivery list, the topic log,
|
|
21
|
+
// and the message record.
|
|
22
|
+
function deleteMessage(sequenceID) {
|
|
23
|
+
const record = messages.get(sequenceID);
|
|
24
|
+
if (record == null) return;
|
|
25
|
+
for (const recipient of record.recipients){
|
|
26
|
+
const list = deliveries.get(recipient);
|
|
27
|
+
if (list != null) {
|
|
28
|
+
const index = list.indexOf(sequenceID);
|
|
29
|
+
if (index !== -1) list.splice(index, 1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const topicLog = topicMessages.get(record.topicID);
|
|
33
|
+
if (topicLog != null) {
|
|
34
|
+
const index = topicLog.indexOf(sequenceID);
|
|
35
|
+
if (index !== -1) topicLog.splice(index, 1);
|
|
36
|
+
if (topicLog.length === 0) topicMessages.delete(record.topicID);
|
|
37
|
+
}
|
|
38
|
+
messages.delete(sequenceID);
|
|
39
|
+
}
|
|
40
|
+
// Drop one subscriber's delivery of a message; GC the message when its last
|
|
41
|
+
// recipient is gone (refcount → 0).
|
|
42
|
+
function removeDelivery(recipientDID, sequenceID) {
|
|
43
|
+
const list = deliveries.get(recipientDID);
|
|
44
|
+
if (list != null) {
|
|
45
|
+
const index = list.indexOf(sequenceID);
|
|
46
|
+
if (index !== -1) list.splice(index, 1);
|
|
47
|
+
}
|
|
48
|
+
const record = messages.get(sequenceID);
|
|
49
|
+
if (record != null) {
|
|
50
|
+
record.recipients.delete(recipientDID);
|
|
51
|
+
if (record.recipients.size === 0) {
|
|
52
|
+
deleteMessage(sequenceID);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
events,
|
|
58
|
+
async publish (params) {
|
|
59
|
+
counter++;
|
|
60
|
+
const sequenceID = formatSequenceID(counter);
|
|
61
|
+
// Recipients = current subscribers minus the sender. Zero recipients
|
|
62
|
+
// (no subscribers, or only the sender) → drop immediately, store nothing.
|
|
63
|
+
const subscribers = subscriptions.get(params.topicID);
|
|
64
|
+
const recipients = new Set();
|
|
65
|
+
if (subscribers != null) {
|
|
66
|
+
for (const did of subscribers){
|
|
67
|
+
if (did !== params.senderDID) recipients.add(did);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (recipients.size === 0) {
|
|
71
|
+
return sequenceID;
|
|
72
|
+
}
|
|
73
|
+
const record = {
|
|
74
|
+
sequenceID,
|
|
75
|
+
senderDID: params.senderDID,
|
|
76
|
+
topicID: params.topicID,
|
|
77
|
+
payload: params.payload,
|
|
78
|
+
recipients,
|
|
79
|
+
storedAt: Date.now()
|
|
80
|
+
};
|
|
81
|
+
messages.set(sequenceID, record);
|
|
82
|
+
for (const recipient of recipients){
|
|
83
|
+
let list = deliveries.get(recipient);
|
|
84
|
+
if (list == null) {
|
|
85
|
+
list = [];
|
|
86
|
+
deliveries.set(recipient, list);
|
|
87
|
+
}
|
|
88
|
+
list.push(sequenceID);
|
|
89
|
+
}
|
|
90
|
+
let topicLog = topicMessages.get(params.topicID);
|
|
91
|
+
if (topicLog == null) {
|
|
92
|
+
topicLog = [];
|
|
93
|
+
topicMessages.set(params.topicID, topicLog);
|
|
94
|
+
}
|
|
95
|
+
topicLog.push(sequenceID);
|
|
96
|
+
// Per-topic max-depth trim: drop oldest beyond the bound.
|
|
97
|
+
while(topicLog.length > maxDepth){
|
|
98
|
+
deleteMessage(topicLog[0]);
|
|
99
|
+
}
|
|
100
|
+
return sequenceID;
|
|
101
|
+
},
|
|
102
|
+
async fetch (params) {
|
|
103
|
+
if (params.ack != null && params.ack.length > 0) {
|
|
104
|
+
for (const sequenceID of params.ack){
|
|
105
|
+
removeDelivery(params.recipientDID, sequenceID);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const recipientDeliveries = deliveries.get(params.recipientDID);
|
|
109
|
+
if (recipientDeliveries == null || recipientDeliveries.length === 0) {
|
|
110
|
+
return {
|
|
111
|
+
messages: [],
|
|
112
|
+
cursor: null
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
let startIndex = 0;
|
|
116
|
+
if (params.after != null) {
|
|
117
|
+
const afterIndex = recipientDeliveries.indexOf(params.after);
|
|
118
|
+
if (afterIndex !== -1) {
|
|
119
|
+
startIndex = afterIndex + 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const available = recipientDeliveries.slice(startIndex);
|
|
123
|
+
const limit = params.limit ?? available.length;
|
|
124
|
+
const selected = available.slice(0, limit);
|
|
125
|
+
const hasMore = available.length > limit;
|
|
126
|
+
const resultMessages = [];
|
|
127
|
+
for (const sequenceID of selected){
|
|
128
|
+
const record = messages.get(sequenceID);
|
|
129
|
+
if (record != null) {
|
|
130
|
+
resultMessages.push({
|
|
131
|
+
sequenceID: record.sequenceID,
|
|
132
|
+
senderDID: record.senderDID,
|
|
133
|
+
topicID: record.topicID,
|
|
134
|
+
payload: record.payload
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const cursor = resultMessages.length > 0 ? resultMessages[resultMessages.length - 1].sequenceID : null;
|
|
139
|
+
const result = {
|
|
140
|
+
messages: resultMessages,
|
|
141
|
+
cursor
|
|
142
|
+
};
|
|
143
|
+
if (hasMore) {
|
|
144
|
+
result.hasMore = true;
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
},
|
|
148
|
+
async ack (params) {
|
|
149
|
+
for (const sequenceID of params.sequenceIDs){
|
|
150
|
+
removeDelivery(params.recipientDID, sequenceID);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
async purge (params) {
|
|
154
|
+
const threshold = Date.now() - params.olderThan * 1000;
|
|
155
|
+
const purgedIDs = [];
|
|
156
|
+
for (const [sequenceID, record] of messages){
|
|
157
|
+
if (record.storedAt <= threshold) {
|
|
158
|
+
purgedIDs.push(sequenceID);
|
|
159
|
+
deleteMessage(sequenceID);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (purgedIDs.length > 0) {
|
|
163
|
+
await events.emit('purge', {
|
|
164
|
+
sequenceIDs: purgedIDs
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return purgedIDs;
|
|
168
|
+
},
|
|
169
|
+
async subscribe (subscriberDID, topicID) {
|
|
170
|
+
let subs = subscriptions.get(topicID);
|
|
171
|
+
if (subs == null) {
|
|
172
|
+
subs = new Set();
|
|
173
|
+
subscriptions.set(topicID, subs);
|
|
174
|
+
}
|
|
175
|
+
subs.add(subscriberDID);
|
|
176
|
+
},
|
|
177
|
+
async unsubscribe (subscriberDID, topicID) {
|
|
178
|
+
const subs = subscriptions.get(topicID);
|
|
179
|
+
if (subs != null) {
|
|
180
|
+
subs.delete(subscriberDID);
|
|
181
|
+
if (subs.size === 0) {
|
|
182
|
+
subscriptions.delete(topicID);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Drop this subscriber's pending deliveries for the topic.
|
|
186
|
+
const list = deliveries.get(subscriberDID);
|
|
187
|
+
if (list != null) {
|
|
188
|
+
for (const sequenceID of [
|
|
189
|
+
...list
|
|
190
|
+
]){
|
|
191
|
+
const record = messages.get(sequenceID);
|
|
192
|
+
if (record != null && record.topicID === topicID) {
|
|
193
|
+
removeDelivery(subscriberDID, sequenceID);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Last subscriber gone → drop the whole topic log immediately.
|
|
198
|
+
if (!subscriptions.has(topicID)) {
|
|
199
|
+
const topicLog = topicMessages.get(topicID);
|
|
200
|
+
if (topicLog != null) {
|
|
201
|
+
for (const sequenceID of [
|
|
202
|
+
...topicLog
|
|
203
|
+
]){
|
|
204
|
+
deleteMessage(sequenceID);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
async getSubscribers (topicID) {
|
|
210
|
+
const subs = subscriptions.get(topicID);
|
|
211
|
+
return subs == null ? [] : [
|
|
212
|
+
...subs
|
|
213
|
+
];
|
|
214
|
+
},
|
|
215
|
+
async storeKeyPackage (ownerDID, keyPackage) {
|
|
216
|
+
let packages = keyPackages.get(ownerDID);
|
|
217
|
+
if (packages == null) {
|
|
218
|
+
packages = [];
|
|
219
|
+
keyPackages.set(ownerDID, packages);
|
|
220
|
+
}
|
|
221
|
+
packages.push(keyPackage);
|
|
222
|
+
},
|
|
223
|
+
async fetchKeyPackages (ownerDID, count) {
|
|
224
|
+
const packages = keyPackages.get(ownerDID);
|
|
225
|
+
if (packages == null || packages.length === 0) return [];
|
|
226
|
+
const n = count ?? 1;
|
|
227
|
+
return packages.splice(0, n);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type RateLimitConfig = {
|
|
2
|
+
/** Sustained refill rate in tokens per second. */
|
|
3
|
+
rate: number;
|
|
4
|
+
/** Maximum bucket capacity (burst). */
|
|
5
|
+
burst: number;
|
|
6
|
+
};
|
|
7
|
+
export type RateLimiter = {
|
|
8
|
+
/** Consumes a token if available, returning true; returns false otherwise. */
|
|
9
|
+
tryConsume(key: string): boolean;
|
|
10
|
+
};
|
|
11
|
+
/** Per-key token-bucket rate limiter. */
|
|
12
|
+
export declare function createRateLimiter(config: RateLimitConfig): RateLimiter;
|
package/lib/rateLimit.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Per-key token-bucket rate limiter. */ export function createRateLimiter(config) {
|
|
2
|
+
const buckets = new Map();
|
|
3
|
+
return {
|
|
4
|
+
tryConsume (key) {
|
|
5
|
+
const now = Date.now();
|
|
6
|
+
let bucket = buckets.get(key);
|
|
7
|
+
if (bucket == null) {
|
|
8
|
+
bucket = {
|
|
9
|
+
tokens: config.burst,
|
|
10
|
+
lastRefill: now
|
|
11
|
+
};
|
|
12
|
+
buckets.set(key, bucket);
|
|
13
|
+
} else {
|
|
14
|
+
const elapsedSeconds = (now - bucket.lastRefill) / 1000;
|
|
15
|
+
bucket.tokens = Math.min(config.burst, bucket.tokens + elapsedSeconds * config.rate);
|
|
16
|
+
bucket.lastRefill = now;
|
|
17
|
+
}
|
|
18
|
+
if (bucket.tokens < 1) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
bucket.tokens -= 1;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { StoredMessage } from '@kumiai/hub-protocol';
|
|
2
|
+
export type ClientEntry = {
|
|
3
|
+
did: string;
|
|
4
|
+
sendMessage: ((message: StoredMessage) => void) | null;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Tracks online clients and their live receive-channel writers. Subscription
|
|
8
|
+
* state is durable in the store, not here — the registry only routes live
|
|
9
|
+
* fan-out to currently-connected subscribers.
|
|
10
|
+
*/
|
|
11
|
+
export declare class HubClientRegistry {
|
|
12
|
+
#private;
|
|
13
|
+
register(did: string): ClientEntry;
|
|
14
|
+
unregister(did: string): void;
|
|
15
|
+
/** Removes the entry only when no receive writer is bound. */
|
|
16
|
+
unregisterIfIdle(did: string): void;
|
|
17
|
+
setReceiveWriter(did: string, writer: (message: StoredMessage) => void): void;
|
|
18
|
+
clearReceiveWriter(did: string): void;
|
|
19
|
+
getClient(did: string): ClientEntry | undefined;
|
|
20
|
+
isOnline(did: string): boolean;
|
|
21
|
+
isWriterBound(did: string): boolean;
|
|
22
|
+
}
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks online clients and their live receive-channel writers. Subscription
|
|
3
|
+
* state is durable in the store, not here — the registry only routes live
|
|
4
|
+
* fan-out to currently-connected subscribers.
|
|
5
|
+
*/ export class HubClientRegistry {
|
|
6
|
+
#clients = new Map();
|
|
7
|
+
register(did) {
|
|
8
|
+
const existing = this.#clients.get(did);
|
|
9
|
+
if (existing != null) {
|
|
10
|
+
return existing;
|
|
11
|
+
}
|
|
12
|
+
const entry = {
|
|
13
|
+
did,
|
|
14
|
+
sendMessage: null
|
|
15
|
+
};
|
|
16
|
+
this.#clients.set(did, entry);
|
|
17
|
+
return entry;
|
|
18
|
+
}
|
|
19
|
+
unregister(did) {
|
|
20
|
+
this.#clients.delete(did);
|
|
21
|
+
}
|
|
22
|
+
/** Removes the entry only when no receive writer is bound. */ unregisterIfIdle(did) {
|
|
23
|
+
const entry = this.#clients.get(did);
|
|
24
|
+
if (entry != null && entry.sendMessage == null) {
|
|
25
|
+
this.#clients.delete(did);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
setReceiveWriter(did, writer) {
|
|
29
|
+
const entry = this.#clients.get(did);
|
|
30
|
+
if (entry == null) return;
|
|
31
|
+
if (entry.sendMessage != null) {
|
|
32
|
+
throw new Error(`receive writer already bound for DID ${did}`);
|
|
33
|
+
}
|
|
34
|
+
entry.sendMessage = writer;
|
|
35
|
+
}
|
|
36
|
+
clearReceiveWriter(did) {
|
|
37
|
+
const entry = this.#clients.get(did);
|
|
38
|
+
if (entry != null) {
|
|
39
|
+
entry.sendMessage = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
getClient(did) {
|
|
43
|
+
return this.#clients.get(did);
|
|
44
|
+
}
|
|
45
|
+
isOnline(did) {
|
|
46
|
+
return this.#clients.get(did)?.sendMessage != null;
|
|
47
|
+
}
|
|
48
|
+
isWriterBound(did) {
|
|
49
|
+
return this.#clients.get(did)?.sendMessage != null;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kumiai/hub-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Hub server for Enkaku E2EE group message routing",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"hub",
|
|
8
|
+
"server",
|
|
9
|
+
"e2ee",
|
|
10
|
+
"routing"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/TairuFramework/kumiai",
|
|
15
|
+
"directory": "packages/hub-server"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "lib/index.js",
|
|
19
|
+
"types": "lib/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./lib/index.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib/*"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@sozai/codec": "^0.1.0",
|
|
29
|
+
"@sozai/event": "^0.1.0",
|
|
30
|
+
"@enkaku/protocol": "^0.18.0",
|
|
31
|
+
"@enkaku/server": "^0.18.0",
|
|
32
|
+
"@sozai/stream": "^0.1.0",
|
|
33
|
+
"@kokuin/token": "^0.1.0",
|
|
34
|
+
"@kumiai/mls": "^0.1.0",
|
|
35
|
+
"@kumiai/hub-protocol": "^0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@enkaku/client": "^0.18.0",
|
|
39
|
+
"@enkaku/transport": "^0.18.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build:clean": "del lib",
|
|
43
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
44
|
+
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
45
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
46
|
+
"test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
|
|
47
|
+
"test:unit": "vitest run",
|
|
48
|
+
"test": "pnpm run test:types && pnpm run test:unit"
|
|
49
|
+
}
|
|
50
|
+
}
|