@kumiai/hub-server 0.3.0 → 0.4.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/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @kumiai/hub-server
2
+
3
+ The hub server: `@kumiai/hub-protocol`'s procedure handlers wired to an `@enkaku` server, a live
4
+ client registry for push fan-out, and an in-memory `HubStore` for development and tests.
5
+
6
+ ## Exports
7
+
8
+ - `createHub({ transport, store, identity, ... })` — the whole server: handlers, registry, access
9
+ rules, rate limits, and an optional scheduled purge. Returns `{ registry, server }`.
10
+ - `createHandlers({ registry, store, ... })` — the handlers alone, for a host assembling its own
11
+ server.
12
+ - `createMemoryStore({ maxDepth?, retention? })` — an in-memory `HubStore`. For testing and
13
+ development; it is also what runs the `@kumiai/hub-conformance` suites in this repo.
14
+ - `HubClientRegistry` — the live-connection table.
15
+ - `createRateLimiter`, `DEFAULT_RATE_LIMITS`, `DEFAULT_KEYPACKAGE_FETCH_LIMITS`,
16
+ `DEFAULT_HUB_ACCESS_RULES`.
17
+
18
+ ```ts
19
+ import { createHub, createMemoryStore } from '@kumiai/hub-server'
20
+
21
+ const { server } = createHub({ transport, identity, store: createMemoryStore() })
22
+ ```
23
+
24
+ ## The hub is blind, and identity comes only from the signature
25
+
26
+ Topic IDs are opaque and payloads are ciphertext the hub never opens. Every handler takes the caller
27
+ DID from the **verified issuer** of the signed message, never from a wire field — so
28
+ `hub/v1/topic/fetch` cannot be pointed at someone else's subscription, and a publish cannot claim
29
+ another sender. The `identity` param is required for that reason.
30
+
31
+ Authorization is two layers, and only the second one knows about topics: `accessRules` gate the
32
+ procedures (the default lets any authenticated DID call them), and the optional
33
+ `authorize(request)` hook decides publish and subscribe per topic. It takes one discriminated
34
+ `AuthorizeRequest` — narrow on `request.action` — and returns `AuthorizeDecision`, either a boolean
35
+ or `{ allow, reason?, code?, retryAfterMs? }`. `AuthorizeRequest` names six actions, but only
36
+ `publish` and `subscribe` are dispatched today. The default allows any authenticated DID.
37
+
38
+ ## Durable subscription, ephemeral connection
39
+
40
+ Subscription state lives in the store. `HubClientRegistry` holds only currently-connected clients and
41
+ their live `hub/v1/receive` writers, so it routes push fan-out and nothing else — a restart loses no
42
+ subscription.
43
+
44
+ Binding a receive channel **evicts** whatever held the lane for that DID. A reconnect happens because
45
+ the old connection broke and the server learns that last, so the stale writer must give way to the
46
+ live one, not the other way round. The evicted channel is resolved rather than thrown: being replaced
47
+ is not its error, and the client that replaced it is the same client.
48
+
49
+ `hub/v1/receive` is always added to the server's `longLivedProcedures`, so open mailbox channels are
50
+ exempt from `controllerTimeoutMs` and from the `maxConcurrentHandlers` cap. A host passing its own
51
+ `limits` does not need to remember this.
52
+
53
+ ## What live fan-out does and does not push
54
+
55
+ A **deduped** publish fans out to nobody. It appended nothing — the frame was already accepted and
56
+ already delivered to whoever was subscribed then, and its sequenceID may since have been acked and
57
+ its delivery row removed — so re-running the loop would push a frame every current subscriber has
58
+ already applied, named by a dead sequenceID. Fan-out is for a genuine append only.
59
+
60
+ The sender is excluded from its own fan-out. `hub/v1/topic/fetch` makes no such exclusion — a topic's
61
+ log holds every log-class frame including the caller's own — so a reader that must not see its own
62
+ messages twice filters them itself, as `@kumiai/rpc`'s drain does.
63
+
64
+ A pushed frame carries `logPosition` **only** when it is log-class, and the key is spread in rather
65
+ than assigned, because `logPosition: undefined` becomes a present key with a falsy value once it is
66
+ off the wire — and the entire point of the field is that a reader can tell "no place in any log" from
67
+ a place.
68
+
69
+ ## Retention: the store refuses, the hub schedules
70
+
71
+ Two different knobs, easily confused. `createHub`'s `purge.olderThan` (default 7 days) is the **age
72
+ bound** the scheduled sweep applies to a topic no subscriber asked to keep longer; the sweep runs on
73
+ `purge.interval` (default 1 hour) and stops with the server. The **ceiling** on what a subscriber may
74
+ request belongs to the store, because the store is what refuses the subscribe —
75
+ `createMemoryStore`'s is 30 days, finite by design, since an unbounded ceiling lets any subscriber
76
+ pin a topic's frames forever with `subscribe({ retention: 2 ** 31 })`. A subscribe above the ceiling
77
+ is refused with `RetentionExceededError`, never clamped.
78
+
79
+ `createMemoryStore`'s `maxDepth` (default 1000) evicts the oldest **log** frames beyond that count.
80
+ Mailbox frames on the same topic are not counted, so a member cannot evict the log with a mailbox
81
+ flood.
82
+
83
+ Publish rate limits are per DID (20/s, burst 50) and per topic (100/s, burst 200), merged over
84
+ `DEFAULT_RATE_LIMITS`; `hub/v1/keypackage/fetch` has its own request quota per requester DID.
package/lib/handlers.d.ts CHANGED
@@ -2,8 +2,61 @@ import { type ProcedureHandlers } from '@enkaku/server';
2
2
  import type { HubProtocol, HubStore } from '@kumiai/hub-protocol';
