wenay-common2 1.0.64 → 1.0.66

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/CLAUDE.md CHANGED
@@ -51,6 +51,26 @@ Write new code in this style by default — no need to ask.
51
51
  ## Comments
52
52
  - Section dividers like `// ===...===` with a block heading.
53
53
  - Explain "why", not "what". Keep them short.
54
+
55
+ ## Work progress files
56
+
57
+ - For any task that is more than a tiny/local edit, create a temporary progress file before
58
+ starting broad changes.
59
+ - Put progress files under `doc/progress/`. This is the working-doc area, separate from public
60
+ API docs, roadmap docs, and release notes.
61
+ - Name them by task, for example `doc/progress/replay-route-handoff.md`.
62
+ - Keep the file short: goal, current checkpoint list, notable decisions, blockers, and verification
63
+ already run or still needed.
64
+ - Update the progress file as checkpoints are completed, especially before switching context or
65
+ making broad edits.
66
+ - When the task is finished, delete the progress file. Preserve only the durable outcome:
67
+ final response, commit message, and, for publishable changes, the matching `doc/changes/<version>.md`
68
+ entry.
69
+ - If work is paused or blocked locally, leave the progress file in place and make the next required
70
+ action explicit. If the paused state must be committed or handed off, promote the useful part into
71
+ a durable doc (`ROADMAP`, `RECOMMENDATIONS`, `doc/target`, or `doc/changes`) instead of relying on
72
+ an ignored progress file.
73
+
54
74
  ## Documentation and release notes
55
75
 
56
76
  - `README.md` is navigation only. Do not put API guides, examples, or long explanations there.
@@ -75,7 +75,11 @@ For seek into the past — not needed for live sync:
75
75
  ## Stages
76
76
 
77
77
  1. `withReplayListen` decorator + seq handover (layer A) — the core, small.
78
- 2. Patch journal in `exposeStore` + `since`-sync in mirror (layer B).
78
+ 2. Patch journal in `exposeStore` + `since`-sync in mirror (layer B).
79
+ 2.5. **Route hand-off helper** — `replayRouteSubscribe` / `syncStoreReplayRoute`: keep the
80
+ old route live, catch up the replacement from the last delivered `seq`, then close the old
81
+ route. This is the replay-level foundation for relay ↔ direct promotion; signaling and
82
+ direct-transport setup remain outside this layer.
79
83
  3. Conflation + snapshot recovery on the wire (first item of D) — this is what turns
80
84
  "semi-pro" into pro for fan-out.
81
85
  4. C and the rest of D — on demand.
