@kumiai/hub-server 0.12.0 → 0.12.1

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 CHANGED
@@ -13,6 +13,7 @@ client registry for push fan-out, and an in-memory `HubStore` for development an
13
13
  development; it is also what runs the `@kumiai/hub-conformance` suites in this repo.
14
14
  - `HubClientRegistry` — the live-connection table.
15
15
  - `createRateLimiter`, `DEFAULT_RATE_LIMITS`, `DEFAULT_KEYPACKAGE_FETCH_LIMITS`,
16
+ `DEFAULT_RECEIVE_WINDOW`, `DEFAULT_RECEIVE_WINDOW_BYTES`, `DEFAULT_RECEIVE_ACK_TIMEOUT`,
16
17
  `DEFAULT_HUB_ACCESS_RULES`.
17
18
  - `HubStoreErrorEvent`, `HubStoreErrorHook` — the `onStoreError` event and hook types.
18
19
 
@@ -74,6 +75,34 @@ than assigned, because `logPosition: undefined` becomes a present key with a fal
74
75
  off the wire — and the entire point of the field is that a reader can tell "no place in any log" from
75
76
  a place.
76
77
 
78
+ ## The receive channel is paced by acks
79
+
80
+ `hub/v1/receive` writes a frame only while the frames in flight on that channel stay under a window:
81
+ `receiveWindow` frames (`DEFAULT_RECEIVE_WINDOW`, 256) and `receiveWindowBytes` bytes
82
+ (`DEFAULT_RECEIVE_WINDOW_BYTES`, 512 KiB, counting the base64 payload plus an envelope estimate; one
83
+ frame always goes through when nothing else is in flight). The backlog drain and live pushes share
84
+ the window, and acks are read from the moment the channel opens, so the drain advances as the client
85
+ acks it.
86
+
87
+ The transport cannot do this pacing: `writer.ready` on a channel says nothing about the HTTP
88
+ bridge's per-session buffer, which `@enkaku/http-serve` drops whole once it passes
89
+ `maxSessionBufferBytes`. Unpaced, a backlog larger than that buffer, read by a slow client, ended
90
+ the session on every connect before any ack reached the hub, and the next connect redelivered the
91
+ same backlog. Keep the byte window well below the transport's session buffer.
92
+
93
+ The channel is ordered, so an ack for a frame proves the client has read every frame written before
94
+ it: for the window, an ack releases that frame and all earlier ones. The store still frees only the
95
+ deliveries actually acked. A client can leave frames unacked for good — a topic it subscribed to but
96
+ reads with `hub/v1/topic/fetch`, or one no consumer on the device claims — and those frames must not
97
+ hold the window, or they would come back first on every connect and stall it.
98
+
99
+ A full window that sees no ack at all for `receiveAckTimeout` ms (`DEFAULT_RECEIVE_ACK_TIMEOUT`,
100
+ 15 s; each ack restarts it) is released whole and the drain goes on; the channel is not ended,
101
+ because a window made only of never-acked frames would come back first on the next connect too. A
102
+ client that has stopped reading cannot grow a buffer without bound: the HTTP bridge drops its
103
+ session, and a transport with real backpressure stalls the write until `receiveBufferLimit` tears
104
+ the channel down.
105
+
77
106
  ## Retention: the store refuses, the hub schedules
78
107
 
79
108
  Two different knobs, easily confused. `createHub`'s `purge.olderThan` (default 7 days) is the **age
package/lib/handlers.d.ts CHANGED
@@ -166,6 +166,34 @@ export declare const DEFAULT_RECEIVE_BUFFER_LIMIT = 256;
166
166
  * maximum window a removed member can keep draining a topic. See the receive handler's `gate`.
167
167
  */
168
168
  export declare const DEFAULT_RECEIVE_AUTH_CACHE_TTL = 5000;
