wenay-common2 1.0.66 → 1.0.68
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 +110 -94
- package/doc/changes/1.0.67.md +10 -0
- package/doc/changes/1.0.68.md +8 -0
- package/doc/wenay-common2-rare.md +56 -5
- package/lib/Common/events/replay-channel.d.ts +9 -0
- package/lib/Common/events/replay-channel.js +129 -0
- package/lib/Common/events/replay-index.d.ts +3 -0
- package/lib/Common/events/replay-index.js +3 -0
- package/lib/Common/events/route-coordinator.d.ts +274 -0
- package/lib/Common/events/route-coordinator.js +316 -0
- package/lib/Common/events/route-signal-webrtc.d.ts +83 -0
- package/lib/Common/events/route-signal-webrtc.js +245 -0
- package/package.json +1 -1
package/doc/ROADMAP.md
CHANGED
|
@@ -35,98 +35,111 @@ 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:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
Status:
|
|
127
|
-
|
|
128
|
-
|
|
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: whether the relay sees payloads or only coordinates encrypted direct streams;
|
|
123
|
+
backpressure across multi-hop and direct paths; group topology beyond a pair of accounts;
|
|
124
|
+
`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.
|
|
134
|
+
v1.0.68 added step 9: `createSignalHub` (offer/answer/ICE/session/revoke over the EXISTING socket/RPC
|
|
135
|
+
control channel; `authorize` = server-side `canExposeEndpoint`), `createWebRtcConnector` /
|
|
136
|
+
`acceptWebRtcDirect` (RTCPeerConnection injected as a runtime factory, structural types, no lib.dom),
|
|
137
|
+
and `serveReplayChannel`/`channelReplayRemote` (replay wire over any ordered channel — the datachannel
|
|
138
|
+
path bypasses the RPC core by design). Oracle `replay/route-webrtc.test.ts` drives promotion, endpoint
|
|
139
|
+
denial, session rejection, and server revoke over both an in-proc hub and a real Socket.IO/RPC wire.
|
|
140
|
+
Still open: browser/Node WebRTC glue (step 10 — now a one-line `rtc` factory plus media re-emit) and
|
|
141
|
+
the account-map lifecycle integration (step 7).
|
|
142
|
+
|
|
130
143
|
## 1. Connection hand-off — relay ↔ direct promotion ("port forwarding") 🟡
|
|
131
144
|
|
|
132
145
|
A relay/intermediary bootstraps a connection between two parties, then — on a signal — **steps out
|
|
@@ -147,10 +160,13 @@ starts flowing on the new path.
|
|
|
147
160
|
catch it up from the last delivered `seq`, then close the old route. This covers relay → direct
|
|
148
161
|
promotion and direct → relay re-interposition for any ordered `ReplayRemote`; overlap is deduped by
|
|
149
162
|
`seq`, and a failed replacement leaves the old route active.
|
|
150
|
-
- Open questions:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
163
|
+
- Open questions: auth continuity across the swap; per-socketKey vs whole-connection hand-off;
|
|
164
|
+
policy trigger rules for direct → relay re-interposition beyond explicit calls and `onFail`.
|
|
165
|
+
- Status: 🟡 mostly done. Route hand-off/resume: `replay/route-handoff.test.ts`. Route decisions +
|
|
166
|
+
state machine: `createRouteCoordinator` (v1.0.67). Signaling + direct endpoint negotiation over the
|
|
167
|
+
existing control channel and fallback-if-never-establishes: `createSignalHub` /
|
|
168
|
+
`createWebRtcConnector` / `acceptWebRtcDirect` (v1.0.68, `replay/route-webrtc.test.ts`). Remaining:
|
|
169
|
+
real NAT/WebRTC runtime glue (injected `rtc` factory) and auth-continuity policy.
|
|
154
170
|
|
|
155
171
|
## 2. Coordinated fan-out send to a large group 🧊
|
|
156
172
|
|
|
@@ -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).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# 1.0.68
|
|
2
|
+
|
|
3
|
+
- Add `Replay.createSignalHub`: WebRTC signaling (offer/answer/ICE/session/revoke envelopes) routed between accounts over the EXISTING socket/RPC control channel — the port shape `{send, signals}` is exposable by `createRpcServerAuto` as is; `authorize(env)` is the server-side `canExposeEndpoint` enforcement point, `from`-spoofing is cut at the port.
|
|
4
|
+
- Add `Replay.createWebRtcConnector`: the direct `RouteConnector` for `createRouteCoordinator` — `open()` negotiates offer/answer/ICE through a signal port and returns a replay wire over the datachannel. `RTCPeerConnection` is NOT bundled: `rtc` is an injected runtime factory (browser `() => new RTCPeerConnection(cfg)`, Node werift/node-datachannel, tests an in-proc fake) over structural `RtcPeerConnection`/`RtcDataChannel` types (no lib.dom).
|
|
5
|
+
- Add `Replay.acceptWebRtcDirect`: responder side — answers offers, serves `exposeReplay(...)` into the incoming datachannel; `accept(env)` validates opaque session material and rejects with a loud `revoke` so the initiator fails fast instead of timing out.
|
|
6
|
+
- Add `Replay.serveReplayChannel` / `channelReplayRemote` / `channelFromDataChannel`: replay wire over any ordered string channel (datachannel/MessagePort/worker/pipe) via a tiny JSON sub/req/res protocol — no RPC core on the direct path; channel close surfaces as a non-envelope (loud `onError`), never silence.
|
|
7
|
+
- Revoke/close signals and channel death during `open()` abort the pending promotion immediately (no `openTimeoutMs` wait); the coordinator auto-falls back to relay from the last `seq`.
|
|
8
|
+
- Add oracle `replay/route-webrtc.test.ts`: fake in-proc RTC runtime + signaling hub (promotion, server-side endpoint denial, bad session revoke, server revoke fallback, recovery into direct) and the same signaling driven over a real Socket.IO/RPC wire.
|
|
@@ -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,58 @@ 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
|
-
|
|
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
|
+
createSignalHub({authorize?}) -> {register(account) -> {send, signals, close}, revoke(pair, accounts, reason?), accounts(), close()}
|
|
515
|
+
// WebRTC signaling over the EXISTING control channel: the port shape {send, signals} is a function +
|
|
516
|
+
// Listen — exactly what createRpcServerAuto exposes, so the relay socket IS the signaling wire
|
|
517
|
+
// (per connection: const port = hub.register(account); object = {send: port.send, signals: port.signals}).
|
|
518
|
+
// authorize(env) is the SERVER-side canExposeEndpoint point — endpoint/session material is revealed only
|
|
519
|
+
// past it; client-side coordinator policy stays advisory. from-spoofing is cut at the port.
|
|
520
|
+
// SignalEnvelope = {type: 'offer'|'answer'|'ice'|'revoke'|'close', pair, from, to, sdp?, candidate?,
|
|
521
|
+
// session?, reason?} — session is opaque auth material (the wire never looks inside).
|
|
522
|
+
createWebRtcConnector({port, rtc, self, peer, pair, session?, label?, openTimeoutMs?}) -> RouteConnector
|
|
523
|
+
// the direct connector for createRouteCoordinator: open() drives offer/answer/ICE through the signal
|
|
524
|
+
// port, waits for the datachannel, returns a replay wire over it. RTCPeerConnection is NOT bundled:
|
|
525
|
+
// rtc is a runtime factory — browser `() => new RTCPeerConnection(cfg)`, Node werift/node-datachannel,
|
|
526
|
+
// tests an in-proc fake (RtcPeerConnection/RtcDataChannel are structural types, no lib.dom).
|
|
527
|
+
// revoke/close signals and channel death (incl. DURING open) fail loudly -> coordinator auto-fallback.
|
|
528
|
+
acceptWebRtcDirect({port, rtc, self, serve, accept?}) -> close()
|
|
529
|
+
// responder side: on offer, negotiates answer/ICE and serves serve(env) (exposeReplay(...) as is) into
|
|
530
|
+
// the incoming datachannel; accept(env) validates session material and rejects with a loud revoke
|
|
531
|
+
// (the initiator fails fast, not by timeout). Repeated offer for a pair recreates the session.
|
|
532
|
+
serveReplayChannel(source, channel) <-> channelReplayRemote(channel) -> ReplayRemote
|
|
533
|
+
// replay wire over ANY ordered string channel (datachannel/MessagePort/worker/pipe): tiny JSON
|
|
534
|
+
// sub/req/res protocol, no RPC core — a direct channel lives OUTSIDE the main rpc connection.
|
|
535
|
+
// Channel close = non-envelope (null) on the line: replay subscribers report onError, never silence.
|
|
536
|
+
// ReplayMessageChannel = {send, onMessage, onClose?, close?}; channelFromDataChannel(dc) adapts a
|
|
537
|
+
// datachannel (and owns its handlers). Oracle: replay/route-webrtc.test.ts (fake RTC runtime +
|
|
538
|
+
// in-proc hub + the same signaling over a real Socket.IO/RPC wire).
|
|
539
|
+
exposeStoreReplay(store, opts?) <-> syncStoreReplay(mirror, remote, opts?) // layer B: patch line; keyframe = root patch ({path: [], value: snapshot})
|
|
489
540
|
syncStoreReplayRoute(mirror, remote, opts?) -> off & {ready, switch(nextRemote, opts), seq(), label(), active()} // same patch fold, but route-replaceable for relay/direct promotion
|
|
490
541
|
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
542
|
createOfflineStore({key, remote?, initial, storage, version?, debounceMs?, syncOpts?}) -> Promise<OfflineStore<T>>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ReplayRemote } from './replay-wire';
|
|
2
|
+
export type ReplayMessageChannel = {
|
|
3
|
+
send: (data: string) => void;
|
|
4
|
+
onMessage: (cb: (data: string) => void) => (() => void) | void;
|
|
5
|
+
onClose?: (cb: () => void) => (() => void) | void;
|
|
6
|
+
close?: () => void;
|
|
7
|
+
};
|
|
8
|
+
export declare function serveReplayChannel<Z extends any[]>(source: ReplayRemote<Z>, channel: ReplayMessageChannel): () => void;
|
|
9
|
+
export declare function channelReplayRemote<Z extends any[]>(channel: ReplayMessageChannel): ReplayRemote<Z>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.serveReplayChannel = serveReplayChannel;
|
|
4
|
+
exports.channelReplayRemote = channelReplayRemote;
|
|
5
|
+
function unsubscribeHandle(handle) {
|
|
6
|
+
if (typeof handle == 'function') {
|
|
7
|
+
handle();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (typeof handle?.off == 'function')
|
|
11
|
+
handle.off();
|
|
12
|
+
else if (typeof handle?.unsubscribe == 'function')
|
|
13
|
+
handle.unsubscribe();
|
|
14
|
+
}
|
|
15
|
+
function serveReplayChannel(source, channel) {
|
|
16
|
+
let lineOff = null;
|
|
17
|
+
let closed = false;
|
|
18
|
+
async function handleRequest(msg) {
|
|
19
|
+
try {
|
|
20
|
+
const v = msg.m == 'since' ? await source.since(msg.a[0])
|
|
21
|
+
: msg.m == 'keyframe' ? await source.keyframe()
|
|
22
|
+
: msg.m == 'frame' ? (source.frame ? await source.frame(msg.a[0], msg.a[1]) : null)
|
|
23
|
+
: undefined;
|
|
24
|
+
if (!closed)
|
|
25
|
+
channel.send(JSON.stringify({ t: 'res', id: msg.id, ok: true, v: v ?? null }));
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
if (!closed)
|
|
29
|
+
channel.send(JSON.stringify({ t: 'res', id: msg.id, ok: false, e: String(e) }));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const offMsg = channel.onMessage(function onReplayChannelMessage(raw) {
|
|
33
|
+
if (closed)
|
|
34
|
+
return;
|
|
35
|
+
let msg;
|
|
36
|
+
try {
|
|
37
|
+
msg = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (msg?.t == 'sub' && !lineOff) {
|
|
43
|
+
lineOff = source.line.on(function forwardEnvelope(ev) {
|
|
44
|
+
if (!closed)
|
|
45
|
+
channel.send(JSON.stringify({ t: 'ev', ev }));
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (msg?.t == 'req')
|
|
50
|
+
void handleRequest(msg);
|
|
51
|
+
});
|
|
52
|
+
function close() {
|
|
53
|
+
if (closed)
|
|
54
|
+
return;
|
|
55
|
+
closed = true;
|
|
56
|
+
unsubscribeHandle(lineOff);
|
|
57
|
+
lineOff = null;
|
|
58
|
+
if (typeof offMsg == 'function')
|
|
59
|
+
offMsg();
|
|
60
|
+
}
|
|
61
|
+
channel.onClose?.(close);
|
|
62
|
+
return close;
|
|
63
|
+
}
|
|
64
|
+
function channelReplayRemote(channel) {
|
|
65
|
+
let nextId = 1;
|
|
66
|
+
let subscribed = false;
|
|
67
|
+
let closed = false;
|
|
68
|
+
const pending = new Map();
|
|
69
|
+
const lineCbs = new Set();
|
|
70
|
+
channel.onMessage(function onRemoteChannelMessage(raw) {
|
|
71
|
+
let msg;
|
|
72
|
+
try {
|
|
73
|
+
msg = JSON.parse(raw);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (msg?.t == 'ev') {
|
|
79
|
+
for (const cb of Array.from(lineCbs))
|
|
80
|
+
cb(msg.ev);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (msg?.t == 'res') {
|
|
84
|
+
const p = pending.get(msg.id);
|
|
85
|
+
pending.delete(msg.id);
|
|
86
|
+
if (!p)
|
|
87
|
+
return;
|
|
88
|
+
if (msg.ok)
|
|
89
|
+
p.resolve(msg.v);
|
|
90
|
+
else
|
|
91
|
+
p.reject(new Error(msg.e ?? 'replay channel request failed'));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
channel.onClose?.(function onRemoteChannelClosed() {
|
|
95
|
+
if (closed)
|
|
96
|
+
return;
|
|
97
|
+
closed = true;
|
|
98
|
+
for (const [, p] of pending)
|
|
99
|
+
p.reject(new Error('replay channel closed'));
|
|
100
|
+
pending.clear();
|
|
101
|
+
for (const cb of Array.from(lineCbs))
|
|
102
|
+
cb(null);
|
|
103
|
+
lineCbs.clear();
|
|
104
|
+
});
|
|
105
|
+
function req(m, a) {
|
|
106
|
+
if (closed)
|
|
107
|
+
return Promise.reject(new Error('replay channel closed'));
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const id = nextId++;
|
|
110
|
+
pending.set(id, { resolve, reject });
|
|
111
|
+
channel.send(JSON.stringify({ t: 'req', id, m, a }));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
line: {
|
|
116
|
+
on(cb) {
|
|
117
|
+
lineCbs.add(cb);
|
|
118
|
+
if (!subscribed && !closed) {
|
|
119
|
+
subscribed = true;
|
|
120
|
+
channel.send(JSON.stringify({ t: 'sub' }));
|
|
121
|
+
}
|
|
122
|
+
return function offChannelLine() { lineCbs.delete(cb); };
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
since: seq => req('since', [seq]),
|
|
126
|
+
keyframe: () => req('keyframe', []),
|
|
127
|
+
frame: (seq, hint) => req('frame', [seq, hint]),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -19,3 +19,6 @@ __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);
|
|
23
|
+
__exportStar(require("./replay-channel"), exports);
|
|
24
|
+
__exportStar(require("./route-signal-webrtc"), exports);
|