package/doc/ROADMAP.md ADDED
@@ -0,0 +1,190 @@
1
+ # wenay-common2 — Roadmap (open / deferred)
2
+
3
+ > Forward-looking backlog of distributed-state / transport features that are **not fully built**.
4
+ > Everything here sits on top of the existing seams — `{emit,on}` transport, `exposeStore` /
5
+ > `createStoreMirror`, and the replay `seq` / `keyframe` / `frame` contract. None of it requires
6
+ > changing the store core; the store stays single-authority (one `seq` sequencer, last-writer-wins
7
+ > per path), and these features are layers or adapters above that.
8
+ > Status: 🔴 not started · 🟡 partial / ongoing · 🧊 deliberately shelved.
9
+
10
+ ## 0. Distributed Runtime Model
11
+
12
+ The strategic direction is not "RPC plus store helpers"; it is a small distributed-state runtime:
13
+ application code keeps a stable typed API while lower layers can choose the transport, route, replay
14
+ source, and authority model.
15
+
16
+ Four concerns must stay explicit:
17
+
18
+ - **Transport** - how two endpoints exchange `{emit,on}` messages: socket.io, in-process loopback,
19
+ relay, WebRTC/direct channel, or a future adapter. Transport is replaceable infrastructure.
20
+ - **Routing** - where a logical call or stream goes: server, peer through relay, peer directly, or a
21
+ promoted relay <-> direct path. Routing must be allowed to change without changing the facade API.
22
+ - **Authority** - who is allowed to write truth: one server, partitioned peer-owned slices, server
23
+ authority with client prediction, or true multi-writer conflict resolution. This is a semantic
24
+ decision, not a transport decision.
25
+ - **Replay** - how live state survives reconnect, lag, transport swap, and history playback:
26
+ `seq` + keyframe/frame + deltas. Replay is the continuity layer that makes route changes boring.
27
+
28
+ Design rule: direct peer links and relay hand-offs are optimizations of transport/routing. They must
29
+ not silently change auth, ownership, validation, or conflict semantics. If a method is exposed as
30
+ `api.hit(playerId)`, it may travel through the server or a direct channel, but the authority rules
31
+ behind that method must remain the same.
32
+
33
+ This implies a useful split:
34
+
35
+ - **API surface** remains facade-shaped and typed.
36
+ - **Transport/routing layer** may substitute sockets and promote paths.
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
43
+ boring and well-tested.
44
+
45
+ ### 0.1 Account route coordinator ("wrapper over wrappers") 🔴
46
+
47
+ Some deployments need separate client/account identities to communicate directly when policy and
48
+ network conditions allow it, while still allowing the server/relay to step back into the path later.
49
+ This is not just a socket trick; it is an account-aware routing shell above the existing facade,
50
+ mirror, and replay primitives.
51
+
52
+ - **Account model:** every participant has its own account/session identity and a scoped facade/store
53
+ set. Runtime-account maps are a dynamic keyspace, so they should look like `noStrict(accountMap)`
54
+ rather than a fixed schema. Access checks stay in the facade/policy layer; `noStrict` is not an ACL.
55
+ - **Wrapper over wrappers:** an app-level coordinator owns the set of per-account clients, exposed
56
+ facades, mirrors, replay subscriptions, and route state. "State of other accounts" is represented as
57
+ selected `Observe` mirrors/replay/offline resources, ideally started and stopped through
58
+ `createStoreManager`, not as a new global store core.
59
+ - **Route states:** a pair of accounts may be `relay`, `direct`, `direct+shadowRelay` (audit/observe
60
+ copy), `blocked`, or `fallback`. Direct links are optional optimizations, useful for latency or
61
+ traffic cost, not a semantic change.
62
+ - **Re-interposition:** if NDA/privacy policy, audit, moderation, throttling, reauth, direct-link
63
+ failure, or group topology changes require it, the relay must be able to re-enter the data path.
64
+ This is the reverse of direct promotion: open the replacement route, resume from the last `seq`,
65
+ switch consumers after catch-up, then close or demote the old route.
66
+ - **Privacy rule:** direct account links are opt-in and policy-gated. Peers only receive the endpoint
67
+ and session material needed for that specific relationship; no implicit broad account discovery.
68
+
69
+ Concrete next API shape, not final names:
70
+
71
+ ```ts
72
+ createRouteCoordinator({
73
+ policy,
74
+ routes,
75
+ resources,
76
+ }) -> {
77
+ pair(a, b),
78
+ state(pair),
79
+ promoteDirect(pair, opts?),
80
+ reinterposeRelay(pair, reason?),
81
+ block(pair, reason?),
82
+ fallback(pair, reason?),
83
+ onRoute(cb),
84
+ }
85
+ ```
86
+
87
+ Policy must run **before** transport promotion:
88
+
89
+ - `canDirect(pair, ctx)` - may these accounts attempt direct at all?
90
+ - `mustRelay(pair, ctx)` - force relay path because of NDA/audit/moderation/reauth.
91
+ - `mustShadowRelay(pair, ctx)` - allow direct payload path, but keep audit/observe copy.
92
+ - `canExposeEndpoint(pair, ctx)` - whether signaling may reveal endpoint/session material.
93
+ - `canReinterpose(pair, ctx)` - whether/when relay is allowed or required to step back in.
94
+
95
+ Minimum state machine:
96
+
97
+ - `relay` -> `direct:connecting` -> `direct` -> `relay:reinterposing` -> `relay`
98
+ - `relay` -> `direct:connecting` -> `fallback` -> `relay`
99
+ - `direct` -> `direct+shadowRelay`
100
+ - any state -> `blocked`
101
+
102
+ Required failure modes:
103
+
104
+ - direct setup never completes: keep relay active and mark fallback;
105
+ - replacement route catches up too slowly: keep old route active and fail the switch;
106
+ - policy changes mid-stream: re-interpose relay through replay hand-off;
107
+ - account reauth changes facade/ACL: rebuild route policy before accepting new writes;
108
+ - endpoint/session material is revoked: close direct and resume relay from `seq`;
109
+ - audit/shadow route lags: decide whether to throttle, fallback, or block by policy.
110
+
111
+ Acceptance tests for 0.1:
112
+
113
+ - policy denial: direct is never attempted;
114
+ - direct promotion: old relay stays live until replacement catches up;
115
+ - failed direct: old relay continues with no data gap;
116
+ - re-interposition: direct -> relay resumes from `seq`;
117
+ - `direct+shadowRelay`: direct path is active while relay/audit mirror observes;
118
+ - revocation: direct closes and relay resumes without changing facade API;
119
+ - account map uses `noStrict`, but all access checks live in policy/facade code;
120
+ - `createStoreManager` starts/stops selected per-account mirrors without store-core changes.
121
+
122
+ Open questions: exact policy object shape; signaling envelope; whether the relay sees payloads or only
123
+ coordinates encrypted direct streams; app-facing route-state events; backpressure across multi-hop and
124
+ direct paths; group topology beyond a pair of accounts.
125
+
126
+ Status: 🔴 not started. This belongs to project-0 architecture: no store-core change, but it defines
127
+ the account/routing shell that later direct-connection work plugs into. This is the next important
128
+ roadmap item; WebRTC and CRDT work should wait until this state machine is proven with fake transports.
129
+
130
+ ## 1. Connection hand-off — relay ↔ direct promotion ("port forwarding") 🟡
131
+
132
+ A relay/intermediary bootstraps a connection between two parties, then — on a signal — **steps out
133
+ of the middle**: both ends open a second, direct socket and migrate the live stream onto it,
134
+ bypassing the relay. The same mechanism must work in reverse: the relay can deliberately
135
+ **re-interpose** and become the middleman again. Under the hood the socket substitutes itself; data
136
+ starts flowing on the new path.
137
+
138
+ - Family: NAT hole-punching / WebRTC TURN→direct promotion, expressed over the existing `{emit,on}`
139
+ abstraction rather than a specific transport.
140
+ - **Design lead (reuse what exists):** the socket swap is a *stream resume*, which the replay layer
141
+ already solves. Migrate with `replaySubscribe(..., {since: prev.seq()})` /
142
+ `syncStoreReplay(..., {since})` on the new socket — the consumer fold is gap-free by contract, so a
143
+ mid-stream transport change is not a special case. The transport carries only `seq`; the semantics
144
+ never learn the socket changed.
145
+ - **Implemented foundation (2026-07-08):** `Replay.replayRouteSubscribe(...)` and
146
+ `Observe.syncStoreReplayRoute(...)` keep the old route alive, subscribe the replacement route,
147
+ catch it up from the last delivered `seq`, then close the old route. This covers relay → direct
148
+ promotion and direct → relay re-interposition for any ordered `ReplayRemote`; overlap is deduped by
149
+ `seq`, and a failed replacement leaves the old route active.
150
+ - Open questions: signaling channel that negotiates the direct endpoint; fallback if the direct path
151
+ never establishes (stay on relay); auth continuity across the swap; per-socketKey vs whole-connection
152
+ hand-off; policy trigger and catch-up boundary for direct → relay re-interposition.
153
+ - Status: 🟡 partial. Route hand-off/resume helper is implemented and covered by `replay/route-handoff.test.ts`; signaling, direct endpoint negotiation, NAT/WebRTC adapter, auth-continuity policy, and trigger rules remain open.
154
+
155
+ ## 2. Coordinated fan-out send to a large group 🧊
156
+
157
+ Synchronized/batched send to a very large audience "at once", as opposed to the current model where
158
+ **each connection is paced independently** (per-connection lag gate + `frame` policy).
159
+
160
+ - Current reality: pacing is per-recipient by design — every client's link is unique. Even in video
161
+ fan-out no two receivers drain at the same rate, so the per-connection gate is usually the *correct*
162
+ answer. That is why this is shelved.
163
+ - When it would matter: true lockstep broadcast (all-or-nothing / same-instant delivery) — rare.
164
+ - Status: 🧊 deliberately shelved. Revisit only against a concrete lockstep requirement.
165
+
166
+ ## 3. Multi-authority stores — two truths, one reconciliation (games) 🔴
167
+
168
+ Two stores, each **dynamically filled by its own authority ("truth")**, that must agree on a shared
169
+ source of truth. Decompose by write topology:
170
+
171
+ - **Partitioned authority — fits today.** Each peer authoritative over its own slice → two
172
+ `exposeStore` / `createStoreMirror` pairs crossed over one duplex `{emit,on}`. No new primitive.
173
+ "Confirmation" = the per-direction `seq` ack.
174
+ - **Authoritative server + client prediction — small layer on top.** Server store is the truth; the
175
+ client applies optimistically, then reconciles when the authoritative patch arrives. Build a
176
+ `predictedStore` = confirmed mirror + a pending-input list, rebased on each `mirror.each()` /
177
+ `changed` tick. Helper factory, not a core change.
178
+ - **Symmetric co-write of the same path — needs a different model.** LWW-per-path converges but can
179
+ silently drop a concurrent write. This is the one case that genuinely needs CRDT/OT. Design lead:
180
+ wrap a Yjs/Automerge doc as a `RemoteStore`-shaped source and mirror from it — the store↔transport
181
+ decoupling makes this a small adapter, not a rewrite.
182
+ - Status: 🔴 prediction layer + CRDT adapter not started; partitioned-authority already expressible.
183
+
184
+ ## 4. Data-transfer optimization backlog (ongoing) 🟡
185
+
186
+ Open-ended transfer/perf work, especially for backend-heavy models. Never "done".
187
+
188
+ - Candidates: tighter `frame` condensation per line; delta/patch minimization; binary framing for hot
189
+ paths; batching heuristics beyond the current `pipe` / `space` modes.
190
+ - Status: 🟡 ongoing; pick items as real bottlenecks surface, not speculatively.
@@ -0,0 +1,8 @@
1
+ # 1.0.65
2
+
3
+ - Add `Replay.replayRouteSubscribe` for transport-agnostic replay route hand-off: keep the old route live, catch up the replacement route by `seq`, then close the old route.
4
+ - Add `Observe.syncStoreReplayRoute` for store mirrors that can switch relay/direct routes without gaps or duplicate patch delivery.
5
+ - Add `replay/route-handoff.test.ts` covering relay -> direct promotion, direct -> relay re-interposition, failed replacement fallback, and store mirror convergence.
6
+ - Update roadmap/replay docs to mark connection hand-off as partially implemented and keep signaling/WebRTC/policy triggers explicitly open.
7
+ - Normalize package metadata for `wenay-common2@1.0.65`.
8
+ - Add `doc/progress/` working-checkpoint rules for non-trivial tasks and keep task progress files out of package output.
@@ -0,0 +1,8 @@
1
+ # 1.0.66
2
+
3
+ - Add `Media.createAudioSource` and `Media.createVideoSource` browser capture helpers that expose media as ordinary binary `Listen` sources.
4
+ - Add fixed-header media frame helpers: `Media.encodeMediaFrame` and `Media.decodeMediaFrame` (`Uint8Array` header + raw payload, no JSON envelope).
5
+ - Add optional `replay:true` media source mode so `createRpcServerAuto` can expose media listeners with legacy + replay surfaces under the same key.
6
+ - Add `wenay-common2/media` package export and root `Media` namespace export.
7
+ - Add `replay/media-socket.test.ts` covering media binary frame headers, no-device typed state, replay media source shape, and real Socket.IO/RPC binary delivery.
8
+ - Document media-over-socket backpressure defaults and reserve WebRTC as a future explicit SFU/signaling opt-in, not the default transport.
@@ -205,6 +205,50 @@ Contract:
205
205
  - Static dotted keys are also supported: `api["a.b"].c` is distinct from `api.a.b.c`. Internal route/listen/cache identity must stay lossless; dotted strings are only a debug display form.
