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 +181 -40
- package/dist/actions.d.ts +40 -0
- package/dist/actions.js +269 -0
- package/dist/client.js +1049 -294
- package/dist/codec.d.ts +74 -0
- package/dist/codec.js +167 -0
- package/dist/framing.d.ts +9 -0
- package/dist/framing.js +39 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/latestState.d.ts +4 -1
- package/dist/latestState.js +23 -19
- package/dist/motion.d.ts +9 -0
- package/dist/motion.js +12 -0
- package/dist/objects.d.ts +34 -5
- package/dist/objects.js +276 -38
- package/dist/protocol.d.ts +15 -2
- package/dist/protocol.js +20 -5
- package/dist/replication.d.ts +31 -0
- package/dist/replication.js +135 -0
- package/dist/roomRuntime.js +18 -16
- package/dist/simulation.d.ts +80 -0
- package/dist/simulation.js +269 -0
- package/dist/snapshots.d.ts +12 -0
- package/dist/snapshots.js +71 -0
- package/dist/state.d.ts +17 -4
- package/dist/state.js +162 -51
- package/dist/types.d.ts +50 -3
- package/dist/validation.d.ts +10 -0
- package/dist/validation.js +88 -0
- package/dist/voice.d.ts +2 -0
- package/dist/voice.js +81 -39
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,61 +1,202 @@
|
|
|
1
1
|
# openrtc-netcode
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
and
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
await
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|
package/dist/actions.js
ADDED
|
@@ -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
|
+
}
|