openrtc-netcode 3.0.0 → 3.0.2

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 CHANGED
@@ -1,61 +1,202 @@
1
1
  # openrtc-netcode
2
2
 
3
- `openrtc-netcode` is a small multiplayer layer on top of OpenRTC. It treats
4
- OpenRTC rooms as lobbies, observes the runtime-owned P2P mesh between lobby members,
5
- and exposes reliable typed messages, lightweight replicated state, networked
6
- objects, and browser voice-chat media.
7
-
8
- Gameplay payloads are sent over OpenRTC peer data paths only: Iroh
9
- relay/direct plus WebRTC or MoQ custom Iroh carriers when enabled. Live
10
- membership and signaling use the OpenRTC coordination gateway; neither core
11
- netcode nor its payload path uses Firebase, Firestore, or RTDB.
12
-
13
- `preferredTransports` filters OpenRTC's typed application-readiness
14
- query; it does not request promotion. OpenRTC owns optional transport promotion
15
- and recovery, while netcode only observes route events. Netcode consumes one
16
- already-activated room handle, so it never retains an unused user or space
17
- avenue.
3
+ Multiplayer state and simulation primitives over an activated OpenRTC room.
4
+ The adapter observes `Room.members` for membership; `Room.peers` remains a
5
+ retained lifecycle projection and is not used as a match roster. Netcode peer
6
+ IDs are the current node bindings in that roster; device-only sparse entries
7
+ are not fabricated into routable game peers. Use a small mesh or an admitted
8
+ authority topology whose required game peers have routes.
9
+ OpenRTC owns authenticated membership, logical connections, admission, carriers
10
+ and recovery. Netcode owns game protocol, authority, replication and actions;
11
+ it never dials peers or installs connection repair timers.
12
+
13
+ This source implements **wire v2**, intended for the coordinated 3.0.0 train.
14
+ It is not compatible with published v1 peers (including 2.9.7/2.9.8). Upgrade a
15
+ whole match together. `protocol:error` reports incompatible frames and
16
+ `waitForPeer()` rejects incompatible negotiation or timeout. Package versions
17
+ remain unchanged until the complete release train qualifies; see the repository
18
+ netcode audit's release-readiness record. No new release is implied by this file.
18
19
 
19
20
  ```ts
20
21
  import { OpenRTC } from 'openrtc';
21
22
  import { createNetcode } from 'openrtc-netcode';
22
23
 
23
- const rtc = OpenRTC({
24
- apiKey: 'pk_live_...',
25
- transports: { webrtc: true },
24
+ const rtc = OpenRTC({ apiKey: 'pk_live_...', transports: { webrtc: true } });
25
+ const room = await rtc.rooms.join('match', {
26
+ access: 'capability', membership: 'ephemeral', maxPeers: 8,
26
27
  });
27
- const room = await rtc.rooms.join('duel-room', {
28
- access: 'capability',
29
- membership: 'ephemeral',
28
+ const game = createNetcode({
29
+ room, tickRate: 60, codec: 'quantized',
30
+ authority: { mode: 'owner', hostOwnedKinds: ['crate'] },
31
+ replication: { bytesPerSecond: 96 * 1024, maxStateAgeMs: 250 },
32
+ motionThresholds: { position: 0.002, rotationRadians: 0.002, maxSilenceMs: 1000 },
30
33
  });
31
- const netcode = createNetcode({ room });
32
- await netcode.joinLobby('duel-room');
33
-
34
- netcode.onMessage('chat', (message) => {
35
- console.log(message.from, message.payload);
34
+ game.on('protocol:error', ({ peerId, reason }) => console.warn(peerId, reason));
35
+ await game.joinLobby(room.id);
36
+ const player = await game.objects.spawn({
37
+ kind: 'player', transform: { position: { x: 0, y: 1, z: 0 } },
36
38
  });
37
-
38
- await netcode.broadcast('chat', { text: 'hello' });
39
39
  ```
40
40
 