206
206
  - If a branch is a fixed public API, keep it strict. If a branch is a personal/dynamic keyspace, wrap that branch in `noStrict` instead of trying to publish all current keys as schema.
207
207
 
208
+ ## 🎙️ Media over socket — browser capture as binary Listen
209
+ > `import { Media } from "wenay-common2"` or `import * as Media from "wenay-common2/media"`.
210
+ > The hot path event is ONE `Uint8Array`: fixed 40-byte common2 media header + raw payload. No JSON envelope.
211
+ ```
212
+ Media.createAudioSource(opts?) -> [emit, listen] & control
213
+ Media.createVideoSource(opts?) -> [emit, listen] & control
214
+ Media.encodeMediaFrame(meta, payload) -> Uint8Array
215
+ Media.decodeMediaFrame(frame) -> {kind, codec, seq, tMono, payload, sampleRate?, channels?, nSamples?, width?, height?}
216
+
217
+ control: start() -> Promise<MediaSourceState> · stop() · getStats() · setDevice(id) · listDevices()
218
+ state: 'idle'|'requesting'|'live'|'denied'|'no-device'|'error'
219
+ ```
220
+ Audio source:
221
+ - default `mode:'pcm'`, `format:'int16'`, raw PCM payload; uses `AudioWorklet` when available and falls back to `ScriptProcessor` only when the browser cannot run a worklet.
222
+ - `mode:'record'` uses `MediaRecorder` chunks (`webm-opus`) for record/upload flows, not live STT.
223
+ - `getStats().rms` gives a VU-meter signal; permission denied/no device returns typed state, not a thrown public failure.
224
+
225
+ Video source:
226
+ - default snapshots, not a 30fps video stream: `video->canvas`, JPEG, `fps` default 3, `quality` default 0.82.
227
+ - each frame carries absolute image bytes, so `replay:true` can safely keep the latest frame for lag recovery.
228
+ - `worker` is API-reserved; current implementation stays main-thread. Any future worker path must transfer `ArrayBuffer` payloads, never structured-clone frame objects.
229
+
230
+ Replay/RPC wiring:
231
+ ```ts
232
+ const audio = Media.createAudioSource({sourceId: 'mic'}) // plain lossless queue Listen
233
+ const video = Media.createVideoSource({sourceId: 'cam', fps: 2, replay: true})
234
+
235
+ createRpcServerAuto({
236
+ socket, socketKey: 'media',
237
+ object: {audio: audio[1], video: video[1]},
238
+ replayOpts: {highWater: 64, lowWater: 8},
239
+ })
240
+ ```
241
+ `replay:true` makes the returned listen a `Replay.replayListen` surface before capture emits into it, so `createRpcServerAuto` brand-detects it and exposes legacy + replay under the same key. Defaults differ by media kind: audio replay is a sacred queue (`history:1024`, no keyframe/frame, do not drop samples); video replay is keep-latest (`history:256`, `current:'last'`, `frame` returns the newest covered frame). Pass `replay:{...}` for custom history/current/frame.
242
+
243
+ Backpressure rule: audio consumers should use the default queue policy unless the app explicitly accepts loss. Video consumers can use `Replay.replaySubscribe(remote.video, cb, {policy:'frame'})`; a slow socket drains to the latest frame instead of accumulating stale images. The binary frame itself is RPC-safe because `rpc-walk` passes `TypedArray`/`ArrayBuffer` leaves through natively and applies `maxBinaryLen`.
244
+
245
+ WebRTC future contract:
246
+ - `transport:'socket'` is the implemented default today.
247
+ - `transport:'webrtc'` is reserved and currently reports `state:'error'` on `start()`; it is not a hidden second transport.
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
+
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
+
208
252
  ## 📈 exchange — params (`CParams`)
