wenay-common2 1.0.66 → 1.0.67

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/doc/ROADMAP.md CHANGED
@@ -35,98 +35,105 @@ This implies a useful split:
35
35
  - **API surface** remains facade-shaped and typed.
36
36
  - **Transport/routing layer** may substitute sockets and promote paths.
37
37
  - **Replay layer** resumes streams and mirrors from the last known `seq`.
38
- - **Authority layer** decides whether an incoming write is accepted, predicted, reconciled, or merged.
39
-
40
- Critical ordering rule: do **not** start with WebRTC/NAT plumbing or CRDTs. The next useful layer is a
41
- small route/account/policy coordinator with a fake/in-process transport adapter. Direct transport and
42
- multi-writer merge are expensive adapters; they only become safe after the coordinator state machine is
38
+ - **Authority layer** decides whether an incoming write is accepted, predicted, reconciled, or merged.
39
+
40
+ Critical ordering rule: do **not** start with WebRTC/NAT plumbing or CRDTs. The next useful layer is a
41
+ small route/account/policy coordinator with a fake/in-process transport adapter. Direct transport and
42
+ multi-writer merge are expensive adapters; they only become safe after the coordinator state machine is
43
43
  boring and well-tested.
44
44
 
45
- ### 0.1 Account route coordinator ("wrapper over wrappers") 🔴
46
-
47
- Some deployments need separate client/account identities to communicate directly when policy and
48
- network conditions allow it, while still allowing the server/relay to step back into the path later.
49
- This is not just a socket trick; it is an account-aware routing shell above the existing facade,
50
- mirror, and replay primitives.
51
-
52
- - **Account model:** every participant has its own account/session identity and a scoped facade/store
53
- set. Runtime-account maps are a dynamic keyspace, so they should look like `noStrict(accountMap)`
54
- rather than a fixed schema. Access checks stay in the facade/policy layer; `noStrict` is not an ACL.
55
- - **Wrapper over wrappers:** an app-level coordinator owns the set of per-account clients, exposed
56
- facades, mirrors, replay subscriptions, and route state. "State of other accounts" is represented as
57
- selected `Observe` mirrors/replay/offline resources, ideally started and stopped through
58
- `createStoreManager`, not as a new global store core.
59
- - **Route states:** a pair of accounts may be `relay`, `direct`, `direct+shadowRelay` (audit/observe
60
- copy), `blocked`, or `fallback`. Direct links are optional optimizations, useful for latency or
61
- traffic cost, not a semantic change.
62
- - **Re-interposition:** if NDA/privacy policy, audit, moderation, throttling, reauth, direct-link
63
- failure, or group topology changes require it, the relay must be able to re-enter the data path.
64
- This is the reverse of direct promotion: open the replacement route, resume from the last `seq`,
65
- switch consumers after catch-up, then close or demote the old route.
66
- - **Privacy rule:** direct account links are opt-in and policy-gated. Peers only receive the endpoint
67
- and session material needed for that specific relationship; no implicit broad account discovery.
68
-
69
- Concrete next API shape, not final names:
70
-
71
- ```ts
72
- createRouteCoordinator({
73
- policy,
74
- routes,
75
- resources,
76
- }) -> {
77
- pair(a, b),
78
- state(pair),
79
- promoteDirect(pair, opts?),
80
- reinterposeRelay(pair, reason?),
81
- block(pair, reason?),
82
- fallback(pair, reason?),
83
- onRoute(cb),
84
- }
85
- ```
86
-
87
- Policy must run **before** transport promotion:
88
-
89
- - `canDirect(pair, ctx)` - may these accounts attempt direct at all?
90
- - `mustRelay(pair, ctx)` - force relay path because of NDA/audit/moderation/reauth.
91
- - `mustShadowRelay(pair, ctx)` - allow direct payload path, but keep audit/observe copy.
92
- - `canExposeEndpoint(pair, ctx)` - whether signaling may reveal endpoint/session material.
93
- - `canReinterpose(pair, ctx)` - whether/when relay is allowed or required to step back in.
94
-
95
- Minimum state machine:
96
-
97
- - `relay` -> `direct:connecting` -> `direct` -> `relay:reinterposing` -> `relay`
98
- - `relay` -> `direct:connecting` -> `fallback` -> `relay`
99
- - `direct` -> `direct+shadowRelay`
100
- - any state -> `blocked`
101
-
102
- Required failure modes:
103
-
104
- - direct setup never completes: keep relay active and mark fallback;
105
- - replacement route catches up too slowly: keep old route active and fail the switch;
106
- - policy changes mid-stream: re-interpose relay through replay hand-off;
107
- - account reauth changes facade/ACL: rebuild route policy before accepting new writes;
108
- - endpoint/session material is revoked: close direct and resume relay from `seq`;
109
- - audit/shadow route lags: decide whether to throttle, fallback, or block by policy.
110
-
111
- Acceptance tests for 0.1:
112
-
113
- - policy denial: direct is never attempted;
114
- - direct promotion: old relay stays live until replacement catches up;
115
- - failed direct: old relay continues with no data gap;
116
- - re-interposition: direct -> relay resumes from `seq`;
117
- - `direct+shadowRelay`: direct path is active while relay/audit mirror observes;
118
- - revocation: direct closes and relay resumes without changing facade API;
119
- - account map uses `noStrict`, but all access checks live in policy/facade code;
120
- - `createStoreManager` starts/stops selected per-account mirrors without store-core changes.
121
-
122
- Open questions: exact policy object shape; signaling envelope; whether the relay sees payloads or only
123
- coordinates encrypted direct streams; app-facing route-state events; backpressure across multi-hop and
124
- direct paths; group topology beyond a pair of accounts.
125
-
126
- Status: 🔴 not started. This belongs to project-0 architecture: no store-core change, but it defines
127
- the account/routing shell that later direct-connection work plugs into. This is the next important
128
- roadmap item; WebRTC and CRDT work should wait until this state machine is proven with fake transports.
129
-
45
+ ### 0.1 Account route coordinator ("wrapper over wrappers") 🟡
46
+
47
+ Some deployments need separate client/account identities to communicate directly when policy and
48
+ network conditions allow it, while still allowing the server/relay to step back into the path later.
49
+ This is not just a socket trick; it is an account-aware routing shell above the existing facade,
50
+ mirror, and replay primitives.
51
+
52
+ - **Account model:** every participant has its own account/session identity and a scoped facade/store
53
+ set. Runtime-account maps are a dynamic keyspace, so they should look like `noStrict(accountMap)`
54
+ rather than a fixed schema. Access checks stay in the facade/policy layer; `noStrict` is not an ACL.
55
+ - **Wrapper over wrappers:** an app-level coordinator owns the set of per-account clients, exposed
56
+ facades, mirrors, replay subscriptions, and route state. "State of other accounts" is represented as
57
+ selected `Observe` mirrors/replay/offline resources, ideally started and stopped through
58
+ `createStoreManager`, not as a new global store core.
59
+ - **Route states:** a pair of accounts may be `relay`, `direct`, `direct+shadowRelay` (audit/observe
60
+ copy), `blocked`, or `fallback`. Direct links are optional optimizations, useful for latency or
61
+ traffic cost, not a semantic change.
62
+ - **Re-interposition:** if NDA/privacy policy, audit, moderation, throttling, reauth, direct-link
63
+ failure, or group topology changes require it, the relay must be able to re-enter the data path.
64
+ This is the reverse of direct promotion: open the replacement route, resume from the last `seq`,
65
+ switch consumers after catch-up, then close or demote the old route.
66
+ - **Privacy rule:** direct account links are opt-in and policy-gated. Peers only receive the endpoint
67
+ and session material needed for that specific relationship; no implicit broad account discovery.
68
+
69
+ Concrete next API shape, not final names:
70
+
71
+ ```ts
72
+ createRouteCoordinator({
73
+ policy,
74
+ routes,
75
+ resources,
76
+ }) -> {
77
+ pair(a, b),
78
+ state(pair),
79
+ promoteDirect(pair, opts?),
80
+ reinterposeRelay(pair, reason?),
81
+ block(pair, reason?),
82
+ fallback(pair, reason?),
83
+ onRoute(cb),
84
+ }
85
+ ```
86
+
87
+ Policy must run **before** transport promotion:
88
+
89
+ - `canDirect(pair, ctx)` - may these accounts attempt direct at all?
90
+ - `mustRelay(pair, ctx)` - force relay path because of NDA/audit/moderation/reauth.
91
+ - `mustShadowRelay(pair, ctx)` - allow direct payload path, but keep audit/observe copy.
92
+ - `canExposeEndpoint(pair, ctx)` - whether signaling may reveal endpoint/session material.
93
+ - `canReinterpose(pair, ctx)` - whether/when relay is allowed or required to step back in.
94
+
95
+ Minimum state machine:
96
+
97
+ - `relay` -> `direct:connecting` -> `direct` -> `relay:reinterposing` -> `relay`
98
+ - `relay` -> `direct:connecting` -> `fallback` -> `relay`
99
+ - `direct` -> `direct+shadowRelay`
100
+ - any state -> `blocked`
101
+
102
+ Required failure modes:
103
+
104
+ - direct setup never completes: keep relay active and mark fallback;
105
+ - replacement route catches up too slowly: keep old route active and fail the switch;
106
+ - policy changes mid-stream: re-interpose relay through replay hand-off;
107
+ - account reauth changes facade/ACL: rebuild route policy before accepting new writes;
108
+ - endpoint/session material is revoked: close direct and resume relay from `seq`;
109
+ - audit/shadow route lags: decide whether to throttle, fallback, or block by policy.
110
+
111
+ Acceptance tests for 0.1:
112
+
113
+ - policy denial: direct is never attempted;
114
+ - direct promotion: old relay stays live until replacement catches up;
115
+ - failed direct: old relay continues with no data gap;
116
+ - re-interposition: direct -> relay resumes from `seq`;
117
+ - `direct+shadowRelay`: direct path is active while relay/audit mirror observes;
118
+ - revocation: direct closes and relay resumes without changing facade API;
119
+ - account map uses `noStrict`, but all access checks live in policy/facade code;
120
+ - `createStoreManager` starts/stops selected per-account mirrors without store-core changes.
121
+
122
+ Open questions: signaling envelope; whether the relay sees payloads or only coordinates encrypted
123
+ direct streams; backpressure across multi-hop and direct paths; group topology beyond a pair of
124
+ accounts; `noStrict(accountMap)` / `createStoreManager` lifecycle integration for dynamic peer maps.
125
+
126
+ Status: 🟡 partial (2026-07-09, v1.0.67). Core implemented as `Replay.createRouteCoordinator`
127
+ (`src/Common/events/route-coordinator.ts`): `RouteConnector` contract (pure transport: open/close/state/
128
+ metrics/onFail/capabilities), all five policy hooks, the full state machine above (including
129
+ `direct+shadowRelay` audit copy, catch-up timeout, revocation auto-fallback, terminal `blocked`), data
130
+ continuity through `replayRouteSubscribe`. Acceptance oracle: `replay/route-coordinator.test.ts` over
131
+ fake in-process connectors — policy denial never touches transport, promotion keeps the old relay live,
132
+ failed/slow direct falls back gap-free, re-interposition resumes from `seq`, shadow relay observes the
133
+ switch window, revocation closes direct without facade changes, block is terminal. Still open: real
134
+ signaling adapter + WebRTC connector (steps 9-10 of `doc/target/webrtc-route-coordinator-tasks.md`) and
135
+ the account-map lifecycle integration.
136
+
130
137
  ## 1. Connection hand-off — relay ↔ direct promotion ("port forwarding") 🟡