41
- Networked game objects are synchronized through `netcode.objects`:
41
+ ## Authority and lifetime
42
+
43
+ `mode: 'owner'` permits each peer to publish its own objects and NetVars.
44
+ Give each NetVar key one agreed owner; namespace per-player keys by peer ID.
45
+ Declaring the same default-local key independently on several peers does not
46
+ create shared write authority. Use
47
+ this for cooperative worlds whose players are trusted to control their avatars.
48
+ `hostOwnedKinds` restricts shared physics kinds to the current host.
49
+ `mode: 'host'` restricts every object and NetVar writer to the host; clients send
50
+ validated actions or inputs to that host. A trusted dedicated Node authority is
51
+ appropriate for competitive rules. Encryption authenticates a writer; application
52
+ handlers must still validate speed, reach, cooldowns, resources and game rules.
53
+
54
+ Without `hostId`, the host is the lexicographically smallest current member ID,
55
+ including the local peer. A joining lower ID can therefore change the host.
56
+ A host change retires old host-owned entities and, in host mode, old host NetVars;
57
+ the new host initializes its scene/state. This is a reset policy, not seamless
58
+ physics migration. Authenticated hello epochs fence delayed host packets, and
59
+ host-owned objects receive a fresh incarnation when authority resets. Pending
60
+ actions to a retired authority reject with an unknown effect outcome. Stable
61
+ action IDs and receipt windows remain valid for unchanged recipients in the
62
+ same peer session. Suspended producers cannot publish into a later authority
63
+ period. Receipts echo the original action sequence; delayed results cannot
64
+ settle a later request that reuses a stable ID after recipient retirement.
65
+ Pin the same admitted `hostId` on every client when continuity
66
+ requires a designated authority. If that peer leaves, choose a new match or
67
+ explicit application migration; this library does not appoint an impersonator.
68
+ `owner: 'lobby-owner'` on an owner-mode NetVar binds the current host.
69
+ Declarations made before joining bind when the first host is resolved; they
70
+ then stay bound to that peer. Remote placeholders do not compete with the
71
+ authoritative owner's initial revision.
72
+
73
+ Object IDs are always owner-prefixed (`${peerId}:...`). Omit `id` for a generated
74
+ ID containing a random store instance. Ownership transfer is unsupported.
75
+ Writers cannot supply `revision` or `incarnation`; every mutation increments a
76
+ safe-integer revision. The match session fences old owner incarnations; host-owned kinds also bind
77
+ their incarnation to the authenticated authority epoch. `tick`
78
+ is simulation time; `updatedAt` is informational and never orders mutations.
79
+ Equal revisions use canonical JSON order; removal wins an equal-version tie.
80
+ Unapproved mutations produce no accepted-state events.
81
+
82
+ Live entities and deletion versions share a 10,000-ID lifetime bound and 7 MiB serialized byte budget per store; an entry
83
+ may not exceed 220 KiB. NetVars have 4096 keys, 4 MiB total and 240 KiB per value.
84
+ Retain tombstones until match retirement to prevent stale resurrection. Exhaustion
85
+ rejects explicitly; start a new match rather than evicting deletion authority.
86
+ Authoritative peer departure retires that peer's entities, state and receipts.
87
+ Leaving resets match data and rejects queued work. Old NetVar handles reject;
88
+ define a new handle in the next match. `stop()` releases netcode observers,
89
+ channels and microphone ownership; it does not stop a borrowed OpenRTC runtime.
90
+ After leaving a borrowed Room, activate a fresh Room capability before reusing it.
91
+
92
+ ## Simulation and rendering
42
93
 
43
94
  ```ts
44
- await netcode.objects.spawn({
45
- id: 'crate-1',
46
- kind: 'crate',
47
- transform: { position: { x: 0, y: 1, z: 0 } },
48
- physics: { linearVelocity: { x: 0, y: 0, z: 0 } },
95
+ import { SnapshotBuffer, InputPrediction, LagHistory } from 'openrtc-netcode';
96
+
97
+ // Drive from the engine's fixed simulation loop, independently of rendering.
98
+ game.clock.advance(frameDeltaSeconds, (_tick, fixedDelta) => simulate(fixedDelta));
99
+ game.flushReplication();
100
+
101
+ const buffer = new SnapshotBuffer({ tickRate: 60, hermite: true });
102
+ const object = game.objects.read(player.id); // deeply frozen; no clone allocation
103
+ if (object) buffer.push({
104
+ epoch: object.incarnation, tick: object.tick, revision: object.revision,
105
+ transform: object.transform, linearVelocity: object.physics?.linearVelocity,
106
+ teleport: object.teleport,
49
107
  });
108
+ buffer.sample(performance.now() / 1000, pooledTransform);
50
109
  ```