209
253
  ```
210
254
  class CParams / CParamsReadonly implements IParams
@@ -420,7 +464,9 @@ exposeReplay(replay) <-> replaySubscribe(remote, cb, {since?, onSeq?, staleMs?
420
464
  // on the line (RPC_STOP — e.g. the gate's loud sacred failure) surfaces via onError + off, never silence.
421
465
  // hint reaches the frame lambda on catch-up and on every explicit frame(seq, hint) call (pull); the push-gate's
422
466
  // drain recovery uses the line's DEFAULT condensation — client-specific rules/pace = the pull path.
423
- // off.ready (catch-up done) · off.seq() (reconnect point) · off.isStale()/off.lastTs(); reconnect = call again with {since: prev.seq()}
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
+ // 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.
424
470
  // DELIVERY CONTRACT (guaranteed, not best-effort): the subscriber's cb sees ONE uniform stream —
425
471
  // first delivery = the snapshot (keyframe as an event of the SAME type; store: root patch),
426
472
  // then only strictly-newer events, seq-ascending, no gaps, no dups. Live events racing ahead of the
@@ -439,7 +485,8 @@ exposeReplay(replay) <-> replaySubscribe(remote, cb, {since?, onSeq?, staleMs?
439
485
  // of clock skew) + ENVELOPE-TS AGE checked at delivery (producer clock — a stale keyframe reports stale
440
486
  // IMMEDIATELY; clock-skew caveat: producer/client clocks may disagree, skewMs tolerance absorbs it, default 0).
441
487
  // A since-tail's historical ts never flaps mid-catch-up (one assessment after handover); off() disarms the timer.
442
- exposeStoreReplay(store, opts?) <-> syncStoreReplay(mirror, remote, opts?) // layer B: patch line; keyframe = root patch ({path: [], value: snapshot})
488
+ exposeStoreReplay(store, opts?) <-> syncStoreReplay(mirror, remote, opts?) // layer B: patch line; keyframe = root patch ({path: [], value: snapshot})
489
+ syncStoreReplayRoute(mirror, remote, opts?) -> off & {ready, switch(nextRemote, opts), seq(), label(), active()} // same patch fold, but route-replaceable for relay/direct promotion
443
490
  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
444
491
  createOfflineStore({key, remote?, initial, storage, version?, debounceMs?, syncOpts?}) -> Promise<OfflineStore<T>>
445
492
  // snapshot-mode persisted mirror: read local {version,seq,snapshot,savedAt}, create a normal Store immediately,
@@ -136,11 +136,24 @@ 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
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
+ await routed.switch(nextRemoteTicks, {label: 'direct'}) // relay/direct hand-off: old route closes after catch-up
140
142
  await l.ticks.frame(mySeq) // pull at YOUR pace (50ms timer etc.) — server condenses via the line's frame lambda
141
143
  // full guide + examples → rpc.md; frame model / lag policies → 🎞️ recipe below and rare docs
142
144
  ```
