@kumiai/hub-server 0.11.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 +34 -0
- package/lib/handlers.d.ts +44 -0
- package/lib/handlers.js +148 -37
- package/lib/hub.d.ts +5 -0
- package/lib/hub.js +24 -9
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/package.json +11 -11
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
|
|
@@ -84,6 +113,11 @@ request belongs to the store, because the store is what refuses the subscribe
|
|
|
84
113
|
pin a topic's frames forever with `subscribe({ retention: 2 ** 31 })`. A subscribe above the ceiling
|
|
85
114
|
is refused with `RetentionExceededError`, never clamped.
|
|
86
115
|
|
|
116
|
+
When the store supports subscription reaping, each tick also removes inactive subscriptions after message purge settles.
|
|
117
|
+
`purge.subscriptionOlderThan` sets the inactivity window in seconds, defaulting to `7776000` (90 days).
|
|
118
|
+
The effective window is `Math.max(subscriptionOlderThan, olderThan)`, so subscriptions outlive the message retention window.
|
|
119
|
+
Both operations report failures through `onStoreError` independently. Disposal waits for both operations.
|
|
120
|
+
|
|
87
121
|
`createMemoryStore`'s `maxDepth` (default 1000) evicts the oldest **log** frames beyond that count.
|
|
88
122
|
Mailbox frames on the same topic are not counted, so a member cannot evict the log with a mailbox
|
|
89
123
|
flood.
|
package/lib/handlers.d.ts
CHANGED
|
@@ -98,6 +98,9 @@ export type AuthorizeHook = (req: AuthorizeRequest) => AuthorizeDecision | Promi
|
|
|
98
98
|
export type HubStoreErrorEvent = {
|
|
99
99
|
method: 'purge';
|
|
100
100
|
error: unknown;
|
|
101
|
+
} | {
|
|
102
|
+
method: 'purgeSubscriptions';
|
|
103
|
+
error: unknown;
|
|
101
104
|
}
|
|
102
105
|
/** The DID whose ack the store refused. */
|
|
103
106
|
| {
|
|
@@ -163,6 +166,34 @@ export declare const DEFAULT_RECEIVE_BUFFER_LIMIT = 256;
|
|
|
163
166
|
* maximum window a removed member can keep draining a topic. See the receive handler's `gate`.
|
|
164
167
|
*/
|
|
165
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;
|
|
166
197
|
export type CreateHandlersParams = {
|
|
167
198
|
registry: HubClientRegistry;
|
|
168
199
|
store: HubStore;
|
|
@@ -172,6 +203,19 @@ export type CreateHandlersParams = {
|
|
|
172
203
|
/** Max frames queued-but-unflushed on a receive channel. See {@link DEFAULT_RECEIVE_BUFFER_LIMIT}
|
|
173
204
|
* for the >= 50-frame-page floor this should respect. Default: 256 */
|
|
174
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;
|
|
175
219
|
/**
|
|
176
220
|
* TTL (ms) for the per-(did, topicID) receive delivery-authorization cache. `0` disables reuse
|
|
177
221
|
* (the hook is consulted for every frame). Non-finite or negative values fall back to
|
package/lib/handlers.js
CHANGED
|
@@ -21,6 +21,7 @@ function normalizeAuthorizeDecision(decision) {
|
|
|
21
21
|
fetchLastResortKeyPackage: 'The fetch returned what the pool could serve, without the last-resort top-up. A read that ' + 'keeps failing means the availability floor the last-resort slot exists to provide is absent, ' + 'and joins fail downstream at the inviter with no signal here.',
|
|
22
22
|
ack: 'The frame stays pending and the client re-acks on the next round. An ack that keeps failing ' + 'redelivers every frame forever.',
|
|
23
23
|
purge: 'Retried on the next interval. A purge that keeps failing means the store grows without bound.',
|
|
24
|
+
purgeSubscriptions: 'Retried on the next interval. Persistent failure leaves stale subscriptions consuming storage and quota.',
|
|
24
25
|
getSubscribers: 'The frame is committed and queued, but the live push to connected subscribers was skipped: ' + 'each of them stops receiving new frames until it reconnects. A getSubscribers that keeps ' + 'failing means the hub has silently degraded from push to pull for every publish.',
|
|
25
26
|
wake: 'The offline device was not pinged for this frame. It still redelivers on the next reconnect ' + '(store-and-forward), so nothing is lost — but a wake that keeps failing means the device ' + 'stays asleep until something else wakes it.'
|
|
26
27
|
};
|
|
@@ -32,6 +33,7 @@ const reportStoreError = getReporter([
|
|
|
32
33
|
* methods, a topic for fan-out, nothing for a store-wide purge. */ function subjectOf(event) {
|
|
33
34
|
switch(event.method){
|
|
34
35
|
case 'purge':
|
|
36
|
+
case 'purgeSubscriptions':
|
|
35
37
|
return '';
|
|
36
38
|
case 'getSubscribers':
|
|
37
39
|
return ` on topic ${event.topicID}`;
|
|
@@ -89,6 +91,31 @@ export const DEFAULT_KEYPACKAGE_FETCH_LIMITS = {
|
|
|
89
91
|
* Bounds how long an already-resolved allow is reused before the hook is re-consulted — i.e. the
|
|
90
92
|
* maximum window a removed member can keep draining a topic. See the receive handler's `gate`.
|
|
91
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;
|
|
92
119
|
function getClientDID(ctx) {
|
|
93
120
|
const iss = ctx.message.payload.iss;
|
|
94
121
|
if (typeof iss !== 'string' || iss.length === 0) {
|
|
@@ -147,6 +174,9 @@ export function createHandlers(params) {
|
|
|
147
174
|
const topicLimiter = createRateLimiter(rateLimits.perTopic);
|
|
148
175
|
const receiveBufferLimit = params.receiveBufferLimit ?? DEFAULT_RECEIVE_BUFFER_LIMIT;
|
|
149
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;
|
|
150
180
|
const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
|
|
151
181
|
const fetchLimits = {
|
|
152
182
|
...DEFAULT_KEYPACKAGE_FETCH_LIMITS,
|
|
@@ -492,6 +522,73 @@ export function createHandlers(params) {
|
|
|
492
522
|
;
|
|
493
523
|
let tornDown = false;
|
|
494
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
|
+
}
|
|
495
592
|
let resolveDone = ()=>{};
|
|
496
593
|
const done = new Promise((resolve)=>{
|
|
497
594
|
resolveDone = resolve;
|
|
@@ -501,6 +598,7 @@ export function createHandlers(params) {
|
|
|
501
598
|
function finish() {
|
|
502
599
|
if (tornDown) return;
|
|
503
600
|
tornDown = true;
|
|
601
|
+
notifyWindow();
|
|
504
602
|
if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
|
|
505
603
|
registry.unregisterIfIdle(clientDID);
|
|
506
604
|
reader.cancel().catch(()=>{});
|
|
@@ -520,7 +618,13 @@ export function createHandlers(params) {
|
|
|
520
618
|
if (tornDown) return;
|
|
521
619
|
try {
|
|
522
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;
|
|
523
625
|
await writer.ready;
|
|
626
|
+
inFlight.set(frame.sequenceID, bytes);
|
|
627
|
+
inFlightBytes += bytes;
|
|
524
628
|
await writer.write(frame);
|
|
525
629
|
if (frame.sequenceID > lastServed) lastServed = frame.sequenceID;
|
|
526
630
|
} catch {
|
|
@@ -553,6 +657,38 @@ export function createHandlers(params) {
|
|
|
553
657
|
pushWrite(frame);
|
|
554
658
|
}
|
|
555
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
|
+
})();
|
|
556
692
|
try {
|
|
557
693
|
// Binding evicts whatever held the lane: a reconnect means the old connection broke and the
|
|
558
694
|
// server learns last, so the stale writer must give way to the live one. See
|
|
@@ -563,6 +699,17 @@ export function createHandlers(params) {
|
|
|
563
699
|
evicted?.();
|
|
564
700
|
// The device is draining; a trailing summary would be noise it has already outrun.
|
|
565
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
|
+
}
|
|
566
713
|
// Drain the backlog. Await each page so lastServed is exact before the flush.
|
|
567
714
|
let cursor = after;
|
|
568
715
|
while(!tornDown){
|
|
@@ -596,49 +743,13 @@ export function createHandlers(params) {
|
|
|
596
743
|
} catch (error) {
|
|
597
744
|
// A synchronous drain error (e.g. store.fetch threw): clean up and reject the handler.
|
|
598
745
|
tornDown = true;
|
|
746
|
+
notifyWindow();
|
|
599
747
|
if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
|
|
600
748
|
registry.unregisterIfIdle(clientDID);
|
|
601
749
|
reader.cancel().catch(()=>{});
|
|
602
750
|
writer.abort(error).catch(()=>{});
|
|
603
751
|
throw error;
|
|
604
752
|
}
|
|
605
|
-
// Ack loop (M1): a store.ack failure must not stop later acks; only a reader error closes.
|
|
606
|
-
void (async ()=>{
|
|
607
|
-
while(true){
|
|
608
|
-
let result;
|
|
609
|
-
try {
|
|
610
|
-
result = await reader.read();
|
|
611
|
-
} catch {
|
|
612
|
-
break;
|
|
613
|
-
}
|
|
614
|
-
if (result.done) break;
|
|
615
|
-
const ack = result.value?.ack;
|
|
616
|
-
if (ack != null) {
|
|
617
|
-
try {
|
|
618
|
-
await store.ack({
|
|
619
|
-
recipientDID: clientDID,
|
|
620
|
-
sequenceIDs: ack
|
|
621
|
-
});
|
|
622
|
-
} catch (error) {
|
|
623
|
-
// Frame stays pending; the client re-acks next round. Do NOT break.
|
|
624
|
-
storeErrorReporter({
|
|
625
|
-
method: 'ack',
|
|
626
|
-
did: clientDID,
|
|
627
|
-
error
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
})();
|
|
633
|
-
// H2: an already-aborted signal never fires 'abort', so run teardown now.
|
|
634
|
-
if (ctx.signal.aborted) {
|
|
635
|
-
finish();
|
|
636
|
-
return done;
|
|
637
|
-
}
|
|
638
|
-
void evictedHere.then(finish);
|
|
639
|
-
ctx.signal.addEventListener('abort', finish, {
|
|
640
|
-
once: true
|
|
641
|
-
});
|
|
642
753
|
return done;
|
|
643
754
|
},
|
|
644
755
|
'hub/v1/keypackage/upload': async (ctx)=>{
|
package/lib/hub.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ export type HubPurgeOptions = {
|
|
|
20
20
|
* store's, since the store is the thing that refuses the subscribe.
|
|
21
21
|
*/
|
|
22
22
|
olderThan?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Subscription inactivity window in seconds. Default: 7776000 (90 days).
|
|
25
|
+
* Clamped to at least olderThan so subscriptions outlive the message retention window.
|
|
26
|
+
*/
|
|
27
|
+
subscriptionOlderThan?: number;
|
|
23
28
|
};
|
|
24
29
|
/** Replay settings a host may tune. The hub always enables replay checks and rejects stale tokens. */
|
|
25
30
|
export type HubReplayOptions = Omit<ReplayOptions, 'enabled' | 'rejectStale'>;
|
package/lib/hub.js
CHANGED
|
@@ -69,16 +69,31 @@ export function createHub(params) {
|
|
|
69
69
|
if (params.purge !== false) {
|
|
70
70
|
const interval = params.purge?.interval ?? 3_600_000;
|
|
71
71
|
const olderThan = params.purge?.olderThan ?? 604_800;
|
|
72
|
+
const subscriptionOlderThan = Math.max(params.purge?.subscriptionOlderThan ?? 7_776_000, olderThan);
|
|
73
|
+
const store = params.store;
|
|
72
74
|
purgeTimer = setInterval(()=>{
|
|
73
|
-
const run =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
75
|
+
const run = (async ()=>{
|
|
76
|
+
try {
|
|
77
|
+
await store.purge({
|
|
78
|
+
olderThan
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
storeErrorReporter({
|
|
82
|
+
method: 'purge',
|
|
83
|
+
error
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
await store.purgeSubscriptions?.({
|
|
88
|
+
olderThan: subscriptionOlderThan
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
storeErrorReporter({
|
|
92
|
+
method: 'purgeSubscriptions',
|
|
93
|
+
error
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
82
97
|
purgesInFlight.add(run);
|
|
83
98
|
void run.finally(()=>{
|
|
84
99
|
purgesInFlight.delete(run);
|
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.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Hub server for Enkaku E2EE group message routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"hub",
|
|
@@ -25,20 +25,20 @@
|
|
|
25
25
|
"lib/*"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@enkaku/protocol": "^0.22.
|
|
29
|
-
"@enkaku/server": "^0.22.
|
|
30
|
-
"@kokuin/capability": "^0.5.
|
|
31
|
-
"@kokuin/token": "^0.6.
|
|
32
|
-
"@kumiai/hub-protocol": "^0.
|
|
33
|
-
"@sozai/codec": "^0.4.
|
|
28
|
+
"@enkaku/protocol": "^0.22.4",
|
|
29
|
+
"@enkaku/server": "^0.22.4",
|
|
30
|
+
"@kokuin/capability": "^0.5.2",
|
|
31
|
+
"@kokuin/token": "^0.6.4",
|
|
32
|
+
"@kumiai/hub-protocol": "^0.12.0",
|
|
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.
|
|
39
|
-
"@enkaku/transport": "^0.22.
|
|
40
|
-
"@kumiai/hub-conformance": "^0.
|
|
41
|
-
"@kumiai/hub-wake": "^0.
|
|
38
|
+
"@enkaku/client": "^0.22.4",
|
|
39
|
+
"@enkaku/transport": "^0.22.4",
|
|
40
|
+
"@kumiai/hub-conformance": "^0.12.0",
|
|
41
|
+
"@kumiai/hub-wake": "^0.12.0"
|
|
42
42
|
},
|
|
43
43
|
"publishConfig": {
|
|
44
44
|
"access": "public"
|