51
110
 
52
- Voice chat publishes microphone tracks through each already-established
53
- OpenRTC logical connection. Netcode does not create a second peer connection,
54
- exchange SDP/ICE, or own transport recovery:
111
+ Push each accepted entity revision once. Buffers estimate arrival offset and
112
+ jitter, interpolate authoritative ticks with linear/Hermite position and
113
+ quaternion slerp, and cap extrapolation (120 ms by default). Epoch changes and
114
+ teleports reset interpolation; retired epochs cannot restore old poses. Use
115
+ monotonic arrival time, never sender wall time. Sleeping/teleport flags are
116
+ explicit. The fixed clock caps catch-up to eight ticks and records dropped ticks.
117
+
118
+ `InputPrediction<State, Input>` stores bounded sequenced inputs; `reconcile()`
119
+ applies an authoritative acknowledged sequence and replays only pending inputs.
120
+ Supply the deterministic movement step and reset at authority epochs. It does
121
+ not make arbitrary rigid-body engines deterministic or provide anti-cheat.
122
+ `LagHistory<State>` retains bounded authoritative ticks for game-owned rewind
123
+ queries, rejects out-of-window requests, and never accepts a client's claimed
124
+ hit as authority. Clamp rewind to your measured latency and game policy.
125
+
126
+ Read entity IDs with `objects.ids(kind)` and poses with `objects.read(id)` from
127
+ the render loop. `get/list/snapshot` clone for general application use; avoid
128
+ calling them for every entity every rendered frame. React consumers should use
129
+ `useNetworkObjectIds` and imperative pose reads. Single-entity and small
130
+ collection hooks remain available for ordinary UI.
131
+
132
+ ## Replication, interest and receipts
133
+
134
+ Each peer has its own motion queue: one in-flight batch, one pending value per
135
+ entity, token budget, priority and state expiry. A blocked peer does not stall
136
+ other recipients. Call `flushReplication()` from the game loop to replenish
137
+ budgets/expire pending state. `replicationStats(peerId)` exposes bytes, age,
138
+ supersession and expiry. Successful motion submission can mean replacement or
139
+ expiry; it is not an application acknowledgement. There is no retry/redial timer.
140
+
141
+ `interest(peerId, object)` controls the exact reliable view sent to each peer;
142
+ call `refreshInterest()` after an interest-only change. Snapshots are bounded
143
+ pages applied atomically, and motion is bound to a view revision. Interest hides
144
+ entities; it does not grant authority. Avoid expensive all-world scans in an
145
+ interest callback; use your spatial index and relevant peer region.
146
+ `priority(peerId, object)` affects budget selection. Optional motion thresholds
147
+ suppress tiny changes relative to the last transmitted pose, with a max-silence
148
+ refresh when the application next publishes. Static metadata/scale changes and
149
+ teleports still replicate. Defaults are bounded, not universal game tuning.
150
+
151
+ The JSON codec sends lossless indexed moving fields. Both codecs keep static
152
+ identity, metadata and scale in the reliable baseline. The quantized codec sends 59-byte motion records
153
+ plus a 5-byte header (64 bytes for one object), before base64/envelope/transport
154
+ overhead. Positions use millimetres (about ±2,147 km), velocities centi-units
155
+ (about ±327 units/s), and normalized quaternion components use int16. Out-of-range
156
+ values reject; choose JSON for larger ranges. Reliable baselines carry static
157
+ metadata and scale. Batches contain at most 1,024 records, frames at most 256 KiB,
158
+ and assembled snapshots at most 8 MiB. No binary delta depends on an unacknowledged
159
+ previous motion packet. The Rust crate validates JSON envelopes; its packed
160
+ motion field is opaque rather than a second binary implementation.
55
161
 
