wenay-common2 1.0.67 β 1.0.69
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 +2 -0
- package/doc/ROADMAP.md +28 -13
- package/doc/changes/1.0.68.md +8 -0
- package/doc/changes/1.0.69.md +11 -0
- package/doc/wenay-common2-rare.md +33 -0
- package/doc/wenay-common2.md +42 -6
- 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 +2 -0
- package/lib/Common/events/replay-index.js +2 -0
- package/lib/Common/events/route-signal-webrtc.d.ts +83 -0
- package/lib/Common/events/route-signal-webrtc.js +247 -0
- package/lib/Common/peer/peer-client.d.ts +46 -0
- package/lib/Common/peer/peer-client.js +108 -0
- package/lib/Common/peer/peer-host.d.ts +32 -0
- package/lib/Common/peer/peer-host.js +45 -0
- package/lib/Common/peer/peer-index.d.ts +3 -0
- package/lib/Common/peer/peer-index.js +19 -0
- package/lib/Common/peer/peer-relay.d.ts +14 -0
- package/lib/Common/peer/peer-relay.js +77 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +2 -1
- package/package.json +3 -2
package/README.md
CHANGED
package/doc/ROADMAP.md
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
> `createStoreMirror`, and the replay `seq` / `keyframe` / `frame` contract. None of it requires
|
|
6
6
|
> changing the store core; the store stays single-authority (one `seq` sequencer, last-writer-wins
|
|
7
7
|
> per path), and these features are layers or adapters above that.
|
|
8
|
-
> Status: π΄ not started Β· π‘ partial / ongoing Β· π§
|
|
8
|
+
> Status: π΄ not started Β· π‘ partial / ongoing Β· π§ deferred (super-low priority, not forbidden).
|
|
9
|
+
>
|
|
10
|
+
> Current focus is NOT more transport: see `doc/target/library-uplift-tasks.md` (showcase, SDK facade,
|
|
11
|
+
> vertical demo app). Transport items below stay available but rank below everything in that plan.
|
|
9
12
|
|
|
10
13
|
## 0. Distributed Runtime Model
|
|
11
14
|
|
|
@@ -119,9 +122,9 @@ Acceptance tests for 0.1:
|
|
|
119
122
|
- account map uses `noStrict`, but all access checks live in policy/facade code;
|
|
120
123
|
- `createStoreManager` starts/stops selected per-account mirrors without store-core changes.
|
|
121
124
|
|
|
122
|
-
Open questions:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
Open questions: whether the relay sees payloads or only coordinates encrypted direct streams;
|
|
126
|
+
backpressure across multi-hop and direct paths; group topology beyond a pair of accounts;
|
|
127
|
+
`noStrict(accountMap)` / `createStoreManager` lifecycle integration for dynamic peer maps.
|
|
125
128
|
|
|
126
129
|
Status: π‘ partial (2026-07-09, v1.0.67). Core implemented as `Replay.createRouteCoordinator`
|
|
127
130
|
(`src/Common/events/route-coordinator.ts`): `RouteConnector` contract (pure transport: open/close/state/
|
|
@@ -130,9 +133,15 @@ metrics/onFail/capabilities), all five policy hooks, the full state machine abov
|
|
|
130
133
|
continuity through `replayRouteSubscribe`. Acceptance oracle: `replay/route-coordinator.test.ts` over
|
|
131
134
|
fake in-process connectors β policy denial never touches transport, promotion keeps the old relay live,
|
|
132
135
|
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
|
-
|
|
135
|
-
|
|
136
|
+
switch window, revocation closes direct without facade changes, block is terminal.
|
|
137
|
+
v1.0.68 added step 9: `createSignalHub` (offer/answer/ICE/session/revoke over the EXISTING socket/RPC
|
|
138
|
+
control channel; `authorize` = server-side `canExposeEndpoint`), `createWebRtcConnector` /
|
|
139
|
+
`acceptWebRtcDirect` (RTCPeerConnection injected as a runtime factory, structural types, no lib.dom),
|
|
140
|
+
and `serveReplayChannel`/`channelReplayRemote` (replay wire over any ordered channel β the datachannel
|
|
141
|
+
path bypasses the RPC core by design). Oracle `replay/route-webrtc.test.ts` drives promotion, endpoint
|
|
142
|
+
denial, session rejection, and server revoke over both an in-proc hub and a real Socket.IO/RPC wire.
|
|
143
|
+
Still open: browser/Node WebRTC glue (step 10 β now a one-line `rtc` factory plus media re-emit) and
|
|
144
|
+
the account-map lifecycle integration (step 7).
|
|
136
145
|
|
|
137
146
|
## 1. Connection hand-off β relay β direct promotion ("port forwarding") π‘
|
|
138
147
|
|
|
@@ -154,10 +163,13 @@ starts flowing on the new path.
|
|
|
154
163
|
catch it up from the last delivered `seq`, then close the old route. This covers relay β direct
|
|
155
164
|
promotion and direct β relay re-interposition for any ordered `ReplayRemote`; overlap is deduped by
|
|
156
165
|
`seq`, and a failed replacement leaves the old route active.
|
|
157
|
-
- Open questions:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
- Open questions: auth continuity across the swap; per-socketKey vs whole-connection hand-off;
|
|
167
|
+
policy trigger rules for direct β relay re-interposition beyond explicit calls and `onFail`.
|
|
168
|
+
- Status: π‘ mostly done. Route hand-off/resume: `replay/route-handoff.test.ts`. Route decisions +
|
|
169
|
+
state machine: `createRouteCoordinator` (v1.0.67). Signaling + direct endpoint negotiation over the
|
|
170
|
+
existing control channel and fallback-if-never-establishes: `createSignalHub` /
|
|
171
|
+
`createWebRtcConnector` / `acceptWebRtcDirect` (v1.0.68, `replay/route-webrtc.test.ts`). Remaining:
|
|
172
|
+
real NAT/WebRTC runtime glue (injected `rtc` factory) and auth-continuity policy.
|
|
161
173
|
|
|
162
174
|
## 2. Coordinated fan-out send to a large group π§
|
|
163
175
|
|
|
@@ -186,7 +198,9 @@ source of truth. Decompose by write topology:
|
|
|
186
198
|
silently drop a concurrent write. This is the one case that genuinely needs CRDT/OT. Design lead:
|
|
187
199
|
wrap a Yjs/Automerge doc as a `RemoteStore`-shaped source and mirror from it β the storeβtransport
|
|
188
200
|
decoupling makes this a small adapter, not a rewrite.
|
|
189
|
-
- Status:
|
|
201
|
+
- Status: π§ deferred, super-low priority. Prediction layer waits for the demo app to demand and shape
|
|
202
|
+
it (`doc/target/library-uplift-tasks.md` task 4); CRDT adapter reopens only on a real co-write need.
|
|
203
|
+
Partitioned-authority already expressible today.
|
|
190
204
|
|
|
191
205
|
## 4. Data-transfer optimization backlog (ongoing) π‘
|
|
192
206
|
|
|
@@ -194,4 +208,5 @@ Open-ended transfer/perf work, especially for backend-heavy models. Never "done"
|
|
|
194
208
|
|
|
195
209
|
- Candidates: tighter `frame` condensation per line; delta/patch minimization; binary framing for hot
|
|
196
210
|
paths; batching heuristics beyond the current `pipe` / `space` modes.
|
|
197
|
-
- Status:
|
|
211
|
+
- Status: π§ deferred, super-low priority; pick items only as real bottlenecks surface in the SDK/demo
|
|
212
|
+
consumers, not speculatively.
|
|
@@ -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.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# 1.0.69
|
|
2
|
+
|
|
3
|
+
- Fix `createWebRtcConnector` / `acceptWebRtcDirect`: `RTCIceCandidate` now rides the signaling wire as its `toJSON()` init β real browser candidate class instances were mangled by transport serialization, so direct never established (caught by the live demo stand, invisible to fakes).
|
|
4
|
+
- Add the demo stand `demo/` (`npm run demo`): shared cursors in two browser tabs over the Peer SDK next to a legacy rpc key β relay mirroring, "Go direct" (real `RTCPeerConnection`), "Back to relay" re-interposition, signaling/route event log. Verified live in Chrome: seq continues across both hand-offs, no keyframe reset, cursor never jumps.
|
|
5
|
+
|
|
6
|
+
- Add the `Peer` namespace (`wenay-common2/peer`) β the one-call SDK over rpc + store + replay + route coordinator, legacy-friendly by design: the server side is an object fragment spread into an EXISTING `createRpcServerAuto` object (old keys untouched), the client side rides the existing connection's deep proxy.
|
|
7
|
+
- `Peer.createPeerHost({authorize?, history?})`: per-account signaling ports + relay journals + a `noStrict` dynamic `peers` feed; `authorize` is the server-side `canExposeEndpoint`; `revoke(pair, accounts)` pushes both parties back to relay.
|
|
8
|
+
- `Peer.createPeerClient({remote, account, initial, rtc?, ...})`: own store published as a patch line; `peer(account)` returns a live mirrored store + route control (`promoteDirect/reinterposeRelay/fallback/block`, `route()`, `ready`, `seq()`); mirrors survive any route change; omitting `rtc` gives a relay-only client.
|
|
9
|
+
- `Peer.createPatchRelayJournal({history?})`: server-side journal of OWNER-sequenced envelopes β relay and direct share the owner's seq space, so a relay <-> direct hand-off is a plain seq resume (no keyframe reset); keyframe is folded server-side, late joiners work while the owner is offline.
|
|
10
|
+
- Add GitHub Actions CI (build + publish gate + full oracle suite on windows-latest) and a README badge.
|
|
11
|
+
- Add oracle `replay/peer-sdk.test.ts`: two accounts over a real Socket.IO/RPC wire next to a legacy rpc key β relay mirroring, direct promotion via injected fake WebRTC runtime, same-seq-space hand-off assertion, server revoke fallback, late joiner from the folded keyframe, loud refusal of `promoteDirect` without an `rtc` factory.
|
|
@@ -511,6 +511,39 @@ createRouteCoordinator({connect, policy?, shadow?, catchUpTimeoutMs?}) -> coordi
|
|
|
511
511
|
// direct+shadowRelay: payload rides direct while deps.shadow(ref, ...ev) receives the relay audit copy,
|
|
512
512
|
// starting from the consumers' seq coordinate β the switch window never escapes the audit.
|
|
513
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
|
+
Peer.createPatchRelayJournal({history?}) -> {push(env), remote, seq(), snapshot(), close()}
|
|
533
|
+
// server-side mirror of an OWNER-sequenced patch line: push() takes the owner's envelopes VERBATIM
|
|
534
|
+
// (dedup by seq; a root patch with a LOWER seq = owner restart, legitimate reset point), keyframe is
|
|
535
|
+
// folded server-side (late joiners don't need the owner online), frame condenses last-patch-per-path.
|
|
536
|
+
// Owner seq space is the point: relay and direct routes share coordinates -> hand-off is a seq resume.
|
|
537
|
+
// remote is ReplayRemote-shaped and rpc-exposable as is (line is a REAL Listen β the rpc layer detects
|
|
538
|
+
// listen nodes by registry, a hand-rolled {on: cb => ...} wrapper would not stream).
|
|
539
|
+
// The full SDK on top (createPeerHost/createPeerClient) is most-used surface -> wenay-common2.md.
|
|
540
|
+
serveReplayChannel(source, channel) <-> channelReplayRemote(channel) -> ReplayRemote
|
|
541
|
+
// replay wire over ANY ordered string channel (datachannel/MessagePort/worker/pipe): tiny JSON
|
|
542
|
+
// sub/req/res protocol, no RPC core β a direct channel lives OUTSIDE the main rpc connection.
|
|
543
|
+
// Channel close = non-envelope (null) on the line: replay subscribers report onError, never silence.
|
|
544
|
+
// ReplayMessageChannel = {send, onMessage, onClose?, close?}; channelFromDataChannel(dc) adapts a
|
|
545
|
+
// datachannel (and owns its handlers). Oracle: replay/route-webrtc.test.ts (fake RTC runtime +
|
|
546
|
+
// in-proc hub + the same signaling over a real Socket.IO/RPC wire).
|
|
514
547
|
exposeStoreReplay(store, opts?) <-> syncStoreReplay(mirror, remote, opts?) // layer B: patch line; keyframe = root patch ({path: [], value: snapshot})
|
|
515
548
|
syncStoreReplayRoute(mirror, remote, opts?) -> off & {ready, switch(nextRemote, opts), seq(), label(), active()} // same patch fold, but route-replaceable for relay/direct promotion
|
|
516
549
|
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
|
package/doc/wenay-common2.md
CHANGED
|
@@ -136,8 +136,8 @@ l.ticks.once(v => console.log(v)) // one event, then aut
|
|
|
136
136
|
const [tick, ticks] = replayListen<[number]>({history: 1024, current: 'last'}) // after β same facade, same key
|
|
137
137
|
// legacy subscribers unchanged (byte-for-byte). Replay consumers now also get:
|
|
138
138
|
const sub = replaySubscribe(l.ticks, v => {}, {since: saved, onSeq: s => saved = s}) // catch-up + live, no gaps/dups
|
|
139
|
-
const sub2 = replaySubscribe(c.math.func.ticks, v => {}) // replay members project on func/strict directly β no cast needed
|
|
140
|
-
const routed = replayRouteSubscribe(l.ticks, v => {}, {label: 'relay'})
|
|
139
|
+
const sub2 = replaySubscribe(c.math.func.ticks, v => {}) // replay members project on func/strict directly β no cast needed
|
|
140
|
+
const routed = replayRouteSubscribe(l.ticks, v => {}, {label: 'relay'})
|
|
141
141
|
await routed.switch(nextRemoteTicks, {label: 'direct'}) // relay/direct hand-off: old route closes after catch-up
|
|
142
142
|
await l.ticks.frame(mySeq) // pull at YOUR pace (50ms timer etc.) β server condenses via the line's frame lambda
|
|
143
143
|
// full guide + examples β rpc.md; frame model / lag policies β ποΈ recipe below and rare docs
|
|
@@ -152,8 +152,44 @@ Media.createVideoSource({fps? = 3, codec? = 'jpeg', quality?, replay?}) -> [emit
|
|
|
152
152
|
control: start() -> Promise<'idle'|'requesting'|'live'|'denied'|'no-device'|'error'> Β· stop() Β· getStats() Β· setDevice(id) Β· listDevices() Β· state
|
|
153
153
|
Media.encodeMediaFrame(meta, payload) / Media.decodeMediaFrame(frame) // one Uint8Array = 40-byte fixed header + raw payload
|
|
154
154
|
```
|
|
155
|
-
Audio default is PCM frames from `AudioWorklet` where available (`mode:'record'` uses MediaRecorder chunks). Video default is camera snapshots (`video->canvas`, JPEG, low fps for vision). Put `listen` into `createRpcServerAuto` like any other Listen; with `replay:true`, the returned listen is a replay line, so RPC auto exposes legacy + replay surfaces under the same key. Backpressure policy: audio is lossless queue; video `replay:true` defaults to keep-latest frame recovery. `transport:'webrtc'` is reserved for a future SFU/signaling adapter; socket binary is the default today.
|
|
156
|
-
|
|
155
|
+
Audio default is PCM frames from `AudioWorklet` where available (`mode:'record'` uses MediaRecorder chunks). Video default is camera snapshots (`video->canvas`, JPEG, low fps for vision). Put `listen` into `createRpcServerAuto` like any other Listen; with `replay:true`, the returned listen is a replay line, so RPC auto exposes legacy + replay surfaces under the same key. Backpressure policy: audio is lossless queue; video `replay:true` defaults to keep-latest frame recovery. `transport:'webrtc'` is reserved for a future SFU/signaling adapter; socket binary is the default today.
|
|
156
|
+
|
|
157
|
+
## π€ Peer β accounts see each other's stores (one-call SDK)
|
|
158
|
+
> `import { Peer } from "wenay-common2"` or `import * as Peer from "wenay-common2/peer"`.
|
|
159
|
+
> The happy-path facade over rpc + store + replay + route coordinator. Legacy-friendly by design:
|
|
160
|
+
> the server side is a FRAGMENT spread into your EXISTING `createRpcServerAuto` object, the client
|
|
161
|
+
> side rides your existing connection β old keys keep working untouched.
|
|
162
|
+
```
|
|
163
|
+
// SERVER β next to your legacy object:
|
|
164
|
+
const host = Peer.createPeerHost({authorize?, history?}) // authorize(env) = server-side canExposeEndpoint
|
|
165
|
+
io.on('connection', socket => {
|
|
166
|
+
const peer = host.connection(accountOf(socket)) // per-account signal port + relay journal
|
|
167
|
+
createRpcServerAuto({socket, socketKey, object: {...legacyObject, peer: peer.fragment}, disconnectListen})
|
|
168
|
+
disconnectListen.on(peer.close)
|
|
169
|
+
})
|
|
170
|
+
host: connection(account) Β· relay(account) Β· accounts() Β· revoke(pair, accounts, reason?) Β· close()
|
|
171
|
+
|
|
172
|
+
// CLIENT β the whole happy path:
|
|
173
|
+
const me = Peer.createPeerClient<World>({
|
|
174
|
+
remote: c.app.func.peer, // deep proxy of the fragment β rest of the connection is yours
|
|
175
|
+
account: 'a',
|
|
176
|
+
initial: {...}, // own store: write me.store.state β others see it
|
|
177
|
+
rtc?: () => new RTCPeerConnection(cfg), // omit = relay-only (promoteDirect unavailable)
|
|
178
|
+
})
|
|
179
|
+
const bob = me.peer('b') // mirror + route control for another account
|
|
180
|
+
await bob.ready // keyframe/tail landed
|
|
181
|
+
bob.store.state // live mirror β reads survive ANY route change
|
|
182
|
+
await bob.promoteDirect() // relay -> WebRTC direct; {ok, state, reason?} result, not a throw
|
|
183
|
+
bob.route() // 'relay' | 'direct' Β· bob.reinterposeRelay() Β· bob.fallback() Β· bob.block()
|
|
184
|
+
me.onRoute(ev => {}) // route transitions for metrics/UI
|
|
185
|
+
```
|
|
186
|
+
Key property: the relay journal stores the owner's envelopes VERBATIM (owner seq space), so a
|
|
187
|
+
relay <-> direct hand-off is a plain seq resume β no keyframe reset, no gaps, no dups. Late joiners
|
|
188
|
+
get a keyframe folded server-side even while the owner is offline. Policy/session material:
|
|
189
|
+
`createPeerClient({session, accept, policy})` + host `authorize` β see rare docs for the envelope
|
|
190
|
+
contract and the underlying primitives (`createRouteCoordinator`, `createSignalHub`,
|
|
191
|
+
`createWebRtcConnector`). Oracle: `replay/peer-sdk.test.ts`.
|
|
192
|
+
|
|
157
193
|
## π Observe β reactive state + store/mirror API
|
|
158
194
|
> `import { Observe } from "wenay-common2"` or `import * as Observe from "wenay-common2/observe"`.
|
|
159
195
|
> This is the documented v2 reactive/store surface.
|
|
@@ -199,8 +235,8 @@ Observe.exposeStoreReplay(store, {history? = 1024}) -> { api /* spread into the
|
|
|
199
235
|
Observe.syncStoreReplay(mirror, remote /*{line, since, keyframe, frame?} of api.replay*/, {since?, onSeq?}) -> off
|
|
200
236
|
// off.ready (catch-up done) Β· off.seq() (save for reconnect: syncStoreReplay(..., {since: prev.seq()}))
|
|
201
237
|
// lagging/late client NEVER gets a backlog: evicted seq -> ONE fresh keyframe + live
|
|
202
|
-
// freshness is an option, not consumer boilerplate: {staleMs, onStale} flags a silent line / stale keyframe (edge-triggered both ways; ποΈ in rare docs)
|
|
203
|
-
Observe.syncStoreReplayRoute(mirror, remote, {label?}) -> off & {switch(nextRemote, opts), ready, seq(), label(), active()}
|
|
238
|
+
// freshness is an option, not consumer boilerplate: {staleMs, onStale} flags a silent line / stale keyframe (edge-triggered both ways; ποΈ in rare docs)
|
|
239
|
+
Observe.syncStoreReplayRoute(mirror, remote, {label?}) -> off & {switch(nextRemote, opts), ready, seq(), label(), active()}
|
|
204
240
|
// relay/direct promotion and re-interposition: replacement route catches up by seq before the old route closes
|
|
205
241
|
Observe.syncStoreReplayEach<T>(remote, (key, value, ctx) => {}, opts?) -> off & {store, ready, seq(), isStale(), lastTs()}
|
|
206
242
|
// one-call remote fold: mirror store + syncStoreReplay + store.each() β the callback fires per CHANGED
|
|
@@ -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
|
+
}
|
|
@@ -20,3 +20,5 @@ __exportStar(require("./replay-conflate"), exports);
|
|
|
20
20
|
__exportStar(require("./replay-history"), exports);
|
|
21
21
|
__exportStar(require("./replay-route"), exports);
|
|
22
22
|
__exportStar(require("./route-coordinator"), exports);
|
|
23
|
+
__exportStar(require("./replay-channel"), exports);
|
|
24
|
+
__exportStar(require("./route-signal-webrtc"), exports);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ReplayRemote } from './replay-wire';
|
|
2
|
+
import { RouteConnector } from './route-coordinator';
|
|
3
|
+
import { ReplayMessageChannel } from './replay-channel';
|
|
4
|
+
export type tSignalType = 'offer' | 'answer' | 'ice' | 'revoke' | 'close';
|
|
5
|
+
export type SignalEnvelope = {
|
|
6
|
+
type: tSignalType;
|
|
7
|
+
pair: string;
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
sdp?: string;
|
|
11
|
+
candidate?: unknown;
|
|
12
|
+
session?: unknown;
|
|
13
|
+
reason?: string;
|
|
14
|
+
};
|
|
15
|
+
export type SignalPort = {
|
|
16
|
+
send: (env: SignalEnvelope) => Promise<boolean | void> | boolean | void;
|
|
17
|
+
signals: {
|
|
18
|
+
on: (cb: (env: SignalEnvelope) => void) => any;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export declare function createSignalHub(deps?: {
|
|
22
|
+
authorize?: (env: SignalEnvelope) => boolean | Promise<boolean>;
|
|
23
|
+
}): {
|
|
24
|
+
register: (account: string) => {
|
|
25
|
+
account: string;
|
|
26
|
+
send: (env: SignalEnvelope) => Promise<boolean>;
|
|
27
|
+
signals: import("./Listen").ListenApi<[SignalEnvelope]>;
|
|
28
|
+
close: () => void;
|
|
29
|
+
};
|
|
30
|
+
revoke: (pair: string, accounts: string[], reason?: string) => void;
|
|
31
|
+
accounts: () => string[];
|
|
32
|
+
close(): void;
|
|
33
|
+
};
|
|
34
|
+
export type SignalHub = ReturnType<typeof createSignalHub>;
|
|
35
|
+
export type RtcSessionDescription = {
|
|
36
|
+
type: string;
|
|
37
|
+
sdp?: string;
|
|
38
|
+
};
|
|
39
|
+
export type RtcDataChannel = {
|
|
40
|
+
send: (data: string) => void;
|
|
41
|
+
close: () => void;
|
|
42
|
+
onopen?: ((ev?: unknown) => void) | null;
|
|
43
|
+
onmessage?: ((ev: {
|
|
44
|
+
data: unknown;
|
|
45
|
+
}) => void) | null;
|
|
46
|
+
onclose?: ((ev?: unknown) => void) | null;
|
|
47
|
+
onerror?: ((ev?: unknown) => void) | null;
|
|
48
|
+
};
|
|
49
|
+
export type RtcPeerConnection = {
|
|
50
|
+
createDataChannel: (label: string, opts?: unknown) => RtcDataChannel;
|
|
51
|
+
createOffer: () => Promise<RtcSessionDescription>;
|
|
52
|
+
createAnswer: () => Promise<RtcSessionDescription>;
|
|
53
|
+
setLocalDescription: (d: RtcSessionDescription) => Promise<unknown> | unknown;
|
|
54
|
+
setRemoteDescription: (d: RtcSessionDescription) => Promise<unknown> | unknown;
|
|
55
|
+
addIceCandidate: (c: unknown) => Promise<unknown> | unknown;
|
|
56
|
+
close: () => void;
|
|
57
|
+
onicecandidate?: ((ev: {
|
|
58
|
+
candidate?: unknown;
|
|
59
|
+
}) => void) | null;
|
|
60
|
+
ondatachannel?: ((ev: {
|
|
61
|
+
channel: RtcDataChannel;
|
|
62
|
+
}) => void) | null;
|
|
63
|
+
};
|
|
64
|
+
export declare function channelFromDataChannel(dc: RtcDataChannel): ReplayMessageChannel;
|
|
65
|
+
export type WebRtcConnectorDeps = {
|
|
66
|
+
port: SignalPort;
|
|
67
|
+
rtc: () => RtcPeerConnection;
|
|
68
|
+
self: string;
|
|
69
|
+
peer: string;
|
|
70
|
+
pair: string;
|
|
71
|
+
session?: unknown;
|
|
72
|
+
label?: string;
|
|
73
|
+
openTimeoutMs?: number;
|
|
74
|
+
};
|
|
75
|
+
export declare function createWebRtcConnector<Z extends any[] = any[]>(deps: WebRtcConnectorDeps): RouteConnector<Z>;
|
|
76
|
+
export type WebRtcAcceptDeps<Z extends any[]> = {
|
|
77
|
+
port: SignalPort;
|
|
78
|
+
rtc: () => RtcPeerConnection;
|
|
79
|
+
self: string;
|
|
80
|
+
serve: (env: SignalEnvelope) => ReplayRemote<Z> | null | Promise<ReplayRemote<Z> | null>;
|
|
81
|
+
accept?: (env: SignalEnvelope) => boolean | Promise<boolean>;
|
|
82
|
+
};
|
|
83
|
+
export declare function acceptWebRtcDirect<Z extends any[] = any[]>(deps: WebRtcAcceptDeps<Z>): () => void;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSignalHub = createSignalHub;
|
|
4
|
+
exports.channelFromDataChannel = channelFromDataChannel;
|
|
5
|
+
exports.createWebRtcConnector = createWebRtcConnector;
|
|
6
|
+
exports.acceptWebRtcDirect = acceptWebRtcDirect;
|
|
7
|
+
const Listen_1 = require("./Listen");
|
|
8
|
+
const replay_channel_1 = require("./replay-channel");
|
|
9
|
+
function createSignalHub(deps = {}) {
|
|
10
|
+
const { authorize } = deps;
|
|
11
|
+
const ports = new Map();
|
|
12
|
+
function register(account) {
|
|
13
|
+
const [emit, signals] = (0, Listen_1.listen)();
|
|
14
|
+
ports.set(account, emit);
|
|
15
|
+
async function send(env) {
|
|
16
|
+
if (env == null || env.from != account)
|
|
17
|
+
return false;
|
|
18
|
+
if (authorize && !(await authorize(env)))
|
|
19
|
+
return false;
|
|
20
|
+
const target = ports.get(env.to);
|
|
21
|
+
if (!target)
|
|
22
|
+
return false;
|
|
23
|
+
target(env);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
function close() {
|
|
27
|
+
if (ports.get(account) == emit)
|
|
28
|
+
ports.delete(account);
|
|
29
|
+
signals.close();
|
|
30
|
+
}
|
|
31
|
+
return { account, send, signals, close };
|
|
32
|
+
}
|
|
33
|
+
function revoke(pair, accounts, reason) {
|
|
34
|
+
for (const account of accounts) {
|
|
35
|
+
ports.get(account)?.({ type: 'revoke', pair, from: '', to: account, reason });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
register,
|
|
40
|
+
revoke,
|
|
41
|
+
accounts: () => Array.from(ports.keys()),
|
|
42
|
+
close() {
|
|
43
|
+
ports.clear();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function channelFromDataChannel(dc) {
|
|
48
|
+
const msgCbs = new Set();
|
|
49
|
+
const closeCbs = new Set();
|
|
50
|
+
let closed = false;
|
|
51
|
+
function fireClose() {
|
|
52
|
+
if (closed)
|
|
53
|
+
return;
|
|
54
|
+
closed = true;
|
|
55
|
+
for (const cb of Array.from(closeCbs))
|
|
56
|
+
cb();
|
|
57
|
+
}
|
|
58
|
+
dc.onmessage = function onDcMessage(ev) {
|
|
59
|
+
const data = String(ev.data);
|
|
60
|
+
for (const cb of Array.from(msgCbs))
|
|
61
|
+
cb(data);
|
|
62
|
+
};
|
|
63
|
+
dc.onclose = fireClose;
|
|
64
|
+
dc.onerror = fireClose;
|
|
65
|
+
return {
|
|
66
|
+
send: data => dc.send(data),
|
|
67
|
+
onMessage: cb => { msgCbs.add(cb); return () => msgCbs.delete(cb); },
|
|
68
|
+
onClose: cb => { closeCbs.add(cb); return () => closeCbs.delete(cb); },
|
|
69
|
+
close: () => { dc.close(); fireClose(); },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function createWebRtcConnector(deps) {
|
|
73
|
+
const { port, rtc, self, peer, pair, session, label = 'direct', openTimeoutMs = 10_000 } = deps;
|
|
74
|
+
let state = 'idle';
|
|
75
|
+
let pc = null;
|
|
76
|
+
let channel = null;
|
|
77
|
+
let offSignals = null;
|
|
78
|
+
let abortOpen = null;
|
|
79
|
+
const [emitFail, failListen] = (0, Listen_1.listen)();
|
|
80
|
+
function teardown(next) {
|
|
81
|
+
state = next;
|
|
82
|
+
if (typeof offSignals == 'function')
|
|
83
|
+
offSignals();
|
|
84
|
+
else
|
|
85
|
+
offSignals?.off?.();
|
|
86
|
+
offSignals = null;
|
|
87
|
+
channel?.close?.();
|
|
88
|
+
channel = null;
|
|
89
|
+
pc?.close();
|
|
90
|
+
pc = null;
|
|
91
|
+
}
|
|
92
|
+
function fail(reason) {
|
|
93
|
+
if (state == 'closed' || state == 'failed')
|
|
94
|
+
return;
|
|
95
|
+
const abort = abortOpen;
|
|
96
|
+
abortOpen = null;
|
|
97
|
+
teardown('failed');
|
|
98
|
+
abort?.(reason instanceof Error ? reason : new Error(String(reason)));
|
|
99
|
+
emitFail(reason);
|
|
100
|
+
}
|
|
101
|
+
async function open() {
|
|
102
|
+
state = 'opening';
|
|
103
|
+
const me = rtc();
|
|
104
|
+
pc = me;
|
|
105
|
+
const dc = me.createDataChannel('replay');
|
|
106
|
+
let openTimer = null;
|
|
107
|
+
const opened = new Promise((resolve, reject) => {
|
|
108
|
+
abortOpen = reject;
|
|
109
|
+
dc.onopen = () => resolve();
|
|
110
|
+
openTimer = setTimeout(function webRtcOpenTimeout() {
|
|
111
|
+
reject(new Error('webrtc direct open timeout: ' + pair));
|
|
112
|
+
}, openTimeoutMs);
|
|
113
|
+
});
|
|
114
|
+
opened.catch(() => { });
|
|
115
|
+
offSignals = port.signals.on(function onSignal(env) {
|
|
116
|
+
if (env == null || env.pair != pair || env.to != self || pc != me)
|
|
117
|
+
return;
|
|
118
|
+
if (env.type == 'answer' && env.sdp != null) {
|
|
119
|
+
void Promise.resolve(me.setRemoteDescription({ type: 'answer', sdp: env.sdp }))
|
|
120
|
+
.catch(fail);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (env.type == 'ice' && env.candidate != null) {
|
|
124
|
+
void Promise.resolve(me.addIceCandidate(env.candidate)).catch(fail);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (env.type == 'revoke' || env.type == 'close') {
|
|
128
|
+
fail(new Error('direct route ' + env.type + (env.reason ? ': ' + env.reason : '')));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
me.onicecandidate = function onIce(ev) {
|
|
132
|
+
if (ev?.candidate != null) {
|
|
133
|
+
const c = ev.candidate;
|
|
134
|
+
void port.send({ type: 'ice', pair, from: self, to: peer, candidate: c?.toJSON ? c.toJSON() : c });
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
try {
|
|
138
|
+
const offer = await me.createOffer();
|
|
139
|
+
await me.setLocalDescription(offer);
|
|
140
|
+
const accepted = await port.send({ type: 'offer', pair, from: self, to: peer, sdp: offer.sdp, session });
|
|
141
|
+
if (accepted == false)
|
|
142
|
+
throw new Error('signaling rejected offer (endpoint not exposed): ' + pair);
|
|
143
|
+
await opened;
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
teardown('failed');
|
|
147
|
+
throw e;
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
clearTimeout(openTimer);
|
|
151
|
+
abortOpen = null;
|
|
152
|
+
}
|
|
153
|
+
state = 'open';
|
|
154
|
+
channel = channelFromDataChannel(dc);
|
|
155
|
+
channel.onClose?.(function onDirectChannelDied() {
|
|
156
|
+
if (state == 'open')
|
|
157
|
+
fail(new Error('direct channel closed: ' + pair));
|
|
158
|
+
});
|
|
159
|
+
return (0, replay_channel_1.channelReplayRemote)(channel);
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
info: { label, kind: 'direct', binary: false, ordered: true, reliable: true },
|
|
163
|
+
open,
|
|
164
|
+
close() {
|
|
165
|
+
if (state == 'closed')
|
|
166
|
+
return;
|
|
167
|
+
void port.send({ type: 'close', pair, from: self, to: peer });
|
|
168
|
+
teardown('closed');
|
|
169
|
+
},
|
|
170
|
+
state: () => state,
|
|
171
|
+
onFail: { on: cb => failListen.on(cb) },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function acceptWebRtcDirect(deps) {
|
|
175
|
+
const { port, rtc, self, serve, accept } = deps;
|
|
176
|
+
const sessions = new Map();
|
|
177
|
+
let closed = false;
|
|
178
|
+
function dropSession(key) {
|
|
179
|
+
const s = sessions.get(key);
|
|
180
|
+
if (!s)
|
|
181
|
+
return;
|
|
182
|
+
sessions.delete(key);
|
|
183
|
+
s.stop?.();
|
|
184
|
+
s.pc.close();
|
|
185
|
+
}
|
|
186
|
+
async function onOffer(env) {
|
|
187
|
+
const key = env.pair + '|' + env.from;
|
|
188
|
+
if (accept && !(await accept(env))) {
|
|
189
|
+
void port.send({ type: 'revoke', pair: env.pair, from: self, to: env.from, reason: 'offer rejected' });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const source = await serve(env);
|
|
193
|
+
if (!source) {
|
|
194
|
+
void port.send({ type: 'revoke', pair: env.pair, from: self, to: env.from, reason: 'nothing to serve' });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
dropSession(key);
|
|
198
|
+
const pc = rtc();
|
|
199
|
+
const session = { pc, stop: null };
|
|
200
|
+
sessions.set(key, session);
|
|
201
|
+
pc.ondatachannel = function onIncomingChannel(ev) {
|
|
202
|
+
session.stop = (0, replay_channel_1.serveReplayChannel)(source, channelFromDataChannel(ev.channel));
|
|
203
|
+
};
|
|
204
|
+
pc.onicecandidate = function onIce(ev) {
|
|
205
|
+
if (ev?.candidate != null) {
|
|
206
|
+
const c = ev.candidate;
|
|
207
|
+
void port.send({ type: 'ice', pair: env.pair, from: self, to: env.from, candidate: c?.toJSON ? c.toJSON() : c });
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
try {
|
|
211
|
+
await pc.setRemoteDescription({ type: 'offer', sdp: env.sdp });
|
|
212
|
+
const answer = await pc.createAnswer();
|
|
213
|
+
await pc.setLocalDescription(answer);
|
|
214
|
+
void port.send({ type: 'answer', pair: env.pair, from: self, to: env.from, sdp: answer.sdp });
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
dropSession(key);
|
|
218
|
+
void port.send({ type: 'revoke', pair: env.pair, from: self, to: env.from, reason: 'negotiation failed' });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const offSignals = port.signals.on(function onAcceptSignal(env) {
|
|
222
|
+
if (closed || env == null || env.to != self)
|
|
223
|
+
return;
|
|
224
|
+
if (env.type == 'offer') {
|
|
225
|
+
void onOffer(env);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const key = env.pair + '|' + env.from;
|
|
229
|
+
if (env.type == 'ice' && env.candidate != null) {
|
|
230
|
+
void Promise.resolve(sessions.get(key)?.pc.addIceCandidate(env.candidate)).catch(() => dropSession(key));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (env.type == 'close' || env.type == 'revoke')
|
|
234
|
+
dropSession(key);
|
|
235
|
+
});
|
|
236
|
+
return function closeAccept() {
|
|
237
|
+
if (closed)
|
|
238
|
+
return;
|
|
239
|
+
closed = true;
|
|
240
|
+
if (typeof offSignals == 'function')
|
|
241
|
+
offSignals();
|
|
242
|
+
else
|
|
243
|
+
offSignals?.off?.();
|
|
244
|
+
for (const key of Array.from(sessions.keys()))
|
|
245
|
+
dropSession(key);
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { StoreDrain, StorePatch } from '../Observe/store';
|
|
2
|
+
import { ReplayRemote } from '../events/replay-wire';
|
|
3
|
+
import { RoutePolicy } from '../events/route-coordinator';
|
|
4
|
+
import { RtcPeerConnection, SignalEnvelope } from '../events/route-signal-webrtc';
|
|
5
|
+
import { PatchEnvelope } from './peer-relay';
|
|
6
|
+
export type PeerRemote = {
|
|
7
|
+
signal: {
|
|
8
|
+
send: (env: SignalEnvelope) => Promise<boolean | void>;
|
|
9
|
+
signals: {
|
|
10
|
+
on: (cb: (env: SignalEnvelope) => void) => any;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
publish: (env: PatchEnvelope) => Promise<boolean | void> | boolean | void;
|
|
14
|
+
peers: Record<string, ReplayRemote<[StorePatch]>>;
|
|
15
|
+
};
|
|
16
|
+
export type PeerClientDeps<T extends object> = {
|
|
17
|
+
remote: PeerRemote;
|
|
18
|
+
account: string;
|
|
19
|
+
initial: T;
|
|
20
|
+
rtc?: () => RtcPeerConnection;
|
|
21
|
+
session?: unknown;
|
|
22
|
+
accept?: (env: SignalEnvelope) => boolean | Promise<boolean>;
|
|
23
|
+
policy?: RoutePolicy;
|
|
24
|
+
peerInitial?: () => T;
|
|
25
|
+
history?: number;
|
|
26
|
+
drain?: StoreDrain;
|
|
27
|
+
};
|
|
28
|
+
export declare function createPeerClient<T extends object>(deps: PeerClientDeps<T>): {
|
|
29
|
+
store: import("../Observe/store").Store<T>;
|
|
30
|
+
peer: (other: string) => {
|
|
31
|
+
account: string;
|
|
32
|
+
store: import("../Observe/store").Store<T>;
|
|
33
|
+
ready: Promise<void>;
|
|
34
|
+
seq: () => number;
|
|
35
|
+
route: () => string;
|
|
36
|
+
state: () => import("../events/route-coordinator").tRouteState;
|
|
37
|
+
promoteDirect: (opts?: import("../events/route-coordinator").PromoteDirectOpts) => Promise<import("../events/route-coordinator").RouteOpResult>;
|
|
38
|
+
reinterposeRelay: (reason?: unknown) => Promise<import("../events/route-coordinator").RouteOpResult>;
|
|
39
|
+
fallback: (reason?: unknown) => Promise<import("../events/route-coordinator").RouteOpResult>;
|
|
40
|
+
block: (reason?: unknown) => Promise<import("../events/route-coordinator").RouteOpResult>;
|
|
41
|
+
close(): void;
|
|
42
|
+
};
|
|
43
|
+
onRoute: (cb: (ev: import("../events/route-coordinator").RouteChangeEvent) => void) => import("../..").ListenOff;
|
|
44
|
+
close(): void;
|
|
45
|
+
};
|
|
46
|
+
export type PeerClient<T extends object> = ReturnType<typeof createPeerClient<T>>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createPeerClient = createPeerClient;
|
|
4
|
+
const store_1 = require("../Observe/store");
|
|
5
|
+
const store_replay_1 = require("../Observe/store-replay");
|
|
6
|
+
const replay_wire_1 = require("../events/replay-wire");
|
|
7
|
+
const route_coordinator_1 = require("../events/route-coordinator");
|
|
8
|
+
const route_signal_webrtc_1 = require("../events/route-signal-webrtc");
|
|
9
|
+
function createPeerClient(deps) {
|
|
10
|
+
const { remote, account, initial, rtc, session, accept, policy, peerInitial = () => ({}), history, drain, } = deps;
|
|
11
|
+
const store = (0, store_1.createStore)(initial, drain !== undefined ? { drain } : {});
|
|
12
|
+
const exposed = (0, store_replay_1.exposeStoreReplay)(store, history !== undefined ? { history } : {});
|
|
13
|
+
const offPublish = exposed.replay.line.on(function publishEnvelope(env) {
|
|
14
|
+
void remote.publish(env);
|
|
15
|
+
});
|
|
16
|
+
const warmup = exposed.replay.keyframe();
|
|
17
|
+
if (warmup)
|
|
18
|
+
void remote.publish(warmup);
|
|
19
|
+
const port = {
|
|
20
|
+
send: env => remote.signal.send(env),
|
|
21
|
+
signals: { on: cb => remote.signal.signals.on(cb) },
|
|
22
|
+
};
|
|
23
|
+
const stopAccept = rtc
|
|
24
|
+
? (0, route_signal_webrtc_1.acceptWebRtcDirect)({
|
|
25
|
+
port, rtc, self: account,
|
|
26
|
+
serve: () => (0, replay_wire_1.exposeReplay)(exposed.replay),
|
|
27
|
+
...(accept ? { accept } : {}),
|
|
28
|
+
})
|
|
29
|
+
: null;
|
|
30
|
+
function relayConnector(other) {
|
|
31
|
+
let state = 'idle';
|
|
32
|
+
return {
|
|
33
|
+
info: { label: 'relay', kind: 'relay', ordered: true, reliable: true },
|
|
34
|
+
open() {
|
|
35
|
+
const node = remote.peers[other];
|
|
36
|
+
state = 'open';
|
|
37
|
+
return {
|
|
38
|
+
line: { on: (cb) => node.line.on(cb) },
|
|
39
|
+
since: (seq) => node.since(seq),
|
|
40
|
+
keyframe: () => node.keyframe(),
|
|
41
|
+
frame: (seq, hint) => node.frame(seq, hint),
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
close: () => { state = 'closed'; },
|
|
45
|
+
state: () => state,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const coord = (0, route_coordinator_1.createRouteCoordinator)({
|
|
49
|
+
...(policy ? { policy } : {}),
|
|
50
|
+
connect(ref, kind) {
|
|
51
|
+
const other = ref.a == account ? ref.b : ref.a;
|
|
52
|
+
if (kind == 'relay')
|
|
53
|
+
return relayConnector(other);
|
|
54
|
+
if (!rtc)
|
|
55
|
+
throw new Error('peer client: promoteDirect needs an rtc factory (deps.rtc)');
|
|
56
|
+
return (0, route_signal_webrtc_1.createWebRtcConnector)({
|
|
57
|
+
port, rtc, self: account, peer: other, pair: ref.key, session,
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const views = new Map();
|
|
62
|
+
function makeView(other) {
|
|
63
|
+
const link = coord.pair(account, other);
|
|
64
|
+
const mirror = (0, store_1.createStore)(peerInitial(), drain !== undefined ? { drain } : {});
|
|
65
|
+
const sub = link.subscribe(function mirrorPatch(patch) {
|
|
66
|
+
(0, store_1.applyStorePatch)(mirror, patch);
|
|
67
|
+
});
|
|
68
|
+
const view = {
|
|
69
|
+
account: other,
|
|
70
|
+
store: mirror,
|
|
71
|
+
ready: sub.ready,
|
|
72
|
+
seq: sub.seq,
|
|
73
|
+
route: link.label,
|
|
74
|
+
state: link.state,
|
|
75
|
+
promoteDirect: link.promoteDirect,
|
|
76
|
+
reinterposeRelay: link.reinterposeRelay,
|
|
77
|
+
fallback: link.fallback,
|
|
78
|
+
block: link.block,
|
|
79
|
+
close() {
|
|
80
|
+
views.delete(other);
|
|
81
|
+
sub();
|
|
82
|
+
link.close();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
return view;
|
|
86
|
+
}
|
|
87
|
+
function peer(other) {
|
|
88
|
+
const existing = views.get(other);
|
|
89
|
+
if (existing)
|
|
90
|
+
return existing;
|
|
91
|
+
const view = makeView(other);
|
|
92
|
+
views.set(other, view);
|
|
93
|
+
return view;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
store,
|
|
97
|
+
peer,
|
|
98
|
+
onRoute: coord.onRoute,
|
|
99
|
+
close() {
|
|
100
|
+
for (const view of Array.from(views.values()))
|
|
101
|
+
view.close();
|
|
102
|
+
coord.close();
|
|
103
|
+
stopAccept?.();
|
|
104
|
+
offPublish();
|
|
105
|
+
exposed.close();
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { StorePatch } from '../Observe/store';
|
|
2
|
+
import { ReplayRemote } from '../events/replay-wire';
|
|
3
|
+
import { SignalEnvelope } from '../events/route-signal-webrtc';
|
|
4
|
+
import { PatchEnvelope } from './peer-relay';
|
|
5
|
+
export type PeerHostDeps = {
|
|
6
|
+
authorize?: (env: SignalEnvelope) => boolean | Promise<boolean>;
|
|
7
|
+
history?: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function createPeerHost(deps?: PeerHostDeps): {
|
|
10
|
+
connection: (account: string) => {
|
|
11
|
+
fragment: {
|
|
12
|
+
signal: {
|
|
13
|
+
send: (env: SignalEnvelope) => Promise<boolean>;
|
|
14
|
+
signals: import("../..").ListenApi<[SignalEnvelope]>;
|
|
15
|
+
};
|
|
16
|
+
publish: (env: PatchEnvelope) => boolean;
|
|
17
|
+
peers: Record<string, ReplayRemote<[StorePatch]>>;
|
|
18
|
+
};
|
|
19
|
+
close: () => void;
|
|
20
|
+
};
|
|
21
|
+
relay: (account: string) => {
|
|
22
|
+
push: (env: PatchEnvelope) => boolean;
|
|
23
|
+
remote: ReplayRemote<[StorePatch]>;
|
|
24
|
+
seq: () => number;
|
|
25
|
+
snapshot: () => any;
|
|
26
|
+
close: () => void;
|
|
27
|
+
};
|
|
28
|
+
accounts: () => string[];
|
|
29
|
+
revoke: (pair: string, accounts: string[], reason?: string) => void;
|
|
30
|
+
close(): void;
|
|
31
|
+
};
|
|
32
|
+
export type PeerHost = ReturnType<typeof createPeerHost>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createPeerHost = createPeerHost;
|
|
4
|
+
const rpc_dynamic_1 = require("../rcp/rpc-dynamic");
|
|
5
|
+
const route_signal_webrtc_1 = require("../events/route-signal-webrtc");
|
|
6
|
+
const peer_relay_1 = require("./peer-relay");
|
|
7
|
+
function createPeerHost(deps = {}) {
|
|
8
|
+
const { authorize, history } = deps;
|
|
9
|
+
const hub = (0, route_signal_webrtc_1.createSignalHub)({ authorize });
|
|
10
|
+
const relays = new Map();
|
|
11
|
+
const peersView = (0, rpc_dynamic_1.noStrict)({});
|
|
12
|
+
function ensureRelay(account) {
|
|
13
|
+
let relay = relays.get(account);
|
|
14
|
+
if (!relay) {
|
|
15
|
+
relay = (0, peer_relay_1.createPatchRelayJournal)({ history });
|
|
16
|
+
relays.set(account, relay);
|
|
17
|
+
peersView[account] = relay.remote;
|
|
18
|
+
}
|
|
19
|
+
return relay;
|
|
20
|
+
}
|
|
21
|
+
function connection(account) {
|
|
22
|
+
const port = hub.register(account);
|
|
23
|
+
const mine = ensureRelay(account);
|
|
24
|
+
return {
|
|
25
|
+
fragment: {
|
|
26
|
+
signal: { send: port.send, signals: port.signals },
|
|
27
|
+
publish: (env) => mine.push(env),
|
|
28
|
+
peers: peersView,
|
|
29
|
+
},
|
|
30
|
+
close: () => port.close(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
connection,
|
|
35
|
+
relay: ensureRelay,
|
|
36
|
+
accounts: () => Array.from(relays.keys()),
|
|
37
|
+
revoke: hub.revoke,
|
|
38
|
+
close() {
|
|
39
|
+
hub.close();
|
|
40
|
+
for (const relay of relays.values())
|
|
41
|
+
relay.close();
|
|
42
|
+
relays.clear();
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./peer-relay"), exports);
|
|
18
|
+
__exportStar(require("./peer-host"), exports);
|
|
19
|
+
__exportStar(require("./peer-client"), exports);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ReplayEvent } from '../events/replay-listen';
|
|
2
|
+
import { ReplayRemote } from '../events/replay-wire';
|
|
3
|
+
import { StorePatch } from '../Observe/store';
|
|
4
|
+
export type PatchEnvelope = ReplayEvent<[StorePatch]>;
|
|
5
|
+
export declare function createPatchRelayJournal(opts?: {
|
|
6
|
+
history?: number;
|
|
7
|
+
}): {
|
|
8
|
+
push: (env: PatchEnvelope) => boolean;
|
|
9
|
+
remote: ReplayRemote<[StorePatch]>;
|
|
10
|
+
seq: () => number;
|
|
11
|
+
snapshot: () => any;
|
|
12
|
+
close: () => void;
|
|
13
|
+
};
|
|
14
|
+
export type PatchRelayJournal = ReturnType<typeof createPatchRelayJournal>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createPatchRelayJournal = createPatchRelayJournal;
|
|
4
|
+
const Listen_1 = require("../events/Listen");
|
|
5
|
+
const store_1 = require("../Observe/store");
|
|
6
|
+
const store_replay_1 = require("../Observe/store-replay");
|
|
7
|
+
function condensePatchTail(tail) {
|
|
8
|
+
const held = new Map();
|
|
9
|
+
for (const ev of tail) {
|
|
10
|
+
const k = (0, store_replay_1.storePatchKey)(ev.event[0]);
|
|
11
|
+
if (k == null)
|
|
12
|
+
return tail;
|
|
13
|
+
held.delete(k);
|
|
14
|
+
held.set(k, ev);
|
|
15
|
+
}
|
|
16
|
+
return Array.from(held.values());
|
|
17
|
+
}
|
|
18
|
+
function createPatchRelayJournal(opts = {}) {
|
|
19
|
+
const { history = 1024 } = opts;
|
|
20
|
+
const [emitLine, line] = (0, Listen_1.listen)();
|
|
21
|
+
const fold = (0, store_1.createStore)({}, { drain: 'micro' });
|
|
22
|
+
let ring = [];
|
|
23
|
+
let last = -1;
|
|
24
|
+
let lastTs = 0;
|
|
25
|
+
let hasState = false;
|
|
26
|
+
function push(env) {
|
|
27
|
+
if (env == null || typeof env.seq != 'number')
|
|
28
|
+
return false;
|
|
29
|
+
const patch = env.event?.[0];
|
|
30
|
+
if (patch == null || !Array.isArray(patch.path))
|
|
31
|
+
return false;
|
|
32
|
+
const isRoot = patch.path.length == 0;
|
|
33
|
+
if (env.seq <= last) {
|
|
34
|
+
if (!isRoot || env.seq == last)
|
|
35
|
+
return false;
|
|
36
|
+
ring = [];
|
|
37
|
+
}
|
|
38
|
+
last = env.seq;
|
|
39
|
+
lastTs = env.ts ?? lastTs;
|
|
40
|
+
hasState = true;
|
|
41
|
+
(0, store_1.applyStorePatch)(fold, patch);
|
|
42
|
+
ring.push(env);
|
|
43
|
+
if (ring.length > history)
|
|
44
|
+
ring.splice(0, ring.length - history);
|
|
45
|
+
emitLine(env);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
function keyframe() {
|
|
49
|
+
if (!hasState)
|
|
50
|
+
return null;
|
|
51
|
+
return { seq: last, ts: lastTs, event: [{ path: [], exists: true, value: fold.snapshot() }] };
|
|
52
|
+
}
|
|
53
|
+
function since(seq) {
|
|
54
|
+
if (!hasState)
|
|
55
|
+
return null;
|
|
56
|
+
if (seq >= last)
|
|
57
|
+
return [];
|
|
58
|
+
if (!ring.length || seq < ring[0].seq - 1)
|
|
59
|
+
return null;
|
|
60
|
+
return ring.filter(ev => ev.seq > seq);
|
|
61
|
+
}
|
|
62
|
+
function frame(seq, _hint) {
|
|
63
|
+
const tail = since(seq);
|
|
64
|
+
if (tail)
|
|
65
|
+
return condensePatchTail(tail);
|
|
66
|
+
const kf = keyframe();
|
|
67
|
+
return kf ? [kf] : null;
|
|
68
|
+
}
|
|
69
|
+
const remote = { line, since, keyframe, frame };
|
|
70
|
+
return {
|
|
71
|
+
push,
|
|
72
|
+
remote,
|
|
73
|
+
seq: () => last,
|
|
74
|
+
snapshot: () => fold.snapshot(),
|
|
75
|
+
close: () => { line.close(); },
|
|
76
|
+
};
|
|
77
|
+
}
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
};
|
|
37
37
|
})();
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.Media = exports.Replay = exports.Observe = exports.Color = exports.Time = exports.Params = exports.Bars = exports.Math = void 0;
|
|
39
|
+
exports.Peer = exports.Media = exports.Replay = exports.Observe = exports.Color = exports.Time = exports.Params = exports.Bars = exports.Math = void 0;
|
|
40
40
|
__exportStar(require("./Common/node_console"), exports);
|
|
41
41
|
__exportStar(require("./Common/core/Decorator"), exports);
|
|
42
42
|
__exportStar(require("./Common/core/BaseTypes"), exports);
|
|
@@ -74,3 +74,4 @@ exports.Color = __importStar(require("./Common/Color"));
|
|
|
74
74
|
exports.Observe = __importStar(require("./Common/Observe"));
|
|
75
75
|
exports.Replay = __importStar(require("./Common/events/replay-index"));
|
|
76
76
|
exports.Media = __importStar(require("./Common/media/media-index"));
|
|
77
|
+
exports.Peer = __importStar(require("./Common/peer/peer-index"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wenay-common2",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.69",
|
|
4
4
|
"description": "Common library",
|
|
5
5
|
"strict": true,
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"./client": "./lib/client.js",
|
|
36
36
|
"./server": "./lib/server.js",
|
|
37
37
|
"./package.json": "./package.json",
|
|
38
|
-
"./media": "./lib/Common/media/media-index.js"
|
|
38
|
+
"./media": "./lib/Common/media/media-index.js",
|
|
39
|
+
"./peer": "./lib/Common/peer/peer-index.js"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
42
|
"express": "^5.2.1"
|