@kumiai/hub-server 0.6.0 → 0.7.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 +7 -4
- package/lib/handlers.d.ts +38 -4
- package/lib/handlers.js +134 -2
- package/lib/hub.d.ts +12 -1
- package/lib/hub.js +19 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/wake.d.ts +33 -0
- package/lib/wake.js +112 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -31,10 +31,13 @@ another sender. The `identity` param is required for that reason.
|
|
|
31
31
|
|
|
32
32
|
Authorization is two layers, and only the second one knows about topics: `accessRules` gate the
|
|
33
33
|
procedures (the default lets any authenticated DID call them), and the optional
|
|
34
|
-
`authorize(request)` hook decides
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
`
|
|
34
|
+
`authorize(request)` hook decides the rest. It takes one discriminated `AuthorizeRequest` — narrow
|
|
35
|
+
on `request.action` — and returns `AuthorizeDecision`, either a boolean or
|
|
36
|
+
`{ allow, reason?, code?, retryAfterMs? }`. `AuthorizeRequest` names nine actions, of which only
|
|
37
|
+
`unsubscribe` is not dispatched today; the two `wake/*` actions gate push registration, which is the
|
|
38
|
+
one durable per-device identifier the hub stores. The default allows any authenticated DID, and a
|
|
39
|
+
hook should allow any action it does not recognise, so that a hook written before a variant shipped
|
|
40
|
+
does not start refusing a procedure that used to be ungated.
|
|
38
41
|
|
|
39
42
|
## Durable subscription, ephemeral connection
|
|
40
43
|
|
package/lib/handlers.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { type ProcedureHandlers } from '@enkaku/server';
|
|
2
|
-
import type { HubProtocol, HubStore } from '@kumiai/hub-protocol';
|
|
2
|
+
import type { HubProtocol, HubStore, WakeRegistry } from '@kumiai/hub-protocol';
|
|
3
3
|
import { type RateLimitConfig } from './rateLimit.js';
|
|
4
4
|
import type { HubClientRegistry } from './registry.js';
|
|
5
|
+
import type { WakeDispatcher } from './wake.js';
|
|
5
6
|
/**
|
|
6
|
-
* A single request to authorize. All
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* A single request to authorize. All nine variants ship even though `unsubscribe` is not yet
|
|
8
|
+
* dispatched: the union is the exhaustive-switch surface a host's own `switch (req.action)` closes
|
|
9
|
+
* over, and widening it after a release is a break for every host that closed over it.
|
|
9
10
|
*
|
|
10
11
|
* A host's `switch` need not handle every action today — an unrecognized action should default to
|
|
11
12
|
* allow, so a hook written before a new variant shipped doesn't silently start refusing a
|
|
@@ -43,6 +44,25 @@ export type AuthorizeRequest = {
|
|
|
43
44
|
} | {
|
|
44
45
|
action: 'keypackage/status';
|
|
45
46
|
did: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A device registering a push endpoint. `kind` (the opaque sender tag) and `expiresAt` (the
|
|
50
|
+
* caller-supplied registration lifetime cap, when given) are the only details a host can usefully
|
|
51
|
+
* gate on — `endpoint`, `publicKey` and `authSecret` are deliberately absent, since a hook that
|
|
52
|
+
* saw them would be a second place the endpoint gets read, and the hub's rule is that it never
|
|
53
|
+
* interprets one.
|
|
54
|
+
*
|
|
55
|
+
* This is the durable cross-group per-device identifier wake introduces, so it is the one a host
|
|
56
|
+
* is most likely to want a say over.
|
|
57
|
+
*/
|
|
58
|
+
| {
|
|
59
|
+
action: 'wake/register';
|
|
60
|
+
did: string;
|
|
61
|
+
kind: string;
|
|
62
|
+
expiresAt?: number;
|
|
63
|
+
} | {
|
|
64
|
+
action: 'wake/unregister';
|
|
65
|
+
did: string;
|
|
46
66
|
};
|
|
47
67
|
/**
|
|
48
68
|
* A plain `boolean` is shorthand for `{ allow: boolean }`, with no reason, code, or retry hint.
|
|
@@ -89,6 +109,12 @@ export type HubStoreErrorEvent = {
|
|
|
89
109
|
method: 'getSubscribers';
|
|
90
110
|
topicID: string;
|
|
91
111
|
error: unknown;
|
|
112
|
+
}
|
|
113
|
+
/** The DID whose wake dispatch (send, or the registry read/delete around it) failed. */
|
|
114
|
+
| {
|
|
115
|
+
method: 'wake';
|
|
116
|
+
did: string;
|
|
117
|
+
error: unknown;
|
|
92
118
|
};
|
|
93
119
|
export type HubStoreErrorHook = (event: HubStoreErrorEvent) => void;
|
|
94
120
|
/**
|
|
@@ -141,5 +167,13 @@ export type CreateHandlersParams = {
|
|
|
141
167
|
* empty handler to silence it deliberately.
|
|
142
168
|
*/
|
|
143
169
|
onStoreError?: HubStoreErrorHook;
|
|
170
|
+
/**
|
|
171
|
+
* Wake support. Absent: both wake procedures refuse. The registry is separate from the
|
|
172
|
+
* dispatcher because the handlers only ever store and remove; sending is the dispatcher's.
|
|
173
|
+
*/
|
|
174
|
+
wake?: {
|
|
175
|
+
registry: WakeRegistry;
|
|
176
|
+
dispatcher?: WakeDispatcher;
|
|
177
|
+
};
|
|
144
178
|
};
|
|
145
179
|
export declare function createHandlers(params: CreateHandlersParams): ProcedureHandlers<HubProtocol>;
|
package/lib/handlers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HandlerError } from '@enkaku/server';
|
|
2
|
-
import { HUB_ERROR_CODES, hubErrorCodeOf, InvalidPayloadError, KeyPackageFetchLimitError, keyPackageDigest } from '@kumiai/hub-protocol';
|
|
2
|
+
import { HUB_ERROR_CODES, hubErrorCodeOf, InvalidPayloadError, KeyPackageFetchLimitError, keyPackageDigest, wakeRecipientKeyProblem } from '@kumiai/hub-protocol';
|
|
3
3
|
import { fromB64, toB64 } from '@sozai/codec';
|
|
4
4
|
import { getReporter } from '@sozai/log';
|
|
5
5
|
import { createRateLimiter } from './rateLimit.js';
|
|
@@ -21,7 +21,8 @@ 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
|
-
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.'
|
|
24
|
+
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
|
+
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.'
|
|
25
26
|
};
|
|
26
27
|
const reportStoreError = getReporter([
|
|
27
28
|
'kumiai',
|
|
@@ -36,6 +37,7 @@ const reportStoreError = getReporter([
|
|
|
36
37
|
return ` on topic ${event.topicID}`;
|
|
37
38
|
case 'ack':
|
|
38
39
|
case 'fetchLastResortKeyPackage':
|
|
40
|
+
case 'wake':
|
|
39
41
|
return ` for ${event.did}`;
|
|
40
42
|
}
|
|
41
43
|
}
|
|
@@ -293,6 +295,29 @@ export function createHandlers(params) {
|
|
|
293
295
|
payload: payloadBytes,
|
|
294
296
|
...logPosition
|
|
295
297
|
});
|
|
298
|
+
} else {
|
|
299
|
+
// No live channel: the frame is durably queued, so all that is missing is a nudge to
|
|
300
|
+
// come and get it. Both retention classes wake — a commit-lane frame is log-class, and
|
|
301
|
+
// a membership change is exactly what a sleeping device must learn.
|
|
302
|
+
//
|
|
303
|
+
// Guarded even though `notify` is documented never to throw: `dispatcher` is
|
|
304
|
+
// caller-injectable (`CreateHandlersParams.wake.dispatcher`), and a misbehaving
|
|
305
|
+
// third-party implementation must not abort this loop mid-way — that would cost live
|
|
306
|
+
// delivery to every subscriber still to come, for a publish whose append already
|
|
307
|
+
// committed.
|
|
308
|
+
try {
|
|
309
|
+
params.wake?.dispatcher?.notify({
|
|
310
|
+
did: recipientDID,
|
|
311
|
+
topicID,
|
|
312
|
+
sequenceID
|
|
313
|
+
});
|
|
314
|
+
} catch (error) {
|
|
315
|
+
storeErrorReporter({
|
|
316
|
+
method: 'wake',
|
|
317
|
+
did: recipientDID,
|
|
318
|
+
error
|
|
319
|
+
});
|
|
320
|
+
}
|
|
296
321
|
}
|
|
297
322
|
}
|
|
298
323
|
}
|
|
@@ -480,6 +505,8 @@ export function createHandlers(params) {
|
|
|
480
505
|
receiveToken = token;
|
|
481
506
|
// After the bind, so the lane is never unheld between the two.
|
|
482
507
|
evicted?.();
|
|
508
|
+
// The device is draining; a trailing summary would be noise it has already outrun.
|
|
509
|
+
params.wake?.dispatcher?.online(clientDID);
|
|
483
510
|
// Drain the backlog. Await each page so lastServed is exact before the flush.
|
|
484
511
|
let cursor = after;
|
|
485
512
|
while(!tornDown){
|
|
@@ -767,6 +794,111 @@ export function createHandlers(params) {
|
|
|
767
794
|
count,
|
|
768
795
|
lastResort: stored == null ? null : keyPackageDigest(stored)
|
|
769
796
|
};
|
|
797
|
+
},
|
|
798
|
+
'hub/v1/wake/register': async (ctx)=>{
|
|
799
|
+
const clientDID = getClientDID(ctx);
|
|
800
|
+
const wake = params.wake;
|
|
801
|
+
if (wake == null) {
|
|
802
|
+
throw new HandlerError({
|
|
803
|
+
code: HUB_ERROR_CODES.wakeNotSupported,
|
|
804
|
+
message: 'This hub does not support wake notifications'
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
// After the not-supported refusal, before the rate limit — the order every enforced variant
|
|
808
|
+
// uses. Configuration is answered first because a hub with no wake support has nothing for a
|
|
809
|
+
// host to have an opinion about.
|
|
810
|
+
const decision = normalizeAuthorizeDecision(await authorize({
|
|
811
|
+
action: 'wake/register',
|
|
812
|
+
did: clientDID,
|
|
813
|
+
kind: ctx.param.kind,
|
|
814
|
+
expiresAt: ctx.param.expiresAt
|
|
815
|
+
}));
|
|
816
|
+
if (!decision.allow) {
|
|
817
|
+
throw new HandlerError({
|
|
818
|
+
code: HUB_ERROR_CODES.authorizationDenied,
|
|
819
|
+
message: decision.reason ?? 'Not authorized to register a wake endpoint'
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
if (!didLimiter.tryConsume(clientDID)) {
|
|
823
|
+
throw new HandlerError({
|
|
824
|
+
code: 'EK01',
|
|
825
|
+
message: 'Wake rate limit exceeded for DID'
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
// The param schema bounds the length (`minLength: 1`, `maxLength: 128`/`64`) but not the
|
|
829
|
+
// shape, which accepts key material the hub can never seal to. Storing it answers
|
|
830
|
+
// `registered: true` and then fails inside every single seal — one error per frame, forever,
|
|
831
|
+
// since a seal failure is not a `gone` verdict and nothing removes the entry. The device
|
|
832
|
+
// believes it is reachable and is never woken.
|
|
833
|
+
// The rule itself lives in hub-protocol next to RFC 8291, so the hub needs no curve of its
|
|
834
|
+
// own to know that a 65-byte value off the curve fails exactly as a 33-byte one does.
|
|
835
|
+
const keyProblem = wakeRecipientKeyProblem({
|
|
836
|
+
publicKey: ctx.param.publicKey,
|
|
837
|
+
authSecret: ctx.param.authSecret
|
|
838
|
+
});
|
|
839
|
+
if (keyProblem != null) {
|
|
840
|
+
throw new HandlerError({
|
|
841
|
+
code: HUB_ERROR_CODES.invalidPayload,
|
|
842
|
+
message: `Wake ${keyProblem}`
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
await wake.registry.put({
|
|
847
|
+
// The DID is the verified issuer, never a wire field — otherwise any member could
|
|
848
|
+
// redirect another member's wakes to an endpoint they control.
|
|
849
|
+
did: clientDID,
|
|
850
|
+
kind: ctx.param.kind,
|
|
851
|
+
endpoint: ctx.param.endpoint,
|
|
852
|
+
publicKey: ctx.param.publicKey,
|
|
853
|
+
authSecret: ctx.param.authSecret,
|
|
854
|
+
...ctx.param.expiresAt != null ? {
|
|
855
|
+
expiresAt: ctx.param.expiresAt
|
|
856
|
+
} : {}
|
|
857
|
+
});
|
|
858
|
+
} catch (error) {
|
|
859
|
+
rethrowAsHandlerError(error);
|
|
860
|
+
}
|
|
861
|
+
return {
|
|
862
|
+
registered: true
|
|
863
|
+
};
|
|
864
|
+
},
|
|
865
|
+
'hub/v1/wake/unregister': async (ctx)=>{
|
|
866
|
+
const clientDID = getClientDID(ctx);
|
|
867
|
+
const wake = params.wake;
|
|
868
|
+
if (wake == null) {
|
|
869
|
+
throw new HandlerError({
|
|
870
|
+
code: HUB_ERROR_CODES.wakeNotSupported,
|
|
871
|
+
message: 'This hub does not support wake notifications'
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
const decision = normalizeAuthorizeDecision(await authorize({
|
|
875
|
+
action: 'wake/unregister',
|
|
876
|
+
did: clientDID
|
|
877
|
+
}));
|
|
878
|
+
if (!decision.allow) {
|
|
879
|
+
throw new HandlerError({
|
|
880
|
+
code: HUB_ERROR_CODES.authorizationDenied,
|
|
881
|
+
message: decision.reason ?? 'Not authorized to unregister a wake endpoint'
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
// Matches `register` above and the subscribe/unsubscribe pair: both halves of a pair cost
|
|
885
|
+
// the same, or the uncounted half is the one an abusive caller uses.
|
|
886
|
+
if (!didLimiter.tryConsume(clientDID)) {
|
|
887
|
+
throw new HandlerError({
|
|
888
|
+
code: 'EK01',
|
|
889
|
+
message: 'Wake rate limit exceeded for DID'
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
let existed;
|
|
893
|
+
try {
|
|
894
|
+
existed = await wake.registry.get(clientDID) != null;
|
|
895
|
+
await wake.registry.delete(clientDID);
|
|
896
|
+
} catch (error) {
|
|
897
|
+
rethrowAsHandlerError(error);
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
unregistered: existed
|
|
901
|
+
};
|
|
770
902
|
}
|
|
771
903
|
};
|
|
772
904
|
}
|
package/lib/hub.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ServerTransportOf } from '@enkaku/protocol';
|
|
2
2
|
import type { AccessRules, ResourceLimits, Server } from '@enkaku/server';
|
|
3
3
|
import type { Identity } from '@kokuin/token';
|
|
4
|
-
import {
|
|
4
|
+
import type { HubProtocol, HubStore, WakeRegistry, WakeSender } from '@kumiai/hub-protocol';
|
|
5
5
|
import { type AuthorizeHook, type HubRateLimits, type HubStoreErrorHook, type KeyPackageFetchLimits } from './handlers.js';
|
|
6
6
|
import { HubClientRegistry } from './registry.js';
|
|
7
7
|
/**
|
|
@@ -52,6 +52,17 @@ export type CreateHubParams = {
|
|
|
52
52
|
* `controllerTimeoutMs` and from the `maxConcurrentHandlers` cap.
|
|
53
53
|
*/
|
|
54
54
|
limits?: Partial<ResourceLimits>;
|
|
55
|
+
/**
|
|
56
|
+
* Wake notifications. Absent: `hub/v1/wake/*` refuse with `WakeNotSupportedError` — refusing is
|
|
57
|
+
* the only honest answer, since accepting a registration the hub will never act on leaves the
|
|
58
|
+
* device believing it is reachable.
|
|
59
|
+
*/
|
|
60
|
+
wake?: {
|
|
61
|
+
registry: WakeRegistry;
|
|
62
|
+
sender: WakeSender;
|
|
63
|
+
/** Coalescing window in milliseconds. Default: 10 000. */
|
|
64
|
+
debounceMs?: number;
|
|
65
|
+
};
|
|
55
66
|
};
|
|
56
67
|
export type HubInstance = {
|
|
57
68
|
registry: HubClientRegistry;
|
package/lib/hub.js
CHANGED
|
@@ -2,6 +2,7 @@ import { serve } from '@enkaku/server';
|
|
|
2
2
|
import { hubProtocol } from '@kumiai/hub-protocol';
|
|
3
3
|
import { createHandlers, createStoreErrorReporter } from './handlers.js';
|
|
4
4
|
import { HubClientRegistry } from './registry.js';
|
|
5
|
+
import { createWakeDispatcher } from './wake.js';
|
|
5
6
|
/**
|
|
6
7
|
* Default access rules: any authenticated DID may call hub procedures.
|
|
7
8
|
* The hub is a blind relay — per-procedure authorization (the `authorize`
|
|
@@ -17,13 +18,27 @@ export function createHub(params) {
|
|
|
17
18
|
// wrapper delegates to this instance rather than building a second one from the same hook —
|
|
18
19
|
// which matters the moment a reporter holds state, as the throttling the README names would.
|
|
19
20
|
const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
|
|
21
|
+
const wakeDispatcher = params.wake == null ? undefined : createWakeDispatcher({
|
|
22
|
+
registry: params.wake.registry,
|
|
23
|
+
sender: params.wake.sender,
|
|
24
|
+
debounceMs: params.wake.debounceMs,
|
|
25
|
+
onError: ({ did, error })=>storeErrorReporter({
|
|
26
|
+
method: 'wake',
|
|
27
|
+
did,
|
|
28
|
+
error
|
|
29
|
+
})
|
|
30
|
+
});
|
|
20
31
|
const handlers = createHandlers({
|
|
21
32
|
registry,
|
|
22
33
|
store: params.store,
|
|
23
34
|
authorize: params.authorize,
|
|
24
35
|
rateLimits: params.rateLimits,
|
|
25
36
|
keyPackageFetchLimits: params.keyPackageFetchLimits,
|
|
26
|
-
onStoreError: storeErrorReporter
|
|
37
|
+
onStoreError: storeErrorReporter,
|
|
38
|
+
wake: params.wake == null ? undefined : {
|
|
39
|
+
registry: params.wake.registry,
|
|
40
|
+
dispatcher: wakeDispatcher
|
|
41
|
+
}
|
|
27
42
|
});
|
|
28
43
|
const limits = {
|
|
29
44
|
...params.limits,
|
|
@@ -58,6 +73,9 @@ export function createHub(params) {
|
|
|
58
73
|
}, interval);
|
|
59
74
|
server.disposed.then(()=>clearInterval(purgeTimer));
|
|
60
75
|
}
|
|
76
|
+
if (wakeDispatcher != null) {
|
|
77
|
+
server.disposed.then(()=>wakeDispatcher.dispose());
|
|
78
|
+
}
|
|
61
79
|
return {
|
|
62
80
|
registry,
|
|
63
81
|
server
|
package/lib/index.d.ts
CHANGED
|
@@ -6,3 +6,4 @@ export { createMemoryStore, type MemoryStoreOptions } from './memoryStore.js';
|
|
|
6
6
|
export { createRateLimiter, type RateLimitConfig, type RateLimiter } from './rateLimit.js';
|
|
7
7
|
export type { ClientEntry } from './registry.js';
|
|
8
8
|
export { HubClientRegistry } from './registry.js';
|
|
9
|
+
export { createWakeDispatcher, type WakeDispatcher, type WakeDispatcherParams, type WakeNotifyParams, } from './wake.js';
|
package/lib/index.js
CHANGED
|
@@ -3,3 +3,4 @@ export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
|
|
|
3
3
|
export { createMemoryStore } from './memoryStore.js';
|
|
4
4
|
export { createRateLimiter } from './rateLimit.js';
|
|
5
5
|
export { HubClientRegistry } from './registry.js';
|
|
6
|
+
export { createWakeDispatcher } from './wake.js';
|
package/lib/wake.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type WakeRegistry, type WakeSender } from '@kumiai/hub-protocol';
|
|
2
|
+
export type WakeDispatcherParams = {
|
|
3
|
+
registry: WakeRegistry;
|
|
4
|
+
sender: WakeSender;
|
|
5
|
+
/** Coalescing window in milliseconds. Default: 10 000. */
|
|
6
|
+
debounceMs?: number;
|
|
7
|
+
/** Reports a transient send failure or a throwing sender. Fire-and-forget. */
|
|
8
|
+
onError?: (params: {
|
|
9
|
+
did: string;
|
|
10
|
+
error: unknown;
|
|
11
|
+
}) => void;
|
|
12
|
+
};
|
|
13
|
+
export type WakeNotifyParams = {
|
|
14
|
+
did: string;
|
|
15
|
+
topicID: string;
|
|
16
|
+
sequenceID: string;
|
|
17
|
+
};
|
|
18
|
+
export type WakeDispatcher = {
|
|
19
|
+
/** A frame was queued for an OFFLINE subscriber. Never throws, never awaited by the caller. */
|
|
20
|
+
notify(params: WakeNotifyParams): void;
|
|
21
|
+
/** The device bound a receive channel: drop any pending trailing ping. */
|
|
22
|
+
online(did: string): void;
|
|
23
|
+
dispose(): void;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Coalesces wake pings per DID on a LEADING edge: the first frame pings immediately, then a window
|
|
27
|
+
* opens and everything inside it collapses into one trailing summary.
|
|
28
|
+
*
|
|
29
|
+
* Leading rather than trailing because the timer map is in-process. A hub restart drops pending
|
|
30
|
+
* windows; on a leading edge that loses at most a summary, whereas trailing-only would lose the
|
|
31
|
+
* notification itself every time a restart landed inside a window.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createWakeDispatcher(params: WakeDispatcherParams): WakeDispatcher;
|
package/lib/wake.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { sealWakeHint } from '@kumiai/hub-protocol';
|
|
2
|
+
import { fromB64U } from '@sozai/codec';
|
|
3
|
+
const DEFAULT_DEBOUNCE_MS = 10_000;
|
|
4
|
+
/**
|
|
5
|
+
* Coalesces wake pings per DID on a LEADING edge: the first frame pings immediately, then a window
|
|
6
|
+
* opens and everything inside it collapses into one trailing summary.
|
|
7
|
+
*
|
|
8
|
+
* Leading rather than trailing because the timer map is in-process. A hub restart drops pending
|
|
9
|
+
* windows; on a leading edge that loses at most a summary, whereas trailing-only would lose the
|
|
10
|
+
* notification itself every time a restart landed inside a window.
|
|
11
|
+
*/ export function createWakeDispatcher(params) {
|
|
12
|
+
const debounceMs = params.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
13
|
+
const pending = new Map();
|
|
14
|
+
let disposed = false;
|
|
15
|
+
function report(did, error) {
|
|
16
|
+
try {
|
|
17
|
+
params.onError?.({
|
|
18
|
+
did,
|
|
19
|
+
error
|
|
20
|
+
});
|
|
21
|
+
} catch {
|
|
22
|
+
// A reporter that throws must not take the dispatcher with it.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function send(did, topicID, sequenceID, count) {
|
|
26
|
+
// Deliberately not awaited: a slow or hanging provider must never delay the publish fan-out
|
|
27
|
+
// this is called from.
|
|
28
|
+
void (async ()=>{
|
|
29
|
+
try {
|
|
30
|
+
const registration = await params.registry.get(did);
|
|
31
|
+
if (registration == null) return;
|
|
32
|
+
const body = sealWakeHint({
|
|
33
|
+
topicID,
|
|
34
|
+
sequenceID,
|
|
35
|
+
count
|
|
36
|
+
}, {
|
|
37
|
+
publicKey: fromB64U(registration.publicKey),
|
|
38
|
+
authSecret: fromB64U(registration.authSecret)
|
|
39
|
+
});
|
|
40
|
+
const verdict = await params.sender.send({
|
|
41
|
+
registration,
|
|
42
|
+
body
|
|
43
|
+
});
|
|
44
|
+
switch(verdict){
|
|
45
|
+
case 'delivered':
|
|
46
|
+
break;
|
|
47
|
+
case 'gone':
|
|
48
|
+
await params.registry.delete(did);
|
|
49
|
+
break;
|
|
50
|
+
case 'retry':
|
|
51
|
+
report(did, new Error('Wake send failed transiently'));
|
|
52
|
+
break;
|
|
53
|
+
default:
|
|
54
|
+
// Exhaustive over WakeVerdict: a future verdict must be handled explicitly here rather
|
|
55
|
+
// than silently falling through as 'delivered'.
|
|
56
|
+
verdict;
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
report(did, error);
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
}
|
|
63
|
+
function openWindow(did) {
|
|
64
|
+
const entry = {
|
|
65
|
+
timer: setTimeout(()=>{
|
|
66
|
+
const current = pending.get(did);
|
|
67
|
+
pending.delete(did);
|
|
68
|
+
if (current?.latest == null) return;
|
|
69
|
+
send(did, current.latest.topicID, current.latest.sequenceID, current.count);
|
|
70
|
+
// Traffic is still flowing, so a fresh window opens behind the summary rather than letting
|
|
71
|
+
// the next frame ping immediately and undo the coalescing.
|
|
72
|
+
pending.set(did, openWindow(did));
|
|
73
|
+
}, debounceMs),
|
|
74
|
+
latest: null,
|
|
75
|
+
count: 0
|
|
76
|
+
};
|
|
77
|
+
// A pending wake must never hold a process open by itself. `.unref` is a Node timer method
|
|
78
|
+
// absent from the DOM `Timeout` type; it only typechecks here because this package's
|
|
79
|
+
// tsconfig.json (unlike its siblings') sets `"types": ["node"]`. If hub-server is ever
|
|
80
|
+
// aligned to drop that, this line needs `(entry.timer as NodeJS.Timeout).unref?.()` or an
|
|
81
|
+
// equivalent cast.
|
|
82
|
+
entry.timer.unref?.();
|
|
83
|
+
return entry;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
notify ({ did, topicID, sequenceID }) {
|
|
87
|
+
if (disposed) return;
|
|
88
|
+
const entry = pending.get(did);
|
|
89
|
+
if (entry == null) {
|
|
90
|
+
pending.set(did, openWindow(did));
|
|
91
|
+
send(did, topicID, sequenceID, 1);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
entry.latest = {
|
|
95
|
+
topicID,
|
|
96
|
+
sequenceID
|
|
97
|
+
};
|
|
98
|
+
entry.count += 1;
|
|
99
|
+
},
|
|
100
|
+
online (did) {
|
|
101
|
+
const entry = pending.get(did);
|
|
102
|
+
if (entry == null) return;
|
|
103
|
+
clearTimeout(entry.timer);
|
|
104
|
+
pending.delete(did);
|
|
105
|
+
},
|
|
106
|
+
dispose () {
|
|
107
|
+
disposed = true;
|
|
108
|
+
for (const entry of pending.values())clearTimeout(entry.timer);
|
|
109
|
+
pending.clear();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kumiai/hub-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Hub server for Enkaku E2EE group message routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"hub",
|
|
@@ -26,17 +26,18 @@
|
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@enkaku/protocol": "^0.21.0",
|
|
29
|
-
"@enkaku/server": "^0.21.
|
|
30
|
-
"@kokuin/token": "^0.
|
|
29
|
+
"@enkaku/server": "^0.21.1",
|
|
30
|
+
"@kokuin/token": "^0.5.0",
|
|
31
31
|
"@sozai/codec": "^0.4.0",
|
|
32
32
|
"@sozai/event": "^0.1.3",
|
|
33
33
|
"@sozai/log": "^0.3.1",
|
|
34
|
-
"@kumiai/hub-protocol": "^0.
|
|
34
|
+
"@kumiai/hub-protocol": "^0.7.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@enkaku/client": "^0.21.0",
|
|
38
38
|
"@enkaku/transport": "^0.21.0",
|
|
39
|
-
"@kumiai/hub-conformance": "^0.
|
|
39
|
+
"@kumiai/hub-conformance": "^0.7.0",
|
|
40
|
+
"@kumiai/hub-wake": "^0.7.0"
|
|
40
41
|
},
|
|
41
42
|
"publishConfig": {
|
|
42
43
|
"access": "public"
|