3
3
  import { type RateLimitConfig } from './rateLimit.js';
4
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>;
5
+ /**
6
+ * A single request to authorize. All six variants ship even though only `publish` and
7
+ * `subscribe` are currently enforced: the union is the exhaustive-switch surface a host's own
8
+ * `switch (req.action)` closes over, so adding a seventh variant later is the compatibility break
9
+ * this type exists to avoid.
10
+ *
11
+ * A host's `switch` need not handle every action today — an unrecognized action should default to
12
+ * allow, so a hook written before a new variant shipped doesn't silently start refusing a
13
+ * procedure that was previously ungated.
14
+ */
15
+ export type AuthorizeRequest = {
16
+ action: 'publish';
17
+ did: string;
18
+ topicID: string;
19
+ retain: 'log' | 'mailbox';
20
+ payloadSize: number;
21
+ } | {
22
+ action: 'subscribe';
23
+ did: string;
24
+ topicID: string;
25
+ retention?: number;
26
+ } | {
27
+ action: 'unsubscribe';
28
+ did: string;
29
+ topicID: string;
30
+ } | {
31
+ action: 'topic/fetch';
32
+ did: string;
33
+ topicID: string;
34
+ } | {
35
+ action: 'keypackage/upload';
36
+ did: string;
37
+ count: number;
38
+ } | {
39
+ action: 'keypackage/fetch';
40
+ did: string;
41
+ targetDID: string;
42
+ count: number;
43
+ };
44
+ /**
45
+ * A plain `boolean` is shorthand for `{ allow: boolean }`, with no reason, code, or retry hint.
46
+ *
47
+ * **`reason` is the only field a caller sees today.** `code` and `retryAfterMs` are reserved for
48
+ * enforcement that hasn't been built yet — a hook may return them, but both are silently dropped
49
+ * before the refusal reaches the client. They ship now because widening this union later is the
50
+ * break the surface exists to avoid; until enforcement lands, don't depend on either reaching a
51
+ * caller.
52
+ */
53
+ export type AuthorizeDecision = boolean | {
54
+ allow: boolean;
55
+ reason?: string;
56
+ code?: string;
57
+ retryAfterMs?: number;
58
+ };
59
+ export type AuthorizeHook = (req: AuthorizeRequest) => AuthorizeDecision | Promise<AuthorizeDecision>;
7
60
  export type HubRateLimits = {
8
61
  perDID: RateLimitConfig;
9
62
  perTopic: RateLimitConfig;
package/lib/handlers.js CHANGED
@@ -2,6 +2,11 @@ import { HandlerError } from '@enkaku/server';
2
2
  import { hubErrorCodeOf } from '@kumiai/hub-protocol';
3
3
  import { fromB64, toB64 } from '@sozai/codec';
4
4
  import { createRateLimiter } from './rateLimit.js';
5
+ function normalizeAuthorizeDecision(decision) {
6
+ return typeof decision === 'boolean' ? {
7
+ allow: decision
8
+ } : decision;
9
+ }
5
10
  export const DEFAULT_RATE_LIMITS = {
6
11
  perDID: {
7
12
  rate: 20,
@@ -81,13 +86,32 @@ export function createHandlers(params) {
81
86
  window.count++;
82
87
  }
83
88
  return {
84
- 'hub/publish': async (ctx)=>{
89
+ 'hub/v1/publish': async (ctx)=>{
85
90
  const { topicID, payload } = ctx.param;
86
91
  const senderDID = getClientDID(ctx);
87
- if (!await authorize(senderDID, 'publish', topicID)) {
92
+ // Decoded before authorize (not after) so the hook can see the real size. Two consequences
93
+ // are deliberate: an unauthorized caller sending malformed base64 gets a decode error instead
94
+ // of EK02, and decoding runs before both rate limiters, so oversized payloads are decoded
95
+ // before being throttled (bounded by the transport's own message limit). Deriving the size
96
+ // arithmetically from the base64 length instead was rejected — it drifts from
97
+ // `@sozai/codec`'s actual decoding and can hand the hook a wrong number.
98
+ //
99
+ // `retain` defaults to 'mailbox' here, matching the store's own default (`memoryStore.ts`) —
100
+ // the hook must never see an absent retain, since `AuthorizeRequest`'s `publish` variant
101
+ // declares it required.
102
+ const payloadBytes = fromB64(payload);
103
+ const retain = ctx.param.retain ?? 'mailbox';
104
+ const decision = normalizeAuthorizeDecision(await authorize({
105
+ action: 'publish',
106
+ did: senderDID,
107
+ topicID,
108
+ retain,
109
+ payloadSize: payloadBytes.length
110
+ }));
111
+ if (!decision.allow) {
88
112
  throw new HandlerError({
89
113
  code: 'EK02',
90
- message: 'Not authorized to publish to topic'
114
+ message: decision.reason ?? 'Not authorized to publish to topic'
91
115
  });
92
116
  }
93
117
  if (!didLimiter.tryConsume(senderDID)) {
@@ -102,7 +126,6 @@ export function createHandlers(params) {
102
126
  message: 'Publish rate limit exceeded for topic'
103
127
  });
104
128
  }
105
- const payloadBytes = fromB64(payload);
106
129
  let sequenceID;
107
130
  let deduped;
108
131
  try {
@@ -123,11 +146,16 @@ export function createHandlers(params) {
123
146
  } catch (error) {
124
147
  rethrowAsHandlerError(error);
125
148
  }
126
- // A deduped publish appended nothing: the frame was already accepted, already fanned out to
127
- // whoever was subscribed then, and its sequenceID may since have been acked and its delivery
128
- // row removed. Re-running the loop would push a frame every current subscriber has already
129
- // applied, named by a dead sequenceID. Live fan-out is for a genuine append only.
149
+ // A deduped publish appended nothing: already fanned out to whoever was subscribed then, and
150
+ // its sequenceID may since be acked and its delivery row gone. Re-running would push a frame
151
+ // every current subscriber already has, named by a dead sequenceID.
130
152
  if (!deduped) {
153
+ // Only a log-class publish carries a logPosition. This sequenceID IS the log position (the
154
+ // store just minted it for this accepted append); a mailbox publish has none, since its
155
+ // recipient's own delivery-queue position isn't usable as a durable log cursor.
156
+ const logPosition = ctx.param.retain === 'log' ? {
157
+ logPosition: sequenceID
158
+ } : {};
131
159
  // Live-deliver to currently-connected subscribers (minus the sender).
132
160
  const subscribers = await store.getSubscribers(topicID);
133
161
  for (const recipientDID of subscribers){
@@ -138,7 +166,8 @@ export function createHandlers(params) {
138
166
  sequenceID,
139
167
  senderDID,
140
168
  topicID,
141
- payload: payloadBytes
169
+ payload: payloadBytes,
170
+ ...logPosition
142
171
  });
143
172
  }
144
173
  }
@@ -147,20 +176,26 @@ export function createHandlers(params) {
147
176
  sequenceID
148
177
  };
149
178
  },
150
- 'hub/subscribe': async (ctx)=>{
151
- const { topicID } = ctx.param;
179
+ 'hub/v1/subscribe': async (ctx)=>{
180
+ const { topicID, retention } = ctx.param;
152
181
  const clientDID = getClientDID(ctx);
153
- if (!await authorize(clientDID, 'subscribe', topicID)) {
182
+ const decision = normalizeAuthorizeDecision(await authorize({
183
+ action: 'subscribe',
184
+ did: clientDID,
185
+ topicID,
186
+ retention
187
+ }));
188
+ if (!decision.allow) {
154
189
  throw new HandlerError({
155
190
  code: 'EK02',
156
- message: 'Not authorized to subscribe to topic'
191
+ message: decision.reason ?? 'Not authorized to subscribe to topic'
157
192
  });
158
193
  }
159
194
  try {
160
195
  await store.subscribe({
161
196
  subscriberDID: clientDID,
162
197
  topicID,
163
- retention: ctx.param.retention
198
+ retention
164
199
  });
165
200
  } catch (error) {
166
201
  rethrowAsHandlerError(error);
@@ -169,10 +204,10 @@ export function createHandlers(params) {
169
204
  subscribed: true
170
205
  };
171
206
  },
172
- 'hub/topic/fetch': async (ctx)=>{
207
+ 'hub/v1/topic/fetch': async (ctx)=>{
173
208
  const { topicID, after, limit } = ctx.param;
174
- // The subscriber is the authenticated caller, taken from the verified issuer of the signed
175
- // message — never a wire field, or any member could read a topic's log by naming another.
209
+ // subscriberDID is the authenticated caller (verified issuer), never a wire field — or any
210
+ // member could read another's topic log by naming them.
176
211
  const subscriberDID = getClientDID(ctx);
177
212
  try {
178
213
  const result = await store.fetchTopic({
@@ -195,7 +230,7 @@ export function createHandlers(params) {
195
230
  rethrowAsHandlerError(error);
196
231
  }
197
232
  },
198
- 'hub/unsubscribe': async (ctx)=>{
233
+ 'hub/v1/unsubscribe': async (ctx)=>{
199
234
  const { topicID } = ctx.param;
200
235
  const clientDID = getClientDID(ctx);
201
236
  await store.unsubscribe(clientDID, topicID);
@@ -203,27 +238,40 @@ export function createHandlers(params) {
203
238
  unsubscribed: true
204
239
  };
205
240
  },
206
- 'hub/receive': async (ctx)=>{
241
+ 'hub/v1/receive': async (ctx)=>{
207
242
  const clientDID = getClientDID(ctx);
208
243
  const { after } = ctx.param ?? {};
209
244
  registry.register(clientDID);
210
- if (registry.isWriterBound(clientDID)) {
211
- throw new HandlerError({
212
- code: 'EK01',
213
- message: `receive writer already bound for DID ${clientDID}`
214
- });
215
- }
216
245
  const writer = ctx.writable.getWriter();
217
246
  const reader = ctx.readable.getReader();
247
+ // Ends this channel when a newer one for the same DID takes the lane. Resolved, not thrown —
248
+ // replacement isn't this channel's error, and it's the same client that replaced it.
249
+ let endEvicted;
250
+ const evictedHere = new Promise((resolve)=>{
251
+ endEvicted = resolve;
252
+ });
253
+ let receiveToken;
218
254
  try {
219
- registry.setReceiveWriter(clientDID, (message)=>{
255
+ // Binding evicts whatever held the lane: a reconnect means the old connection broke and the
256
+ // server learns last, so the stale writer must give way to the live one. See
257
+ // `HubClientRegistry.bindReceiveWriter`.
258
+ const { token, evicted } = registry.bindReceiveWriter(clientDID, (message)=>{
220
259
  writer.write({
221
260
  sequenceID: message.sequenceID,
222
261
  senderDID: message.senderDID,
223
262
  topicID: message.topicID,
224
- payload: toB64(message.payload)
263
+ payload: toB64(message.payload),
264
+ // Spread, not plain assignment — `logPosition: undefined` becomes a present key
265
+ // once serialized, defeating the field's whole point (telling absent from present).
266
+ ...message.logPosition != null ? {
267
+ logPosition: message.logPosition
268
+ } : {}
225
269
  }).catch(()=>{});
226
- });
270
+ }, ()=>endEvicted?.());
271
+ receiveToken = token;
272
+ // After the bind, so the lane is never unheld between the two — a frame arriving in that
273
+ // gap would have nowhere to go, and mailbox frames drop when nobody's there.
274
+ evicted?.();
227
275
  let cursor = after;
228
276
  while(true){
229
277
  const result = await store.fetch({
@@ -236,19 +284,23 @@ export function createHandlers(params) {
236
284
  sequenceID: msg.sequenceID,
237
285
  senderDID: msg.senderDID,
238
286
  topicID: msg.topicID,
239
- payload: toB64(msg.payload)
287
+ payload: toB64(msg.payload),
288
+ ...msg.logPosition != null ? {
289
+ logPosition: msg.logPosition
290
+ } : {}
240
291
  });
241
292
  }
242
293
  cursor = result.cursor;
243
294
  if (!result.hasMore) break;
244
295
  }
245
296
  } catch (error) {
246
- registry.clearReceiveWriter(clientDID);
297
+ if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
247
298
  registry.unregisterIfIdle(clientDID);
248
299
  reader.cancel().catch(()=>{});
249
300
  writer.abort(error).catch(()=>{});
250
301
  throw error;
251
302
  }
303
+ const token = receiveToken;
252
304
  void (async ()=>{
253
305
  try {
254
306
  while(true){
@@ -266,18 +318,23 @@ export function createHandlers(params) {
266
318
  }
267
319
  })();
268
320
  return new Promise((resolve)=>{
269
- ctx.signal.addEventListener('abort', ()=>{
270
- registry.clearReceiveWriter(clientDID);
321
+ const finish = ()=>{
322
+ // Token-scoped, so an already-evicted channel can't unbind the one that replaced it.
323
+ if (token != null) registry.releaseReceiveWriter(clientDID, token);
271
324
  registry.unregisterIfIdle(clientDID);
272
325
  reader.cancel().catch(()=>{});
273
326
  writer.close().catch(()=>{});
274
327
  resolve(undefined);
275
- }, {
328
+ };
329
+ // Evicted by a newer channel: end this one; `finish` releases only its own token, which is
330
+ // no longer current.
331
+ void evictedHere.then(finish);
332
+ ctx.signal.addEventListener('abort', finish, {
276
333
  once: true
277
334
  });
278
335
  });
279
336
  },
280
- 'hub/keypackage/upload': async (ctx)=>{
337
+ 'hub/v1/keypackage/upload': async (ctx)=>{
281
338
  const { keyPackages } = ctx.param;
282
339
  const clientDID = getClientDID(ctx);
283
340
  await Promise.all(keyPackages.map((kp)=>store.storeKeyPackage(clientDID, kp)));
@@ -285,7 +342,7 @@ export function createHandlers(params) {
285
342
  stored: keyPackages.length
286
343
  };
287
344
  },
288
- 'hub/keypackage/fetch': async (ctx)=>{
345
+ 'hub/v1/keypackage/fetch': async (ctx)=>{
289
346
  const requesterDID = getClientDID(ctx);
290
347
  assertKeyPackageFetchAllowed(requesterDID);
291
348
  const { did, count } = ctx.param;
package/lib/hub.d.ts CHANGED
@@ -34,12 +34,15 @@ export type CreateHubParams = {
34
34
  authorize?: AuthorizeHook;
35
35
  /** Publish rate limits. Merged over {@link DEFAULT_RATE_LIMITS}. */
36
36
  rateLimits?: Partial<HubRateLimits>;
37
- /** Quotas applied to hub/keypackage/fetch. Merged over {@link DEFAULT_KEYPACKAGE_FETCH_LIMITS}. */
37
+ /**
38
+ * Quotas applied to hub/v1/keypackage/fetch. Merged over
39
+ * {@link DEFAULT_KEYPACKAGE_FETCH_LIMITS}.
40
+ */
38
41
  keyPackageFetchLimits?: Partial<KeyPackageFetchLimits>;
39
42
  /** Scheduled purge of expired stored messages. Set to `false` to disable. */
40
43
  purge?: HubPurgeOptions | false;
41
44
  /**
42
- * Server resource limits. `hub/receive` is always added to
45
+ * Server resource limits. `hub/v1/receive` is always added to
43
46
  * `longLivedProcedures` so open mailbox channels are exempt from
44
47
  * `controllerTimeoutMs` and from the `maxConcurrentHandlers` cap.
45
48
  */
package/lib/hub.js CHANGED
@@ -25,7 +25,7 @@ export function createHub(params) {
25
25
  longLivedProcedures: [
26
26
  ...new Set([
27
27
  ...params.limits?.longLivedProcedures ?? [],
28
- 'hub/receive'
28
+ 'hub/v1/receive'
29
29
  ])
30
30
  ]
31
31
  };
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { AuthorizeAction, AuthorizeHook, CreateHandlersParams, HubRateLimits, KeyPackageFetchLimits, } from './handlers.js';
1
+ export type { AuthorizeDecision, AuthorizeHook, AuthorizeRequest, CreateHandlersParams, HubRateLimits, KeyPackageFetchLimits, } from './handlers.js';
2
2
  export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS } from './handlers.js';
3
3
  export type { CreateHubParams, HubInstance, HubPurgeOptions } from './hub.js';
4
4
  export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
@@ -5,6 +5,27 @@ const DEFAULT_MAX_DEPTH = 1000;
5
5
  function formatSequenceID(counter) {
6
6
  return String(counter).padStart(12, '0');
7
7
  }
8
+ /**
9
+ * The entry as a reader sees it, with the ONE piece of routing metadata a reader cannot recover for
10
+ * itself: where the frame sits in its topic's log.
11
+ *
12
+ * Present iff the entry is log-class, and the key is left off entirely otherwise — a mailbox frame
13
+ * has no place in a log, and a falsy placeholder is a position a reader's cursor would move to,
14
+ * skipping every log frame below it. This store mints one sequence for both, so the log position of
15
+ * a log-class frame IS its sequenceID here; a store that mints a separate per-recipient delivery
16
+ * sequence reports its log's own value, and the conformance suite pins the equality that matters —
17
+ * a pushed log frame's `logPosition` is the position `fetchTopic` serves that same frame at.
18
+ */ function asStoredMessage(entry) {
19
+ return {
20
+ sequenceID: entry.sequenceID,
21
+ senderDID: entry.senderDID,
22
+ topicID: entry.topicID,
23
+ payload: entry.payload,
24
+ ...entry.retain === 'log' ? {
25
+ logPosition: entry.sequenceID
26
+ } : {}
27
+ };
28
+ }
8
29
  /**
9
30
  * In-memory HubStore for testing and development.
10
31
  *
@@ -224,12 +245,7 @@ function formatSequenceID(counter) {
224
245
  for (const sequenceID of selected){
225
246
  const entry = entries.get(sequenceID);
226
247
  if (entry != null) {
227
- resultMessages.push({
228
- sequenceID: entry.sequenceID,
229
- senderDID: entry.senderDID,
230
- topicID: entry.topicID,
231
- payload: entry.payload
232
- });
248
+ resultMessages.push(asStoredMessage(entry));
233
249
  }
234
250
  }
235
251
  const cursor = resultMessages.length > 0 ? resultMessages[resultMessages.length - 1].sequenceID : null;
@@ -262,12 +278,7 @@ function formatSequenceID(counter) {
262
278
  for (const sequenceID of limited){
263
279
  const entry = entries.get(sequenceID);
264
280
  if (entry != null) {
265
- messages.push({
266
- sequenceID: entry.sequenceID,
267
- senderDID: entry.senderDID,
268
- topicID: entry.topicID,
269
- payload: entry.payload
270
- });
281
+ messages.push(asStoredMessage(entry));
271
282
  }
272
283
  }
273
284
  return {
package/lib/registry.d.ts CHANGED
@@ -2,6 +2,10 @@ import type { StoredMessage } from '@kumiai/hub-protocol';
2
2
  export type ClientEntry = {
3
3
  did: string;
4
4
  sendMessage: ((message: StoredMessage) => void) | null;
5
+ /** Ends the currently bound receive channel. Set with the writer and cleared with it. */
6
+ endReceive: (() => void) | null;
7
+ /** Identifies the binding, so a channel can only ever release its OWN. */
8
+ token: symbol | null;
5
9
  };
6
10
  /**
7
11
  * Tracks online clients and their live receive-channel writers. Subscription
@@ -14,8 +18,33 @@ export declare class HubClientRegistry {
14
18
  unregister(did: string): void;
15
19
  /** Removes the entry only when no receive writer is bound. */
16
20
  unregisterIfIdle(did: string): void;
17
- setReceiveWriter(did: string, writer: (message: StoredMessage) => void): void;
18
- clearReceiveWriter(did: string): void;
21
+ /**
22
+ * Bind this DID's receive writer, EVICTING whatever was bound before it.
23
+ *
24
+ * One writer per DID, and the newest one wins. The alternative — refuse the second — reads as
25
+ * the safer rule and is not: a client whose socket died has a writer the server still believes
26
+ * in, because nothing has told it otherwise yet, and that belief outlives the socket by however
27
+ * long the transport takes to notice. Refusing on it means the reconnect that the client makes
28
+ * precisely BECAUSE its connection dropped is the one that gets turned away, and the member has
29
+ * no push lane until a timeout it cannot see or influence expires. Last-writer-wins costs a
30
+ * live connection only when the same authenticated DID opens a second channel, which is the
31
+ * same client and its own business.
32
+ *
33
+ * The eviction is handed back rather than run here: ending a channel is the handler's to do,
34
+ * and a registry that closed streams would be doing two jobs.
35
+ *
36
+ * The TOKEN is what keeps the eviction from unbinding its successor. The evicted handler runs
37
+ * its own cleanup afterwards — it does not know why it ended — and a `clear(did)` there would
38
+ * null the writer the new channel has just bound, leaving the DID online with nothing behind
39
+ * it. So a release names the binding it is releasing, and one that is no longer current is a
40
+ * no-op.
41
+ */
42
+ bindReceiveWriter(did: string, writer: (message: StoredMessage) => void, endReceive: () => void): {
43
+ token: symbol;
44
+ evicted: (() => void) | null;
45
+ };
46
+ /** Clear the binding this token names. A token that is no longer current is a no-op. */
47
+ releaseReceiveWriter(did: string, token: symbol): void;
19
48
  getClient(did: string): ClientEntry | undefined;
20
49
  isOnline(did: string): boolean;
21
50
  isWriterBound(did: string): boolean;
package/lib/registry.js CHANGED
@@ -11,7 +11,9 @@
11
11
  }
12
12
  const entry = {
13
13
  did,
14
- sendMessage: null
14
+ sendMessage: null,
15
+ endReceive: null,
16
+ token: null
15
17
  };
16
18
  this.#clients.set(did, entry);
17
19
  return entry;
@@ -25,19 +27,48 @@
25
27
  this.#clients.delete(did);
26
28
  }
27
29
  }
28
- setReceiveWriter(did, writer) {
30
+ /**
31
+ * Bind this DID's receive writer, EVICTING whatever was bound before it.
32
+ *
33
+ * One writer per DID, and the newest one wins. The alternative — refuse the second — reads as
34
+ * the safer rule and is not: a client whose socket died has a writer the server still believes
35
+ * in, because nothing has told it otherwise yet, and that belief outlives the socket by however
36
+ * long the transport takes to notice. Refusing on it means the reconnect that the client makes
37
+ * precisely BECAUSE its connection dropped is the one that gets turned away, and the member has
38
+ * no push lane until a timeout it cannot see or influence expires. Last-writer-wins costs a
39
+ * live connection only when the same authenticated DID opens a second channel, which is the
40
+ * same client and its own business.
41
+ *
42
+ * The eviction is handed back rather than run here: ending a channel is the handler's to do,
43
+ * and a registry that closed streams would be doing two jobs.
44
+ *
45
+ * The TOKEN is what keeps the eviction from unbinding its successor. The evicted handler runs
46
+ * its own cleanup afterwards — it does not know why it ended — and a `clear(did)` there would
47
+ * null the writer the new channel has just bound, leaving the DID online with nothing behind
48
+ * it. So a release names the binding it is releasing, and one that is no longer current is a
49
+ * no-op.
50
+ */ bindReceiveWriter(did, writer, endReceive) {
29
51
  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
- }
52
+ if (entry == null) return {
53
+ token: Symbol('unbound'),
54
+ evicted: null
55
+ };
56
+ const evicted = entry.endReceive;
57
+ const token = Symbol('receive');
34
58
  entry.sendMessage = writer;
59
+ entry.endReceive = endReceive;
60
+ entry.token = token;
61
+ return {
62
+ token,
63
+ evicted
64
+ };
35
65
  }
36
- clearReceiveWriter(did) {
66
+ /** Clear the binding this token names. A token that is no longer current is a no-op. */ releaseReceiveWriter(did, token) {
37
67
  const entry = this.#clients.get(did);
38
- if (entry != null) {
39
- entry.sendMessage = null;
40
- }
68
+ if (entry == null || entry.token !== token) return;
69
+ entry.sendMessage = null;
70
+ entry.endReceive = null;
71
+ entry.token = null;
41
72
  }
42
73
  getClient(did) {
43
74
  return this.#clients.get(did);
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-server",
3
- "version": "0.3.0",
4
- "license": "MIT",
3
+ "version": "0.4.0",
5
4
  "description": "Hub server for Enkaku E2EE group message routing",
6
5
  "keywords": [
7
6
  "hub",
@@ -14,38 +13,37 @@
14
13
  "url": "https://github.com/TairuFramework/kumiai",
15
14
  "directory": "packages/hub-server"
16
15
  },
16
+ "license": "MIT",
17
+ "sideEffects": false,
17
18
  "type": "module",
18
- "main": "lib/index.js",
19
- "types": "lib/index.d.ts",
20
19
  "exports": {
21
20
  ".": "./lib/index.js"
22
21
  },
22
+ "main": "lib/index.js",
23
+ "types": "lib/index.d.ts",
23
24
  "files": [
24
25
  "lib/*"
25
26
  ],
26
- "sideEffects": false,
27
27
  "dependencies": {
28
- "@sozai/codec": "^0.2.0",
29
- "@sozai/event": "^0.1.1",
30
28
  "@enkaku/protocol": "^0.19.0",
31
29
  "@enkaku/server": "^0.19.0",
32
- "@sozai/stream": "^0.2.0",
33
- "@kokuin/token": "^0.2.0",
34
- "@kumiai/hub-protocol": "^0.3.0",
35
- "@kumiai/mls": "^0.3.0"
30
+ "@kokuin/token": "^0.3.0",
31
+ "@sozai/codec": "^0.2.0",
32
+ "@sozai/event": "^0.1.1",
33
+ "@kumiai/hub-protocol": "^0.4.0"
36
34
  },
37
35
  "devDependencies": {
38
36
  "@enkaku/client": "^0.19.0",
39
37
  "@enkaku/transport": "^0.19.1",
40
- "@kumiai/hub-conformance": "^0.3.0"
38
+ "@kumiai/hub-conformance": "^0.4.0"
41
39
  },
42
40
  "scripts": {
41
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
43
42
  "build:clean": "del lib",
44
43
  "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
45
44
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
46
- "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
45
+ "test": "pnpm run test:types && pnpm run test:unit",
47
46
  "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
48
- "test:unit": "vitest run",
49
- "test": "pnpm run test:types && pnpm run test:unit"
47
+ "test:unit": "vitest run"
50
48
  }
51
49
  }