56
162
  ```ts
57
- await netcode.voice.start();
58
- netcode.voice.on('peer:stream', ({ stream }) => {
59
- audioElement.srcObject = stream;
163
+ game.handleAction('crate:interact', (payload, { from, signal }) => {
164
+ if (signal.aborted) throw new Error('Retired match');
165
+ validateReachAndCooldown(from, payload);
166
+ return applyAuthoritativeImpulse(payload);
167
+ });
168
+ const receipt = await game.request(hostId, 'crate:interact', { crateId }, {
169
+ id: 'immutable-input-123', timeoutMs: 5000,
60
170
  });
61
171
  ```
172
+
173
+ Action receipts acknowledge the handler result. A timeout means the outcome is
174
+ unknown: reuse the same immutable stable ID to query a retained result, never
175
+ invent a new ID to retry an uncertain effect. Generated action IDs continue in
176
+ long matches. Sequences have a 256-action window per authenticated sender
177
+ session; expired or evicted results reject without replaying the effect. At most
178
+ 256 custom stable IDs are retained per caller match. Active effects are capped
179
+ at 8 per peer/64 globally; payload/result limits are 16 KiB and full receipt data
180
+ is capped at 4 MiB. Handlers must honor their AbortSignal; the library cannot undo
181
+ external side effects. No durable exactly-once guarantee spans process loss.
182
+
183
+ Reliable control and replaceable motion use distinct logical channels.
184
+ Targeted actions also bind the recipient session so a delayed request cannot
185
+ execute against a restarted peer. `send()`/`broadcast()` use reliable control delivery and reject missing routes;
186
+ they acknowledge transport submission, not a gameplay effect. Voice adds tracks
187
+ to existing logical connections and fences late microphone capture on stop.
188
+ BroadcastChannel fallback was removed because it could forge authenticated
189
+ peer identity. Test local games through a real explicit OpenRTC emulator lane.
190
+
191
+ ## Technique references and qualification
192
+
193
+ The chosen separation of fixed simulation, authoritative physics, delayed
194
+ interpolation and bounded extrapolation follows the documented approaches in
195
+ [Unity authoritative physics](https://docs-multiplayer.unity3d.com/netcode/1.8.1/advanced-topics/physics/),
196
+ [Unity interpolation](https://docs.unity.cn/Packages/com.unity.netcode.gameobjects%402.13/manual/learn/clientside-interpolation.html),
197
+ and [Valve networking](https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking).
198
+ Game genre, authority topology, physical transport and bandwidth determine the
199
+ right rates and budgets. Local fake-runtime tests prove protocol contracts;
200
+ source-built browsers, native hosts, real networks and adverse-load measurements
201
+ are separate qualification. Reliable carriers can still have transport-level
202
+ head-of-line latency; application coalescing does not turn them into datagrams.
@@ -0,0 +1,40 @@
1
+ export interface ActionContext {
2
+ from: string;
3
+ id: string;
4
+ sessionId: string;
5
+ signal: AbortSignal;
6
+ }
7
+ export type ActionHandler = (payload: unknown, context: ActionContext) => unknown | Promise<unknown>;
8
+ /** Match-scoped, bounded action receipts and idempotent effects. No redial/retry. */
9
+ export declare class ActionRegistry {
10
+ private readonly send;
11
+ private readonly handlers;
12
+ private readonly pending;
13
+ private readonly receipts;
14
+ private receiptBytes;
15
+ private active;
16
+ private readonly activeByPeer;
17
+ private readonly controllers;
18
+ private sequence;
19
+ private readonly stableRequests;
20
+ private readonly receiptWindows;
21
+ constructor(send: (peerId: string, kind: 'action' | 'action.receipt', body: unknown) => Promise<void>);
22
+ handle(type: string, handler: ActionHandler): () => void;
23
+ request(peerId: string, type: string, payload: unknown, options?: {
24
+ id?: string;
25
+ timeoutMs?: number;
26
+ }): Promise<unknown>;
27
+ receive(from: string, sessionId: string, body: unknown): Promise<void>;
28
+ receiveReceipt(from: string, body: unknown): void;
29
+ /** Called after the runtime retires a peer or the peer changes session. */
30
+ removePeer(peerId: string, options?: {
31
+ preserveReceipts?: boolean;
32
+ }): void;
33
+ clear(options?: {
34
+ preserveOutgoing?: boolean;
35
+ }): void;
36
+ private reject;
37
+ private deliver;
38
+ private deleteReceipt;
39
+ private trimReceipts;
40
+ }
@@ -0,0 +1,269 @@
1
+ import { canonicalJson, cloneJson, isId, isCounter } from './validation.js';
2
+ const MAX_ACTION_BYTES = 16 * 1024;
3
+ const MAX_RECEIPT_BYTES = 4 * 1024 * 1024;
4
+ const encoder = new TextEncoder();
5
+ function actionValue(value) {
6
+ const clone = cloneJson(value);
7
+ if (encoder.encode(JSON.stringify(clone)).byteLength > MAX_ACTION_BYTES)
8
+ throw new Error('Action value exceeds the 16 KiB limit.');
9
+ return clone;
10
+ }
11
+ /** Match-scoped, bounded action receipts and idempotent effects. No redial/retry. */
12
+ export class ActionRegistry {
13
+ constructor(send) {
14
+ this.send = send;
15
+ this.handlers = new Map();
16
+ this.pending = new Map();
17
+ this.receipts = new Map();
18
+ this.receiptBytes = 0;
19
+ this.active = 0;
20
+ this.activeByPeer = new Map();
21
+ this.controllers = new Map();
22
+ this.sequence = 0;
23
+ this.stableRequests = new Map();
24
+ this.receiptWindows = new Map();
25
+ }
26
+ handle(type, handler) {
27
+ if (!isId(type) || this.handlers.has(type) || this.handlers.size >= 128)
28
+ throw new Error('Invalid or duplicate action handler.');
29
+ this.handlers.set(type, handler);
30
+ return () => { if (this.handlers.get(type) === handler)
31
+ this.handlers.delete(type); };
32
+ }
33
+ request(peerId, type, payload, options = {}) {
34
+ const id = options.id ?? crypto.randomUUID();
35
+ const timeoutMs = options.timeoutMs ?? 5000;
36
+ if (!isId(peerId) || !isId(id) || !isId(type) || !Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000 || this.pending.size >= 64 || this.pending.has(id)) {
37
+ return Promise.reject(new Error('Action request limit or identity rejected.'));
38
+ }
39
+ let value;
40
+ try {
41
+ value = actionValue(payload);
42
+ }
43
+ catch (error) {
44
+ return Promise.reject(error);
45
+ }
46
+ const identity = canonicalJson([peerId, type, value]);
47
+ const stable = options.id ? this.stableRequests.get(id) : undefined;
48
+ if ((stable && stable.identity !== identity) || (options.id && !stable && this.stableRequests.size >= 256) || !isCounter(this.sequence + 1))
49
+ return Promise.reject(new Error('Stable action identity capacity or content rejected.'));
50
+ const sequence = stable?.sequence ?? ++this.sequence;
51
+ if (options.id && !stable)
52
+ this.stableRequests.set(id, { peerId, sequence, identity });
53
+ return new Promise((resolve, reject) => {
54
+ const timer = setTimeout(() => {
55
+ if (this.pending.get(id) !== request)
56
+ return;
57
+ this.pending.delete(id);
58
+ reject(new Error('Action receipt timed out; effect outcome is unknown.'));
59
+ }, timeoutMs);
60
+ const request = { peerId, sequence, resolve, reject, timer };
61
+ this.pending.set(id, request);
62
+ void Promise.resolve().then(() => {
63
+ if (this.pending.get(id) !== request)
64
+ return;
65
+ return this.send(peerId, 'action', { id, type, sequence, payload: value });
66
+ }).catch((error) => {
67
+ const pending = this.pending.get(id);
68
+ if (pending !== request)
69
+ return;
70
+ clearTimeout(pending.timer);
71
+ this.pending.delete(id);
72
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
73
+ });
74
+ });
75
+ }
76
+ async receive(from, sessionId, body) {
77
+ if (!isId(from) || !isId(sessionId) || !body || typeof body !== 'object')
78
+ return;
79
+ const action = body;
80
+ if (!isId(action.id) || !isId(action.type) || !isCounter(action.sequence) || action.sequence < 1)
81
+ return;
82
+ const id = action.id, type = action.type, sequence = action.sequence;
83
+ const source = JSON.stringify([from, sessionId]);
84
+ let window = this.receiptWindows.get(source);
85
+ if (!window) {
86
+ if (this.receiptWindows.size >= 128) {
87
+ await this.reject(from, id, sequence, 'Action source capacity exhausted.');
88
+ return;
89
+ }
90
+ window = { peerId: from, high: 0, entries: new Map() };
91
+ this.receiptWindows.set(source, window);
92
+ }
93
+ const key = JSON.stringify([from, sessionId, sequence]);
94
+ if (sequence <= window.high - 256) {
95
+ await this.reject(from, id, sequence, 'Action receipt window expired; effect will not be replayed.');
96
+ return;
97
+ }
98
+ if (sequence > window.high) {
99
+ window.high = sequence;
100
+ for (const [old, receiptKey] of window.entries)
101
+ if (old <= sequence - 256) {
102
+ this.deleteReceipt(receiptKey);
103
+ window.entries.delete(old);
104
+ }
105
+ }
106
+ let payload;
107
+ try {
108
+ payload = actionValue(action.payload);
109
+ }
110
+ catch {
111
+ await this.reject(from, id, sequence, 'Action payload exceeds the schema or 16 KiB limit.');
112
+ return;
113
+ }
114
+ const identity = canonicalJson([id, type, payload]);
115
+ const existing = this.receipts.get(key);
116
+ if (existing) {
117
+ if (existing.identity !== identity)
118
+ await this.reject(from, id, sequence, 'Action identity reused with different content.');
119
+ else if (!existing.pending)
120
+ await this.deliver(from, existing, this.controllers.get(from)?.signal);
121
+ return;
122
+ }
123
+ if (window.entries.has(sequence)) {
124
+ // Byte-budget eviction retains the sequence marker, so a missing
125
+ // receipt never permits the already completed effect to run again.
126
+ await this.reject(from, id, sequence, 'Action receipt expired; effect will not be replayed.');
127
+ return;
128
+ }
129
+ if (this.active >= 64 || (this.activeByPeer.get(from) ?? 0) >= 8) {
130
+ await this.reject(from, id, sequence, 'Action execution capacity exhausted.');
131
+ return;
132
+ }
133
+ let controller = this.controllers.get(from);
134
+ if (!controller) {
135
+ controller = new AbortController();
136
+ this.controllers.set(from, controller);
137
+ }
138
+ const signal = controller.signal;
139
+ const entry = { identity, bytes: encoder.encode(identity).byteLength, pending: true, sending: false };
140
+ this.receipts.set(key, entry);
141
+ this.receiptBytes += entry.bytes;
142
+ window.entries.set(sequence, key);
143
+ this.active++;
144
+ this.activeByPeer.set(from, (this.activeByPeer.get(from) ?? 0) + 1);
145
+ this.trimReceipts();
146
+ try {
147
+ if (signal.aborted)
148
+ throw new Error('Action peer retired.');
149
+ const handler = this.handlers.get(type);
150
+ if (!handler)
151
+ throw new Error('Action is not registered.');
152
+ const result = await handler(payload, { from, id, sessionId, signal });
153
+ entry.result = { id, sequence, ok: true, result: actionValue(result === undefined ? null : result) };
154
+ }
155
+ catch (error) {
156
+ entry.result = { id, sequence, ok: false, error: error instanceof Error ? error.message.slice(0, 256) : 'Action rejected.' };
157
+ }
158
+ finally {
159
+ this.active--;
160
+ const count = (this.activeByPeer.get(from) ?? 1) - 1;
161
+ if (count)
162
+ this.activeByPeer.set(from, count);
163
+ else
164
+ this.activeByPeer.delete(from);
165
+ entry.pending = false;
166
+ }
167
+ if (this.receipts.get(key) === entry) {
168
+ const bytes = encoder.encode(JSON.stringify(entry.result)).byteLength;
169
+ entry.bytes += bytes;
170
+ this.receiptBytes += bytes;
171
+ this.trimReceipts();
172
+ }
173
+ await this.deliver(from, entry, signal);
174
+ }
175
+ receiveReceipt(from, body) {
176
+ if (!body || typeof body !== 'object')
177
+ return;
178
+ const receipt = body;
179
+ if (!isId(receipt.id) || typeof receipt.ok !== 'boolean')
180
+ return;
181
+ const pending = this.pending.get(receipt.id);
182
+ if (!pending || pending.peerId !== from || receipt.sequence !== pending.sequence)
183
+ return;
184
+ let result;
185
+ if (receipt.ok) {
186
+ try {
187
+ result = actionValue(receipt.result);
188
+ }
189
+ catch {
190
+ return;
191
+ }
192
+ }
193
+ clearTimeout(pending.timer);
194
+ this.pending.delete(receipt.id);
195
+ if (receipt.ok)
196
+ pending.resolve(result);
197
+ else
198
+ pending.reject(new Error(typeof receipt.error === 'string' ? receipt.error.slice(0, 256) : 'Action rejected.'));
199
+ }
200
+ /** Called after the runtime retires a peer or the peer changes session. */
201
+ removePeer(peerId, options = {}) {
202
+ if (!options.preserveReceipts) {
203
+ this.controllers.get(peerId)?.abort();
204
+ this.controllers.delete(peerId);
205
+ }
206
+ for (const [id, pending] of this.pending)
207
+ if (pending.peerId === peerId) {
208
+ clearTimeout(pending.timer);
209
+ this.pending.delete(id);
210
+ pending.reject(new Error('Action peer retired; effect outcome is unknown.'));
211
+ }
212
+ for (const [source, window] of this.receiptWindows)
213
+ if (!options.preserveReceipts && window.peerId === peerId) {
214
+ for (const key of window.entries.values())
215
+ this.deleteReceipt(key);
216
+ this.receiptWindows.delete(source);
217
+ }
218
+ for (const [id, stable] of this.stableRequests)
219
+ if (stable.peerId === peerId)
220
+ this.stableRequests.delete(id);
221
+ }
222
+ clear(options = {}) {
223
+ for (const controller of this.controllers.values())
224
+ controller.abort();
225
+ this.controllers.clear();
226
+ for (const pending of this.pending.values()) {
227
+ clearTimeout(pending.timer);
228
+ pending.reject(new Error('Action match retired.'));
229
+ }
230
+ this.pending.clear();
231
+ this.receipts.clear();
232
+ this.receiptBytes = 0;
233
+ this.receiptWindows.clear();
234
+ if (!options.preserveOutgoing) {
235
+ this.stableRequests.clear();
236
+ this.sequence = 0;
237
+ }
238
+ // Keep active slots until settlement even when handlers ignore abort.
239
+ }
240
+ reject(peerId, id, sequence, error) {
241
+ return this.send(peerId, 'action.receipt', { id, sequence, ok: false, error });
242
+ }
243
+ async deliver(peerId, entry, signal) {
244
+ if (!entry.result || entry.sending || !signal || signal.aborted)
245
+ return;
246
+ entry.sending = true;
247
+ try {
248
+ await this.send(peerId, 'action.receipt', entry.result);
249
+ }
250
+ finally {
251
+ entry.sending = false;
252
+ }
253
+ }
254
+ deleteReceipt(key) {
255
+ const entry = this.receipts.get(key);
256
+ if (entry) {
257
+ this.receiptBytes -= entry.bytes;
258
+ this.receipts.delete(key);
259
+ }
260
+ }
261
+ trimReceipts() {
262
+ for (const [key, entry] of this.receipts) {
263
+ if (this.receiptBytes <= MAX_RECEIPT_BYTES && this.receipts.size <= 2048)
264
+ break;
265
+ if (!entry.pending)
266
+ this.deleteReceipt(key);
267
+ }
268
+ }
269
+ }