131
138
 
132
139
  A relay/intermediary bootstraps a connection between two parties, then — on a signal — **steps out
@@ -0,0 +1,10 @@
1
+ # 1.0.67
2
+
3
+ - Add `Replay.createRouteCoordinator`: policy-gated relay <-> direct routing shell over pure transport connectors (ROADMAP 0.1 core).
4
+ - Add the `RouteConnector` contract: `open/close/state`, capabilities (`binary/ordered/reliable`), route label, `metrics` (rtt/pending), and `onFail` events — connectors stay pure transports, the coordinator alone owns route decisions.
5
+ - Route state machine: `relay -> direct:connecting -> direct | direct+shadowRelay`, re-interposition (`relay:reinterposing -> relay`), `fallback` on failed/slow/revoked direct, terminal `blocked`.
6
+ - Policy hooks run before any transport action: `canDirect`, `mustRelay`, `mustShadowRelay`, `canExposeEndpoint`, `canReinterpose`; denial means direct is never attempted.
7
+ - `promoteDirect({timeoutMs?})` treats policy denial and transport failure as result objects, not exceptions; a slow replacement route times out while the old route keeps flowing; direct `onFail` (endpoint revoked) auto-falls back to relay from the last `seq`.
8
+ - `direct+shadowRelay`: payload rides direct while `shadow(ref, ...ev)` receives the relay audit copy starting from the consumers' seq coordinate (the switch window never escapes the audit).
9
+ - Data continuity is `replayRouteSubscribe` underneath: every route change is gap-free and dup-free by the `seq` contract; consumer facade/authority semantics never learn the transport switched.
10
+ - Add acceptance oracle `replay/route-coordinator.test.ts` over fake in-process relay/direct connectors (policy denial, promotion, failed direct, catch-up timeout, shadow relay, revocation, block, store mirror over a switched route).
@@ -247,8 +247,8 @@ WebRTC future contract:
247
247
  - `transport:'webrtc'` is reserved and currently reports `state:'error'` on `start()`; it is not a hidden second transport.