143
145
 
146
+ ## 🎙️ Media over socket — binary Listen frames
147
+ ```
148
+ import { Media } from "wenay-common2" // or: import * as Media from "wenay-common2/media"
149
+
150
+ Media.createAudioSource({format?: 'int16'|'float32', mode?: 'pcm'|'record', replay?}) -> [emit, listen] & control
151
+ Media.createVideoSource({fps? = 3, codec? = 'jpeg', quality?, replay?}) -> [emit, listen] & control
152
+ control: start() -> Promise<'idle'|'requesting'|'live'|'denied'|'no-device'|'error'> · stop() · getStats() · setDevice(id) · listDevices() · state
153
+ Media.encodeMediaFrame(meta, payload) / Media.decodeMediaFrame(frame) // one Uint8Array = 40-byte fixed header + raw payload
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
+
144
157
  ## 🔁 Observe — reactive state + store/mirror API
145
158
  > `import { Observe } from "wenay-common2"` or `import * as Observe from "wenay-common2/observe"`.
146
159
  > This is the documented v2 reactive/store surface.
@@ -186,7 +199,9 @@ Observe.exposeStoreReplay(store, {history? = 1024}) -> { api /* spread into the
186
199
  Observe.syncStoreReplay(mirror, remote /*{line, since, keyframe, frame?} of api.replay*/, {since?, onSeq?}) -> off
187
200
  // off.ready (catch-up done) · off.seq() (save for reconnect: syncStoreReplay(..., {since: prev.seq()}))
188
201
  // lagging/late client NEVER gets a backlog: evicted seq -> ONE fresh keyframe + live
189
- // freshness is an option, not consumer boilerplate: {staleMs, onStale} flags a silent line / stale keyframe (edge-triggered both ways; 🎞️ in rare docs)
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()}
204
+ // relay/direct promotion and re-interposition: replacement route catches up by seq before the old route closes
190
205
  Observe.syncStoreReplayEach<T>(remote, (key, value, ctx) => {}, opts?) -> off & {store, ready, seq(), isStale(), lastTs()}
191
206
  // one-call remote fold: mirror store + syncStoreReplay + store.each() — the callback fires per CHANGED
192
207
  // top-level key; first delivery = keyframe EXPANDED per key; (key, undefined) = key deleted
@@ -1,6 +1,7 @@
1
1
  import { Store, StorePatch, StoreDrain, StoreEachCtx } from './store';
2
2
  import { ReplayListenOptions, ReplayEvent } from '../events/replay-listen';
3
3
  import { ReplayRemote, ReplaySubscribeOpts } from '../events/replay-wire';
4
+ import { ReplayRouteSubscribeOpts } from '../events/replay-route';
4
5
  import { ReplayStorage } from '../events/replay-history';
5
6
  export type StoreReplayOpts = Pick<ReplayListenOptions<[StorePatch]>, 'history' | 'getSince' | 'onJournal' | 'now'>;
6
7
  export declare function storePatchKey(patch: StorePatch): string | null;
@@ -48,6 +49,13 @@ export declare function syncStoreReplay<T extends object>(store: Store<T>, remot
48
49
  isStale: () => boolean;
49
50
  lastTs: () => number;
50
51
  };
52
+ export declare function syncStoreReplayRoute<T extends object>(store: Store<T>, remote: ReplayRemote<[StorePatch]>, opts?: ReplayRouteSubscribeOpts): (() => void) & {
53
+ ready: Promise<void>;
54
+ switch: (nextRemote: ReplayRemote<[StorePatch]>, nextOpts?: import("../events/replay-route").ReplayRouteSwitchOpts) => Promise<void>;
55
+ seq: () => number;
56
+ label: () => string | undefined;
57
+ active: () => boolean;
58
+ };
51
59
  export declare function syncStoreReplayEach<T extends object>(remote: ReplayRemote<[StorePatch]>, cb: (key: string, value: T[keyof T] | undefined, ctx: StoreEachCtx) => void, opts?: ReplaySubscribeOpts & {
52
60
  drain?: StoreDrain;
53
61
  initial?: T;
@@ -3,11 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.storePatchKey = storePatchKey;
4
4
  exports.exposeStoreReplay = exposeStoreReplay;
5
5
  exports.syncStoreReplay = syncStoreReplay;
6
+ exports.syncStoreReplayRoute = syncStoreReplayRoute;
6
7
  exports.syncStoreReplayEach = syncStoreReplayEach;
7
8
  exports.storeReplayAt = storeReplayAt;
8
9
  const store_1 = require("./store");
9
10
  const replay_listen_1 = require("../events/replay-listen");
10
11
  const replay_wire_1 = require("../events/replay-wire");
12
+ const replay_route_1 = require("../events/replay-route");
11
13
  const replay_history_1 = require("../events/replay-history");
12
14
  function makeStorePatch(store, path) {
13
15
  let node = store.node;
@@ -55,6 +57,9 @@ function exposeStoreReplay(store, opts = {}) {
55
57
  function syncStoreReplay(store, remote, opts = {}) {
56
58
  return (0, replay_wire_1.replaySubscribe)(remote, function applyLine(patch) { (0, store_1.applyStorePatch)(store, patch); }, opts);
57
59
  }
60
+ function syncStoreReplayRoute(store, remote, opts = {}) {
61
+ return (0, replay_route_1.replayRouteSubscribe)(remote, function applyRoutePatch(patch) { (0, store_1.applyStorePatch)(store, patch); }, opts);
62
+ }
58
63
  function syncStoreReplayEach(remote, cb, opts = {}) {
59
64
  const { drain, initial, ...wireOpts } = opts;
60
65
  const store = (0, store_1.createStore)((initial ?? {}), drain !== undefined ? { drain } : {});
@@ -2,3 +2,4 @@ export * from './replay-listen';
2
2
  export * from './replay-wire';
3
3
  export * from './replay-conflate';
4
4
  export * from './replay-history';
5
+ export * from './replay-route';
@@ -18,3 +18,4 @@ __exportStar(require("./replay-listen"), exports);
18
18
  __exportStar(require("./replay-wire"), exports);
19
19
  __exportStar(require("./replay-conflate"), exports);
20
20
  __exportStar(require("./replay-history"), exports);
21
+ __exportStar(require("./replay-route"), exports);
@@ -0,0 +1,25 @@
1
+ import { Listener } from './Listen';
2
+ import { ReplayRemote, ReplaySubscribeOpts } from './replay-wire';
3
+ export type ReplayRoutePhase = 'switching' | 'ready' | 'error' | 'closed';
4
+ export type ReplayRouteEvent = {
5
+ phase: ReplayRoutePhase;
6
+ seq: number;
7
+ from?: string;
8
+ to?: string;
9
+ error?: unknown;
10
+ };
11
+ export type ReplayRouteSwitchOpts = Pick<ReplaySubscribeOpts, 'policy' | 'hint'> & {
12
+ label?: string;
13
+ since?: number;
14
+ reset?: boolean;
15
+ };
16
+ export type ReplayRouteSubscribeOpts = ReplayRouteSwitchOpts & Pick<ReplaySubscribeOpts, 'onSeq' | 'onError'> & {
17
+ onRoute?: (ev: ReplayRouteEvent) => void;
18
+ };
19
+ export declare function replayRouteSubscribe<Z extends any[]>(remote: ReplayRemote<Z>, cb: Listener<Z>, opts?: ReplayRouteSubscribeOpts): (() => void) & {
20
+ ready: Promise<void>;
21
+ switch: (nextRemote: ReplayRemote<Z>, nextOpts?: ReplayRouteSwitchOpts) => Promise<void>;
22
+ seq: () => number;
23
+ label: () => string | undefined;
24
+ active: () => boolean;
25
+ };
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.replayRouteSubscribe = replayRouteSubscribe;
4
+ function unsubscribeHandle(handle) {
5
+ if (typeof handle == 'function') {
6
+ handle();
7
+ return;
8
+ }
9
+ if (typeof handle?.off == 'function')
10
+ handle.off();
11
+ else if (typeof handle?.unsubscribe == 'function')
12
+ handle.unsubscribe();
13
+ }
14
+ function replayRouteSubscribe(remote, cb, opts = {}) {
15
+ const { onSeq, onError, onRoute } = opts;
16
+ let lastDelivered = opts.since ?? -1;
17
+ let closed = false;
18
+ let active = null;
19
+ let currentLabel = opts.label;
20
+ let switchChain = Promise.resolve();
21
+ const slots = new Set();
22
+ function emitRoute(ev) {
23
+ if (!onRoute)
24
+ return;
25
+ try {
26
+ onRoute(ev);
27
+ }
28
+ catch (e) {
29
+ setTimeout(function rethrowRouteEvent() { throw e; }, 0);
30
+ }
31
+ }
32
+ function deliver(ev) {
33
+ if (closed || ev.seq <= lastDelivered)
34
+ return;
35
+ lastDelivered = ev.seq;
36
+ cb(...ev.event);
37
+ onSeq?.(ev.seq);
38
+ }
39
+ function deliverMany(envs, allowReset) {
40
+ if (allowReset && envs.length && envs[0].seq <= lastDelivered) {
41
+ lastDelivered = envs[0].seq - 1;
42
+ }
43
+ for (const ev of envs)
44
+ deliver(ev);
45
+ }
46
+ function attach(nextRemote, since, nextOpts, allowReset) {
47
+ const { policy = 'queue', hint, label } = nextOpts;
48
+ let slot;
49
+ let slotClosed = false;
50
+ let replaying = true;
51
+ let lineError;
52
+ const queue = [];
53
+ const liveLine = policy == 'frame' && nextRemote.frameLine ? nextRemote.frameLine : nextRemote.line;
54
+ const handle = liveLine.on(function liveTap(ev) {
55
+ if (slotClosed)
56
+ return;
57
+ if (ev == null || typeof ev.seq != 'number') {
58
+ lineError = new Error('replayRouteSubscribe: line ended by route (' + String(ev) + ')');
59
+ slot.close();
60
+ if (!replaying)
61
+ onError?.(lineError);
62
+ return;
63
+ }
64
+ if (replaying)
65
+ queue.push(ev);
66
+ else
67
+ deliver(ev);
68
+ });
69
+ function closeSlot() {
70
+ if (slotClosed)
71
+ return;
72
+ slotClosed = true;
73
+ unsubscribeHandle(handle);
74
+ slots.delete(slot);
75
+ }
76
+ async function catchUp() {
77
+ try {
78
+ let done = false;
79
+ if (since >= 0 && nextRemote.frame) {
80
+ const envs = await nextRemote.frame(since, hint);
81
+ if (slotClosed)
82
+ return;
83
+ if (envs) {
84
+ deliverMany(envs, allowReset);
85
+ done = true;
86
+ }
87
+ }
88
+ if (!done) {
89
+ const tail = since >= 0 ? await nextRemote.since(since) : null;
90
+ if (slotClosed)
91
+ return;
92
+ if (tail) {
93
+ deliverMany(tail, false);
94
+ }
95
+ else {
96
+ const kf = await nextRemote.keyframe();
97
+ if (slotClosed)
98
+ return;
99
+ if (kf)
100
+ deliverMany([kf], allowReset);
101
+ }
102
+ }
103
+ if (lineError)
104
+ throw lineError;
105
+ while (queue.length)
106
+ deliver(queue.shift());
107
+ replaying = false;
108
+ }
109
+ catch (e) {
110
+ closeSlot();
111
+ throw e;
112
+ }
113
+ }
114
+ slot = {
115
+ label,
116
+ ready: catchUp(),
117
+ close: closeSlot,
118
+ closed: () => slotClosed,
119
+ };
120
+ slots.add(slot);
121
+ return slot;
122
+ }
123
+ async function doSwitch(nextRemote, nextOpts = {}, initial = false) {
124
+ if (closed)
125
+ throw new Error('replayRouteSubscribe: closed');
126
+ const from = active;
127
+ const fromLabel = from?.label ?? currentLabel;
128
+ const toLabel = nextOpts.label;
129
+ const since = nextOpts.since ?? lastDelivered;
130
+ const allowReset = nextOpts.reset ?? (initial || !from);
131
+ const slot = attach(nextRemote, since, nextOpts, allowReset);
132
+ emitRoute({ phase: 'switching', from: fromLabel, to: toLabel, seq: lastDelivered });
133
+ try {
134
+ await slot.ready;
135
+ if (closed || slot.closed())
136
+ return;
137
+ active = slot;
138
+ currentLabel = slot.label;
139
+ if (from && from !== slot)
140
+ from.close();
141
+ emitRoute({ phase: 'ready', from: fromLabel, to: toLabel, seq: lastDelivered });
142
+ }
143
+ catch (e) {
144
+ slot.close();
145
+ emitRoute({ phase: 'error', from: fromLabel, to: toLabel, seq: lastDelivered, error: e });
146
+ onError?.(e);
147
+ throw e;
148
+ }
149
+ }
150
+ const ready = doSwitch(remote, opts, true);
151
+ switchChain = ready.catch(() => { });
152
+ function switchRoute(nextRemote, nextOpts = {}) {
153
+ const run = () => doSwitch(nextRemote, nextOpts, false);
154
+ const p = switchChain.then(run, run);
155
+ switchChain = p.catch(() => { });
156
+ return p;
157
+ }
158
+ function off() {
159
+ if (closed)
160
+ return;
161
+ closed = true;
162
+ for (const slot of Array.from(slots))
163
+ slot.close();
164
+ active = null;
165
+ emitRoute({ phase: 'closed', seq: lastDelivered, to: currentLabel });
166
+ }
167
+ return Object.assign(off, {
168
+ ready,
169
+ switch: switchRoute,
170
+ seq: () => lastDelivered,
171
+ label: () => currentLabel,
172
+ active: () => active != null && !active.closed(),
173
+ });
174
+ }
@@ -0,0 +1 @@
1
+ export * from './media-source';
@@ -0,0 +1,17 @@
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("./media-source"), exports);