169
+ /**
170
+ * Max frames in flight on a receive channel: written, and not yet followed by an ack for that frame
171
+ * or a later one. The hub writes the next frame only once acks bring the count back under this
172
+ * window.
173
+ *
174
+ * Without it the drain writes a whole backlog at once, and a transport that buffers per session
175
+ * (the SSE bridge drops a session whose unread buffer passes `maxSessionBufferBytes`) ends the
176
+ * session before the slow client's acks reach the hub: every connect then redelivers the same
177
+ * backlog. The window must stay well above the client's ack batch (10 for Kubun's device client)
178
+ * so batched acks keep the pipe full.
179
+ */
180
+ export declare const DEFAULT_RECEIVE_WINDOW = 256;
181
+ /**
182
+ * Max bytes (base64 payload plus an estimate of the frame's envelope) in flight on a receive
183
+ * channel. Kept well below the HTTP transport's per-session buffer (1 MiB by default in `@enkaku/http-serve`, 8 MiB
184
+ * on Kubun's relay) so a window of large frames cannot overflow it. A single frame is always let
185
+ * through when nothing else is in flight, whatever its size.
186
+ */
187
+ export declare const DEFAULT_RECEIVE_WINDOW_BYTES: number;
188
+ /**
189
+ * How long (ms) a receive channel with a full window waits without any ack before it releases the
190
+ * whole window and writes on. Each ack restarts the wait. The channel is NOT ended: a window full
191
+ * of frames the client will never ack (topics no sink on the device claims) would come back first
192
+ * on every reconnect and livelock it. Long enough for a busy phone to open the first frames of a
193
+ * window and flush an ack batch (10 frames or 500 ms on Kubun's device client); short, because it
194
+ * is the delay a window of never-acked frames costs per connect.
195
+ */
196
+ export declare const DEFAULT_RECEIVE_ACK_TIMEOUT = 15000;
169
197
  export type CreateHandlersParams = {
170
198
  registry: HubClientRegistry;
171
199
  store: HubStore;
@@ -175,6 +203,19 @@ export type CreateHandlersParams = {
175
203
  /** Max frames queued-but-unflushed on a receive channel. See {@link DEFAULT_RECEIVE_BUFFER_LIMIT}
176
204
  * for the >= 50-frame-page floor this should respect. Default: 256 */
177
205
  receiveBufferLimit?: number;
206
+ /** Max frames in flight on a receive channel. Default: {@link DEFAULT_RECEIVE_WINDOW} */
207
+ receiveWindow?: number;
208
+ /**
209
+ * Max bytes (payload plus envelope estimate) in flight on a receive channel. Default:
210
+ * {@link DEFAULT_RECEIVE_WINDOW_BYTES}
211
+ */
212
+ receiveWindowBytes?: number;
213
+ /**
214
+ * How long (ms) a receive channel with a full window waits for an ack before it releases the
215
+ * window and writes on.
216
+ * Default: {@link DEFAULT_RECEIVE_ACK_TIMEOUT}
217
+ */
218
+ receiveAckTimeout?: number;
178
219
  /**
179
220
  * TTL (ms) for the per-(did, topicID) receive delivery-authorization cache. `0` disables reuse
180
221
  * (the hook is consulted for every frame). Non-finite or negative values fall back to
package/lib/handlers.js CHANGED
@@ -91,6 +91,31 @@ export const DEFAULT_KEYPACKAGE_FETCH_LIMITS = {
91
91
  * Bounds how long an already-resolved allow is reused before the hook is re-consulted — i.e. the
92
92
  * maximum window a removed member can keep draining a topic. See the receive handler's `gate`.
93
93
  */ export const DEFAULT_RECEIVE_AUTH_CACHE_TTL = 5000;
94
+ /**
95
+ * Max frames in flight on a receive channel: written, and not yet followed by an ack for that frame
96
+ * or a later one. The hub writes the next frame only once acks bring the count back under this
97
+ * window.
98
+ *
99
+ * Without it the drain writes a whole backlog at once, and a transport that buffers per session
100
+ * (the SSE bridge drops a session whose unread buffer passes `maxSessionBufferBytes`) ends the
101
+ * session before the slow client's acks reach the hub: every connect then redelivers the same
102
+ * backlog. The window must stay well above the client's ack batch (10 for Kubun's device client)
103
+ * so batched acks keep the pipe full.
104
+ */ export const DEFAULT_RECEIVE_WINDOW = 256;
105
+ /**
106
+ * Max bytes (base64 payload plus an estimate of the frame's envelope) in flight on a receive
107
+ * channel. Kept well below the HTTP transport's per-session buffer (1 MiB by default in `@enkaku/http-serve`, 8 MiB
108
+ * on Kubun's relay) so a window of large frames cannot overflow it. A single frame is always let
109
+ * through when nothing else is in flight, whatever its size.
110
+ */ export const DEFAULT_RECEIVE_WINDOW_BYTES = 512 * 1024;
111
+ /**
112
+ * How long (ms) a receive channel with a full window waits without any ack before it releases the
113
+ * whole window and writes on. Each ack restarts the wait. The channel is NOT ended: a window full
114
+ * of frames the client will never ack (topics no sink on the device claims) would come back first
115
+ * on every reconnect and livelock it. Long enough for a busy phone to open the first frames of a
116
+ * window and flush an ack batch (10 frames or 500 ms on Kubun's device client); short, because it
117
+ * is the delay a window of never-acked frames costs per connect.
118
+ */ export const DEFAULT_RECEIVE_ACK_TIMEOUT = 15_000;
94
119
  function getClientDID(ctx) {
95
120
  const iss = ctx.message.payload.iss;
96
121
  if (typeof iss !== 'string' || iss.length === 0) {
@@ -149,6 +174,9 @@ export function createHandlers(params) {
149
174
  const topicLimiter = createRateLimiter(rateLimits.perTopic);
150
175
  const receiveBufferLimit = params.receiveBufferLimit ?? DEFAULT_RECEIVE_BUFFER_LIMIT;
151
176
  const receiveAuthCacheTTL = resolveReceiveAuthCacheTTL(params.receiveAuthCacheTTL);
177
+ const receiveWindow = Math.max(1, params.receiveWindow ?? DEFAULT_RECEIVE_WINDOW);
178
+ const receiveWindowBytes = params.receiveWindowBytes ?? DEFAULT_RECEIVE_WINDOW_BYTES;
179
+ const receiveAckTimeout = params.receiveAckTimeout ?? DEFAULT_RECEIVE_ACK_TIMEOUT;
152
180
  const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
153
181
  const fetchLimits = {
154
182
  ...DEFAULT_KEYPACKAGE_FETCH_LIMITS,
@@ -494,6 +522,73 @@ export function createHandlers(params) {
494
522
  ;
495
523
  let tornDown = false;
496
524
  let writeChain = Promise.resolve();
525
+ // Ack window (flow control). `writer.ready` says nothing about the transport's per-session
526
+ // buffer (the HTTP bridge's writable is shared by every session and resolves at once), so the
527
+ // hub paces itself on acks instead: at most `receiveWindow` frames and `receiveWindowBytes`
528
+ // bytes are in flight, i.e. written and not yet known to have reached the client. Denied frames
529
+ // are never written, so never counted.
530
+ //
531
+ // The channel is ordered, so an ack for one frame proves the client has read every frame
532
+ // written before it: an ack releases that frame and all earlier ones from the window. A
533
+ // client may leave some frames unacked for good (a topic no sink on the device claims, a sink
534
+ // torn down mid-session); they stay pending in the store but cannot hold the window. This is
535
+ // window accounting only — the store still frees exactly the deliveries acked.
536
+ const inFlight = new Map() // sequenceID -> counted bytes, in write order
537
+ ;
538
+ let inFlightBytes = 0;
539
+ let onWindowChange = null;
540
+ const hasRoom = (bytes)=>inFlight.size === 0 || inFlight.size < receiveWindow && inFlightBytes + bytes <= receiveWindowBytes;
541
+ function releaseAll() {
542
+ inFlight.clear();
543
+ inFlightBytes = 0;
544
+ }
545
+ // Resolves once the frame fits or the channel is torn down. A full window that sees no ack for
546
+ // `receiveAckTimeout` ms is released whole and the drain goes on: the frames in it may be ones
547
+ // the client will never ack, and ending the channel would only redeliver them first on the
548
+ // next connect. A client that has stopped reading is not left to grow a buffer: the HTTP
549
+ // bridge drops a session that falls `maxSessionBufferBytes` behind, and a transport with real
550
+ // backpressure stalls `writer.write` until `receiveBufferLimit` tears the channel down.
551
+ function waitForRoom(bytes) {
552
+ if (tornDown || hasRoom(bytes)) return Promise.resolve();
553
+ return new Promise((resolve)=>{
554
+ let timer;
555
+ const arm = ()=>{
556
+ clearTimeout(timer);
557
+ timer = setTimeout(()=>{
558
+ releaseAll();
559
+ notifyWindow();
560
+ }, receiveAckTimeout);
561
+ };
562
+ onWindowChange = ()=>{
563
+ if (tornDown || hasRoom(bytes)) {
564
+ clearTimeout(timer);
565
+ onWindowChange = null;
566
+ resolve();
567
+ } else {
568
+ arm();
569
+ }
570
+ };
571
+ arm();
572
+ });
573
+ }
574
+ function notifyWindow() {
575
+ onWindowChange?.();
576
+ }
577
+ function release(sequenceIDs) {
578
+ const acked = new Set(sequenceIDs);
579
+ // The acked frame written last, in write order (an ack batch need not be sorted).
580
+ let last;
581
+ for (const id of inFlight.keys()){
582
+ if (acked.has(id)) last = id;
583
+ }
584
+ if (last === undefined) return;
585
+ for (const [id, bytes] of inFlight){
586
+ inFlight.delete(id);
587
+ inFlightBytes -= bytes;
588
+ if (id === last) break;
589
+ }
590
+ notifyWindow();
591
+ }
497
592
  let resolveDone = ()=>{};
498
593
  const done = new Promise((resolve)=>{
499
594
  resolveDone = resolve;
@@ -503,6 +598,7 @@ export function createHandlers(params) {
503
598
  function finish() {
504
599
  if (tornDown) return;
505
600
  tornDown = true;
601
+ notifyWindow();
506
602
  if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
507
603
  registry.unregisterIfIdle(clientDID);
508
604
  reader.cancel().catch(()=>{});
@@ -522,7 +618,13 @@ export function createHandlers(params) {
522
618
  if (tornDown) return;
523
619
  try {
524
620
  if (!await gate(frame.topicID)) return; // denied: skip, leave pending, no ack
621
+ // The payload plus the frame's other fields and the transport's envelope (~256 chars).
622
+ const bytes = frame.payload.length + frame.senderDID.length + frame.topicID.length + 256;
623
+ await waitForRoom(bytes);
624
+ if (tornDown) return;
525
625
  await writer.ready;
626
+ inFlight.set(frame.sequenceID, bytes);
627
+ inFlightBytes += bytes;
526
628
  await writer.write(frame);
527
629
  if (frame.sequenceID > lastServed) lastServed = frame.sequenceID;
528
630
  } catch {
@@ -555,6 +657,38 @@ export function createHandlers(params) {
555
657
  pushWrite(frame);
556
658
  }
557
659
  };
660
+ // Ack loop (M1): a store.ack failure must not stop later acks; only a reader error closes.
661
+ // It runs from connect, alongside the drain: the drain waits on the ack window, so acks for the
662
+ // backlog must be read while it is still being served.
663
+ void (async ()=>{
664
+ while(true){
665
+ let result;
666
+ try {
667
+ result = await reader.read();
668
+ } catch {
669
+ break;
670
+ }
671
+ if (result.done) break;
672
+ const ack = result.value?.ack;
673
+ if (ack != null) {
674
+ // The client has the frames, whatever the store does with the ack.
675
+ release(ack);
676
+ try {
677
+ await store.ack({
678
+ recipientDID: clientDID,
679
+ sequenceIDs: ack
680
+ });
681
+ } catch (error) {
682
+ // Frame stays pending; the client re-acks next round. Do NOT break.
683
+ storeErrorReporter({
684
+ method: 'ack',
685
+ did: clientDID,
686
+ error
687
+ });
688
+ }
689
+ }
690
+ }
691
+ })();
558
692
  try {
559
693
  // Binding evicts whatever held the lane: a reconnect means the old connection broke and the
560
694
  // server learns last, so the stale writer must give way to the live one. See
@@ -565,6 +699,17 @@ export function createHandlers(params) {
565
699
  evicted?.();
566
700
  // The device is draining; a trailing summary would be noise it has already outrun.
567
701
  params.wake?.dispatcher?.online(clientDID);
702
+ // Teardown triggers are armed before the drain: the drain waits on the ack window, so it
703
+ // can last as long as the client takes to ack, and an abort or eviction must end it.
704
+ // H2: an already-aborted signal never fires 'abort', so run teardown now.
705
+ if (ctx.signal.aborted) {
706
+ finish();
707
+ } else {
708
+ void evictedHere.then(finish);
709
+ ctx.signal.addEventListener('abort', finish, {
710
+ once: true
711
+ });
712
+ }
568
713
  // Drain the backlog. Await each page so lastServed is exact before the flush.
569
714
  let cursor = after;
570
715
  while(!tornDown){
@@ -598,49 +743,13 @@ export function createHandlers(params) {
598
743
  } catch (error) {
599
744
  // A synchronous drain error (e.g. store.fetch threw): clean up and reject the handler.
600
745
  tornDown = true;
746
+ notifyWindow();
601
747
  if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
602
748
  registry.unregisterIfIdle(clientDID);
603
749
  reader.cancel().catch(()=>{});
604
750
  writer.abort(error).catch(()=>{});
605
751
  throw error;
606
752
  }
607
- // Ack loop (M1): a store.ack failure must not stop later acks; only a reader error closes.
608
- void (async ()=>{
609
- while(true){
610
- let result;
611
- try {
612
- result = await reader.read();
613
- } catch {
614
- break;
615
- }
616
- if (result.done) break;
617
- const ack = result.value?.ack;
618
- if (ack != null) {
619
- try {
620
- await store.ack({
621
- recipientDID: clientDID,
622
- sequenceIDs: ack
623
- });
624
- } catch (error) {
625
- // Frame stays pending; the client re-acks next round. Do NOT break.
626
- storeErrorReporter({
627
- method: 'ack',
628
- did: clientDID,
629
- error
630
- });
631
- }
632
- }
633
- }
634
- })();
635
- // H2: an already-aborted signal never fires 'abort', so run teardown now.
636
- if (ctx.signal.aborted) {
637
- finish();
638
- return done;
639
- }
640
- void evictedHere.then(finish);
641
- ctx.signal.addEventListener('abort', finish, {
642
- once: true
643
- });
644
753
  return done;
645
754
  },
646
755
  'hub/v1/keypackage/upload': async (ctx)=>{
package/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { AuthorizeDecision, AuthorizeHook, AuthorizeRequest, CreateHandlersParams, HubRateLimits, HubStoreErrorEvent, HubStoreErrorHook, KeyPackageFetchLimits, } from './handlers.js';
2
- export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_BUFFER_LIMIT, } from './handlers.js';
2
+ export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_ACK_TIMEOUT, DEFAULT_RECEIVE_BUFFER_LIMIT, DEFAULT_RECEIVE_WINDOW, DEFAULT_RECEIVE_WINDOW_BYTES, } from './handlers.js';
3
3
  export type { CreateHubParams, HubInstance, HubPurgeOptions, HubReplayOptions } from './hub.js';
4
4
  export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
5
5
  export { createMemoryStore, type MemoryStoreOptions } from './memoryStore.js';
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_BUFFER_LIMIT } from './handlers.js';
1
+ export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_ACK_TIMEOUT, DEFAULT_RECEIVE_BUFFER_LIMIT, DEFAULT_RECEIVE_WINDOW, DEFAULT_RECEIVE_WINDOW_BYTES } from './handlers.js';
2
2
  export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
3
3
  export { createMemoryStore } from './memoryStore.js';
4
4
  export { createRateLimiter } from './rateLimit.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-server",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Hub server for Enkaku E2EE group message routing",
5
5
  "keywords": [
6
6
  "hub",
@@ -25,18 +25,18 @@
25
25
  "lib/*"
26
26
  ],
27
27
  "dependencies": {
28
- "@enkaku/protocol": "^0.22.2",
29
- "@enkaku/server": "^0.22.2",
28
+ "@enkaku/protocol": "^0.22.4",
29
+ "@enkaku/server": "^0.22.4",
30
30
  "@kokuin/capability": "^0.5.2",
31
- "@kokuin/token": "^0.6.2",
31
+ "@kokuin/token": "^0.6.4",
32
32
  "@kumiai/hub-protocol": "^0.12.0",
33
- "@sozai/codec": "^0.4.3",
33
+ "@sozai/codec": "^0.4.4",
34
34
  "@sozai/event": "^0.1.6",
35
35
  "@sozai/log": "^0.4.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@enkaku/client": "^0.22.2",
39
- "@enkaku/transport": "^0.22.2",
38
+ "@enkaku/client": "^0.22.4",
39
+ "@enkaku/transport": "^0.22.4",
40
40
  "@kumiai/hub-conformance": "^0.12.0",
41
41
  "@kumiai/hub-wake": "^0.12.0"
42
42
  },