248
248
  - Future WebRTC support must be explicit opt-in for sub-200ms human duplex. Signaling belongs on the existing socket/RPC control channel (offer/answer/ICE), and backend/AI access requires an SFU that re-emits media bytes into the same `Media` Listen/replay surface. Downstream RPC/replay/store consumers must not change.
249
249
 
250
- Oracle: `npx ts-node replay/media-socket.test.ts` checks header decode, plain Listen shape, `replay:true`, typed no-device state in Node, and real Socket.IO binary delivery.
251
-
250
+ Oracle: `npx ts-node replay/media-socket.test.ts` checks header decode, plain Listen shape, `replay:true`, typed no-device state in Node, and real Socket.IO binary delivery.
251
+
252
252
  ## 📈 exchange — params (`CParams`)
253
253
  ```
254
254
  class CParams / CParamsReadonly implements IParams
@@ -464,8 +464,8 @@ exposeReplay(replay) <-> replaySubscribe(remote, cb, {since?, onSeq?, staleMs?
464
464
  // on the line (RPC_STOP — e.g. the gate's loud sacred failure) surfaces via onError + off, never silence.
465
465
  // hint reaches the frame lambda on catch-up and on every explicit frame(seq, hint) call (pull); the push-gate's
466
466
  // drain recovery uses the line's DEFAULT condensation — client-specific rules/pace = the pull path.
467
- // off.ready (catch-up done) · off.seq() (reconnect point) · off.isStale()/off.lastTs(); reconnect = call again with {since: prev.seq()}
468
- replayRouteSubscribe(remote, cb, {label?, since?, onSeq?, onError?, onRoute?}) -> off & {ready, switch(nextRemote, {label?, since?, reset?, policy?, hint?}), seq(), label(), active()}
467
+ // off.ready (catch-up done) · off.seq() (reconnect point) · off.isStale()/off.lastTs(); reconnect = call again with {since: prev.seq()}
468
+ replayRouteSubscribe(remote, cb, {label?, since?, onSeq?, onError?, onRoute?}) -> off & {ready, switch(nextRemote, {label?, since?, reset?, policy?, hint?}), seq(), label(), active()}
469
469
  // transport hand-off helper: old route remains live, replacement subscribes+catches up from seq, then old closes; overlap is seq-deduped. Use for relay -> direct and direct -> relay over any ordered ReplayRemote.
470
470
  // DELIVERY CONTRACT (guaranteed, not best-effort): the subscriber's cb sees ONE uniform stream —
471
471
  // first delivery = the snapshot (keyframe as an event of the SAME type; store: root patch),
@@ -485,7 +485,33 @@ replayRouteSubscribe(remote, cb, {label?, since?, onSeq?, onError?, onRoute?}) -
485
485
  // of clock skew) + ENVELOPE-TS AGE checked at delivery (producer clock — a stale keyframe reports stale
486
486
  // IMMEDIATELY; clock-skew caveat: producer/client clocks may disagree, skewMs tolerance absorbs it, default 0).
487
487
  // A since-tail's historical ts never flaps mid-catch-up (one assessment after handover); off() disarms the timer.
488
- exposeStoreReplay(store, opts?) <-> syncStoreReplay(mirror, remote, opts?) // layer B: patch line; keyframe = root patch ({path: [], value: snapshot})
488
+ createRouteCoordinator({connect, policy?, shadow?, catchUpTimeoutMs?}) -> coordinator // policy-gated relay <-> direct routing shell over pure connectors (ROADMAP 0.1)
489
+ // LAYERING: a CONNECTOR is a pure transport (no route decisions) — {info: {label, kind: 'relay'|'direct',
490
+ // binary?, ordered?, reliable?}, open() -> ReplayRemote, close(), state(), metrics?() -> {rtt?, pending?},
491
+ // onFail?}; the COORDINATOR alone owns promotion/fallback (state machine + policy); data continuity is
492
+ // replayRouteSubscribe underneath, so ANY route change is gap-free and dup-free by the seq contract.
493
+ // WebRTC/NAT is deliberately absent here: a future datachannel is just another RouteConnector.
494
+ // connect(ref, kind) -> RouteConnector — transport factory per pair and kind (called per activation).
495
+ // policy hooks run BEFORE any transport action; absent hook = allowed, present hook must return true:
496
+ // canDirect (may the pair attempt direct) · mustRelay (force relay: NDA/audit/moderation — beats canDirect)
497
+ // · mustShadowRelay (direct payload + relay audit copy) · canExposeEndpoint (may signaling reveal
498
+ // endpoint/session material) · canReinterpose (may relay step back into the path).
499
+ // coordinator.pair(a, b) -> link (symmetric key: pair(a,b) == pair(b,a)) · state/promoteDirect/
500
+ // reinterposeRelay/fallback/block(pairOrKey, ...) · onRoute(cb) -> off (all transitions, all pairs)
501
+ // · pairs() · close()
502
+ // link: .subscribe(cb, opts?) -> off & {ready, seq(), label(), active()} // the pair's data: a replay stream
503
+ // that survives every route change; facade/authority semantics never learn the transport switched
504
+ // .promoteDirect({timeoutMs?, reason?}) -> Promise<{ok, state, reason?}> // policy denial and transport
505
+ // failure are EXPECTED OUTCOMES (result object), not exceptions; ops are serialized per link
506
+ // .reinterposeRelay(reason?) / .fallback(reason?) / .block(reason?) · .state() · .label() · .metrics() · .close()
507
+ // STATE MACHINE: relay -> direct:connecting -> direct | direct+shadowRelay; direct -> relay:reinterposing -> relay;
508
+ // failed/slow direct (timeoutMs) -> fallback (relay kept live the whole time, switch failed loudly);
509
+ // direct onFail (endpoint revoked, link died) -> auto fallback: close direct, resume relay from seq;
510
+ // any state -> blocked (terminal: subs closed, new subscribe throws, promote denied).
511
+ // direct+shadowRelay: payload rides direct while deps.shadow(ref, ...ev) receives the relay audit copy,
512
+ // starting from the consumers' seq coordinate — the switch window never escapes the audit.
513
+ // Acceptance oracle: replay/route-coordinator.test.ts (fake in-process relay/direct connectors).
514
+ exposeStoreReplay(store, opts?) <-> syncStoreReplay(mirror, remote, opts?) // layer B: patch line; keyframe = root patch ({path: [], value: snapshot})
489
515
  syncStoreReplayRoute(mirror, remote, opts?) -> off & {ready, switch(nextRemote, opts), seq(), label(), active()} // same patch fold, but route-replaceable for relay/direct promotion
490
516
  syncStoreReplayEach<T>(remote, cb, opts?) -> off & {store, ready, seq(), isStale(), lastTs()} // one-call per-key fold over the patch line (mirror + syncStoreReplay + store.each()); most-used surface — full contract + example in wenay-common2.md
491
517
  createOfflineStore({key, remote?, initial, storage, version?, debounceMs?, syncOpts?}) -> Promise<OfflineStore<T>>
@@ -3,3 +3,4 @@ export * from './replay-wire';
3
3
  export * from './replay-conflate';
4
4
  export * from './replay-history';
5
5
  export * from './replay-route';
6
+ export * from './route-coordinator';
@@ -19,3 +19,4 @@ __exportStar(require("./replay-wire"), exports);
19
19
  __exportStar(require("./replay-conflate"), exports);
20
20
  __exportStar(require("./replay-history"), exports);
21
21
  __exportStar(require("./replay-route"), exports);
22
+ __exportStar(require("./route-coordinator"), exports);
@@ -0,0 +1,274 @@
1
+ import { Listener } from './Listen';
2
+ import { ReplayRemote } from './replay-wire';
3
+ import { ReplayRouteSubscribeOpts } from './replay-route';
4
+ export type tRouteKind = 'relay' | 'direct';
5
+ export type tConnectorState = 'idle' | 'opening' | 'open' | 'closed' | 'failed';
6
+ export type RouteConnectorInfo = {
7
+ label: string;
8
+ kind: tRouteKind;
9
+ binary?: boolean;
10
+ ordered?: boolean;
11
+ reliable?: boolean;
12
+ };
13
+ export type RouteConnectorMetrics = {
14
+ rtt?: number;
15
+ pending?: number;
16
+ };
17
+ export type RouteConnector<Z extends any[] = any[]> = {
18
+ info: RouteConnectorInfo;
19
+ open: () => Promise<ReplayRemote<Z>> | ReplayRemote<Z>;
20
+ close: () => void;
21
+ state: () => tConnectorState;
22
+ metrics?: () => RouteConnectorMetrics;
23
+ onFail?: {
24
+ on: (cb: (reason?: unknown) => void) => any;
25
+ };
26
+ };
27
+ export type RoutePairRef = {
28
+ a: string;
29
+ b: string;
30
+ key: string;
31
+ };
32
+ export type RoutePolicyCtx = RoutePairRef & {
33
+ state: tRouteState;
34
+ reason?: unknown;
35
+ };
36
+ type tPolicyHook = (ctx: RoutePolicyCtx) => boolean | Promise<boolean>;
37
+ export type RoutePolicy = {
38
+ canDirect?: tPolicyHook;
39
+ mustRelay?: tPolicyHook;
40
+ mustShadowRelay?: tPolicyHook;
41
+ canExposeEndpoint?: tPolicyHook;
42
+ canReinterpose?: tPolicyHook;
43
+ };
44
+ export type tRouteState = 'relay' | 'direct:connecting' | 'direct' | 'direct+shadowRelay' | 'relay:reinterposing' | 'fallback' | 'blocked' | 'closed';
45
+ export type RouteChangeEvent = RoutePairRef & {
46
+ from: tRouteState;
47
+ to: tRouteState;
48
+ reason?: unknown;
49
+ };
50
+ export type RouteOpResult = {
51
+ ok: boolean;
52
+ state: tRouteState;
53
+ reason?: unknown;
54
+ };
55
+ export type PromoteDirectOpts = {
56
+ timeoutMs?: number;
57
+ reason?: unknown;
58
+ };
59
+ export type RouteCoordinatorDeps<Z extends any[] = any[]> = {
60
+ policy?: RoutePolicy;
61
+ connect: (ref: RoutePairRef, kind: tRouteKind) => RouteConnector<Z>;
62
+ shadow?: (ref: RoutePairRef, ...ev: Z) => void;
63
+ catchUpTimeoutMs?: number;
64
+ };
65
+ export declare function createRouteCoordinator<Z extends any[] = any[]>(deps: RouteCoordinatorDeps<Z>): {
66
+ pair(a: string, b: string): {
67
+ ref: RoutePairRef;
68
+ state: () => tRouteState;
69
+ reason: () => unknown;
70
+ label: () => string;
71
+ metrics: () => {
72
+ relay: {
73
+ rtt?: number;
74
+ pending?: number;
75
+ state: tConnectorState;
76
+ } | null;
77
+ direct: {
78
+ rtt?: number;
79
+ pending?: number;
80
+ state: tConnectorState;
81
+ } | null;
82
+ };
83
+ subscribe: (cb: Listener<Z>, opts?: Omit<ReplayRouteSubscribeOpts, "label">) => (() => void) & {
84
+ ready: Promise<void>;
85
+ seq: () => number;
86
+ label: () => string | undefined;
87
+ active: () => boolean;
88
+ };
89
+ promoteDirect: (opts?: PromoteDirectOpts) => Promise<RouteOpResult>;
90
+ reinterposeRelay: (reason?: unknown) => Promise<RouteOpResult>;
91
+ fallback: (reason?: unknown) => Promise<RouteOpResult>;
92
+ block: (reason?: unknown) => Promise<RouteOpResult>;
93
+ close: () => void;
94
+ };
95
+ state: (pairOrKey: {
96
+ ref: RoutePairRef;
97
+ state: () => tRouteState;
98
+ reason: () => unknown;
99
+ label: () => string;
100
+ metrics: () => {
101
+ relay: {
102
+ rtt?: number;
103
+ pending?: number;
104
+ state: tConnectorState;
105
+ } | null;
106
+ direct: {
107
+ rtt?: number;
108
+ pending?: number;
109
+ state: tConnectorState;
110
+ } | null;
111
+ };
112
+ subscribe: (cb: Listener<Z>, opts?: Omit<ReplayRouteSubscribeOpts, "label">) => (() => void) & {
113
+ ready: Promise<void>;
114
+ seq: () => number;
115
+ label: () => string | undefined;
116
+ active: () => boolean;
117
+ };
118
+ promoteDirect: (opts?: PromoteDirectOpts) => Promise<RouteOpResult>;
119
+ reinterposeRelay: (reason?: unknown) => Promise<RouteOpResult>;
120
+ fallback: (reason?: unknown) => Promise<RouteOpResult>;
121
+ block: (reason?: unknown) => Promise<RouteOpResult>;
122
+ close: () => void;
123
+ } | RoutePairRef | string) => tRouteState;
124
+ promoteDirect: (pairOrKey: {
125
+ ref: RoutePairRef;
126
+ state: () => tRouteState;
127
+ reason: () => unknown;
128
+ label: () => string;
129
+ metrics: () => {
130
+ relay: {
131
+ rtt?: number;
132
+ pending?: number;
133
+ state: tConnectorState;
134
+ } | null;
135
+ direct: {
136
+ rtt?: number;
137
+ pending?: number;
138
+ state: tConnectorState;
139
+ } | null;
140
+ };
141
+ subscribe: (cb: Listener<Z>, opts?: Omit<ReplayRouteSubscribeOpts, "label">) => (() => void) & {
142
+ ready: Promise<void>;
143
+ seq: () => number;
144
+ label: () => string | undefined;
145
+ active: () => boolean;
146
+ };
147
+ promoteDirect: (opts?: PromoteDirectOpts) => Promise<RouteOpResult>;
148
+ reinterposeRelay: (reason?: unknown) => Promise<RouteOpResult>;
149
+ fallback: (reason?: unknown) => Promise<RouteOpResult>;
150
+ block: (reason?: unknown) => Promise<RouteOpResult>;
151
+ close: () => void;
152
+ } | RoutePairRef | string, opts?: PromoteDirectOpts) => Promise<RouteOpResult>;
153
+ reinterposeRelay: (pairOrKey: {
154
+ ref: RoutePairRef;
155
+ state: () => tRouteState;
156
+ reason: () => unknown;
157
+ label: () => string;
158
+ metrics: () => {
159
+ relay: {
160
+ rtt?: number;
161
+ pending?: number;
162
+ state: tConnectorState;
163
+ } | null;
164
+ direct: {
165
+ rtt?: number;
166
+ pending?: number;
167
+ state: tConnectorState;
168
+ } | null;
169
+ };
170
+ subscribe: (cb: Listener<Z>, opts?: Omit<ReplayRouteSubscribeOpts, "label">) => (() => void) & {
171
+ ready: Promise<void>;
172
+ seq: () => number;
173
+ label: () => string | undefined;
174
+ active: () => boolean;
175
+ };
176
+ promoteDirect: (opts?: PromoteDirectOpts) => Promise<RouteOpResult>;
177
+ reinterposeRelay: (reason?: unknown) => Promise<RouteOpResult>;
178
+ fallback: (reason?: unknown) => Promise<RouteOpResult>;
179
+ block: (reason?: unknown) => Promise<RouteOpResult>;
180
+ close: () => void;
181
+ } | RoutePairRef | string, reason?: unknown) => Promise<RouteOpResult>;
182
+ fallback: (pairOrKey: {
183
+ ref: RoutePairRef;
184
+ state: () => tRouteState;
185
+ reason: () => unknown;
186
+ label: () => string;
187
+ metrics: () => {
188
+ relay: {
189
+ rtt?: number;
190
+ pending?: number;
191
+ state: tConnectorState;
192
+ } | null;
193
+ direct: {
194
+ rtt?: number;
195
+ pending?: number;
196
+ state: tConnectorState;
197
+ } | null;
198
+ };
199
+ subscribe: (cb: Listener<Z>, opts?: Omit<ReplayRouteSubscribeOpts, "label">) => (() => void) & {
200
+ ready: Promise<void>;
201
+ seq: () => number;
202
+ label: () => string | undefined;
203
+ active: () => boolean;
204
+ };
205
+ promoteDirect: (opts?: PromoteDirectOpts) => Promise<RouteOpResult>;
206
+ reinterposeRelay: (reason?: unknown) => Promise<RouteOpResult>;
207
+ fallback: (reason?: unknown) => Promise<RouteOpResult>;
208
+ block: (reason?: unknown) => Promise<RouteOpResult>;
209
+ close: () => void;
210
+ } | RoutePairRef | string, reason?: unknown) => Promise<RouteOpResult>;
211
+ block: (pairOrKey: {
212
+ ref: RoutePairRef;
213
+ state: () => tRouteState;
214
+ reason: () => unknown;
215
+ label: () => string;
216
+ metrics: () => {
217
+ relay: {
218
+ rtt?: number;
219
+ pending?: number;
220
+ state: tConnectorState;
221
+ } | null;
222
+ direct: {
223
+ rtt?: number;
224
+ pending?: number;
225
+ state: tConnectorState;
226
+ } | null;
227
+ };
228
+ subscribe: (cb: Listener<Z>, opts?: Omit<ReplayRouteSubscribeOpts, "label">) => (() => void) & {
229
+ ready: Promise<void>;
230
+ seq: () => number;
231
+ label: () => string | undefined;
232
+ active: () => boolean;
233
+ };
234
+ promoteDirect: (opts?: PromoteDirectOpts) => Promise<RouteOpResult>;
235
+ reinterposeRelay: (reason?: unknown) => Promise<RouteOpResult>;
236
+ fallback: (reason?: unknown) => Promise<RouteOpResult>;
237
+ block: (reason?: unknown) => Promise<RouteOpResult>;
238
+ close: () => void;
239
+ } | RoutePairRef | string, reason?: unknown) => Promise<RouteOpResult>;
240
+ onRoute: (cb: (ev: RouteChangeEvent) => void) => import("./Listen").ListenOff;
241
+ pairs: () => {
242
+ ref: RoutePairRef;
243
+ state: () => tRouteState;
244
+ reason: () => unknown;
245
+ label: () => string;
246
+ metrics: () => {
247
+ relay: {
248
+ rtt?: number;
249
+ pending?: number;
250
+ state: tConnectorState;
251
+ } | null;
252
+ direct: {
253
+ rtt?: number;
254
+ pending?: number;
255
+ state: tConnectorState;
256
+ } | null;
257
+ };
258
+ subscribe: (cb: Listener<Z>, opts?: Omit<ReplayRouteSubscribeOpts, "label">) => (() => void) & {
259
+ ready: Promise<void>;
260
+ seq: () => number;
261
+ label: () => string | undefined;
262
+ active: () => boolean;
263
+ };
264
+ promoteDirect: (opts?: PromoteDirectOpts) => Promise<RouteOpResult>;
265
+ reinterposeRelay: (reason?: unknown) => Promise<RouteOpResult>;
266
+ fallback: (reason?: unknown) => Promise<RouteOpResult>;
267
+ block: (reason?: unknown) => Promise<RouteOpResult>;
268
+ close: () => void;
269
+ }[];
270
+ close(): void;
271
+ };
272
+ export type RouteCoordinator<Z extends any[] = any[]> = ReturnType<typeof createRouteCoordinator<Z>>;
273
+ export type RouteLink<Z extends any[] = any[]> = ReturnType<RouteCoordinator<Z>['pair']>;
274
+ export {};
@@ -0,0 +1,316 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRouteCoordinator = createRouteCoordinator;
4
+ const Listen_1 = require("./Listen");
5
+ const replay_route_1 = require("./replay-route");
6
+ function unsubscribeHandle(handle) {
7
+ if (typeof handle == 'function') {
8
+ handle();
9
+ return;
10
+ }
11
+ if (typeof handle?.off == 'function')
12
+ handle.off();
13
+ else if (typeof handle?.unsubscribe == 'function')
14
+ handle.unsubscribe();
15
+ }
16
+ function lazyRemote(connector) {
17
+ let opened = null;
18
+ const get = () => opened ??= Promise.resolve(connector.open());
19
+ function lazyLine(pick) {
20
+ return {
21
+ on(cb) {
22
+ let off = null;
23
+ let dead = false;
24
+ get().then(function attachLazyLine(r) { if (!dead)
25
+ off = pick(r).on(cb); }, function swallowOpenError() { });
26
+ return function offLazyLine() { dead = true; unsubscribeHandle(off); };
27
+ },
28
+ };
29
+ }
30
+ return {
31
+ line: lazyLine(r => r.line),
32
+ frameLine: lazyLine(r => r.frameLine ?? r.line),
33
+ since: async (seq) => (await get()).since(seq),
34
+ keyframe: async () => (await get()).keyframe(),
35
+ frame: async (seq, hint) => {
36
+ const r = await get();
37
+ return r.frame ? r.frame(seq, hint) : null;
38
+ },
39
+ };
40
+ }
41
+ function createRouteCoordinator(deps) {
42
+ const { policy = {}, connect, shadow, catchUpTimeoutMs } = deps;
43
+ const links = new Map();
44
+ const [emitRoute, routeListen] = (0, Listen_1.listen)();
45
+ async function allowed(hook, ctx, absent = true) {
46
+ return hook ? !!(await hook(ctx)) : absent;
47
+ }
48
+ function pairKey(a, b) {
49
+ return a <= b ? a + '|' + b : b + '|' + a;
50
+ }
51
+ function createLink(a, b) {
52
+ const ref = { a, b, key: pairKey(a, b) };
53
+ let state = 'relay';
54
+ let lastReason;
55
+ let relayConn = null;
56
+ let relayRemote = null;
57
+ let directConn = null;
58
+ let directRemote = null;
59
+ let shadowSub = null;
60
+ const subs = new Set();
61
+ let opChain = Promise.resolve();
62
+ function chained(run) {
63
+ const p = opChain.then(run, run);
64
+ opChain = p.catch(() => { });
65
+ return p;
66
+ }
67
+ function setState(to, reason) {
68
+ if (state == 'closed')
69
+ return;
70
+ const from = state;
71
+ state = to;
72
+ lastReason = reason;
73
+ emitRoute({ ...ref, from, to, reason });
74
+ }
75
+ function ctx(reason) {
76
+ return { ...ref, state, reason };
77
+ }
78
+ function ensureRelay() {
79
+ if (!relayConn || relayConn.state() == 'closed' || relayConn.state() == 'failed') {
80
+ relayConn = connect(ref, 'relay');
81
+ relayRemote = lazyRemote(relayConn);
82
+ watchFail(relayConn, 'relay');
83
+ }
84
+ return relayRemote;
85
+ }
86
+ function watchFail(conn, kind) {
87
+ conn.onFail?.on(function onConnectorFail(reason) {
88
+ if (kind == 'relay') {
89
+ if (conn == relayConn)
90
+ emitRoute({ ...ref, from: state, to: state, reason });
91
+ return;
92
+ }
93
+ if (conn == directConn && (state == 'direct' || state == 'direct+shadowRelay')) {
94
+ demoteToRelay('fallback', reason).catch(() => { });
95
+ }
96
+ });
97
+ }
98
+ function currentRemote() {
99
+ const direct = (state == 'direct' || state == 'direct+shadowRelay') && directRemote;
100
+ return direct || ensureRelay();
101
+ }
102
+ function currentLabel() {
103
+ const direct = (state == 'direct' || state == 'direct+shadowRelay') && directConn;
104
+ return direct ? directConn.info.label : (relayConn?.info.label ?? 'relay');
105
+ }
106
+ async function switchSubs(remote, label, timeoutMs) {
107
+ const jobs = Array.from(subs, function switchOne(sub) { return sub.switch(remote, { label }); });
108
+ if (timeoutMs == null)
109
+ return Promise.all(jobs);
110
+ let timer;
111
+ const timeout = new Promise((_, reject) => {
112
+ timer = setTimeout(function catchUpTimeout() {
113
+ reject(new Error('route catch-up timeout: ' + label));
114
+ }, timeoutMs);
115
+ });
116
+ try {
117
+ await Promise.race([Promise.all(jobs), timeout]);
118
+ }
119
+ finally {
120
+ clearTimeout(timer);
121
+ }
122
+ }
123
+ function resyncStraySubs() {
124
+ const remote = currentRemote();
125
+ const label = currentLabel();
126
+ for (const sub of subs) {
127
+ if (sub.label() != label)
128
+ sub.switch(remote, { label }).catch(() => { });
129
+ }
130
+ }
131
+ function attachShadow() {
132
+ if (!shadow || shadowSub)
133
+ return;
134
+ const since = subs.size ? Math.min(...Array.from(subs, sub => sub.seq())) : -1;
135
+ shadowSub = (0, replay_route_1.replayRouteSubscribe)(ensureRelay(), function shadowTap(...ev) { shadow(ref, ...ev); }, { label: 'shadowRelay', since });
136
+ }
137
+ function dropShadow() {
138
+ shadowSub?.();
139
+ shadowSub = null;
140
+ }
141
+ function closeDirect() {
142
+ dropShadow();
143
+ directConn?.close();
144
+ directConn = null;
145
+ directRemote = null;
146
+ }
147
+ async function demoteToRelay(finalState, reason) {
148
+ return chained(async function demoteOp() {
149
+ if (state == 'blocked' || state == 'closed')
150
+ return { ok: false, state, reason: state };
151
+ if (state != 'direct' && state != 'direct+shadowRelay') {
152
+ if (finalState == 'fallback')
153
+ setState('fallback', reason);
154
+ return { ok: true, state };
155
+ }
156
+ if (finalState == 'relay' && !(await allowed(policy.canReinterpose, ctx(reason)))) {
157
+ return { ok: false, state, reason: 'policy: canReinterpose' };
158
+ }
159
+ setState('relay:reinterposing', reason);
160
+ const remote = ensureRelay();
161
+ try {
162
+ await switchSubs(remote, relayConn.info.label);
163
+ closeDirect();
164
+ setState(finalState, reason);
165
+ resyncStraySubs();
166
+ return { ok: true, state };
167
+ }
168
+ catch (e) {
169
+ setState(shadowSub ? 'direct+shadowRelay' : 'direct', e);
170
+ return { ok: false, state, reason: e };
171
+ }
172
+ });
173
+ }
174
+ async function promoteDirect(opts = {}) {
175
+ return chained(async function promoteOp() {
176
+ const { timeoutMs = catchUpTimeoutMs, reason } = opts;
177
+ if (state == 'blocked' || state == 'closed')
178
+ return { ok: false, state, reason: state };
179
+ if (state == 'direct' || state == 'direct+shadowRelay')
180
+ return { ok: true, state };
181
+ if (await allowed(policy.mustRelay, ctx(reason), false)) {
182
+ return { ok: false, state, reason: 'policy: mustRelay' };
183
+ }
184
+ if (!(await allowed(policy.canDirect, ctx(reason)))) {
185
+ return { ok: false, state, reason: 'policy: canDirect' };
186
+ }
187
+ if (!(await allowed(policy.canExposeEndpoint, ctx(reason)))) {
188
+ return { ok: false, state, reason: 'policy: canExposeEndpoint' };
189
+ }
190
+ const wantShadow = await allowed(policy.mustShadowRelay, ctx(reason), false);
191
+ setState('direct:connecting', reason);
192
+ const conn = connect(ref, 'direct');
193
+ const remote = lazyRemote(conn);
194
+ try {
195
+ await switchSubs(remote, conn.info.label, timeoutMs);
196
+ directConn = conn;
197
+ directRemote = remote;
198
+ watchFail(conn, 'direct');
199
+ if (wantShadow) {
200
+ setState('direct+shadowRelay', reason);
201
+ attachShadow();
202
+ }
203
+ else {
204
+ relayConn?.close();
205
+ relayConn = null;
206
+ relayRemote = null;
207
+ setState('direct', reason);
208
+ }
209
+ resyncStraySubs();
210
+ return { ok: true, state };
211
+ }
212
+ catch (e) {
213
+ conn.close();
214
+ try {
215
+ await switchSubs(ensureRelay(), relayConn.info.label);
216
+ }
217
+ catch { }
218
+ setState('fallback', e);
219
+ return { ok: false, state, reason: e };
220
+ }
221
+ });
222
+ }
223
+ function block(reason) {
224
+ return chained(async function blockOp() {
225
+ if (state == 'closed')
226
+ return { ok: false, state, reason: state };
227
+ closeDirect();
228
+ relayConn?.close();
229
+ relayConn = null;
230
+ relayRemote = null;
231
+ for (const sub of Array.from(subs))
232
+ sub();
233
+ subs.clear();
234
+ setState('blocked', reason);
235
+ return { ok: true, state };
236
+ });
237
+ }
238
+ function subscribe(cb, opts = {}) {
239
+ if (state == 'blocked' || state == 'closed') {
240
+ throw new Error('route coordinator: pair ' + ref.key + ' is ' + state);
241
+ }
242
+ const sub = (0, replay_route_1.replayRouteSubscribe)(currentRemote(), cb, { ...opts, label: currentLabel() });
243
+ subs.add(sub);
244
+ function off() {
245
+ subs.delete(sub);
246
+ sub();
247
+ }
248
+ return Object.assign(off, {
249
+ ready: sub.ready,
250
+ seq: sub.seq,
251
+ label: sub.label,
252
+ active: sub.active,
253
+ });
254
+ }
255
+ function close() {
256
+ if (state == 'closed')
257
+ return;
258
+ closeDirect();
259
+ relayConn?.close();
260
+ relayConn = null;
261
+ relayRemote = null;
262
+ for (const sub of Array.from(subs))
263
+ sub();
264
+ subs.clear();
265
+ links.delete(ref.key);
266
+ setState('closed');
267
+ }
268
+ const link = {
269
+ ref,
270
+ state: () => state,
271
+ reason: () => lastReason,
272
+ label: currentLabel,
273
+ metrics: () => ({
274
+ relay: relayConn ? { state: relayConn.state(), ...relayConn.metrics?.() } : null,
275
+ direct: directConn ? { state: directConn.state(), ...directConn.metrics?.() } : null,
276
+ }),
277
+ subscribe,
278
+ promoteDirect,
279
+ reinterposeRelay: (reason) => demoteToRelay('relay', reason),
280
+ fallback: (reason) => demoteToRelay('fallback', reason),
281
+ block,
282
+ close,
283
+ };
284
+ return link;
285
+ }
286
+ function resolve(pairOrKey) {
287
+ const key = typeof pairOrKey == 'string' ? pairOrKey
288
+ : 'ref' in pairOrKey ? pairOrKey.ref.key : pairOrKey.key;
289
+ const link = links.get(key);
290
+ if (!link)
291
+ throw new Error('route coordinator: unknown pair ' + key);
292
+ return link;
293
+ }
294
+ return {
295
+ pair(a, b) {
296
+ const key = pairKey(a, b);
297
+ let link = links.get(key);
298
+ if (!link || link.state() == 'closed') {
299
+ link = createLink(a, b);
300
+ links.set(key, link);
301
+ }
302
+ return link;
303
+ },
304
+ state: (pairOrKey) => resolve(pairOrKey).state(),
305
+ promoteDirect: (pairOrKey, opts) => resolve(pairOrKey).promoteDirect(opts),
306
+ reinterposeRelay: (pairOrKey, reason) => resolve(pairOrKey).reinterposeRelay(reason),
307
+ fallback: (pairOrKey, reason) => resolve(pairOrKey).fallback(reason),
308
+ block: (pairOrKey, reason) => resolve(pairOrKey).block(reason),
309
+ onRoute: (cb) => routeListen.on(cb),
310
+ pairs: () => Array.from(links.values()),
311
+ close() {
312
+ for (const link of Array.from(links.values()))
313
+ link.close();
314
+ },
315
+ };
316
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wenay-common2",
3
- "version": "1.0.66",
3
+ "version": "1.0.67",
4
4
  "description": "Common library",
5
5
  "strict": true,
6
6
  "main": "lib/index.js",